Skip to content

Commit e3c474e

Browse files
committed
workspace global-filter removal logic
Signed-off-by: Davis Silverman <davis@thedav.is>
1 parent bfc12d8 commit e3c474e

4 files changed

Lines changed: 238 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,4 @@ docs/static/browser
6565
docs/static/viewer
6666
docs/static/react
6767
rust/perspective-server/build
68+
target/

packages/react/src/workspace.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export interface ToggleGloalFilterEventDetail {
2727
isGlobalFilter: boolean;
2828
}
2929

30-
interface PerspectiveWorkspaceProps extends React.HTMLAttributes<HTMLElement> {
30+
export interface PerspectiveWorkspaceProps
31+
extends React.HTMLAttributes<HTMLElement> {
3132
client: psp.Client | Promise<psp.Client>;
3233
layout: PerspectiveWorkspaceConfig;
3334
onLayoutUpdate?: (layout: PerspectiveWorkspaceConfig) => void;

packages/workspace/src/ts/workspace/workspace.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export type PerspectiveTabArea = {
7171
widgets: string[];
7272
};
7373

74+
/// When using the global filter functionality in a plugin
75+
/// you can provide either a filter for the viewer (`psp.Filter`)
76+
/// or a 'tombstone' to clear the filter at position [0].
77+
export type GlobalFilterTerm = psp.Filter | [string, undefined];
78+
7479
export type PerspectiveLayout = PerspectiveSplitArea | PerspectiveTabArea;
7580

7681
export interface PerspectiveWorkspaceConfig {
@@ -683,20 +688,33 @@ export class PerspectiveWorkspace extends SplitPanel {
683688

684689
async _filterViewer(
685690
viewer: HTMLPerspectiveViewerElement,
686-
filters: [string, string, string][],
691+
filters: GlobalFilterTerm[],
687692
candidates: Set<string>,
688693
) {
689694
const config = await viewer.save();
690695
const table = await viewer.getTable();
691696
const availableColumns = Object.keys(await table.schema());
692697
const currentFilters = config.filter || [];
693-
const columnAvailable = (filter: [string, string, any]) =>
698+
const columnAvailable = (filter: psp.Filter) =>
694699
filter[0] && availableColumns.includes(filter[0]);
695700

696-
const validFilters = filters.filter(columnAvailable);
701+
// Separate tombstone entries ([col, undefined]) from real filters.
702+
// A tombstone explicitly removes a column's filter from slave viewers.
703+
const clearColumns = new Set<string>();
704+
const activeFilters: psp.Filter[] = [];
705+
for (const f of filters) {
706+
if (f[1] === undefined) {
707+
clearColumns.add(f[0]);
708+
} else {
709+
activeFilters.push(f as psp.Filter);
710+
}
711+
}
712+
713+
const validFilters = activeFilters.filter(columnAvailable);
697714
validFilters.push(
698715
...currentFilters.filter(
699-
(x: [string, ..._: string[]]) => !candidates.has(x[0]),
716+
(x: [string, ..._: string[]]) =>
717+
!candidates.has(x[0]) && !clearColumns.has(x[0]),
700718
),
701719
);
702720

@@ -712,10 +730,10 @@ export class PerspectiveWorkspace extends SplitPanel {
712730
const candidates = new Set([
713731
...(config["group_by"] || []),
714732
...(config["split_by"] || []),
715-
...(config.filter || []).map((x: [string, string, any]) => x[0]),
733+
...(config.filter || []).map((x: psp.Filter) => x[0]),
716734
]);
717735

718-
const filters = [...event.detail.config.filter];
736+
const filters = [...event.detail.config.filter] as GlobalFilterTerm[];
719737
toArray(this.dockpanel.widgets()).forEach((widget) => {
720738
this._filterViewer(
721739
(widget as PerspectiveViewerWidget).viewer,

packages/workspace/test/js/global_filter.spec.js

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,217 @@ function tests(context, compare) {
141141
return compare(page, `${context}-datagrid-filters-work.txt`);
142142
});
143143

144+
test("Filter tombstone removes a programmatically applied filter from slave viewers", async ({
145+
page,
146+
}) => {
147+
const config = {
148+
viewers: {
149+
One: {
150+
table: "superstore",
151+
name: "Test",
152+
group_by: ["State"],
153+
columns: ["Sales"],
154+
plugin: "Datagrid",
155+
},
156+
Two: { table: "superstore", name: "One" },
157+
},
158+
master: {
159+
widgets: ["One"],
160+
},
161+
detail: {
162+
main: {
163+
currentIndex: 0,
164+
type: "tab-area",
165+
widgets: ["Two"],
166+
},
167+
},
168+
};
169+
170+
async function awaitConfigChange() {
171+
return await page.evaluate(async () => {
172+
let resolve;
173+
const timer = new Promise((x) => {
174+
resolve = x;
175+
});
176+
177+
workspace.addEventListener("workspace-layout-update", resolve);
178+
await timer;
179+
workspace.removeEventListener(
180+
"workspace-layout-update",
181+
resolve,
182+
);
183+
184+
return await workspace.save();
185+
});
186+
}
187+
188+
await page.evaluate(async (config) => {
189+
const workspace = document.getElementById("workspace");
190+
await workspace.restore(config);
191+
await workspace.flush();
192+
}, config);
193+
194+
// Apply a filter for "Category" via programmatic dispatch.
195+
// "Category" is not in the master's group_by/split_by/filter, so it
196+
// would not be cleared by the existing candidates mechanism.
197+
let cfgPromise = awaitConfigChange();
198+
await page.evaluate(async () => {
199+
const masterViewer = document.querySelector(
200+
".workspace-master-widget",
201+
);
202+
masterViewer.dispatchEvent(
203+
new CustomEvent("perspective-select", {
204+
bubbles: true,
205+
composed: true,
206+
detail: {
207+
selected: true,
208+
row: {},
209+
column_names: ["Category"],
210+
config: { filter: [["Category", "==", "Furniture"]] },
211+
},
212+
}),
213+
);
214+
});
215+
216+
let cfg = await cfgPromise;
217+
expect(cfg.viewers.Two.filter).toEqual([
218+
["Category", "==", "Furniture"],
219+
]);
220+
221+
// Send a tombstone [["Category", undefined]] to explicitly clear
222+
// the Category filter from slave viewers.
223+
cfgPromise = awaitConfigChange();
224+
await page.evaluate(async () => {
225+
const masterViewer = document.querySelector(
226+
".workspace-master-widget",
227+
);
228+
masterViewer.dispatchEvent(
229+
new CustomEvent("perspective-select", {
230+
bubbles: true,
231+
composed: true,
232+
detail: {
233+
selected: true,
234+
row: {},
235+
column_names: [],
236+
config: { filter: [["Category", undefined]] },
237+
},
238+
}),
239+
);
240+
});
241+
242+
cfg = await cfgPromise;
243+
expect(cfg.viewers.Two.filter).toEqual([]);
244+
});
245+
246+
test("Filter tombstone preserves other slave filters while clearing targeted column", async ({
247+
page,
248+
}) => {
249+
const config = {
250+
viewers: {
251+
One: {
252+
table: "superstore",
253+
name: "Test",
254+
group_by: ["State"],
255+
columns: ["Sales"],
256+
plugin: "Datagrid",
257+
},
258+
Two: { table: "superstore", name: "One" },
259+
},
260+
master: {
261+
widgets: ["One"],
262+
},
263+
detail: {
264+
main: {
265+
currentIndex: 0,
266+
type: "tab-area",
267+
widgets: ["Two"],
268+
},
269+
},
270+
};
271+
272+
async function awaitConfigChange() {
273+
return await page.evaluate(async () => {
274+
let resolve;
275+
const timer = new Promise((x) => {
276+
resolve = x;
277+
});
278+
279+
workspace.addEventListener("workspace-layout-update", resolve);
280+
await timer;
281+
workspace.removeEventListener(
282+
"workspace-layout-update",
283+
resolve,
284+
);
285+
286+
return await workspace.save();
287+
});
288+
}
289+
290+
await page.evaluate(async (config) => {
291+
const workspace = document.getElementById("workspace");
292+
await workspace.restore(config);
293+
await workspace.flush();
294+
}, config);
295+
296+
// Apply filters for both "Category" and "Segment" via programmatic
297+
// dispatch.
298+
let cfgPromise = awaitConfigChange();
299+
await page.evaluate(async () => {
300+
const masterViewer = document.querySelector(
301+
".workspace-master-widget",
302+
);
303+
masterViewer.dispatchEvent(
304+
new CustomEvent("perspective-select", {
305+
bubbles: true,
306+
composed: true,
307+
detail: {
308+
selected: true,
309+
row: {},
310+
column_names: ["Category", "Segment"],
311+
config: {
312+
filter: [
313+
["Category", "==", "Furniture"],
314+
["Segment", "==", "Consumer"],
315+
],
316+
},
317+
},
318+
}),
319+
);
320+
});
321+
322+
let cfg = await cfgPromise;
323+
expect(cfg.viewers.Two.filter).toEqual([
324+
["Category", "==", "Furniture"],
325+
["Segment", "==", "Consumer"],
326+
]);
327+
328+
// Send a tombstone only for "Category"; "Segment" filter should be
329+
// preserved.
330+
cfgPromise = awaitConfigChange();
331+
await page.evaluate(async () => {
332+
const masterViewer = document.querySelector(
333+
".workspace-master-widget",
334+
);
335+
masterViewer.dispatchEvent(
336+
new CustomEvent("perspective-select", {
337+
bubbles: true,
338+
composed: true,
339+
detail: {
340+
selected: true,
341+
row: {},
342+
column_names: [],
343+
config: { filter: [["Category", undefined]] },
344+
},
345+
}),
346+
);
347+
});
348+
349+
cfg = await cfgPromise;
350+
// Category is cleared, but Segment is still there because it was not
351+
// in candidates and was not tombstoned.
352+
expect(cfg.viewers.Two.filter).toEqual([["Segment", "==", "Consumer"]]);
353+
});
354+
144355
test("Child classes of datagrid behave the same way", async ({ page }) => {
145356
const config = {
146357
viewers: {

0 commit comments

Comments
 (0)