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
4 changes: 2 additions & 2 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ async function HomeContent({
initialFormats,
initialGroupFilter,
}: {
initialEngines: string[];
initialFormats: string[];
initialEngines: string[] | null;
initialFormats: string[] | null;
initialGroupFilter: GroupFilter | null;
}) {
const [groups, universe] = await Promise.all([cachedGroups(), cachedFilterUniverse()]);
Expand Down
107 changes: 107 additions & 0 deletions web/components/FilterBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

// @vitest-environment jsdom

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { FilterBar } from '@/components/FilterBar';
import { parseFilterCsv, seriesPassesFilter } from '@/lib/chart-format';
import { getGlobalFilterSnapshot } from '@/lib/chart-store';

const universe = { engines: ['duckdb', 'datafusion'], formats: ['vortex', 'parquet', 'lance'] };

describe('global filter URL round trips', () => {
let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});

async function mount(): Promise<void> {
const params = new URL(window.location.href).searchParams;
root = createRoot(container);
await act(async () => {
root.render(
<FilterBar
universe={universe}
initialEngines={parseFilterCsv(params.get('engine'))}
initialFormats={parseFilterCsv(params.get('format'))}
/>,
);
});
}

async function click(dim: string, value: string): Promise<void> {
await act(async () => {
container
.querySelector<HTMLButtonElement>(`[data-filter="${dim}"][data-value="${value}"]`)!
.click();
});
}

async function reload(): Promise<void> {
await act(async () => root.unmount());
await mount();
}

it.each(['/', '/chart/qm.example'])(
'preserves none and all formats after reload on %s',
async (path) => {
window.history.replaceState(
null,
'',
`${path}?n=all&group=random-access&hide=duckdb&show=lance#random-access`,
);
await mount();
expect(getGlobalFilterSnapshot().active.formats).toEqual(['vortex', 'parquet']);

for (const engine of universe.engines) await click('engine', engine);
for (const format of ['vortex', 'parquet']) await click('format', format);
let url = new URL(window.location.href);
expect(url.searchParams.get('engine')).toBe('');
expect(url.searchParams.get('format')).toBe('');
await reload();
expect(getGlobalFilterSnapshot().active).toEqual({ engines: [], formats: [] });

await click('engine', '*');
await click('format', '*');
url = new URL(window.location.href);
expect(url.searchParams.has('engine')).toBe(false);
expect(url.searchParams.get('format')).toBe('vortex,parquet,lance');
expect(url.searchParams.get('n')).toBe('all');
expect(url.searchParams.get('group')).toBe('random-access');
expect(url.searchParams.get('hide')).toBe('duckdb');
expect(url.searchParams.get('show')).toBe('lance');
expect(url.hash).toBe('#random-access');
expect(url.pathname).toBe(path);
await reload();
expect(getGlobalFilterSnapshot().active).toEqual(universe);
},
);

it('keeps stale explicit allowlists filtered even when their length matches the universe', async () => {
window.history.replaceState(null, '', '/?engine=duckdb,gone&format=vortex,old,older');
await mount();
const { active } = getGlobalFilterSnapshot();
expect(seriesPassesFilter({ engine: 'datafusion', format: 'vortex' }, active, universe)).toBe(
false,
);
expect(seriesPassesFilter({ engine: 'duckdb', format: 'lance' }, active, universe)).toBe(false);
expect(container.querySelector('[data-role="filter-badge"]')?.textContent).toBe('3');
await click('engine', '*');
expect(new URL(window.location.href).searchParams.get('format')).toBe('vortex,old,older');
await reload();
expect(getGlobalFilterSnapshot().active.formats).toEqual(['vortex', 'old', 'older']);
});
});
24 changes: 12 additions & 12 deletions web/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ import {
* indicator. Every change re-paints the chips (via the store subscription, here
* and in every chart island) and syncs the URL `?engine=`/`?format=` allowlists
* with `history.replaceState`, so a refresh or share preserves the view; the
* params are omitted when a row is fully active so the no-filter URL is clean.
* params are omitted only when the active set matches the defaults.
*/
export function FilterBar({
universe,
initialEngines,
initialFormats,
}: {
universe: FilterUniverse;
/** URL `?engine=` allowlist parsed server-side; empty means no filter. */
initialEngines: string[];
/** URL `?format=` allowlist parsed server-side; empty means no filter. */
initialFormats: string[];
/** URL `?engine=` allowlist parsed server-side; null uses defaults; empty hides all. */
initialEngines: string[] | null;
/** URL `?format=` allowlist parsed server-side; null uses defaults; empty hides all. */
initialFormats: string[] | null;
}) {
const [open, setOpen] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -101,8 +101,8 @@ export function FilterBar({
}, [open]);

const hiddenCount =
Math.max(0, universe.engines.length - activeEngines.length) +
Math.max(0, universe.formats.length - activeFormats.length);
universe.engines.filter((engine) => !activeEngines.includes(engine)).length +
universe.formats.filter((format) => !activeFormats.includes(format)).length;

const onChipClick = (dim: 'engine' | 'format', value: string): void => {
toggleGlobalFilterValue(dim, value);
Expand Down Expand Up @@ -204,8 +204,8 @@ function FilterRow({

/**
* Mirror the active filter onto the URL as `?engine=`/`?format=` allowlists via
* `history.replaceState`. A param is emitted only when its active set is a
* strict subset of the universe; an all-active row leaves the URL clean.
* `history.replaceState`. Only default selections omit their parameter. An
* empty selection writes an empty value, and all formats includes Lance.
*/
function syncFilterUrl(): void {
if (!window.history?.replaceState) {
Expand All @@ -214,12 +214,12 @@ function syncFilterUrl(): void {
const { universe, active } = getGlobalFilterSnapshot();
const url = new URL(window.location.href);
syncDimensionUrl(url, 'engine', active.engines, universe.engines);
syncDimensionUrl(url, 'format', active.formats, universe.formats);
syncDimensionUrl(url, 'format', active.formats, seedActiveFormats(null, universe.formats));
window.history.replaceState(null, '', url.toString());
}

function syncDimensionUrl(url: URL, paramName: string, active: string[], universe: string[]): void {
if (active.length < universe.length) {
function syncDimensionUrl(url: URL, paramName: string, active: string[], defaults: string[]): void {
if (active.length !== defaults.length || !defaults.every((value) => active.includes(value))) {
url.searchParams.set(paramName, active.join(','));
} else {
url.searchParams.delete(paramName);
Expand Down
8 changes: 4 additions & 4 deletions web/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export function Header({
initialFormats,
}: {
universe?: FilterUniverse;
initialEngines?: string[];
initialFormats?: string[];
initialEngines?: string[] | null;
initialFormats?: string[] | null;
}) {
const [navOpen, setNavOpen] = useState(false);
const [nextTheme, setNextTheme] = useState<'light' | 'dark'>('light');
Expand Down Expand Up @@ -159,8 +159,8 @@ export function Header({
{showFilters && (
<FilterBar
universe={universe}
initialEngines={initialEngines ?? []}
initialFormats={initialFormats ?? []}
initialEngines={initialEngines ?? null}
initialFormats={initialFormats ?? null}
/>
)}
{/* Mobile-only GitHub link rendered inside the hamburger panel;
Expand Down
20 changes: 15 additions & 5 deletions web/lib/chart-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,24 +576,25 @@ describe('filter helpers', () => {

it('parses CSV allowlists with trimming and dedupe', () => {
expect(parseFilterCsv('duckdb, datafusion,duckdb,,')).toEqual(['duckdb', 'datafusion']);
expect(parseFilterCsv(null)).toEqual([]);
expect(parseFilterCsv(null)).toBeNull();
expect(parseFilterCsv(undefined)).toBeNull();
expect(parseFilterCsv('')).toEqual([]);
});

it('seeds the active set from the allowlist or the whole universe', () => {
expect(seedActiveFromAllowlist([], universe.engines)).toEqual(['duckdb', 'datafusion']);
expect(seedActiveFromAllowlist(null, universe.engines)).toEqual(['duckdb', 'datafusion']);
// A non-empty allowlist is verbatim, even when stale against the universe.
expect(seedActiveFromAllowlist(['gone'], universe.engines)).toEqual(['gone']);
});

it('seeds formats with lance hidden by default, but honors an explicit allowlist', () => {
const formats = ['vortex', 'parquet', 'lance'];
// No allowlist: lance is dropped from the default active set.
expect(seedActiveFormats([], formats)).toEqual(['vortex', 'parquet']);
expect(seedActiveFormats(null, formats)).toEqual(['vortex', 'parquet']);
// An explicit allowlist is verbatim, so a `?format=` URL can pin lance on.
expect(seedActiveFormats(['lance'], formats)).toEqual(['lance']);
// A universe without lance is unaffected.
expect(seedActiveFormats([], ['vortex', 'parquet'])).toEqual(['vortex', 'parquet']);
expect(seedActiveFormats(null, ['vortex', 'parquet'])).toEqual(['vortex', 'parquet']);
});

it('hides a series only when its own dimension is filtered', () => {
Expand All @@ -607,9 +608,18 @@ describe('filter helpers', () => {
expect(seriesPassesFilter(undefined, active, universe)).toBe(true);
});

it('treats an all-active dimension as unfiltered', () => {
it('checks explicit membership after the filter universe initializes', () => {
const active = { engines: ['duckdb', 'datafusion'], formats: ['vortex', 'parquet'] };
expect(seriesPassesFilter({ engine: 'duckdb' }, active, universe)).toBe(true);
expect(seriesPassesFilter({ engine: 'new-engine' }, active, universe)).toBe(false);
expect(seriesPassesFilter({ format: 'new-format' }, active, universe)).toBe(false);
expect(
seriesPassesFilter(
{ engine: 'duckdb' },
{ engines: [], formats: [] },
{ engines: [], formats: [] },
),
).toBe(true);
});

it('applies per-group overrides before the fallback visibility', () => {
Expand Down
38 changes: 15 additions & 23 deletions web/lib/chart-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,12 +956,12 @@ export interface GlobalFilterState {
/**
* Parse one `?engine=` / `?format=` CSV param into a deduplicated, trimmed
* allowlist, the TypeScript port of the Axum server's `parse_csv`. Empty
* entries (e.g. trailing commas) are dropped; an absent or entirely empty param
* means "no filter active" and is encoded as an empty array.
* entries (e.g. trailing commas) are dropped. An absent parameter returns null
* for the defaults; a present empty parameter returns an empty active set.
*/
export function parseFilterCsv(raw: string | null | undefined): string[] {
export function parseFilterCsv(raw: string | null | undefined): string[] | null {
if (raw === null || raw === undefined) {
return [];
return null;
}
const seen = new Set<string>();
const out: string[] = [];
Expand All @@ -977,16 +977,16 @@ export function parseFilterCsv(raw: string | null | undefined): string[] {
}

/**
* Translate a URL allowlist into the active chip set. An empty allowlist means
* "no filter", so every chip in the universe is active. A non-empty allowlist
* Translate a URL allowlist into the active chip set. A null allowlist uses
* the defaults, with every engine active. An explicit allowlist
* is taken verbatim, even if a chip has since been added or removed from the
* universe, which keeps stale URLs deterministic.
*/
export function seedActiveFromAllowlist(
allowlist: readonly string[],
allowlist: readonly string[] | null,
universe: readonly string[],
): string[] {
return allowlist.length === 0 ? [...universe] : [...allowlist];
return allowlist === null ? [...universe] : [...allowlist];
}

/**
Expand All @@ -1005,20 +1005,20 @@ export const DEFAULT_HIDDEN_FORMATS: readonly string[] = ['lance'];
* [`seedActiveFromAllowlist`], which the engine dimension still uses directly.
*/
export function seedActiveFormats(
allowlist: readonly string[],
allowlist: readonly string[] | null,
universe: readonly string[],
): string[] {
if (allowlist.length > 0) {
if (allowlist !== null) {
return [...allowlist];
}
return universe.filter((format) => !DEFAULT_HIDDEN_FORMATS.includes(format));
}

/**
* Whether a series passes the global filter. A series is hidden when its
* engine/format dimension is filtered (the active set is a strict subset of
* the universe) AND its tag is not in the active set. Series without an engine
* tag (e.g. compression-time `format:op` series) are unaffected by the engine
* engine/format dimension has initialized chips and its tag is not in the active
* set. Series without an engine tag (e.g. compression-time `format:op` series)
* are unaffected by the engine
* filter, symmetric for format, so hiding an engine does not nuke charts that
* have no engine dimension.
*/
Expand All @@ -1028,18 +1028,10 @@ export function seriesPassesFilter(
universe: FilterUniverse,
): boolean {
const m = meta ?? {};
if (
m.engine &&
active.engines.length < universe.engines.length &&
!active.engines.includes(m.engine)
) {
if (m.engine && universe.engines.length > 0 && !active.engines.includes(m.engine)) {
return false;
}
if (
m.format &&
active.formats.length < universe.formats.length &&
!active.formats.includes(m.format)
) {
if (m.format && universe.formats.length > 0 && !active.formats.includes(m.format)) {
return false;
}
return true;
Expand Down
Loading
Loading