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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Fixed
- **Dashboard filters sharing one Filter-source query now populate** (#359).
When several Dashboard filter definitions referenced the same Filter-role
source query, the viewer ran that query once per definition and keyed each
provider by the filter-definition id — so `mergeDashboardFilterHelpers`
rejected every helper as a duplicate provider, every control rendered empty,
and the explanatory diagnostics were silently discarded. The filter runtime
is now split into per-definition state and one `FilterSourceRuntime` per
unique `sourceQueryId`: the source SQL executes exactly once per refresh wave
no matter how many filters/parameters it feeds, the provider is keyed by the
saved source-query id (two DISTINCT sources providing the same helper still
correctly collide with no arbitrary winner), and the merge's diagnostics
(info/warning/error, severity preserved) are now published to the Dashboard
instead of dropped — including a `filter-helper-missing` warning (naming the
source and column) when a source succeeds but omits a consumer's helper, so
that filter no longer renders as an unexplained empty control. Each source
also retains its last-known provider on its runtime and the merge reads the
complete set, so a future selective wave (#360) can re-merge unaffected
sources without clearing or rerunning them. This is the one-source-runtime
boundary #360 builds on.

### Changed
- **Dashboard Filter execution no longer injects `readonly`** (#359). Server
read-only policy belongs to ClickHouse user/profile configuration, not
Dashboard/Workbench feature code; `filterExecution` keeps only its
result-format/byte-cap transport settings. Panel execution is unchanged.
- **Filter option state is honest on failure** (#359). A source failure,
missing helper, type conflict, or duplicate-provider collision now CLEARS a
filter's curated options (with a visible diagnostic) instead of silently
retaining stale ones, and an unresolvable `sourceQueryId` surfaces a visible
`source-error` rather than a silent plain field. An active value no longer
present in refreshed options is deactivated (its value kept) before dependent
panels run; a non-empty → different-non-empty option replacement now updates
the rendered combobox (a per-filter option revision folded into the
filter-bar rebuild signature); and a superseded refresh wave can no longer
publish options or activation over a fresher one.

## [0.6.1] - 2026-07-21

### Changed
Expand Down
1 change: 0 additions & 1 deletion src/core/filter-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export function filterExecution(sql?: string | null, defaults: FilterExecutionDe
format: 'Filter',
rowLimit: FILTER_TOP_LEVEL_ROW_LIMIT,
params: {
readonly: 2,
max_result_bytes: FILTER_RESULT_BYTE_CAP,
output_format_json_named_tuples_as_objects: 1,
output_format_json_quote_64bit_integers: 1,
Expand Down
270 changes: 230 additions & 40 deletions src/dashboard/application/dashboard-viewer-session.ts

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/ui/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1566,7 +1566,7 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
// Rebuild the shared filter bar only when its field structure changes
// (activation, committed value, or curated options arriving) — not on tile
// progress ticks — so in-progress typing is not disturbed mid-wave.
const sig = JSON.stringify(sview.filters.map((f) => [f.id, f.active, valueString(f.value), !!(f.options && f.options.length)]));
const sig = JSON.stringify(sview.filters.map((f) => [f.id, f.active, valueString(f.value), !!(f.options && f.options.length), f.optionsRev]));
if (sig !== barSig) { barSig = sig; rebuildFilterBar(sview); }
// #303: persist committed filter value/active into the isolated per-dashboard
// store — isolated from the Workbench's asb:varValues/asb:filterActive keys.
Expand All @@ -1584,6 +1584,10 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
// leaves its target tiles in their normal unfilled state.
filterDiagnosticsHost.replaceChildren(
...sview.diagnostics.map((d) => h('div', { class: 'dash-config-diagnostic is-error' }, d.message)),
// #359: the shared-source filter wave's own merge diagnostics
// (info/warning/error), separate from the presentation diagnostics
// above — each severity maps directly to its `is-*` class.
...sview.filterDiagnostics.map((d) => h('div', { class: 'dash-config-diagnostic is-' + d.severity }, d.message)),
);
if (sview.layout.engine !== lastEngineRendered) { lastLayoutSig = ''; lastGridSig = ''; lastEngineRendered = sview.layout.engine; }
activeEngine = sview.layout.engine;
Expand Down
78 changes: 78 additions & 0 deletions tests/e2e/filter-source.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
<meta charset="utf-8" />
<title>Filter source harness</title>
<link rel="stylesheet" href="/src/styles.css" />
<!-- The Dashboard viewer session imports the signals runtime as a bare
specifier; the e2e server serves raw /src with no bundler, so map it to
the shipped ESM build (same as editor.html). -->
<script type="importmap">
{
"imports": {
"@preact/signals-core": "/node_modules/@preact/signals-core/dist/signals-core.mjs"
}
}
</script>
<style>
body { overflow: auto; padding: 24px; background: var(--bg); color: var(--fg); font-family: var(--ui); }
.surface { width: min(640px, 100%); margin-bottom: 32px; }
Expand All @@ -12,9 +22,11 @@
<body data-theme="dark">
<main id="preview" class="surface" aria-label="Workbench Filter preview"></main>
<main id="dashboard" class="surface" aria-label="Dashboard curated filter"></main>
<main id="shared-source" class="surface" aria-label="Dashboard filters sharing one source"></main>
<script type="module">
import { renderFilterPreview } from '/src/ui/filter-preview.js';
import { buildFilterOptionField } from '/src/ui/filter-option-field.js';
import { createDashboardViewerSession } from '/src/dashboard/application/dashboard-viewer-session.js';

const options = [
{ value: '', label: '(empty)' },
Expand All @@ -37,6 +49,72 @@
onCommit: () => { window.__selection.commits++; },
});
document.querySelector('#dashboard').append(field.el);

// #359: a REAL Dashboard viewer session where TWO filter definitions share
// ONE Filter-source query (`qle-filter`). This drives the actual
// FilterSourceRuntime split, one-request dedup, provider merge, and
// per-consumer option publication — not two hand-built widgets. The source
// returns a single two-column bundle; both filters must populate from that
// ONE execution, and each curated option set is rendered through the same
// buildFilterOptionField the Dashboard filter bar uses.
let sourceRequests = 0;
const exec = {
async executeRead(result, req) {
const shared = req.sql.includes('/* shared */');
if (shared) sourceRequests += 1;
if (req.onChunk) { result.progress.rows = 1; req.onChunk(); }
result.columns = shared
? [{ name: 'user', type: 'Array(String)' }, { name: 'query_kind', type: 'Array(String)' }]
: [{ name: 'n' }];
result.rows = shared ? [[['alice', 'bob'], ['Select', 'Insert']]] : [[1]];
result.progress.bytes = 10;
result.error = null;
result.cancelled = false;
},
};
const savedQuery = (id, sql, spec = {}) => ({ id, sql, specVersion: 1, spec: { name: id, ...spec } });
const document_ = {
documentVersion: 1, id: 'd', title: 'D', revision: 1,
layout: { type: 'flow', version: 1, preset: 'columns-2', items: {} },
filters: [
{ id: 'f-user', parameter: 'user', label: 'user', sourceQueryId: 'qle-filter' },
{ id: 'f-kind', parameter: 'query_kind', label: 'query_kind', sourceQueryId: 'qle-filter' },
],
tiles: [{ id: 'ta', queryId: 'qa' }, { id: 'tb', queryId: 'qb' }],
};
let clock = 1000;
const session = createDashboardViewerSession({
document: document_,
queries: [
savedQuery('qa', 'SELECT {user:String} AS n'),
savedQuery('qb', 'SELECT {query_kind:String} AS n'),
savedQuery('qle-filter', "SELECT ['alice','bob'] AS user, ['Select','Insert'] AS query_kind /* shared */", { dashboard: { role: 'filter' } }),
],
exec,
connection: { ensureFreshToken: async () => true },
now: () => (clock += 5),
wallNow: () => 2000,
});
await session.start();

const filters = session.state.value.filters;
const byParam = (p) => filters.find((f) => f.parameter === p);
window.__sharedRequests = sourceRequests;
window.__filterStatus = { user: byParam('user').status, query_kind: byParam('query_kind').status };

const host = document.querySelector('#shared-source');
const mountField = (param, sink) => {
window[sink] = { value: '', active: false, commits: 0 };
const built = buildFilterOptionField({
document, name: param, options: byParam(param).options || [], inactiveLabel: 'All',
onValueChange: (value, active) => Object.assign(window[sink], { value, active }),
onCommit: () => { window[sink].commits++; },
});
host.append(built.el);
};
mountField('user', '__userSel');
mountField('query_kind', '__kindSel');

window.__ready = true;
</script>
</body>
Expand Down
43 changes: 43 additions & 0 deletions tests/e2e/filter-source.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,47 @@ test.describe('Dashboard Filter sources', () => {
await expect(input).toHaveValue('Atlanta');
expect(await page.evaluate(() => window.__selection)).toEqual({ value: 'ATL', active: true, commits: 1 });
});

test('a shared Filter source renders curated values for all configured filters (#359)', async ({ page }) => {
// The real DashboardViewerSession ran the ONE shared source query exactly
// once for the two filters that reference it (the #359 bug ran it per
// definition and dropped every helper as a duplicate provider).
expect(await page.evaluate(() => window.__sharedRequests)).toBe(1);
expect(await page.evaluate(() => window.__filterStatus)).toEqual({ user: 'ready', query_kind: 'ready' });

const shared = page.getByRole('main', { name: 'Dashboard filters sharing one source' });
const userInput = shared.getByRole('combobox', { name: 'user' });
const kindInput = shared.getByRole('combobox', { name: 'query_kind' });
await expect(userInput).toHaveAttribute('placeholder', 'All');
await expect(kindInput).toHaveAttribute('placeholder', 'All');

// Each filter's own curated column from that single result populates its
// control — the merge published `user` -> [alice, bob] and
// `query_kind` -> [Select, Insert] from one bundle.
// Commit an exact returned value in each field. We assert value + active
// (the selection that flows to the panel params); the commit-fires-once
// contract of the combobox itself is covered by the single-field test
// above — re-asserting a raw commit COUNT here is off-topic for #359 and
// brittle (a blur strictCommit can re-fire a same-value commit).
await userInput.fill('ali');
await expect(shared.getByRole('option')).toHaveCount(1);
await expect(shared.getByRole('option')).toHaveText('alice');
await shared.getByRole('option').click();
await expect(userInput).toHaveValue('alice');
expect(await page.evaluate(() => ({ value: window.__userSel.value, active: window.__userSel.active })))
.toEqual({ value: 'alice', active: true });

await kindInput.fill('ins');
await expect(shared.getByRole('option')).toHaveCount(1);
await expect(shared.getByRole('option')).toHaveText('Insert');
await shared.getByRole('option').click();
await expect(kindInput).toHaveValue('Insert');
expect(await page.evaluate(() => ({ value: window.__kindSel.value, active: window.__kindSel.active })))
.toEqual({ value: 'Insert', active: true });

// Committing the second filter must not disturb the first — each
// consumer of the one shared source operates independently.
await expect(userInput).toHaveValue('alice');
expect(await page.evaluate(() => window.__userSel.value)).toBe('alice');
});
});
4 changes: 3 additions & 1 deletion tests/unit/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,9 @@ describe('query run', () => {
const request = asMock(app.conn.chCtx.fetch).mock.calls.find(([, init]) => /SELECT \['ATL'\]/.test(init.body))!;
expect(request[0]).toContain('default_format=JSONEachRowWithProgress');
expect(request[0]).toContain('max_result_rows=2');
expect(request[0]).toContain('readonly=2');
// #359: Filter execution no longer injects `readonly` — server read-only
// policy belongs to ClickHouse user/profile config, not feature code.
expect(request[0]).not.toContain('readonly=');
expect(request[0]).toContain('output_format_json_quote_64bit_integers=1');
expect(tab.filterPreview!.status).toBe('success');
expect(filterPreviewNormalizedOf(tab).helpers[0].options).toEqual([
Expand Down
Loading
Loading