@multiplatform.one/table 7.6.3 → 7.7.1
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/DataTable.spec.tsx +13 -7
- package/src/components/DataTable/DataTableCardControls.stories.tsx +116 -0
- package/src/components/DataTable/DataTableDocumentList.stories.tsx +132 -0
- package/src/components/DataTable/DataTableMobileCards.stories.tsx +124 -0
- package/src/components/DataTable/DataTableVirtualized.stories.tsx +69 -0
- package/src/components/DataTable/chromeInterplay.spec.tsx +4 -1
- package/src/components/DataTable/dataTableCardControls.spec.tsx +99 -0
- package/src/components/DataTable/dataTableDocumentList.spec.tsx +143 -0
- package/src/components/DataTable/dataTableMobileCards.spec.tsx +151 -0
- package/src/components/DataTableCell/CellOptionsEditor.stories.tsx +139 -0
- package/src/components/DataTableCell/cellOptionsEditor.spec.tsx +127 -0
- package/src/components/DataTableExport/DataTableExport.spec.tsx +5 -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/DataTableFilterChips/FilterChip.spec.tsx +77 -0
- package/src/components/DataTableFilterChips/FilterChip.stories.tsx +111 -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
|
@@ -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
|
+
};
|