Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
per-tile controls, and export arrive in later phases (#149 D2–D7). Known
limitation: two tabs independently refreshing a *rotating* OAuth refresh token
can race (BroadcastChannel sync deferred).
- **Dashboard (phase 2): Arrange / Report layout switcher** (#149). A toolbar
below the dashboard header (the future filter bar) adds a primary **Arrange |
Report** segmented control: **Arrange** is the uniform multi-column grid, and
**Report** lays the tiles out as a single full-width scrolling column with
taller charts. A secondary **Columns 2 | 3** control tunes the Arrange grid
(hidden in Report's single column). Both are presentation-only — switching
reshapes the grid and the chart tiles resize themselves, with no tile re-query
— and the choice is persisted per browser (`asb:dashLayout` / `asb:dashCols`),
surviving reloads and Refresh. Chart tiles were also brought closer to the
design: the saved query **description** shows as a subtitle under the tile
name, the chart now draws on the **tile's own background** (instead of the
darker results-table background), and the value-axis **gridlines are hidden**
on tiles (they read as noisy light lines on a dark panel). Drag-to-reorder and
1/2-column tile spans arrive in the next phase (#149 D3).
- **Schema-aware, FROM-driven autocompletion** (#84) — column completion now
fires *while you type*, driven by the statement's `FROM`/`JOIN` clause, so you
no longer have to expand a table in the sidebar first. A new pure module
Expand Down
9 changes: 6 additions & 3 deletions src/core/chart-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,12 @@ const withAlpha = (hex, frac) => {
/**
* Build a complete Chart.js config object (type + data + themed options) from a
* result and the user's `cfg`. Pure: returns a plain object (Chart.js draws it).
* `colors` is a resolved token bundle from `chartColors`.
* `colors` is a resolved token bundle from `chartColors`. `opts.hideGrid`
* suppresses the value-axis gridlines (dashboard tiles draw on the panel
* background where a light gridline reads as noise — #149; the tick labels
* stay), keeping the chart body clean like the design's tiles.
*/
export function chartJsConfig(columns, rows, cfg, colors) {
export function chartJsConfig(columns, rows, cfg, colors, opts = {}) {
const { labels, datasets } = buildChartData(columns, rows, cfg);
const pal = colors.palette;
const horizontal = cfg.type === 'hbar';
Expand All @@ -336,7 +339,7 @@ export function chartJsConfig(columns, rows, cfg, colors) {
});

const multi = datasets.length > 1;
const grid = { color: colors.borderFaint, drawBorder: false };
const grid = { color: colors.borderFaint, drawBorder: false, display: !opts.hideGrid };
const ticks = { color: colors.fgMute, font: { family: colors.mono, size: 10 } };
const valueTicks = { ...ticks, callback: (v) => chartNumFmt(typeof v === 'number' ? v : Number(v)) };

Expand Down
20 changes: 20 additions & 0 deletions src/core/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ export function configBase(pathname) {
return (pathname || '').replace(/\/dashboard\/?$/, '');
}

/**
* Dashboard layout modes (#149 D2): `arrange` = uniform multi-column grid
* (default), `report` = single full-width scrolling column with taller tiles.
* Persisted per browser (`asb:dashLayout`).
*/
export const DASH_LAYOUTS = ['arrange', 'report'];

/** Snap a persisted layout to a known mode, defaulting to `arrange`. Pure. */
export function normalizeDashLayout(v) {
return DASH_LAYOUTS.includes(v) ? v : 'arrange';
}

/** Column-count options for Arrange mode (persisted `asb:dashCols`). */
export const DASH_COLS = [2, 3];

/** Snap a persisted column count to 2 or 3, defaulting to 3. Pure. */
export function normalizeDashCols(n) {
return DASH_COLS.includes(n) ? n : 3;
}

/**
* A favorite's SQL prepared for a one-shot tile fetch: `FORMAT JSON` appended
* unless the query already ends in its own trailing `FORMAT` clause (which we
Expand Down
8 changes: 8 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { clamp } from './core/format.js';
import { mergeSaved } from './core/saved-io.js';
import { cloneChartCfg } from './core/chart-data.js';
import { normalizeDashLayout, normalizeDashCols } from './core/dashboard.js';
import { loadJSON, saveJSON, loadStr, saveStr } from './core/storage.js';
import { signal } from '@preact/signals-core';

Expand All @@ -28,6 +29,8 @@ export const KEYS = {
libraryName: 'asb:libraryName',
resultRowLimit: 'asb:resultRowLimit',
varValues: 'asb:varValues',
dashLayout: 'asb:dashLayout',
dashCols: 'asb:dashCols',
};

/** Row-limit options for the result cap selector (shared between state + UI). */
Expand Down Expand Up @@ -74,6 +77,11 @@ export function createState(read = { loadJSON, loadStr }) {
// One persisted preference, default 500; a non-option stored value snaps
// back to the default so the selector always reflects a real choice.
resultRowLimit: normalizeRowLimit(parseInt(read.loadStr(KEYS.resultRowLimit, '500'), 10)),
// Dashboard layout prefs (#149 D2), persisted per browser. Plain (non-signal)
// like theme/density — the standalone dashboard page reads them at build time
// and mutates + re-saves on the Arrange/Report + column-count controls.
dashLayout: normalizeDashLayout(read.loadStr(KEYS.dashLayout, 'arrange')),
dashCols: normalizeDashCols(parseInt(read.loadStr(KEYS.dashCols, '3'), 10)),
sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420),
editorPct: num(KEYS.editorPct, 45, 15, 85),
sideSplitPct: num(KEYS.sideSplitPct, 58, 25, 85),
Expand Down
41 changes: 36 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1744,11 +1744,29 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
/* The dashboard's own scroll container: #root is a fixed overflow:hidden flex
column (the workbench shell), so the dashboard fills it and scrolls itself. */
.dash-page { height: 100%; overflow-y: auto; overflow-x: hidden; background: var(--bg); }
/* Header + layout toolbar share one sticky top bar (#149 D2) so the
Arrange/Report switcher stays visible while the grid scrolls. */
.dash-topbar { position: sticky; top: 0; z-index: 40; background: var(--bg-header); }
.dash-header {
position: sticky; top: 0; z-index: 40; display: flex; align-items: center;
display: flex; align-items: center;
gap: 12px; padding: 11px 20px; background: var(--bg-header);
border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
/* Layout toolbar (#149 D2) — becomes the filter bar in D4. */
.dash-toolbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 8px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border);
}
.dash-seg { display: inline-flex; background: var(--bg-chip); border: 1px solid var(--border); border-radius: 8px; padding: 2px; }
.dash-seg-btn {
appearance: none; border: none; background: none; cursor: pointer;
padding: 4px 12px; border-radius: 6px; font: 500 12px var(--ui); color: var(--fg-mute);
}
.dash-seg-btn:hover { color: var(--fg); }
.dash-seg-btn.is-active { background: var(--bg); color: var(--fg); box-shadow: 0 1px 2px rgba(0,0,0,.12); }
.dash-cols-wrap { display: inline-flex; align-items: center; gap: 8px; }
.dash-seg-cols .dash-seg-btn { padding: 4px 10px; font-family: var(--mono); }
.dash-seg-label { font-size: 11px; color: var(--fg-faint); }
.dash-icobtn {
width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center;
background: var(--bg-chip); color: var(--fg-mute); border: 1px solid var(--border);
Expand Down Expand Up @@ -1779,16 +1797,19 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
}
.dash-btn:hover { background: var(--bg-hover); }
.dash-btn:disabled { opacity: .55; cursor: default; }
/* Arrange grid: 3 across on wide screens (the design's default), degrading to
2 then 1 as width shrinks. A configurable 2/3-column switcher + drag-reorder
land in D5. */
/* Arrange grid: the persisted column count (2 or 3, via `--dash-cols`, default
3) on wide screens, degrading to 2 then 1 as width shrinks. The 2/3 switcher
lives in the toolbar (#149 D2); drag-reorder + 1/2-col span land in D3. */
.dash-grid {
display: grid; gap: 14px; padding: 18px 20px 40px;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(var(--dash-cols, 3), minmax(0, 1fr));
max-width: 1560px; margin: 0 auto;
}
@media (max-width: 1100px) { .dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .dash-grid { grid-template-columns: 1fr; } }
/* Report mode (#149 D2): one full-width scrolling column with taller tiles. */
.dash-grid.is-report { grid-template-columns: 1fr; max-width: 1100px; }
.dash-grid.is-report .dash-tile { min-height: 440px; }
/* D1 shows charts read-only: renderChart is called with controls:false so the
Type/X/Y config bar isn't built at all (a settings-popover arrives in D6). */
.dash-empty { padding: 60px 20px; text-align: center; color: var(--fg-mute); font-size: 13px; }
Expand All @@ -1803,8 +1824,18 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
font-size: 13px; font-weight: 600; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;
}
/* Saved-query description subtitle under the tile name (matches the design). */
.dash-tile-desc {
font-size: 11px; color: var(--fg-mute); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; }
.dash-tile-body > .chart-view { flex: 1; min-width: 0; }
/* The chart draws on the tile's own background (--bg-side), not the darker
results-table bg the workbench chart view uses; this both matches the design
and lets the faint value-axis gridlines recede instead of standing out on a
near-black panel in dark mode. */
.dash-tile .chart-view, .dash-tile .chart-empty { background: transparent; }
.dash-tile-load {
display: flex; align-items: center; gap: 8px; margin: auto;
color: var(--fg-faint); font-size: 12px;
Expand Down
74 changes: 68 additions & 6 deletions src/ui/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ import { formatBytes, formatRows } from '../core/format.js';
// browser's per-host pool and the cluster) on open and on every Refresh.
const TILE_CONCURRENCY = 6;

/**
* Build a segmented control (`Arrange | Report`, `2 | 3`): a row of buttons of
* which exactly one reads active. `getActive` returns the currently-selected
* value; `onPick(value)` fires on a click. Returns `{ el, sync }` — `sync()`
* repaints the active button from `getActive()` (called after a pick so the
* two controls can share one `apply()`).
*/
function buildSeg(cls, options, getActive, onPick) {
const btns = options.map(([, label]) =>
h('button', { class: 'dash-seg-btn', type: 'button' }, label));
const sync = () => btns.forEach((b, i) => {
const on = options[i][0] === getActive();
b.classList.toggle('is-active', on);
b.setAttribute('aria-pressed', String(on));
});
btns.forEach((b, i) => { b.onclick = () => onPick(options[i][0]); });
const el = h('div', { class: 'dash-seg ' + cls, role: 'group' }, ...btns);
sync();
return { el, sync };
}

/** Build a tile's footer meta row (rows · ms · bytes), omitting stats CH didn't return. */
function tileFooter(meta) {
const parts = [h('span', null, formatRows(meta.rows) + ' rows')];
Expand Down Expand Up @@ -55,9 +76,12 @@ async function renderTile(app, q, grid, tiles) {
const body = h('div', { class: 'dash-tile-body' },
h('div', { class: 'dash-tile-load' }, Icon.spinner(), h('span', null, 'Loading…')));
const foot = h('div', { class: 'dash-tile-foot' });
const card = h('div', { class: 'dash-tile' },
h('div', { class: 'dash-tile-head' }, h('span', { class: 'dash-tile-name', title: q.name }, q.name)),
body, foot);
// Header: the favorite's name, plus its saved description as a subtitle when it
// has one (single line, ellipsized) — mirrors the design mockup's tile header.
const head = h('div', { class: 'dash-tile-head' },
h('span', { class: 'dash-tile-name', title: q.name }, q.name));
if (q.description) head.appendChild(h('div', { class: 'dash-tile-desc', title: q.description }, q.description));
const card = h('div', { class: 'dash-tile' }, head, body, foot);
grid.appendChild(card);

const r = await app.runTile(q.sql);
Expand All @@ -78,7 +102,7 @@ async function renderTile(app, q, grid, tiles) {
const chartTab = { chartKey: schemaKey(r.columns), chartCfg: cls.cfg };
let inst = null;
body.replaceChildren(renderChart(app, res, {
tab: chartTab, setChart: (c) => { inst = c; }, running: false, controls: false,
tab: chartTab, setChart: (c) => { inst = c; }, running: false, controls: false, hideGrid: true,
}));
tiles.push({ destroy: () => inst.destroy() });
foot.replaceChildren(...tileFooter(r.meta));
Expand Down Expand Up @@ -125,10 +149,48 @@ export function renderDashboard(app) {
const empty = h('div', { class: 'dash-empty', style: { display: favorites.length ? 'none' : '' } },
'No favorites yet — star a query in the Library to add it to the dashboard.');

// Layout toolbar (#149 D2), the row that becomes the filter bar in D4. The
// Arrange|Report switcher is the primary control; the 2/3 column count is a
// secondary setting, meaningful only in Arrange (hidden in Report's single
// column). Both are presentation-only: `apply()` reshapes the grid and the
// tiles' Chart.js instances resize themselves via their ResizeObserver — no
// tile re-query. State is mutated + persisted (asb:dashLayout/dashCols) so the
// choice survives reloads and Refresh (which rebuilds the grid's children, not
// the grid element, so its class/`--dash-cols` persist across a refresh).
const apply = () => {
grid.classList.toggle('is-report', state.dashLayout === 'report');
grid.style.setProperty('--dash-cols', String(state.dashCols));
colsWrap.style.display = state.dashLayout === 'report' ? 'none' : '';
layoutSeg.sync();
colsSeg.sync();
};
const layoutSeg = buildSeg('dash-seg-layout', [['arrange', 'Arrange'], ['report', 'Report']],
() => state.dashLayout, (v) => {
if (v === state.dashLayout) return;
state.dashLayout = v;
app.savePref('dashLayout', v);
apply();
});
const colsSeg = buildSeg('dash-seg-cols', [[2, '2'], [3, '3']],
() => state.dashCols, (v) => {
if (v === state.dashCols) return;
state.dashCols = v;
app.savePref('dashCols', v);
apply();
});
const colsWrap = h('div', { class: 'dash-cols-wrap' },
h('span', { class: 'dash-seg-label' }, 'Columns'), colsSeg.el);
const toolbar = h('div', { class: 'dash-toolbar' },
layoutSeg.el,
h('div', { class: 'dash-spacer', style: { flex: '1' } }),
colsWrap);
apply();

// #root is a fixed, overflow:hidden flex column (the workbench layout), so the
// dashboard needs its own scroll container — otherwise a tall grid clips with
// no vertical scroll. The sticky header lives inside it.
app.root.replaceChildren(h('div', { class: 'dash-page' }, header, empty, grid));
// no vertical scroll. The header + toolbar share one sticky top bar inside it.
app.root.replaceChildren(h('div', { class: 'dash-page' },
h('div', { class: 'dash-topbar' }, header, toolbar), empty, grid));

// Chart.js instances of the tiles currently in the grid, torn down before the
// next Refresh rebuilds them (grid.replaceChildren() alone would orphan them,
Expand Down
3 changes: 2 additions & 1 deletion src/ui/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,7 @@ export function installChartZoomFix(chart, canvas) {
* `opts.running` overrides the run-state gate — a detached snapshot's `r` is
* always already-complete, independent of whatever the live tab is doing.
* `opts.controls === false` omits the Type/X/Y config bar (read-only tiles).
* `opts.hideGrid` suppresses the value-axis gridlines (dashboard tiles — #149).
*/
export function renderChart(app, r, opts = {}) {
const tab = opts.tab || app.activeTab();
Expand Down Expand Up @@ -1019,7 +1020,7 @@ export function renderChart(app, r, opts = {}) {
// contradicting the "first N rows" note. It would also sort up to VIS_CAP
// rows just to discard all but the first `cap`.
const chart = installChartZoomFix(
new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar))),
new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar), { hideGrid: opts.hideGrid })),
canvas);
setChart(chart);
// Chart.js's own responsive sizing reads layout through APIs (getComputedStyle,
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/chart-data.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@ describe('chartJsConfig', () => {
expect(cfg.options.scales.y.grid.display).toBe(false); // category axis
expect(cfg.data.datasets[0].backgroundColor).toBe(colors.palette[0]);
});
it('shows value-axis gridlines by default, hides them with opts.hideGrid (#149)', () => {
const shown = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors);
expect(shown.options.scales.y.grid.display).toBe(true);
const hidden = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors, { hideGrid: true });
expect(hidden.options.scales.y.grid.display).toBe(false);
// the horizontal-bar value axis (x) is hidden too, not just the category axis
const hbar = chartJsConfig(cols, rows, { type: 'hbar', x: 0, y: [1], series: null }, colors, { hideGrid: true });
expect(hbar.options.scales.x.grid.display).toBe(false);
});
it('vertical column keeps indexAxis x', () => {
const cfg = chartJsConfig(cols, rows, { type: 'bar', x: 0, y: [1], series: null }, colors);
expect(cfg.type).toBe('bar');
Expand Down
Loading