@multiplatform.one/table 7.6.2 → 7.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -13
- package/src/components/ChildTable/ChildTable.stories.tsx +4 -1
- package/src/components/DataTable/DataTableVirtualized.stories.tsx +69 -0
- package/src/components/DataTableFilterButton/DataTableFilterButton.spec.tsx +128 -0
- package/src/components/DataTableFilterButton/DataTableFilterButton.stories.tsx +99 -0
- package/src/components/DataTableFilterChips/DataTableFilterChips.spec.tsx +6 -81
- package/src/components/DataTableFilterChips/DataTableFilterChips.stories.tsx +105 -0
- package/src/components/FilterManager.stories.tsx +75 -0
- package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.spec.tsx +261 -0
- package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.stories.tsx +183 -0
- package/src/components/KnobDialog.spec.tsx +88 -0
- package/src/components/KnobDialog.stories.tsx +66 -0
- package/src/components/SavedFilterDropdown.stories.tsx +90 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/table",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.0",
|
|
4
4
|
"description": "Advanced table components with filtering, pagination, sorting, and URL state management",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cross-platform",
|
|
@@ -80,18 +80,17 @@
|
|
|
80
80
|
"@tanstack/react-table": "^8.21.3",
|
|
81
81
|
"@tanstack/table-core": "^8.21.3",
|
|
82
82
|
"tamagui": "2.7.6",
|
|
83
|
-
"@multiplatform.one/components": "7.
|
|
84
|
-
"@multiplatform.one/
|
|
85
|
-
"@multiplatform.one/
|
|
86
|
-
"@multiplatform.one/rich-text": "7.
|
|
87
|
-
"@multiplatform.one/router": "7.
|
|
88
|
-
"@multiplatform.one/table-primitives": "7.
|
|
89
|
-
"@multiplatform.one/theme": "7.
|
|
83
|
+
"@multiplatform.one/components": "7.7.0",
|
|
84
|
+
"@multiplatform.one/forms": "7.7.0",
|
|
85
|
+
"@multiplatform.one/platform": "7.7.0",
|
|
86
|
+
"@multiplatform.one/rich-text": "7.7.0",
|
|
87
|
+
"@multiplatform.one/router": "7.7.0",
|
|
88
|
+
"@multiplatform.one/table-primitives": "7.7.0",
|
|
89
|
+
"@multiplatform.one/theme": "7.7.0"
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
92
|
"@tamagui/build": "2.7.6",
|
|
93
93
|
"@testing-library/react": "^16.3.2",
|
|
94
|
-
"@types/react": "~19.2.14",
|
|
95
94
|
"@types/react-dom": "^19.2.3",
|
|
96
95
|
"@vitejs/plugin-react": "^6.0.1",
|
|
97
96
|
"@vitest/coverage-v8": "^4.1.5",
|
|
@@ -99,10 +98,10 @@
|
|
|
99
98
|
"react": "19.2.5",
|
|
100
99
|
"react-dom": "19.2.5",
|
|
101
100
|
"vitest": "^4.1.5",
|
|
102
|
-
"@multiplatform.one/config": "7.
|
|
103
|
-
"@multiplatform.one/router": "7.
|
|
104
|
-
"@multiplatform.one/storybook": "7.
|
|
105
|
-
"@multiplatform.one/test-utils": "7.
|
|
101
|
+
"@multiplatform.one/config": "7.7.0",
|
|
102
|
+
"@multiplatform.one/router": "7.7.0",
|
|
103
|
+
"@multiplatform.one/storybook": "7.7.0",
|
|
104
|
+
"@multiplatform.one/test-utils": "7.7.0"
|
|
106
105
|
},
|
|
107
106
|
"peerDependencies": {
|
|
108
107
|
"i18next": "^25.0.0",
|
|
@@ -30,7 +30,10 @@ const columns: DataTableColumn<LineItem>[] = [
|
|
|
30
30
|
{ accessorKey: "qty", header: "Qty", field: "stepper", fieldProps: { min: 0 } },
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Instantiation expression: `Meta<typeof ChildTable>` resolves ChildTable's
|
|
34
|
+
// TRow to its default (DataTableData), so `args.columns` typed as
|
|
35
|
+
// DataTableColumn<DataTableData>[] and rejected the LineItem columns below.
|
|
36
|
+
const meta: Meta<typeof ChildTable<LineItem>> = {
|
|
34
37
|
title: "Table/ChildTable",
|
|
35
38
|
component: ChildTable,
|
|
36
39
|
parameters: {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableVirtualized is an internal body renderer. The public surface is
|
|
3
|
+
* DataTable with enableVirtualization — mounting the helper bare crashed
|
|
4
|
+
* playground SSR on virtualItems.map. This catalog row is that mode, not
|
|
5
|
+
* the helper's prop dump.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
9
|
+
import { YStack } from "tamagui";
|
|
10
|
+
import type { DataTableColumn } from "../../types";
|
|
11
|
+
import { DataTable } from "./index";
|
|
12
|
+
|
|
13
|
+
const meta: Meta = {
|
|
14
|
+
title: "Table/DataTableVirtualized",
|
|
15
|
+
parameters: {
|
|
16
|
+
status: { type: "stable" },
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"Virtual-scroll body for large row sets. Enable it on DataTable; do not mount " +
|
|
21
|
+
"DataTableVirtualized as a standalone component.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
interface Employee {
|
|
30
|
+
id: number;
|
|
31
|
+
name: string;
|
|
32
|
+
email: string;
|
|
33
|
+
department: string;
|
|
34
|
+
salary: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const departments = ["Engineering", "Marketing", "Sales", "Design", "Support"];
|
|
38
|
+
|
|
39
|
+
const rows: Employee[] = Array.from({ length: 200 }, (_, i) => ({
|
|
40
|
+
id: i + 1,
|
|
41
|
+
name: `Employee ${i + 1}`,
|
|
42
|
+
email: `emp${i + 1}@example.com`,
|
|
43
|
+
department: departments[i % departments.length],
|
|
44
|
+
salary: 50000 + (i % 80) * 1000,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const columns: DataTableColumn<Employee>[] = [
|
|
48
|
+
{ accessorKey: "id", header: "ID" },
|
|
49
|
+
{ accessorKey: "name", header: "Name" },
|
|
50
|
+
{ accessorKey: "email", header: "Email" },
|
|
51
|
+
{ accessorKey: "department", header: "Department" },
|
|
52
|
+
{ accessorKey: "salary", header: "Salary" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export const Default = {
|
|
56
|
+
name: "Main",
|
|
57
|
+
render: () => (
|
|
58
|
+
<YStack padding="$4" height={360}>
|
|
59
|
+
<DataTable
|
|
60
|
+
data={rows}
|
|
61
|
+
columns={columns}
|
|
62
|
+
enableVirtualization
|
|
63
|
+
virtualizationHeight={320}
|
|
64
|
+
enableGlobalSearch
|
|
65
|
+
aria-label="Virtualized employees"
|
|
66
|
+
/>
|
|
67
|
+
</YStack>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableFilterButton spec (MPO-113). The button owns the toolbar Filter
|
|
3
|
+
* surface: trigger, searchable field list, live-commit into the shared
|
|
4
|
+
* operator/value editor. Chip rendering belongs to DataTableFilterChips.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
11
|
+
import { DataTable } from "../DataTable";
|
|
12
|
+
|
|
13
|
+
type Person = {
|
|
14
|
+
id: number;
|
|
15
|
+
firstName: string;
|
|
16
|
+
age: number;
|
|
17
|
+
email: string;
|
|
18
|
+
department: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const data: Person[] = [
|
|
22
|
+
{ id: 1, firstName: "Ada", age: 36, email: "ada@corp.com", department: "Engineering" },
|
|
23
|
+
{ id: 2, firstName: "Grace", age: 45, email: "grace@corp.com", department: "Engineering" },
|
|
24
|
+
{ id: 3, firstName: "Alan", age: 41, email: "alan@other.org", department: "Marketing" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const columns: DataTableColumn<Person>[] = [
|
|
28
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
29
|
+
{ accessorKey: "age", header: "Age" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const filterableFields: FilterableField[] = [
|
|
33
|
+
{ id: "email", label: "Email", type: "text" },
|
|
34
|
+
{
|
|
35
|
+
id: "department",
|
|
36
|
+
label: "Department",
|
|
37
|
+
type: "select",
|
|
38
|
+
options: [
|
|
39
|
+
{ label: "Engineering", value: "Engineering" },
|
|
40
|
+
{ label: "Marketing", value: "Marketing" },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function emailFilter(value: string): TableFilter {
|
|
46
|
+
return {
|
|
47
|
+
id: "flt-email",
|
|
48
|
+
columnId: "email",
|
|
49
|
+
value: { operator: "contains", value },
|
|
50
|
+
label: "Email",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderTable(initialFilters: TableFilter[] = []) {
|
|
55
|
+
return renderWithProviders(
|
|
56
|
+
<DataTable
|
|
57
|
+
data={data}
|
|
58
|
+
columns={columns as DataTableColumn<Record<string, any>>[]}
|
|
59
|
+
filterableFields={filterableFields}
|
|
60
|
+
enableFiltering
|
|
61
|
+
enableGlobalSearch
|
|
62
|
+
initialState={{ filters: initialFilters }}
|
|
63
|
+
aria-label="People table"
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("DataTableFilterButton", () => {
|
|
69
|
+
it("renders the toolbar Filter affordance when filterableFields exist", async () => {
|
|
70
|
+
renderTable([]);
|
|
71
|
+
expect(await screen.findByRole("button", { name: "Filter" })).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("shows the active filter count on the trigger", async () => {
|
|
75
|
+
renderTable([emailFilter("corp")]);
|
|
76
|
+
expect(await screen.findByText("Filter (1)")).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("field list is a single-tab-stop listbox (LC-49 / LC-71)", async () => {
|
|
80
|
+
renderTable([]);
|
|
81
|
+
fireEvent.click(await screen.findByRole("button", { name: "Filter" }));
|
|
82
|
+
const listbox = await screen.findByRole("listbox", { name: "Filterable fields" });
|
|
83
|
+
expect(listbox.getAttribute("tabindex")).toBe("0");
|
|
84
|
+
const options = screen.getAllByRole("option");
|
|
85
|
+
expect(options.length).toBeGreaterThan(0);
|
|
86
|
+
for (const option of options) {
|
|
87
|
+
expect(option.getAttribute("tabindex")).toBe("-1");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("opens a searchable field list with visible columns grouped before hidden fields", async () => {
|
|
92
|
+
renderTable([]);
|
|
93
|
+
|
|
94
|
+
fireEvent.click(await screen.findByRole("button", { name: "Filter" }));
|
|
95
|
+
|
|
96
|
+
expect(await screen.findByText("Columns")).toBeTruthy();
|
|
97
|
+
expect(screen.getByText("More fields")).toBeTruthy();
|
|
98
|
+
|
|
99
|
+
expect(screen.getByText("Email")).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("Department")).toBeTruthy();
|
|
101
|
+
|
|
102
|
+
const search = screen.getByPlaceholderText("Search fields...");
|
|
103
|
+
fireEvent.change(search, { target: { value: "dep" } });
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(screen.queryByText("Email")).toBeNull();
|
|
106
|
+
expect(screen.getByText("Department")).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("live-commits a hidden-field filter (no Apply button)", async () => {
|
|
111
|
+
renderTable([]);
|
|
112
|
+
|
|
113
|
+
fireEvent.click(await screen.findByRole("button", { name: "Filter" }));
|
|
114
|
+
fireEvent.click(await screen.findByText("Email"));
|
|
115
|
+
|
|
116
|
+
expect(await screen.findByText("Condition")).toBeTruthy();
|
|
117
|
+
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
118
|
+
|
|
119
|
+
const valueInput = await screen.findByPlaceholderText("Enter value...");
|
|
120
|
+
fireEvent.change(valueInput, { target: { value: "other" } });
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(screen.getByText("Email: contains other")).toBeTruthy();
|
|
124
|
+
expect(screen.getByText("Alan")).toBeTruthy();
|
|
125
|
+
expect(screen.queryByText("Ada")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableFilterButton — toolbar Filter affordance. It reads
|
|
3
|
+
* DataTableStateContext, so these stories mount it the way the toolbar does:
|
|
4
|
+
* inside DataTable with filterableFields. Stage 1 is the searchable field
|
|
5
|
+
* list (visible columns first, then More fields). Stage 2 is the shared
|
|
6
|
+
* operator/value editor (MPO-101). Commit is live (LC-21).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
10
|
+
import { YStack } from "tamagui";
|
|
11
|
+
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
12
|
+
import { DataTable } from "../DataTable";
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: "Table/DataTableFilterButton",
|
|
16
|
+
parameters: {
|
|
17
|
+
status: { type: "stable" },
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"Toolbar Filter control for any field, including hidden ones. Opens a " +
|
|
22
|
+
"FloatingPanel with a searchable field list, then the shared operator/value editor.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
|
|
30
|
+
interface Person {
|
|
31
|
+
id: number;
|
|
32
|
+
firstName: string;
|
|
33
|
+
age: number;
|
|
34
|
+
email: string;
|
|
35
|
+
department: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const people: Person[] = [
|
|
39
|
+
{ id: 1, firstName: "Ada", age: 36, email: "ada@corp.com", department: "Engineering" },
|
|
40
|
+
{ id: 2, firstName: "Grace", age: 45, email: "grace@corp.com", department: "Engineering" },
|
|
41
|
+
{ id: 3, firstName: "Alan", age: 41, email: "alan@other.org", department: "Marketing" },
|
|
42
|
+
{ id: 4, firstName: "Katherine", age: 29, email: "kate@corp.com", department: "Design" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const columns: DataTableColumn<Person>[] = [
|
|
46
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
47
|
+
{ accessorKey: "age", header: "Age" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const filterableFields: FilterableField[] = [
|
|
51
|
+
{ id: "firstName", label: "First Name", type: "text" },
|
|
52
|
+
{ id: "age", label: "Age", type: "number" },
|
|
53
|
+
{ id: "email", label: "Email", type: "text" },
|
|
54
|
+
{
|
|
55
|
+
id: "department",
|
|
56
|
+
label: "Department",
|
|
57
|
+
type: "select",
|
|
58
|
+
options: [
|
|
59
|
+
{ label: "Engineering", value: "Engineering" },
|
|
60
|
+
{ label: "Marketing", value: "Marketing" },
|
|
61
|
+
{ label: "Design", value: "Design" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function emailContains(value: string): TableFilter {
|
|
67
|
+
return {
|
|
68
|
+
id: "flt-email",
|
|
69
|
+
columnId: "email",
|
|
70
|
+
value: { operator: "contains", value },
|
|
71
|
+
label: "Email",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function FilterTable({ initialFilters = [] }: { initialFilters?: TableFilter[] }) {
|
|
76
|
+
return (
|
|
77
|
+
<YStack padding="$4" width="100%">
|
|
78
|
+
<DataTable
|
|
79
|
+
data={people}
|
|
80
|
+
columns={columns}
|
|
81
|
+
filterableFields={filterableFields}
|
|
82
|
+
enableFiltering
|
|
83
|
+
enableGlobalSearch
|
|
84
|
+
initialState={{ filters: initialFilters }}
|
|
85
|
+
aria-label="People with toolbar filter"
|
|
86
|
+
/>
|
|
87
|
+
</YStack>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const Default = {
|
|
92
|
+
name: "Main",
|
|
93
|
+
render: () => <FilterTable />,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Active filters turn the trigger accent and stamp the count (Filter (N)). */
|
|
97
|
+
export const ActiveCount = {
|
|
98
|
+
render: () => <FilterTable initialFilters={[emailContains("corp")]} />,
|
|
99
|
+
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hidden-field filtering surface: chips bar
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Hidden-field filtering surface: chips bar, integration-tested through
|
|
3
|
+
* the compound DataTable (context wiring, client-side pre-filter, live
|
|
4
|
+
* state). The toolbar Filter button lives in DataTableFilterButton.spec.tsx.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
-
import { screen, waitFor } from "@testing-library/react";
|
|
9
|
-
import userEvent from "@testing-library/user-event";
|
|
8
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
10
9
|
import { describe, expect, it } from "vitest";
|
|
11
10
|
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
12
11
|
import { DataTable } from "../DataTable";
|
|
@@ -91,14 +90,13 @@ describe("DataTableFilterChips", () => {
|
|
|
91
90
|
});
|
|
92
91
|
|
|
93
92
|
it("removing the chip restores the rows", async () => {
|
|
94
|
-
const user = userEvent.setup();
|
|
95
93
|
renderTable([emailFilter("corp")]);
|
|
96
94
|
|
|
97
95
|
expect(await screen.findByText("Email: contains corp")).toBeTruthy();
|
|
98
96
|
expect(screen.queryByText("Alan")).toBeNull();
|
|
99
97
|
|
|
100
98
|
const removeButtons = screen.getAllByRole("button", { name: "Remove" });
|
|
101
|
-
|
|
99
|
+
fireEvent.click(removeButtons[0]);
|
|
102
100
|
|
|
103
101
|
await waitFor(() => {
|
|
104
102
|
expect(screen.queryByText("Email: contains corp")).toBeNull();
|
|
@@ -107,7 +105,6 @@ describe("DataTableFilterChips", () => {
|
|
|
107
105
|
});
|
|
108
106
|
|
|
109
107
|
it("shows Clear all at 2+ chips and clears every filter at once", async () => {
|
|
110
|
-
const user = userEvent.setup();
|
|
111
108
|
renderTable([emailFilter("corp"), departmentFilter("Engineering")]);
|
|
112
109
|
|
|
113
110
|
expect(await screen.findByText("Email: contains corp")).toBeTruthy();
|
|
@@ -115,7 +112,7 @@ describe("DataTableFilterChips", () => {
|
|
|
115
112
|
expect(screen.getByText("Department: is Engineering")).toBeTruthy();
|
|
116
113
|
|
|
117
114
|
const clearAll = screen.getByRole("button", { name: "Clear all" });
|
|
118
|
-
|
|
115
|
+
fireEvent.click(clearAll);
|
|
119
116
|
|
|
120
117
|
await waitFor(() => {
|
|
121
118
|
expect(screen.queryByText("Email: contains corp")).toBeNull();
|
|
@@ -138,75 +135,3 @@ describe("DataTableFilterChips", () => {
|
|
|
138
135
|
expect(remove.className).toMatch(/mp-chip-dismiss/);
|
|
139
136
|
});
|
|
140
137
|
});
|
|
141
|
-
|
|
142
|
-
describe("DataTableFilterButton", () => {
|
|
143
|
-
it("renders the toolbar Filter affordance when filterableFields exist", async () => {
|
|
144
|
-
renderTable([]);
|
|
145
|
-
expect(await screen.findByRole("button", { name: "Filter" })).toBeTruthy();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("shows the active filter count on the trigger", async () => {
|
|
149
|
-
renderTable([emailFilter("corp")]);
|
|
150
|
-
expect(await screen.findByText("Filter (1)")).toBeTruthy();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("field list is a single-tab-stop listbox (LC-49 / LC-71)", async () => {
|
|
154
|
-
const user = userEvent.setup();
|
|
155
|
-
renderTable([]);
|
|
156
|
-
await user.click(await screen.findByRole("button", { name: "Filter" }));
|
|
157
|
-
const listbox = await screen.findByRole("listbox", { name: "Filterable fields" });
|
|
158
|
-
expect(listbox.getAttribute("tabindex")).toBe("0");
|
|
159
|
-
const options = screen.getAllByRole("option");
|
|
160
|
-
expect(options.length).toBeGreaterThan(0);
|
|
161
|
-
for (const option of options) {
|
|
162
|
-
expect(option.getAttribute("tabindex")).toBe("-1");
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("opens a searchable field list with visible columns grouped before hidden fields", async () => {
|
|
167
|
-
const user = userEvent.setup();
|
|
168
|
-
renderTable([]);
|
|
169
|
-
|
|
170
|
-
const trigger = await screen.findByRole("button", { name: "Filter" });
|
|
171
|
-
await user.click(trigger);
|
|
172
|
-
|
|
173
|
-
// Group headers
|
|
174
|
-
expect(await screen.findByText("Columns")).toBeTruthy();
|
|
175
|
-
expect(screen.getByText("More fields")).toBeTruthy();
|
|
176
|
-
|
|
177
|
-
// Hidden fields listed
|
|
178
|
-
expect(screen.getByText("Email")).toBeTruthy();
|
|
179
|
-
expect(screen.getByText("Department")).toBeTruthy();
|
|
180
|
-
|
|
181
|
-
// Search narrows the list
|
|
182
|
-
const search = screen.getByPlaceholderText("Search fields...");
|
|
183
|
-
await user.type(search, "dep");
|
|
184
|
-
await waitFor(() => {
|
|
185
|
-
expect(screen.queryByText("Email")).toBeNull();
|
|
186
|
-
expect(screen.getByText("Department")).toBeTruthy();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("live-commits a hidden-field filter (no Apply button)", async () => {
|
|
191
|
-
const user = userEvent.setup();
|
|
192
|
-
renderTable([]);
|
|
193
|
-
|
|
194
|
-
await user.click(await screen.findByRole("button", { name: "Filter" }));
|
|
195
|
-
await user.click(await screen.findByText("Email"));
|
|
196
|
-
|
|
197
|
-
// Operator/value editor opens with the shared editor (Condition select)
|
|
198
|
-
expect(await screen.findByText("Condition")).toBeTruthy();
|
|
199
|
-
// No Apply button in the live-commit surface (LC-21)
|
|
200
|
-
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
201
|
-
|
|
202
|
-
const valueInput = await screen.findByPlaceholderText("Enter value...");
|
|
203
|
-
await user.type(valueInput, "other");
|
|
204
|
-
|
|
205
|
-
// Filter applied live: chip + filtered rows without any Apply press
|
|
206
|
-
await waitFor(() => {
|
|
207
|
-
expect(screen.getByText("Email: contains other")).toBeTruthy();
|
|
208
|
-
expect(screen.getByText("Alan")).toBeTruthy();
|
|
209
|
-
expect(screen.queryByText("Ada")).toBeNull();
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableFilterChips — every active filter as a removable pill under the
|
|
3
|
+
* toolbar (Axiom 6 HONEST STATE), including filters on fields with no
|
|
4
|
+
* visible column. Chip press reopens the shared editor; X removes; Clear
|
|
5
|
+
* all appears at 2+ chips.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
9
|
+
import { YStack } from "tamagui";
|
|
10
|
+
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
11
|
+
import { DataTable } from "../DataTable";
|
|
12
|
+
|
|
13
|
+
const meta: Meta = {
|
|
14
|
+
title: "Table/DataTableFilterChips",
|
|
15
|
+
parameters: {
|
|
16
|
+
status: { type: "stable" },
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"Wrapping pills for every active filter. Gray fill, two focusables per chip " +
|
|
21
|
+
"(edit + dismiss), Clear all from the second chip on.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
interface Person {
|
|
30
|
+
id: number;
|
|
31
|
+
firstName: string;
|
|
32
|
+
age: number;
|
|
33
|
+
email: string;
|
|
34
|
+
department: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const people: Person[] = [
|
|
38
|
+
{ id: 1, firstName: "Ada", age: 36, email: "ada@corp.com", department: "Engineering" },
|
|
39
|
+
{ id: 2, firstName: "Grace", age: 45, email: "grace@corp.com", department: "Engineering" },
|
|
40
|
+
{ id: 3, firstName: "Alan", age: 41, email: "alan@other.org", department: "Marketing" },
|
|
41
|
+
{ id: 4, firstName: "Katherine", age: 29, email: "kate@corp.com", department: "Design" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const columns: DataTableColumn<Person>[] = [
|
|
45
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
46
|
+
{ accessorKey: "age", header: "Age" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const filterableFields: FilterableField[] = [
|
|
50
|
+
{ id: "email", label: "Email", type: "text" },
|
|
51
|
+
{
|
|
52
|
+
id: "department",
|
|
53
|
+
label: "Department",
|
|
54
|
+
type: "select",
|
|
55
|
+
options: [
|
|
56
|
+
{ label: "Engineering", value: "Engineering" },
|
|
57
|
+
{ label: "Marketing", value: "Marketing" },
|
|
58
|
+
{ label: "Design", value: "Design" },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function emailContains(value: string): TableFilter {
|
|
64
|
+
return {
|
|
65
|
+
id: "flt-email",
|
|
66
|
+
columnId: "email",
|
|
67
|
+
value: { operator: "contains", value },
|
|
68
|
+
label: "Email",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function departmentIs(value: string): TableFilter {
|
|
73
|
+
return {
|
|
74
|
+
id: "flt-department",
|
|
75
|
+
columnId: "department",
|
|
76
|
+
value: { operator: "is", value },
|
|
77
|
+
label: "Department",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ChipTable({ initialFilters }: { initialFilters: TableFilter[] }) {
|
|
82
|
+
return (
|
|
83
|
+
<YStack padding="$4" width="100%">
|
|
84
|
+
<DataTable
|
|
85
|
+
data={people}
|
|
86
|
+
columns={columns}
|
|
87
|
+
filterableFields={filterableFields}
|
|
88
|
+
enableFiltering
|
|
89
|
+
enableGlobalSearch
|
|
90
|
+
initialState={{ filters: initialFilters }}
|
|
91
|
+
aria-label="People with filter chips"
|
|
92
|
+
/>
|
|
93
|
+
</YStack>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const Default = {
|
|
98
|
+
name: "Main",
|
|
99
|
+
render: () => <ChipTable initialFilters={[emailContains("corp")]} />,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Clear all lands once two chips are active. */
|
|
103
|
+
export const ClearAll = {
|
|
104
|
+
render: () => <ChipTable initialFilters={[emailContains("corp"), departmentIs("Engineering")]} />,
|
|
105
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterManager — save-current-filters dialog. Frappe-free: the caller
|
|
3
|
+
* supplies the filter tuples and the save callback. Playground already
|
|
4
|
+
* mounts this; the Kitchen Sink is story-driven, so this file is the
|
|
5
|
+
* on-device page.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { action } from "@multiplatform.one/storybook";
|
|
9
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
10
|
+
import { YStack } from "tamagui";
|
|
11
|
+
import { FilterManager } from "./FilterManager";
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof FilterManager> = {
|
|
14
|
+
title: "Table/FilterManager",
|
|
15
|
+
component: FilterManager,
|
|
16
|
+
parameters: {
|
|
17
|
+
status: { type: "stable" },
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"Save-filter dialog: name, optional global checkbox, live count of the " +
|
|
22
|
+
"current filter tuples. Disabled with a reason when nothing is applied.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
|
|
30
|
+
const currentFilters: Array<[string, string, unknown]> = [
|
|
31
|
+
["department", "=", "Engineering"],
|
|
32
|
+
["salary", ">", 100000],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const Default = {
|
|
36
|
+
name: "Main",
|
|
37
|
+
render: () => (
|
|
38
|
+
<YStack padding="$4" alignItems="flex-start">
|
|
39
|
+
<FilterManager
|
|
40
|
+
currentFilters={currentFilters}
|
|
41
|
+
onSaveFilter={(name, isGlobal) => {
|
|
42
|
+
action("onSaveFilter")(name, isGlobal);
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</YStack>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** No filters applied: the save trigger stays focusable and explains why. */
|
|
50
|
+
export const DisabledEmpty = {
|
|
51
|
+
render: () => (
|
|
52
|
+
<YStack padding="$4" alignItems="flex-start">
|
|
53
|
+
<FilterManager
|
|
54
|
+
currentFilters={[]}
|
|
55
|
+
onSaveFilter={(name, isGlobal) => {
|
|
56
|
+
action("onSaveFilter")(name, isGlobal);
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
</YStack>
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const CanCreateGlobal = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<YStack padding="$4" alignItems="flex-start">
|
|
66
|
+
<FilterManager
|
|
67
|
+
currentFilters={currentFilters}
|
|
68
|
+
canCreateGlobal
|
|
69
|
+
onSaveFilter={(name, isGlobal) => {
|
|
70
|
+
action("onSaveFilter")(name, isGlobal);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
</YStack>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterOperatorValueEditor spec (MPO-101). The editor owns two things and
|
|
3
|
+
* nothing else: the operator vocabulary for a field type, and the value
|
|
4
|
+
* editor that type gets. Commit semantics belong to the surfaces
|
|
5
|
+
* (ColumnHeaderFilter / DataTableFilterButton) and are specced there.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
9
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
10
|
+
import React, { useState } from "react";
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import type { DataTableColumnType, FilterOperator } from "../../types";
|
|
13
|
+
import { filterOperatorLabel, operatorsForFilterType } from "../../utils/filterFields";
|
|
14
|
+
import { FilterOperatorValueEditor, type FilterOperatorValueEditorProps } from "./index";
|
|
15
|
+
|
|
16
|
+
const statusOptions = [
|
|
17
|
+
{ label: "Open", value: "open" },
|
|
18
|
+
{ label: "In progress", value: "in-progress" },
|
|
19
|
+
{ label: "Done", value: "done" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const fieldTypes: DataTableColumnType[] = ["text", "number", "date", "boolean", "select"];
|
|
23
|
+
|
|
24
|
+
/** Every forms Select trigger in the tree, in render order (Condition first). */
|
|
25
|
+
const selectTriggers = (container: HTMLElement) =>
|
|
26
|
+
Array.from(container.querySelectorAll('[data-testid="select-trigger"]')) as HTMLElement[];
|
|
27
|
+
|
|
28
|
+
const textInputs = (container: HTMLElement) =>
|
|
29
|
+
Array.from(container.querySelectorAll("input")) as HTMLInputElement[];
|
|
30
|
+
|
|
31
|
+
async function openSelect(container: HTMLElement, trigger: HTMLElement) {
|
|
32
|
+
fireEvent.pointerDown(trigger);
|
|
33
|
+
fireEvent.mouseDown(trigger);
|
|
34
|
+
fireEvent.click(trigger);
|
|
35
|
+
await waitFor(() => {
|
|
36
|
+
expect(container.querySelectorAll('[role="option"]').length).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
return Array.from(container.querySelectorAll('[role="option"]')) as HTMLElement[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Choose an open Select option the way the keyboard does. Tamagui's SelectItem
|
|
43
|
+
* arms its click handler on a timer after the list opens (allowSelectRef), so a
|
|
44
|
+
* synthetic click right after opening is dropped; Enter selects unconditionally.
|
|
45
|
+
*/
|
|
46
|
+
function pickOption(option: HTMLElement) {
|
|
47
|
+
fireEvent.keyDown(option, { key: "Enter" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const optionLabels = (options: HTMLElement[]) =>
|
|
51
|
+
options.map((option) => option.textContent?.trim() ?? "");
|
|
52
|
+
|
|
53
|
+
function renderEditor(
|
|
54
|
+
props: Partial<FilterOperatorValueEditorProps> & { fieldType: DataTableColumnType },
|
|
55
|
+
) {
|
|
56
|
+
const operator = props.operator ?? operatorsForFilterType(props.fieldType)[0];
|
|
57
|
+
return renderWithProviders(
|
|
58
|
+
<FilterOperatorValueEditor
|
|
59
|
+
value=""
|
|
60
|
+
onOperatorChange={vi.fn()}
|
|
61
|
+
onValueChange={vi.fn()}
|
|
62
|
+
{...props}
|
|
63
|
+
operator={operator}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Controlled harness: round-trips operator + value like the surfaces do. */
|
|
69
|
+
function ControlledEditor({
|
|
70
|
+
fieldType,
|
|
71
|
+
onOperatorSpy,
|
|
72
|
+
onValueSpy,
|
|
73
|
+
options,
|
|
74
|
+
}: {
|
|
75
|
+
fieldType: DataTableColumnType;
|
|
76
|
+
onOperatorSpy: (operator: FilterOperator) => void;
|
|
77
|
+
onValueSpy: (value: string) => void;
|
|
78
|
+
options?: FilterOperatorValueEditorProps["options"];
|
|
79
|
+
}) {
|
|
80
|
+
const [operator, setOperator] = useState<FilterOperator>(operatorsForFilterType(fieldType)[0]);
|
|
81
|
+
const [value, setValue] = useState("");
|
|
82
|
+
return (
|
|
83
|
+
<FilterOperatorValueEditor
|
|
84
|
+
fieldType={fieldType}
|
|
85
|
+
operator={operator}
|
|
86
|
+
value={value}
|
|
87
|
+
options={options}
|
|
88
|
+
onOperatorChange={(next) => {
|
|
89
|
+
setOperator(next);
|
|
90
|
+
onOperatorSpy(next);
|
|
91
|
+
}}
|
|
92
|
+
onValueChange={(next) => {
|
|
93
|
+
setValue(next);
|
|
94
|
+
onValueSpy(next);
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("FilterOperatorValueEditor", () => {
|
|
101
|
+
describe("operator vocabulary", () => {
|
|
102
|
+
it.each(fieldTypes)(
|
|
103
|
+
"%s: the Condition select offers exactly operatorsForFilterType, labelled via filterOperatorLabel",
|
|
104
|
+
async (fieldType) => {
|
|
105
|
+
const { container } = renderEditor({ fieldType, options: statusOptions });
|
|
106
|
+
expect(screen.getByText("Condition")).toBeInTheDocument();
|
|
107
|
+
|
|
108
|
+
const [condition] = selectTriggers(container);
|
|
109
|
+
const options = await openSelect(container, condition);
|
|
110
|
+
expect(optionLabels(options)).toEqual(
|
|
111
|
+
operatorsForFilterType(fieldType).map((op) => filterOperatorLabel(op)),
|
|
112
|
+
);
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
it("picking a condition reports the operator, not its label", async () => {
|
|
117
|
+
const onOperator = vi.fn();
|
|
118
|
+
const { container } = renderWithProviders(
|
|
119
|
+
<ControlledEditor fieldType="text" onOperatorSpy={onOperator} onValueSpy={vi.fn()} />,
|
|
120
|
+
);
|
|
121
|
+
const [condition] = selectTriggers(container);
|
|
122
|
+
const options = await openSelect(container, condition);
|
|
123
|
+
const startsWith = options.find((option) => option.textContent?.trim() === "Starts with");
|
|
124
|
+
expect(startsWith).toBeDefined();
|
|
125
|
+
pickOption(startsWith!);
|
|
126
|
+
await waitFor(() => expect(onOperator).toHaveBeenLastCalledWith("startsWith"));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("value editor per field type", () => {
|
|
131
|
+
it("text: a plain Input with the generic placeholder", () => {
|
|
132
|
+
const { container } = renderEditor({ fieldType: "text" });
|
|
133
|
+
expect(screen.getByText("Value")).toBeInTheDocument();
|
|
134
|
+
const input = screen.getByPlaceholderText("Enter value...") as HTMLInputElement;
|
|
135
|
+
expect(input.getAttribute("inputmode")).not.toBe("numeric");
|
|
136
|
+
expect(selectTriggers(container)).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("number: the Input asks for a numeric keyboard", () => {
|
|
140
|
+
renderEditor({ fieldType: "number" });
|
|
141
|
+
const input = screen.getByPlaceholderText("Enter value...") as HTMLInputElement;
|
|
142
|
+
expect(input.getAttribute("inputmode")).toBe("numeric");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("date: the Input hints the ISO day format", () => {
|
|
146
|
+
renderEditor({ fieldType: "date" });
|
|
147
|
+
expect(screen.getByPlaceholderText("YYYY-MM-DD")).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("boolean: a Value select with True / False and no text input", async () => {
|
|
151
|
+
const { container } = renderEditor({ fieldType: "boolean" });
|
|
152
|
+
const triggers = selectTriggers(container);
|
|
153
|
+
expect(triggers).toHaveLength(2);
|
|
154
|
+
expect(textInputs(container)).toHaveLength(0);
|
|
155
|
+
expect(screen.getByText("Select...")).toBeInTheDocument();
|
|
156
|
+
|
|
157
|
+
const options = await openSelect(container, triggers[1]);
|
|
158
|
+
expect(optionLabels(options)).toEqual(["True", "False"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("select: a Value select over the column's options", async () => {
|
|
162
|
+
const { container } = renderEditor({ fieldType: "select", options: statusOptions });
|
|
163
|
+
const triggers = selectTriggers(container);
|
|
164
|
+
expect(triggers).toHaveLength(2);
|
|
165
|
+
expect(textInputs(container)).toHaveLength(0);
|
|
166
|
+
|
|
167
|
+
const options = await openSelect(container, triggers[1]);
|
|
168
|
+
expect(optionLabels(options)).toEqual(statusOptions.map((option) => option.label));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("select without options falls back to the text Input", () => {
|
|
172
|
+
const { container } = renderEditor({ fieldType: "select" });
|
|
173
|
+
expect(selectTriggers(container)).toHaveLength(1);
|
|
174
|
+
expect(screen.getByPlaceholderText("Enter value...")).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("shows the current value", () => {
|
|
178
|
+
renderEditor({ fieldType: "text", value: "ada" });
|
|
179
|
+
expect((screen.getByPlaceholderText("Enter value...") as HTMLInputElement).value).toBe("ada");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("value-less operators", () => {
|
|
184
|
+
it.each<FilterOperator>(["isEmpty", "isNotEmpty"])(
|
|
185
|
+
"%s renders the Condition select only",
|
|
186
|
+
(operator) => {
|
|
187
|
+
const { container } = renderEditor({ fieldType: "text", operator });
|
|
188
|
+
expect(screen.getByText("Condition")).toBeInTheDocument();
|
|
189
|
+
expect(screen.queryByText("Value")).toBeNull();
|
|
190
|
+
expect(selectTriggers(container)).toHaveLength(1);
|
|
191
|
+
expect(textInputs(container)).toHaveLength(0);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
it("switching from a value-less operator back to one with a value restores the input", async () => {
|
|
196
|
+
const onOperator = vi.fn();
|
|
197
|
+
const { container, rerender } = renderWithProviders(
|
|
198
|
+
<FilterOperatorValueEditor
|
|
199
|
+
fieldType="text"
|
|
200
|
+
operator="isEmpty"
|
|
201
|
+
value=""
|
|
202
|
+
onOperatorChange={onOperator}
|
|
203
|
+
onValueChange={vi.fn()}
|
|
204
|
+
/>,
|
|
205
|
+
);
|
|
206
|
+
expect(textInputs(container)).toHaveLength(0);
|
|
207
|
+
rerender(
|
|
208
|
+
<FilterOperatorValueEditor
|
|
209
|
+
fieldType="text"
|
|
210
|
+
operator="contains"
|
|
211
|
+
value=""
|
|
212
|
+
onOperatorChange={onOperator}
|
|
213
|
+
onValueChange={vi.fn()}
|
|
214
|
+
/>,
|
|
215
|
+
);
|
|
216
|
+
await waitFor(() => expect(textInputs(container)).toHaveLength(1));
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("callbacks", () => {
|
|
221
|
+
it("typing reports the raw text through onValueChange", async () => {
|
|
222
|
+
const onValue = vi.fn();
|
|
223
|
+
renderWithProviders(
|
|
224
|
+
<ControlledEditor fieldType="text" onOperatorSpy={vi.fn()} onValueSpy={onValue} />,
|
|
225
|
+
);
|
|
226
|
+
const input = screen.getByPlaceholderText("Enter value...") as HTMLInputElement;
|
|
227
|
+
fireEvent.change(input, { target: { value: "ada" } });
|
|
228
|
+
await waitFor(() => expect(onValue).toHaveBeenLastCalledWith("ada"));
|
|
229
|
+
expect(input.value).toBe("ada");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("picking a boolean value reports the stringified flag", async () => {
|
|
233
|
+
const onValue = vi.fn();
|
|
234
|
+
const { container } = renderWithProviders(
|
|
235
|
+
<ControlledEditor fieldType="boolean" onOperatorSpy={vi.fn()} onValueSpy={onValue} />,
|
|
236
|
+
);
|
|
237
|
+
const options = await openSelect(container, selectTriggers(container)[1]);
|
|
238
|
+
const no = options.find((option) => option.textContent?.trim() === "False");
|
|
239
|
+
expect(no).toBeDefined();
|
|
240
|
+
pickOption(no!);
|
|
241
|
+
await waitFor(() => expect(onValue).toHaveBeenLastCalledWith("false"));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("picking a select value reports the option value, not its label", async () => {
|
|
245
|
+
const onValue = vi.fn();
|
|
246
|
+
const { container } = renderWithProviders(
|
|
247
|
+
<ControlledEditor
|
|
248
|
+
fieldType="select"
|
|
249
|
+
options={statusOptions}
|
|
250
|
+
onOperatorSpy={vi.fn()}
|
|
251
|
+
onValueSpy={onValue}
|
|
252
|
+
/>,
|
|
253
|
+
);
|
|
254
|
+
const options = await openSelect(container, selectTriggers(container)[1]);
|
|
255
|
+
const inProgress = options.find((option) => option.textContent?.trim() === "In progress");
|
|
256
|
+
expect(inProgress).toBeDefined();
|
|
257
|
+
pickOption(inProgress!);
|
|
258
|
+
await waitFor(() => expect(onValue).toHaveBeenLastCalledWith("in-progress"));
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterOperatorValueEditor — the ONE fieldtype-aware operator/value editor
|
|
3
|
+
* (LC-16). Both filter surfaces render it: the ColumnHeaderFilter funnel and
|
|
4
|
+
* the DataTableFilterButton toolbar panel. The surface owns commit semantics,
|
|
5
|
+
* so these stories exercise only what the editor owns: the operator
|
|
6
|
+
* vocabulary per field type, and the value editor each type gets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { action } from "@multiplatform.one/storybook";
|
|
10
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
11
|
+
import React, { useState } from "react";
|
|
12
|
+
import { Card, Text, XStack, YStack } from "tamagui";
|
|
13
|
+
import type { DataTableColumnType, FilterOperator } from "../../types";
|
|
14
|
+
import {
|
|
15
|
+
defaultOperatorForFilterType,
|
|
16
|
+
filterOperatorLabel,
|
|
17
|
+
operatorNeedsNoValue,
|
|
18
|
+
} from "../../utils/filterFields";
|
|
19
|
+
import { FilterOperatorValueEditor } from "./index";
|
|
20
|
+
|
|
21
|
+
const meta: Meta<typeof FilterOperatorValueEditor> = {
|
|
22
|
+
title: "Table/FilterOperatorValueEditor",
|
|
23
|
+
component: FilterOperatorValueEditor,
|
|
24
|
+
parameters: {
|
|
25
|
+
status: { type: "stable" },
|
|
26
|
+
docs: {
|
|
27
|
+
description: {
|
|
28
|
+
component:
|
|
29
|
+
"The shared operator + value editor behind every DataTable filter surface. " +
|
|
30
|
+
"The operator list and the value editor both follow the field type.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
|
|
38
|
+
const statusOptions = [
|
|
39
|
+
{ label: "Open", value: "open" },
|
|
40
|
+
{ label: "In progress", value: "in-progress" },
|
|
41
|
+
{ label: "Done", value: "done" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const fieldTypeLabels: Record<DataTableColumnType, string> = {
|
|
45
|
+
text: "Text",
|
|
46
|
+
number: "Number",
|
|
47
|
+
date: "Date",
|
|
48
|
+
boolean: "Boolean",
|
|
49
|
+
select: "Select",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
interface HarnessProps {
|
|
53
|
+
fieldType: DataTableColumnType;
|
|
54
|
+
options?: Array<{ label: string; value: any }>;
|
|
55
|
+
initialOperator?: FilterOperator;
|
|
56
|
+
initialValue?: string;
|
|
57
|
+
/** Show the field-type heading (the all-types board needs it). */
|
|
58
|
+
heading?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Drives the editor the way both surfaces do: operator and value live in the
|
|
63
|
+
* caller's state, and the editor only reports changes. The card below is the
|
|
64
|
+
* filter the surface would commit.
|
|
65
|
+
*/
|
|
66
|
+
function EditorHarness({
|
|
67
|
+
fieldType,
|
|
68
|
+
options,
|
|
69
|
+
initialOperator,
|
|
70
|
+
initialValue = "",
|
|
71
|
+
heading = false,
|
|
72
|
+
}: HarnessProps) {
|
|
73
|
+
const [operator, setOperator] = useState<FilterOperator>(
|
|
74
|
+
initialOperator ?? defaultOperatorForFilterType(fieldType),
|
|
75
|
+
);
|
|
76
|
+
const [value, setValue] = useState(initialValue);
|
|
77
|
+
|
|
78
|
+
const handleOperatorChange = (next: FilterOperator) => {
|
|
79
|
+
setOperator(next);
|
|
80
|
+
action("onOperatorChange")(next);
|
|
81
|
+
};
|
|
82
|
+
const handleValueChange = (next: string) => {
|
|
83
|
+
setValue(next);
|
|
84
|
+
action("onValueChange")(next);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const shownValue =
|
|
88
|
+
fieldType === "select" && options
|
|
89
|
+
? (options.find((opt) => String(opt.value) === value)?.label ?? value)
|
|
90
|
+
: value;
|
|
91
|
+
const summary = operatorNeedsNoValue(operator)
|
|
92
|
+
? filterOperatorLabel(operator)
|
|
93
|
+
: `${filterOperatorLabel(operator)} ${shownValue || "(no value)"}`;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<YStack gap="$3" width={280}>
|
|
97
|
+
{heading && (
|
|
98
|
+
<Text fontSize="$3" fontWeight="600">
|
|
99
|
+
{fieldTypeLabels[fieldType]}
|
|
100
|
+
</Text>
|
|
101
|
+
)}
|
|
102
|
+
{/* Same vertical rhythm the funnel popover gives the editor. */}
|
|
103
|
+
<YStack gap="$2">
|
|
104
|
+
<FilterOperatorValueEditor
|
|
105
|
+
fieldType={fieldType}
|
|
106
|
+
operator={operator}
|
|
107
|
+
value={value}
|
|
108
|
+
onOperatorChange={handleOperatorChange}
|
|
109
|
+
onValueChange={handleValueChange}
|
|
110
|
+
options={options}
|
|
111
|
+
/>
|
|
112
|
+
</YStack>
|
|
113
|
+
<Card padding="$3" borderRadius="$4">
|
|
114
|
+
<YStack gap="$1">
|
|
115
|
+
<Text fontSize="$2" color="$color10">
|
|
116
|
+
Filter the surface would commit
|
|
117
|
+
</Text>
|
|
118
|
+
<Text fontSize="$3">{summary}</Text>
|
|
119
|
+
</YStack>
|
|
120
|
+
</Card>
|
|
121
|
+
</YStack>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const Default = () => (
|
|
126
|
+
<YStack padding="$4">
|
|
127
|
+
<EditorHarness fieldType="text" />
|
|
128
|
+
</YStack>
|
|
129
|
+
);
|
|
130
|
+
Default.storyName = "Main";
|
|
131
|
+
|
|
132
|
+
/** Text fields: contains / starts with / ends with plus the empty checks. */
|
|
133
|
+
export const TextField = () => (
|
|
134
|
+
<YStack padding="$4">
|
|
135
|
+
<EditorHarness fieldType="text" initialValue="ada" />
|
|
136
|
+
</YStack>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/** Number fields: comparison operators and a numeric keyboard on the value. */
|
|
140
|
+
export const NumberField = () => (
|
|
141
|
+
<YStack padding="$4">
|
|
142
|
+
<EditorHarness fieldType="number" initialOperator="isGreaterThan" initialValue="42" />
|
|
143
|
+
</YStack>
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/** Date fields: before / after / on-or-before / on-or-after, typed as YYYY-MM-DD. */
|
|
147
|
+
export const DateField = () => (
|
|
148
|
+
<YStack padding="$4">
|
|
149
|
+
<EditorHarness fieldType="date" initialOperator="isAfter" initialValue="2026-01-01" />
|
|
150
|
+
</YStack>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
/** Boolean fields: the value is a True / False select, not free text. */
|
|
154
|
+
export const BooleanField = () => (
|
|
155
|
+
<YStack padding="$4">
|
|
156
|
+
<EditorHarness fieldType="boolean" initialValue="true" />
|
|
157
|
+
</YStack>
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
/** Select fields: the value picks from the field's own options. */
|
|
161
|
+
export const SelectField = () => (
|
|
162
|
+
<YStack padding="$4">
|
|
163
|
+
<EditorHarness fieldType="select" options={statusOptions} initialValue="open" />
|
|
164
|
+
</YStack>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
/** isEmpty / isNotEmpty carry no value, so the value editor is not rendered. */
|
|
168
|
+
export const NoValueOperator = () => (
|
|
169
|
+
<YStack padding="$4">
|
|
170
|
+
<EditorHarness fieldType="text" initialOperator="isEmpty" />
|
|
171
|
+
</YStack>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
/** Every field type on one board, for theme-state verification. */
|
|
175
|
+
export const AllFieldTypes = () => (
|
|
176
|
+
<XStack padding="$4" gap="$6" flexWrap="wrap">
|
|
177
|
+
<EditorHarness fieldType="text" heading />
|
|
178
|
+
<EditorHarness fieldType="number" heading />
|
|
179
|
+
<EditorHarness fieldType="date" heading />
|
|
180
|
+
<EditorHarness fieldType="boolean" heading />
|
|
181
|
+
<EditorHarness fieldType="select" options={statusOptions} heading />
|
|
182
|
+
</XStack>
|
|
183
|
+
);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KnobDialog spec (MPO-114). The wrapper owns open/close plumbing and the
|
|
3
|
+
* knob-wired content surface. FilterManager specs cover the save-dialog copy.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
7
|
+
import { fireEvent } from "@testing-library/react";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { Button } from "@multiplatform.one/forms";
|
|
11
|
+
import {
|
|
12
|
+
KnobDialog,
|
|
13
|
+
KnobDialogClose,
|
|
14
|
+
KnobDialogContent,
|
|
15
|
+
KnobDialogDescription,
|
|
16
|
+
KnobDialogTitle,
|
|
17
|
+
KnobDialogTrigger,
|
|
18
|
+
} from "./KnobDialog";
|
|
19
|
+
|
|
20
|
+
/** Dialog portals into document.body — query there, not the RTL container. */
|
|
21
|
+
function root(): HTMLElement {
|
|
22
|
+
return document.body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function DialogHarness({
|
|
26
|
+
startOpen = true,
|
|
27
|
+
onOpenChange,
|
|
28
|
+
}: {
|
|
29
|
+
startOpen?: boolean;
|
|
30
|
+
onOpenChange?: (open: boolean) => void;
|
|
31
|
+
}) {
|
|
32
|
+
const [open, setOpen] = useState(startOpen);
|
|
33
|
+
return (
|
|
34
|
+
<KnobDialog
|
|
35
|
+
open={open}
|
|
36
|
+
onOpenChange={(next) => {
|
|
37
|
+
setOpen(next);
|
|
38
|
+
onOpenChange?.(next);
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<KnobDialogTrigger asChild>
|
|
42
|
+
<Button>Open dialog</Button>
|
|
43
|
+
</KnobDialogTrigger>
|
|
44
|
+
<KnobDialogContent minWidth={400}>
|
|
45
|
+
<KnobDialogTitle>Save view</KnobDialogTitle>
|
|
46
|
+
<KnobDialogDescription>Name this filter set for later.</KnobDialogDescription>
|
|
47
|
+
<KnobDialogClose asChild>
|
|
48
|
+
<Button>Close</Button>
|
|
49
|
+
</KnobDialogClose>
|
|
50
|
+
</KnobDialogContent>
|
|
51
|
+
</KnobDialog>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("KnobDialog", () => {
|
|
56
|
+
it("open state portals title and description into the document", () => {
|
|
57
|
+
renderWithProviders(<DialogHarness startOpen />);
|
|
58
|
+
expect(root().textContent).toContain("Save view");
|
|
59
|
+
expect(root().textContent).toContain("Name this filter set for later.");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("closed state does not render the dialog body", () => {
|
|
63
|
+
renderWithProviders(<DialogHarness startOpen={false} />);
|
|
64
|
+
expect(root().textContent).not.toContain("Save view");
|
|
65
|
+
expect(root().textContent).toContain("Open dialog");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("paints an overlay while open", () => {
|
|
69
|
+
renderWithProviders(<DialogHarness startOpen />);
|
|
70
|
+
const overlay =
|
|
71
|
+
root().querySelector("[data-state]") ?? root().querySelector("[class*='Overlay']");
|
|
72
|
+
// Tamagui Dialog.Overlay is in the portal tree whenever the dialog is open.
|
|
73
|
+
const portalBits = Array.from(root().querySelectorAll("[data-state='open']"));
|
|
74
|
+
expect(portalBits.length).toBeGreaterThan(0);
|
|
75
|
+
expect(overlay || portalBits[0]).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("Close reports onOpenChange(false)", () => {
|
|
79
|
+
const onOpenChange = vi.fn();
|
|
80
|
+
renderWithProviders(<DialogHarness startOpen onOpenChange={onOpenChange} />);
|
|
81
|
+
const close = Array.from(root().querySelectorAll('[role="button"]')).find((el) =>
|
|
82
|
+
el.textContent?.includes("Close"),
|
|
83
|
+
);
|
|
84
|
+
expect(close).toBeTruthy();
|
|
85
|
+
fireEvent.click(close!);
|
|
86
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KnobDialog — Tamagui Dialog with table knobs on the content surface
|
|
3
|
+
* (borderRadius, elevation, gap). FilterManager is the production consumer.
|
|
4
|
+
* These boards show the wrapper itself, open and closed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { Button } from "@multiplatform.one/forms";
|
|
10
|
+
import { Paragraph, XStack, YStack } from "tamagui";
|
|
11
|
+
import {
|
|
12
|
+
KnobDialog,
|
|
13
|
+
KnobDialogClose,
|
|
14
|
+
KnobDialogContent,
|
|
15
|
+
KnobDialogDescription,
|
|
16
|
+
KnobDialogTitle,
|
|
17
|
+
KnobDialogTrigger,
|
|
18
|
+
} from "./KnobDialog";
|
|
19
|
+
|
|
20
|
+
const meta: Meta = {
|
|
21
|
+
title: "Table/KnobDialog",
|
|
22
|
+
parameters: {
|
|
23
|
+
status: { type: "stable" },
|
|
24
|
+
docs: {
|
|
25
|
+
description: {
|
|
26
|
+
component:
|
|
27
|
+
"Internal table Dialog wrapper. Content inherits container radius, elevation " +
|
|
28
|
+
"and gap from useResolvedKnobs so FilterManager chrome matches the rest of the table.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default meta;
|
|
35
|
+
|
|
36
|
+
function DialogHarness({ startOpen = true }: { startOpen?: boolean }) {
|
|
37
|
+
const [open, setOpen] = useState(startOpen);
|
|
38
|
+
return (
|
|
39
|
+
<YStack minHeight={400} padding="$4" alignItems="flex-start">
|
|
40
|
+
<KnobDialog open={open} onOpenChange={setOpen}>
|
|
41
|
+
<KnobDialogTrigger asChild>
|
|
42
|
+
<Button>Open dialog</Button>
|
|
43
|
+
</KnobDialogTrigger>
|
|
44
|
+
<KnobDialogContent minWidth={400}>
|
|
45
|
+
<KnobDialogTitle>Save view</KnobDialogTitle>
|
|
46
|
+
<KnobDialogDescription>Name this filter set for later.</KnobDialogDescription>
|
|
47
|
+
<Paragraph>Short dialog body.</Paragraph>
|
|
48
|
+
<XStack justifyContent="flex-end">
|
|
49
|
+
<KnobDialogClose asChild>
|
|
50
|
+
<Button>Close</Button>
|
|
51
|
+
</KnobDialogClose>
|
|
52
|
+
</XStack>
|
|
53
|
+
</KnobDialogContent>
|
|
54
|
+
</KnobDialog>
|
|
55
|
+
</YStack>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const Default = {
|
|
60
|
+
name: "Main",
|
|
61
|
+
render: () => <DialogHarness />,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Closed = {
|
|
65
|
+
render: () => <DialogHarness startOpen={false} />,
|
|
66
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SavedFilterDropdown — picker over persisted filter records. Uses the
|
|
3
|
+
* forms Select trigger (same FloatingPanel contract as every other Select
|
|
4
|
+
* in the catalog). Renders nothing when the list is empty.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { action } from "@multiplatform.one/storybook";
|
|
8
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { YStack } from "tamagui";
|
|
11
|
+
import type { ParsedFilter } from "../hooks/useSavedFilters";
|
|
12
|
+
import { SavedFilterDropdown } from "./SavedFilterDropdown";
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof SavedFilterDropdown> = {
|
|
15
|
+
title: "Table/SavedFilterDropdown",
|
|
16
|
+
component: SavedFilterDropdown,
|
|
17
|
+
parameters: {
|
|
18
|
+
status: { type: "stable" },
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
"Select over saved filter records. Global filters are labelled in the option " +
|
|
23
|
+
"text. The empty list is a silent no-op so toolbars do not grow a dead trigger.",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
|
|
31
|
+
const savedFilters: ParsedFilter[] = [
|
|
32
|
+
{
|
|
33
|
+
name: "Engineering Only",
|
|
34
|
+
filter_name: "Engineering Only",
|
|
35
|
+
for_user: "admin",
|
|
36
|
+
filters: [["department", "=", "Engineering"]],
|
|
37
|
+
isGlobal: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "High Salary",
|
|
41
|
+
filter_name: "High Salary",
|
|
42
|
+
for_user: "",
|
|
43
|
+
filters: [["salary", ">", 100000]],
|
|
44
|
+
isGlobal: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "Recent hires",
|
|
48
|
+
filter_name: "Recent hires",
|
|
49
|
+
for_user: "admin",
|
|
50
|
+
filters: [["joinDate", "after", "2024-01-01"]],
|
|
51
|
+
isGlobal: false,
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
function DropdownHarness({
|
|
56
|
+
initial,
|
|
57
|
+
disabled = false,
|
|
58
|
+
}: {
|
|
59
|
+
initial?: ParsedFilter | null;
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
}) {
|
|
62
|
+
const [selected, setSelected] = useState<ParsedFilter | null>(initial ?? null);
|
|
63
|
+
return (
|
|
64
|
+
<YStack padding="$4" alignItems="flex-start" width={280}>
|
|
65
|
+
<SavedFilterDropdown
|
|
66
|
+
filters={savedFilters}
|
|
67
|
+
selectedFilter={selected}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
onSelectFilter={(next) => {
|
|
70
|
+
setSelected(next);
|
|
71
|
+
action("onSelectFilter")(next);
|
|
72
|
+
}}
|
|
73
|
+
onDeleteFilter={(name) => action("onDeleteFilter")(name)}
|
|
74
|
+
/>
|
|
75
|
+
</YStack>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Default = {
|
|
80
|
+
name: "Main",
|
|
81
|
+
render: () => <DropdownHarness />,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const Selected = {
|
|
85
|
+
render: () => <DropdownHarness initial={savedFilters[0]} />,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const Disabled = {
|
|
89
|
+
render: () => <DropdownHarness initial={savedFilters[0]} disabled />,
|
|
90
|
+
};
|