@viliha/vui-ui 1.7.0 → 1.9.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/AGENT.md +6 -0
- package/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/record-view.tsx +216 -34
- package/template/app/(app)/system/regions/regions-table.tsx +68 -8
package/AGENT.md
CHANGED
|
@@ -334,6 +334,12 @@ Build forms with VUI and shadcn/ui together, reaching for shadcn Form, React Hoo
|
|
|
334
334
|
|
|
335
335
|
Never hand-build an HTML table; always use `RecordView`. Configure columns through its field props (`editable`, `required`, `copyable`, `options`, `render`), and let `RecordView` own the rest: sorting, filtering, pagination, bulk actions, and import/export.
|
|
336
336
|
|
|
337
|
+
Sorting follows column visibility by default; use `sortable` to decouple them — `sortable: true` sorts a field with no column (e.g. a `hideInTable` name shown via `getPrimary`), `sortable: false` locks a visible column. Set the flag; don't rewire the Sort dropdown or headers.
|
|
338
|
+
|
|
339
|
+
## Filtering
|
|
340
|
+
|
|
341
|
+
The Filter panel is a single keyword box by default (built in, matches all fields). For a labeled control per field, set `filterable` on the relevant fields and never hand-roll a filter form: `filterable: true` (text) or `filterable: { control, label, placeholder, options }` where `control` is `"text" | "number" | "date" | "select" | "combobox" | "checkbox"`. When any field is filterable the panel shows a control per field plus Search / Clear. It only **collects** values — handle `onFilter(values)` on `RecordView` to run a query or client filter (Search and Clear both call it). Types: `FilterControl`, `FieldFilter`, `FilterValues<T>`. Need a control kind that isn't listed? Extend the `FilterControl` union in the component.
|
|
342
|
+
|
|
337
343
|
## Add / edit form
|
|
338
344
|
|
|
339
345
|
The buffered add/edit form renders in one of two layouts:
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ backward-compatible features, **major** for breaking changes.
|
|
|
7
7
|
|
|
8
8
|
To upgrade, see [Upgrading](./AGENT.md#upgrading) in the agent guide.
|
|
9
9
|
|
|
10
|
+
## 1.9.0 — 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`sortable` field flag in `RecordView`** — decouples sorting from column
|
|
15
|
+
visibility. By default a field is sortable iff it's a visible column
|
|
16
|
+
(`!hideInTable`), unchanged. Now:
|
|
17
|
+
- `sortable: true` sorts a field with **no column** (e.g. a `hideInTable` name
|
|
18
|
+
shown via `getPrimary`) — it appears in the Sort dropdown.
|
|
19
|
+
- `sortable: false` keeps a **visible column unsortable** (its header stops
|
|
20
|
+
being a sort toggle).
|
|
21
|
+
|
|
22
|
+
Affects both the Sort dropdown and the column-header click. Fully backward
|
|
23
|
+
compatible — omit the flag for today's behavior.
|
|
24
|
+
|
|
25
|
+
**For agents:** to make a non-column field sortable (or lock a column), set
|
|
26
|
+
`sortable` on the `RecordField`; don't rewire the Sort dropdown or headers.
|
|
27
|
+
Demo: `system/regions` sorts by Name (which has no column).
|
|
28
|
+
|
|
29
|
+
## 1.8.0 — 2026-07-26
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **Per-field filtering in `RecordView`** (opt-in, backward compatible). Mark a
|
|
34
|
+
field `filterable` and the Filter panel switches from the single keyword box to
|
|
35
|
+
a labeled control per field plus **Search / Clear**. The control is dynamic so
|
|
36
|
+
the front end can compose a different filter form per request:
|
|
37
|
+
- `filterable: true` → a text input.
|
|
38
|
+
- `filterable: { control, label, placeholder, options }` → pick the control:
|
|
39
|
+
`"text" | "number" | "date" | "select" | "combobox" | "checkbox"` (unknown or
|
|
40
|
+
omitted → text). `options` falls back to the field's own `options`.
|
|
41
|
+
- New exported types: `FilterControl`, `FieldFilter`, `FilterValues<T>`.
|
|
42
|
+
- The panel **gathers values only** — it does not match rows in per-field mode.
|
|
43
|
+
Wire matching through the new `RecordView` prop `onFilter(values)` (Search and
|
|
44
|
+
Clear both call it), typically a server query or your own client filter. The
|
|
45
|
+
single-keyword box (and its built-in row matching) is unchanged when no field
|
|
46
|
+
is `filterable`.
|
|
47
|
+
|
|
48
|
+
**For agents:** to add per-field filters, only set `filterable` on the relevant
|
|
49
|
+
`RecordField`s and handle `onFilter` — never hand-roll a filter form. Extend
|
|
50
|
+
`FilterControl` when a new control kind is needed. Demo: `system/regions`
|
|
51
|
+
(Name + Code). Docs: `/docs/data-table`.
|
|
52
|
+
|
|
10
53
|
## 1.7.0 — 2026-07-25
|
|
11
54
|
|
|
12
55
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Suman Bonakurthi",
|
package/src/record-view.tsx
CHANGED
|
@@ -171,6 +171,42 @@ export function usePageTitle(title: string, icon?: IconType) {
|
|
|
171
171
|
}, [title]);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/** Control kinds the Filter panel can render for a `filterable` field. Omitted
|
|
175
|
+
* or unknown kinds render a text input — extend this union as you add controls
|
|
176
|
+
* (e.g. `"daterange"`, `"multiselect"`). */
|
|
177
|
+
export type FilterControl =
|
|
178
|
+
| "text"
|
|
179
|
+
| "number"
|
|
180
|
+
| "date"
|
|
181
|
+
| "select"
|
|
182
|
+
| "combobox"
|
|
183
|
+
| "checkbox";
|
|
184
|
+
|
|
185
|
+
/** Per-field Filter-panel config. `filterable: true` is shorthand for
|
|
186
|
+
* `{ control: "text" }`; pass an object to pick the control and shape it, so
|
|
187
|
+
* the front end can compose a different filter form per request (Name + Code as
|
|
188
|
+
* text for one screen, a status dropdown + tag checkboxes for another). */
|
|
189
|
+
export interface FieldFilter {
|
|
190
|
+
/** Which control to render. Default `"text"`. */
|
|
191
|
+
control?: FilterControl;
|
|
192
|
+
/** Label above the control. Defaults to the field's `label`. */
|
|
193
|
+
label?: string;
|
|
194
|
+
/** Placeholder for text / number / combobox inputs. */
|
|
195
|
+
placeholder?: string;
|
|
196
|
+
/** Choices for `select` / `combobox` / `checkbox`. Falls back to the field's
|
|
197
|
+
* own `options` when omitted. */
|
|
198
|
+
options?: { value: string; label: string }[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Values collected by the Filter panel, keyed by field. Single-value controls
|
|
202
|
+
* yield a `string`; multi-select `checkbox` yields a `string[]`. This object is
|
|
203
|
+
* the contract you hand to your own query / refetch via {@link RecordView}'s
|
|
204
|
+
* `onFilter` — in per-field mode the panel gathers values but does not match
|
|
205
|
+
* rows itself, so the search is yours (client-side or server-side). */
|
|
206
|
+
export type FilterValues<T> = Partial<
|
|
207
|
+
Record<Extract<keyof T, string>, string | string[]>
|
|
208
|
+
>;
|
|
209
|
+
|
|
174
210
|
export interface RecordField<T> {
|
|
175
211
|
key: Extract<keyof T, string>;
|
|
176
212
|
label: string;
|
|
@@ -190,6 +226,12 @@ export interface RecordField<T> {
|
|
|
190
226
|
copyable?: boolean;
|
|
191
227
|
/** Show in the detail panel only, not as a table column (e.g. first/last name). */
|
|
192
228
|
hideInTable?: boolean;
|
|
229
|
+
/** Whether this field can be sorted — decoupled from column visibility.
|
|
230
|
+
* Default: sortable iff it's a visible column (`!hideInTable`), the historic
|
|
231
|
+
* behavior. Set `true` to sort a field with no column (e.g. a `hideInTable`
|
|
232
|
+
* name shown via `getPrimary`); set `false` to keep a visible column
|
|
233
|
+
* unsortable. Controls both the Sort dropdown and the column-header toggle. */
|
|
234
|
+
sortable?: boolean;
|
|
193
235
|
/** Custom, non-editable cell/value renderer. */
|
|
194
236
|
render?: (row: T) => React.ReactNode;
|
|
195
237
|
/** If set, the field becomes a choice field: the Add/Edit form renders a
|
|
@@ -199,6 +241,13 @@ export interface RecordField<T> {
|
|
|
199
241
|
* textarea). `"number"`/`"date"` render the matching native input; a field
|
|
200
242
|
* with `options` always renders a `Select` regardless of this. */
|
|
201
243
|
input?: "text" | "number" | "date";
|
|
244
|
+
/** Expose this field in the Filter panel. When ANY field is filterable, the
|
|
245
|
+
* panel switches from the single keyword box to a labeled control per field
|
|
246
|
+
* plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
|
|
247
|
+
* choose the control (dropdown, checkbox, combobox, number, date …) so the
|
|
248
|
+
* filter form is composed per request. The panel only gathers values — wire
|
|
249
|
+
* matching through RecordView's `onFilter`. Omit to leave the field out. */
|
|
250
|
+
filterable?: boolean | FieldFilter;
|
|
202
251
|
}
|
|
203
252
|
|
|
204
253
|
/**
|
|
@@ -292,6 +341,11 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
292
341
|
/** Allow dragging column edges to resize them. Off by default — columns
|
|
293
342
|
* auto-size, and no resize handle appears on hover. */
|
|
294
343
|
resizableColumns?: boolean;
|
|
344
|
+
/** Called from the Filter panel's Search (and Clear) when fields are
|
|
345
|
+
* `filterable`. Receives the collected per-field values; run your own query
|
|
346
|
+
* or client-side filtering here. In per-field mode the panel does not match
|
|
347
|
+
* rows itself, so the behavior is entirely yours. */
|
|
348
|
+
onFilter?: (values: FilterValues<T>) => void;
|
|
295
349
|
}
|
|
296
350
|
|
|
297
351
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -313,6 +367,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
313
367
|
onEdit,
|
|
314
368
|
persistKey,
|
|
315
369
|
resizableColumns = false,
|
|
370
|
+
onFilter,
|
|
316
371
|
}: RecordViewProps<T>) {
|
|
317
372
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
318
373
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -339,6 +394,12 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
339
394
|
persistKey ? `${persistKey}::filter` : undefined,
|
|
340
395
|
"",
|
|
341
396
|
);
|
|
397
|
+
// Per-field Filter-panel values (opt-in via `field.filterable`). Kept apart
|
|
398
|
+
// from the single-keyword `filter`; persisted like the rest of the view.
|
|
399
|
+
const [filterValues, setFilterValues] = usePersistentState<FilterValues<T>>(
|
|
400
|
+
persistKey ? `${persistKey}::filterValues` : undefined,
|
|
401
|
+
{},
|
|
402
|
+
);
|
|
342
403
|
const [sort, setSort] = usePersistentState<{
|
|
343
404
|
key: string;
|
|
344
405
|
dir: "asc" | "desc";
|
|
@@ -405,6 +466,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
405
466
|
|
|
406
467
|
const tableFields = fields.filter((f) => !f.hideInTable);
|
|
407
468
|
const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
|
|
469
|
+
// Sorting is decoupled from column visibility: a field is sortable when its
|
|
470
|
+
// `sortable` flag says so, else it falls back to "is a visible column".
|
|
471
|
+
const canSort = (f: RecordField<T>) => f.sortable ?? !f.hideInTable;
|
|
472
|
+
// Fields offered in the Sort dropdown (may include hidden-but-sortable fields
|
|
473
|
+
// and exclude visible-but-unsortable ones).
|
|
474
|
+
const sortFields = fields.filter(canSort);
|
|
475
|
+
// Fields opted into per-field filtering. Non-empty → the Filter panel renders
|
|
476
|
+
// a control per field instead of the single keyword box.
|
|
477
|
+
const filterFields = fields.filter((f) => f.filterable);
|
|
408
478
|
|
|
409
479
|
// The primary "Name" column renders the record's name field, which is hidden
|
|
410
480
|
// as a regular column (hideInTable) because it shows here. Mirror its
|
|
@@ -1007,24 +1077,124 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1007
1077
|
</Dropdown>
|
|
1008
1078
|
)}
|
|
1009
1079
|
<Dropdown label="Filter" icon={<ListFilter className="size-3.5 text-amber-500" />}>
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1080
|
+
{filterFields.length > 0 ? (
|
|
1081
|
+
// Per-field mode: a labeled control per `filterable` field, plus
|
|
1082
|
+
// Search / Clear. The panel only gathers values — matching is the
|
|
1083
|
+
// consumer's job via `onFilter` (see the field's `filterable`).
|
|
1084
|
+
<>
|
|
1085
|
+
<DropdownLabel>Filter</DropdownLabel>
|
|
1086
|
+
<div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
|
|
1087
|
+
{filterFields.map((f) => {
|
|
1088
|
+
const cfg: FieldFilter =
|
|
1089
|
+
typeof f.filterable === "object" ? f.filterable : {};
|
|
1090
|
+
const control = cfg.control ?? "text";
|
|
1091
|
+
const label = cfg.label ?? f.label;
|
|
1092
|
+
const opts = cfg.options ?? f.options ?? [];
|
|
1093
|
+
const raw = filterValues[f.key];
|
|
1094
|
+
const setVal = (v: string | string[]) =>
|
|
1095
|
+
setFilterValues((prev) => ({ ...prev, [f.key]: v }));
|
|
1096
|
+
return (
|
|
1097
|
+
<div key={f.key} className="flex flex-col gap-1">
|
|
1098
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
1099
|
+
{label}
|
|
1100
|
+
</label>
|
|
1101
|
+
{control === "select" || control === "combobox" ? (
|
|
1102
|
+
<Select
|
|
1103
|
+
value={typeof raw === "string" ? raw : ""}
|
|
1104
|
+
onValueChange={setVal}
|
|
1105
|
+
options={opts}
|
|
1106
|
+
ariaLabel={label}
|
|
1107
|
+
placeholder={
|
|
1108
|
+
cfg.placeholder ?? `Any ${label.toLowerCase()}`
|
|
1109
|
+
}
|
|
1110
|
+
className="w-full"
|
|
1111
|
+
/>
|
|
1112
|
+
) : control === "checkbox" ? (
|
|
1113
|
+
<div className="flex flex-col gap-1">
|
|
1114
|
+
{opts.map((o) => {
|
|
1115
|
+
const arr = Array.isArray(raw) ? raw : [];
|
|
1116
|
+
const on = arr.includes(o.value);
|
|
1117
|
+
return (
|
|
1118
|
+
<label
|
|
1119
|
+
key={o.value}
|
|
1120
|
+
className="flex items-center gap-2 text-sm"
|
|
1121
|
+
>
|
|
1122
|
+
<input
|
|
1123
|
+
type="checkbox"
|
|
1124
|
+
checked={on}
|
|
1125
|
+
onChange={() =>
|
|
1126
|
+
setVal(
|
|
1127
|
+
on
|
|
1128
|
+
? arr.filter((v) => v !== o.value)
|
|
1129
|
+
: [...arr, o.value],
|
|
1130
|
+
)
|
|
1131
|
+
}
|
|
1132
|
+
/>
|
|
1133
|
+
{o.label}
|
|
1134
|
+
</label>
|
|
1135
|
+
);
|
|
1136
|
+
})}
|
|
1137
|
+
</div>
|
|
1138
|
+
) : (
|
|
1139
|
+
<Input
|
|
1140
|
+
type={
|
|
1141
|
+
control === "number"
|
|
1142
|
+
? "number"
|
|
1143
|
+
: control === "date"
|
|
1144
|
+
? "date"
|
|
1145
|
+
: "text"
|
|
1146
|
+
}
|
|
1147
|
+
value={typeof raw === "string" ? raw : ""}
|
|
1148
|
+
onChange={(e) => setVal(e.target.value)}
|
|
1149
|
+
placeholder={cfg.placeholder ?? "Contains…"}
|
|
1150
|
+
aria-label={label}
|
|
1151
|
+
className="h-8"
|
|
1152
|
+
/>
|
|
1153
|
+
)}
|
|
1154
|
+
</div>
|
|
1155
|
+
);
|
|
1156
|
+
})}
|
|
1157
|
+
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
|
|
1158
|
+
<Button
|
|
1159
|
+
onClick={() => {
|
|
1160
|
+
setFilterValues({});
|
|
1161
|
+
onFilter?.({});
|
|
1162
|
+
}}
|
|
1163
|
+
>
|
|
1164
|
+
Clear
|
|
1165
|
+
</Button>
|
|
1166
|
+
<Button
|
|
1167
|
+
variant="primary"
|
|
1168
|
+
onClick={() => onFilter?.(filterValues)}
|
|
1169
|
+
>
|
|
1170
|
+
<Search className="size-3.5" />
|
|
1171
|
+
Search
|
|
1172
|
+
</Button>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
</>
|
|
1176
|
+
) : (
|
|
1177
|
+
<>
|
|
1178
|
+
<DropdownLabel>Filter by keyword</DropdownLabel>
|
|
1179
|
+
<div className="p-3">
|
|
1180
|
+
<div className="relative">
|
|
1181
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
1182
|
+
<Input
|
|
1183
|
+
value={filter}
|
|
1184
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
1185
|
+
placeholder="Contains…"
|
|
1186
|
+
aria-label="Filter"
|
|
1187
|
+
className="h-8 pl-9"
|
|
1188
|
+
/>
|
|
1189
|
+
</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
</>
|
|
1192
|
+
)}
|
|
1023
1193
|
</Dropdown>
|
|
1024
1194
|
|
|
1025
1195
|
<Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
|
|
1026
1196
|
<DropdownLabel>Sort by</DropdownLabel>
|
|
1027
|
-
{
|
|
1197
|
+
{sortFields.map((f) => (
|
|
1028
1198
|
<DropdownItem
|
|
1029
1199
|
key={f.key}
|
|
1030
1200
|
onSelect={() => toggleSort(f.key)}
|
|
@@ -1142,32 +1312,44 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1142
1312
|
</TableHead>
|
|
1143
1313
|
{visibleFields.map((f) => {
|
|
1144
1314
|
const HeadIcon = f.icon ?? DEFAULT_FIELD_ICON;
|
|
1315
|
+
const sortable = canSort(f);
|
|
1316
|
+
const headInner = (
|
|
1317
|
+
<>
|
|
1318
|
+
<HeadIcon className="size-3.5 shrink-0 text-foreground" />
|
|
1319
|
+
<span className="flex items-center gap-1 whitespace-nowrap">
|
|
1320
|
+
{f.label}
|
|
1321
|
+
{f.required && <RequiredMark />}
|
|
1322
|
+
</span>
|
|
1323
|
+
{sort?.key === f.key &&
|
|
1324
|
+
(sort.dir === "asc" ? (
|
|
1325
|
+
<ArrowUp className="size-3 shrink-0" />
|
|
1326
|
+
) : (
|
|
1327
|
+
<ArrowDown className="size-3 shrink-0" />
|
|
1328
|
+
))}
|
|
1329
|
+
</>
|
|
1330
|
+
);
|
|
1331
|
+
const headClass = cn(
|
|
1332
|
+
"flex h-8 w-full items-center gap-1.5 whitespace-nowrap",
|
|
1333
|
+
ALIGN_BOX[alignOf(f.key)],
|
|
1334
|
+
);
|
|
1145
1335
|
return (
|
|
1146
1336
|
<TableHead
|
|
1147
1337
|
key={f.key}
|
|
1148
1338
|
className="relative w-max"
|
|
1149
1339
|
style={{ width: colWidths[f.key] }}
|
|
1150
1340
|
>
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
"
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
</span>
|
|
1164
|
-
{sort?.key === f.key &&
|
|
1165
|
-
(sort.dir === "asc" ? (
|
|
1166
|
-
<ArrowUp className="size-3 shrink-0" />
|
|
1167
|
-
) : (
|
|
1168
|
-
<ArrowDown className="size-3 shrink-0" />
|
|
1169
|
-
))}
|
|
1170
|
-
</button>
|
|
1341
|
+
{sortable ? (
|
|
1342
|
+
<button
|
|
1343
|
+
type="button"
|
|
1344
|
+
onClick={() => toggleSort(f.key)}
|
|
1345
|
+
className={cn(headClass, "hover:text-foreground")}
|
|
1346
|
+
>
|
|
1347
|
+
{headInner}
|
|
1348
|
+
</button>
|
|
1349
|
+
) : (
|
|
1350
|
+
// Not sortable: a static label, no toggle / hover affordance.
|
|
1351
|
+
<span className={headClass}>{headInner}</span>
|
|
1352
|
+
)}
|
|
1171
1353
|
{resizeHandle(f.key, f.label)}
|
|
1172
1354
|
</TableHead>
|
|
1173
1355
|
);
|
|
@@ -1,26 +1,86 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
GlobeIcon as Globe,
|
|
6
|
-
} from "@radix-ui/react-icons";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { CodeIcon as Hash, GlobeIcon as Globe } from "@radix-ui/react-icons";
|
|
7
5
|
|
|
8
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
RecordView,
|
|
8
|
+
type FilterValues,
|
|
9
|
+
type RecordField,
|
|
10
|
+
} from "@viliha/vui-ui/record-view";
|
|
9
11
|
import { regions, type Region } from "@/lib/mock-data";
|
|
10
12
|
|
|
13
|
+
// `filterable` opts a field into the Filter panel. With any field filterable the
|
|
14
|
+
// panel shows a labeled control per field + Search/Clear instead of the single
|
|
15
|
+
// keyword box. `true` = a text input; pass a config to pick the control.
|
|
11
16
|
const fields: RecordField<Region>[] = [
|
|
12
|
-
{
|
|
13
|
-
|
|
17
|
+
{
|
|
18
|
+
key: "name",
|
|
19
|
+
label: "Name",
|
|
20
|
+
editable: true,
|
|
21
|
+
required: true,
|
|
22
|
+
group: "General",
|
|
23
|
+
hideInTable: true,
|
|
24
|
+
// No Name column (shown in the identity cell via getPrimary), but still
|
|
25
|
+
// sortable from the Sort dropdown — `sortable` decouples it from visibility.
|
|
26
|
+
sortable: true,
|
|
27
|
+
filterable: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: "code",
|
|
31
|
+
label: "Code",
|
|
32
|
+
icon: Hash,
|
|
33
|
+
editable: true,
|
|
34
|
+
group: "General",
|
|
35
|
+
filterable: { control: "text", placeholder: "e.g. APAC" },
|
|
36
|
+
},
|
|
14
37
|
];
|
|
15
38
|
|
|
16
39
|
export function RegionsTable() {
|
|
40
|
+
const [all, setAll] = useState<Region[]>(regions);
|
|
41
|
+
const [filters, setFilters] = useState<FilterValues<Region>>({});
|
|
42
|
+
|
|
43
|
+
// Demo: match the per-field values client-side. The component does NOT filter
|
|
44
|
+
// in per-field mode — `onFilter` hands you the values; swap this for a server
|
|
45
|
+
// query in a real app. ponytail: naive contains-match, fine for demo data.
|
|
46
|
+
const rows = useMemo(
|
|
47
|
+
() =>
|
|
48
|
+
all.filter((r) =>
|
|
49
|
+
Object.entries(filters).every(([k, v]) => {
|
|
50
|
+
const needle = String(v ?? "").toLowerCase();
|
|
51
|
+
return (
|
|
52
|
+
!needle ||
|
|
53
|
+
String(r[k as keyof Region]).toLowerCase().includes(needle)
|
|
54
|
+
);
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
[all, filters],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Reconcile edits/adds/deletes made on the filtered view back into `all`.
|
|
61
|
+
const handleChange = (next: Region[]) => {
|
|
62
|
+
setAll((prev) => {
|
|
63
|
+
const nextById = new Map(next.map((r) => [r.id, r] as const));
|
|
64
|
+
const visible = new Set(rows.map((r) => r.id));
|
|
65
|
+
const kept = prev
|
|
66
|
+
.filter((r) => !visible.has(r.id) || nextById.has(r.id)) // drop deleted
|
|
67
|
+
.map((r) => nextById.get(r.id) ?? r); // apply edits
|
|
68
|
+
const added = next.filter((r) => !prev.some((p) => p.id === r.id));
|
|
69
|
+
return [...kept, ...added];
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
17
73
|
return (
|
|
18
74
|
<RecordView
|
|
19
75
|
title="Regions"
|
|
20
76
|
singular="Region"
|
|
21
77
|
icon={Globe}
|
|
78
|
+
persistKey="/system/regions"
|
|
22
79
|
fields={fields}
|
|
23
|
-
initialData={
|
|
80
|
+
initialData={rows}
|
|
81
|
+
data={rows}
|
|
82
|
+
onDataChange={handleChange}
|
|
83
|
+
onFilter={setFilters}
|
|
24
84
|
getPrimary={(row) => ({
|
|
25
85
|
title: row.name,
|
|
26
86
|
subtitle: row.code,
|