@k3-tech/react-kit 0.0.61 → 0.0.62
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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +482 -9
- package/dist/kit/builder/data-table/components/DataTable.d.ts +3 -1
- package/dist/kit/builder/data-table/components/DataTable.d.ts.map +1 -1
- package/dist/kit/components/data-table-headers/DataTableHeader.d.ts +25 -0
- package/dist/kit/components/data-table-headers/DataTableHeader.d.ts.map +1 -0
- package/dist/kit/components/data-table-headers/DebouncedFilterField.d.ts +23 -0
- package/dist/kit/components/data-table-headers/DebouncedFilterField.d.ts.map +1 -0
- package/dist/kit/components/data-table-headers/FilterFieldRenderer.d.ts +19 -0
- package/dist/kit/components/data-table-headers/FilterFieldRenderer.d.ts.map +1 -0
- package/dist/kit/components/data-table-headers/index.d.ts +7 -0
- package/dist/kit/components/data-table-headers/index.d.ts.map +1 -0
- package/dist/kit/components/data-table-headers/types.d.ts +76 -0
- package/dist/kit/components/data-table-headers/types.d.ts.map +1 -0
- package/dist/kit/components/data-table-headers/urlSerialization.d.ts +12 -0
- package/dist/kit/components/data-table-headers/urlSerialization.d.ts.map +1 -0
- package/dist/kit/components/data-table-headers/useDataTableHeaderUrl.d.ts +36 -0
- package/dist/kit/components/data-table-headers/useDataTableHeaderUrl.d.ts.map +1 -0
- package/dist/kit/themes/clean-slate.css +8 -0
- package/dist/kit/themes/default.css +8 -0
- package/dist/kit/themes/minimal-modern.css +8 -0
- package/dist/kit/themes/spotify.css +8 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/kit/builder/data-table/components/DataTable.tsx +5 -2
- package/src/kit/components/data-table-headers/DataTableHeader.tsx +158 -0
- package/src/kit/components/data-table-headers/DebouncedFilterField.tsx +83 -0
- package/src/kit/components/data-table-headers/FilterFieldRenderer.tsx +194 -0
- package/src/kit/components/data-table-headers/index.ts +6 -0
- package/src/kit/components/data-table-headers/types.ts +89 -0
- package/src/kit/components/data-table-headers/urlSerialization.ts +173 -0
- package/src/kit/components/data-table-headers/useDataTableHeaderUrl.ts +107 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Control, FieldValues } from 'react-hook-form';
|
|
3
|
+
import { cn } from '@/shadcn/lib/utils';
|
|
4
|
+
import { Label } from '@/shadcn/ui/label';
|
|
5
|
+
import {
|
|
6
|
+
AutocompleteField,
|
|
7
|
+
CheckboxField,
|
|
8
|
+
DateField,
|
|
9
|
+
DatePickerField,
|
|
10
|
+
DateRangePickerField,
|
|
11
|
+
DateTimePickerField,
|
|
12
|
+
DateTimeRangePickerField,
|
|
13
|
+
FileField,
|
|
14
|
+
MonthPickerField,
|
|
15
|
+
MonthRangePickerField,
|
|
16
|
+
RadioField,
|
|
17
|
+
SelectField,
|
|
18
|
+
SwitchField,
|
|
19
|
+
TimePickerField,
|
|
20
|
+
TimeRangePickerField,
|
|
21
|
+
} from '@/kit/builder/form/components/fields';
|
|
22
|
+
import type { FieldRenderProps } from '@/kit/builder/form/components/fields/types';
|
|
23
|
+
import { DebouncedFilterField } from './DebouncedFilterField';
|
|
24
|
+
import type { FilterFieldConfig } from './types';
|
|
25
|
+
|
|
26
|
+
/** Free-typing types that get an internal debounce. */
|
|
27
|
+
const DEBOUNCED_TYPES = new Set([
|
|
28
|
+
'text',
|
|
29
|
+
'email',
|
|
30
|
+
'password',
|
|
31
|
+
'number',
|
|
32
|
+
'textarea',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/** Types not supported inside a filter header (require RHF or are non-inputs). */
|
|
36
|
+
const UNSUPPORTED_TYPES = new Set([
|
|
37
|
+
'object',
|
|
38
|
+
'array',
|
|
39
|
+
'custom_field',
|
|
40
|
+
'hidden',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const warned = new Set<string>();
|
|
44
|
+
const warnUnsupported = (type: string) => {
|
|
45
|
+
if (process.env.NODE_ENV !== 'production' && !warned.has(type)) {
|
|
46
|
+
warned.add(type);
|
|
47
|
+
// eslint-disable-next-line no-console
|
|
48
|
+
console.warn(
|
|
49
|
+
`[DataTableHeader] field type '${type}' is not supported in filter headers and was skipped.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface FilterFieldRendererProps {
|
|
55
|
+
field: FilterFieldConfig;
|
|
56
|
+
/** Current value for this field (from the external values map). */
|
|
57
|
+
value: unknown;
|
|
58
|
+
/** Push a new value for this field upstream. */
|
|
59
|
+
onChange: (value: unknown) => void;
|
|
60
|
+
debounceMs: number;
|
|
61
|
+
/** Throwaway control shim so date fields' `useWatch` does not throw. */
|
|
62
|
+
control: Control<FieldValues>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Renders a single filter field by mirroring FormBuilderField's type → component
|
|
67
|
+
* mapping, but WITHOUT react-hook-form's `useController`. Values flow in via
|
|
68
|
+
* props and out via `onChange`; the header owns no field state.
|
|
69
|
+
*/
|
|
70
|
+
export function FilterFieldRenderer({
|
|
71
|
+
field,
|
|
72
|
+
value,
|
|
73
|
+
onChange,
|
|
74
|
+
debounceMs,
|
|
75
|
+
control,
|
|
76
|
+
}: FilterFieldRendererProps) {
|
|
77
|
+
if (UNSUPPORTED_TYPES.has(field.type)) {
|
|
78
|
+
warnUnsupported(field.type);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const inputClassName = cn('bg-background', field.className);
|
|
83
|
+
|
|
84
|
+
let input: ReactNode;
|
|
85
|
+
|
|
86
|
+
if (DEBOUNCED_TYPES.has(field.type)) {
|
|
87
|
+
input = (
|
|
88
|
+
<DebouncedFilterField
|
|
89
|
+
field={field}
|
|
90
|
+
value={value}
|
|
91
|
+
onChange={onChange}
|
|
92
|
+
debounceMs={debounceMs}
|
|
93
|
+
control={control}
|
|
94
|
+
className={inputClassName}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
const common: FieldRenderProps = {
|
|
99
|
+
field,
|
|
100
|
+
control,
|
|
101
|
+
fieldPath: field.name,
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
className: inputClassName,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
switch (field.type) {
|
|
108
|
+
case 'select':
|
|
109
|
+
input = <SelectField {...common} />;
|
|
110
|
+
break;
|
|
111
|
+
case 'autocomplete':
|
|
112
|
+
input = <AutocompleteField {...common} />;
|
|
113
|
+
break;
|
|
114
|
+
case 'checkbox':
|
|
115
|
+
input = <CheckboxField {...common} />;
|
|
116
|
+
break;
|
|
117
|
+
case 'switch':
|
|
118
|
+
input = <SwitchField {...common} />;
|
|
119
|
+
break;
|
|
120
|
+
case 'radio':
|
|
121
|
+
input = <RadioField {...common} />;
|
|
122
|
+
break;
|
|
123
|
+
case 'date':
|
|
124
|
+
input = <DateField {...common} />;
|
|
125
|
+
break;
|
|
126
|
+
case 'date_picker':
|
|
127
|
+
input = <DatePickerField {...common} />;
|
|
128
|
+
break;
|
|
129
|
+
case 'date_range':
|
|
130
|
+
input = <DateRangePickerField {...common} />;
|
|
131
|
+
break;
|
|
132
|
+
case 'date_time':
|
|
133
|
+
input = <DateTimePickerField {...common} />;
|
|
134
|
+
break;
|
|
135
|
+
case 'date_time_range':
|
|
136
|
+
input = <DateTimeRangePickerField {...common} />;
|
|
137
|
+
break;
|
|
138
|
+
case 'month':
|
|
139
|
+
input = <MonthPickerField {...common} />;
|
|
140
|
+
break;
|
|
141
|
+
case 'month_range':
|
|
142
|
+
input = <MonthRangePickerField {...common} />;
|
|
143
|
+
break;
|
|
144
|
+
case 'time':
|
|
145
|
+
input = <TimePickerField {...common} />;
|
|
146
|
+
break;
|
|
147
|
+
case 'time_range':
|
|
148
|
+
input = <TimeRangePickerField {...common} />;
|
|
149
|
+
break;
|
|
150
|
+
case 'file':
|
|
151
|
+
input = <FileField {...common} />;
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
// Unknown but not explicitly unsupported: fall back to plain text.
|
|
155
|
+
input = (
|
|
156
|
+
<DebouncedFilterField
|
|
157
|
+
field={field}
|
|
158
|
+
value={value}
|
|
159
|
+
onChange={onChange}
|
|
160
|
+
debounceMs={debounceMs}
|
|
161
|
+
control={control}
|
|
162
|
+
className={inputClassName}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Show a label only when an explicit, non-blank label is provided and not
|
|
169
|
+
// hidden — filters commonly rely on placeholders alone.
|
|
170
|
+
const labelText =
|
|
171
|
+
typeof field.label === 'string' ? field.label.trim() : field.label;
|
|
172
|
+
const showLabel =
|
|
173
|
+
field.labelPlacement !== 'hidden' &&
|
|
174
|
+
labelText != null &&
|
|
175
|
+
labelText !== '' &&
|
|
176
|
+
field.type !== 'checkbox' &&
|
|
177
|
+
field.type !== 'switch';
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
className={cn(
|
|
182
|
+
'w-full space-y-1 sm:w-auto sm:min-w-[180px]',
|
|
183
|
+
field.wrapperClassName,
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{showLabel && (
|
|
187
|
+
<Label htmlFor={field.name} className="text-xs text-muted-foreground">
|
|
188
|
+
{field.label}
|
|
189
|
+
</Label>
|
|
190
|
+
)}
|
|
191
|
+
{input}
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { DataTableAction } from '@/kit/builder/data-table/types';
|
|
3
|
+
import type { FormBuilderFieldConfig } from '@/kit/builder/form/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single filter field definition. Reuses {@link FormBuilderFieldConfig} so
|
|
7
|
+
* field definitions for the header work exactly like FormBuilder fields, but
|
|
8
|
+
* the header renders them WITHOUT react-hook-form and holds no value state.
|
|
9
|
+
*
|
|
10
|
+
* Supported `type`s: text, email, password, number (plain), textarea, select,
|
|
11
|
+
* autocomplete, checkbox, switch, radio, month, month_range, time, time_range,
|
|
12
|
+
* and the date family (date, date_picker, date_range, date_time,
|
|
13
|
+
* date_time_range).
|
|
14
|
+
*
|
|
15
|
+
* Unsupported in a filter header: object, array, custom_field, hidden — these
|
|
16
|
+
* are skipped with a dev warning.
|
|
17
|
+
*/
|
|
18
|
+
export type FilterFieldConfig = FormBuilderFieldConfig;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Action shown on the right side of the header. Reuses the DataTable action
|
|
22
|
+
* shape and rendering, mirroring DataTable's `renderActionButton`.
|
|
23
|
+
*/
|
|
24
|
+
export type DataTableHeaderAction = DataTableAction;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adapter that lets the URL sync work against an arbitrary URL source instead
|
|
28
|
+
* of the browser History API. Provide this in environments with their own
|
|
29
|
+
* router (e.g. TanStack Router) so the header does not fight the router for
|
|
30
|
+
* control of the URL.
|
|
31
|
+
*/
|
|
32
|
+
export interface UrlSyncAdapter {
|
|
33
|
+
/** Read the current query params as a flat map (repeated keys -> string[]). */
|
|
34
|
+
read: () => Record<string, string | string[]>;
|
|
35
|
+
/** Write the given query params back to the URL. */
|
|
36
|
+
write: (params: Record<string, string | string[]>) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* URL sync option.
|
|
41
|
+
* - `false` / `undefined`: disabled.
|
|
42
|
+
* - `true`: use the native History API (URLSearchParams + replaceState).
|
|
43
|
+
* - `UrlSyncAdapter`: use the provided adapter (recommended when a router owns
|
|
44
|
+
* the URL).
|
|
45
|
+
*/
|
|
46
|
+
export type UrlSyncOption = boolean | UrlSyncAdapter;
|
|
47
|
+
|
|
48
|
+
export interface DataTableHeaderProps {
|
|
49
|
+
/** Filter field definitions (reuses FormBuilder field config). */
|
|
50
|
+
fields: FilterFieldConfig[];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Externally-owned filter values, keyed by `field.name`. This is the single
|
|
54
|
+
* source of truth — the header never holds its own copy.
|
|
55
|
+
*/
|
|
56
|
+
values: Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
/** Called whenever a single field changes (debounced for free-typing inputs). */
|
|
59
|
+
onChange: (name: string, value: unknown) => void;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bulk setter used to hydrate values from the URL on mount. If omitted, the
|
|
63
|
+
* header falls back to issuing one `onChange` per hydrated field.
|
|
64
|
+
*/
|
|
65
|
+
onChangeAll?: (next: Record<string, unknown>) => void;
|
|
66
|
+
|
|
67
|
+
/** Debounce (ms) applied to free-typing inputs. Default 400. */
|
|
68
|
+
debounceMs?: number;
|
|
69
|
+
|
|
70
|
+
/** Enable URL sync. See {@link UrlSyncOption}. Default disabled. */
|
|
71
|
+
url?: UrlSyncOption;
|
|
72
|
+
|
|
73
|
+
/** Right-side action buttons. */
|
|
74
|
+
actions?: DataTableHeaderAction[];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Refresh handler. When provided, a default refresh button (mirroring the
|
|
78
|
+
* DataTable's standard refresh button) is rendered before the actions. Omit
|
|
79
|
+
* to hide it.
|
|
80
|
+
*/
|
|
81
|
+
onRefresh?: () => void | Promise<void>;
|
|
82
|
+
|
|
83
|
+
/** Optional content rendered between filters and actions. */
|
|
84
|
+
children?: ReactNode;
|
|
85
|
+
|
|
86
|
+
className?: string;
|
|
87
|
+
filtersClassName?: string;
|
|
88
|
+
actionsClassName?: string;
|
|
89
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { FilterFieldConfig } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type-aware (de)serialization between filter values and URL query params.
|
|
5
|
+
*
|
|
6
|
+
* Design rules:
|
|
7
|
+
* - `null` / `''` / `undefined` values are OMITTED (a missing key means "no
|
|
8
|
+
* filter"); we never serialize empty values.
|
|
9
|
+
* - Numbers round-trip via String/Number (NaN dropped).
|
|
10
|
+
* - Booleans (checkbox/switch) round-trip via "true"/"false".
|
|
11
|
+
* - Multiple values (multi-select / multi autocomplete / arrays) use repeated
|
|
12
|
+
* keys, read back via `getAll`.
|
|
13
|
+
* - Date-family values serialize to ISO; ranges use two suffixed keys
|
|
14
|
+
* `<name>_from` / `<name>_to`.
|
|
15
|
+
* - select/radio values are recovered against `field.options` so the original
|
|
16
|
+
* typed value (string | number | boolean | null) is preserved.
|
|
17
|
+
* - Unsupported field types (object/array/custom_field/hidden) are skipped.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DATE_TYPES = new Set([
|
|
21
|
+
'date',
|
|
22
|
+
'date_picker',
|
|
23
|
+
'month',
|
|
24
|
+
'time',
|
|
25
|
+
'date_time',
|
|
26
|
+
]);
|
|
27
|
+
const RANGE_TYPES = new Set([
|
|
28
|
+
'date_range',
|
|
29
|
+
'date_time_range',
|
|
30
|
+
'month_range',
|
|
31
|
+
'time_range',
|
|
32
|
+
]);
|
|
33
|
+
const SKIP_TYPES = new Set(['object', 'array', 'custom_field', 'hidden']);
|
|
34
|
+
|
|
35
|
+
const isEmpty = (v: unknown) => v === null || v === undefined || v === '';
|
|
36
|
+
|
|
37
|
+
const toIso = (v: unknown): string | null => {
|
|
38
|
+
const d = v instanceof Date ? v : new Date(v as string);
|
|
39
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const fromIso = (raw: string): Date | null => {
|
|
43
|
+
const d = new Date(raw);
|
|
44
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Recover a select/radio value's original type by matching against options. */
|
|
48
|
+
const recoverOptionValue = (field: FilterFieldConfig, raw: string): unknown => {
|
|
49
|
+
const match = field.options?.find((o) => String(o.value) === raw);
|
|
50
|
+
return match ? match.value : raw;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Numeric-looking strings become numbers (autocomplete IDs); else stay strings. */
|
|
54
|
+
const coerceScalar = (raw: string): string | number =>
|
|
55
|
+
raw !== '' && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Serialize a full values object into a query-param map. Repeated keys are
|
|
59
|
+
* represented as string[]; everything else as string.
|
|
60
|
+
*/
|
|
61
|
+
export function serializeAll(
|
|
62
|
+
fields: FilterFieldConfig[],
|
|
63
|
+
values: Record<string, unknown>,
|
|
64
|
+
): Record<string, string | string[]> {
|
|
65
|
+
const params: Record<string, string | string[]> = {};
|
|
66
|
+
|
|
67
|
+
for (const field of fields) {
|
|
68
|
+
if (SKIP_TYPES.has(field.type)) continue;
|
|
69
|
+
const name = field.name;
|
|
70
|
+
const value = values[name];
|
|
71
|
+
|
|
72
|
+
if (RANGE_TYPES.has(field.type)) {
|
|
73
|
+
const range = value as { from?: unknown; to?: unknown } | null | undefined;
|
|
74
|
+
const from = range && !isEmpty(range.from) ? toIso(range.from) : null;
|
|
75
|
+
const to = range && !isEmpty(range.to) ? toIso(range.to) : null;
|
|
76
|
+
if (from) params[`${name}_from`] = from;
|
|
77
|
+
if (to) params[`${name}_to`] = to;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isEmpty(value)) continue;
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
const arr = value
|
|
85
|
+
.filter((v) => !isEmpty(v))
|
|
86
|
+
.map((v) => String(v));
|
|
87
|
+
if (arr.length) params[name] = arr;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (DATE_TYPES.has(field.type)) {
|
|
92
|
+
const iso = toIso(value);
|
|
93
|
+
if (iso) params[name] = iso;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
params[name] = String(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return params;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deserialize a query-param map back into typed filter values, guided by field
|
|
105
|
+
* definitions. Only keys present in the params are included in the result.
|
|
106
|
+
*/
|
|
107
|
+
export function deserializeAll(
|
|
108
|
+
fields: FilterFieldConfig[],
|
|
109
|
+
params: Record<string, string | string[]>,
|
|
110
|
+
): Record<string, unknown> {
|
|
111
|
+
const result: Record<string, unknown> = {};
|
|
112
|
+
const first = (v: string | string[] | undefined) =>
|
|
113
|
+
Array.isArray(v) ? v[0] : v;
|
|
114
|
+
|
|
115
|
+
for (const field of fields) {
|
|
116
|
+
if (SKIP_TYPES.has(field.type)) continue;
|
|
117
|
+
const name = field.name;
|
|
118
|
+
|
|
119
|
+
if (RANGE_TYPES.has(field.type)) {
|
|
120
|
+
const from = first(params[`${name}_from`]);
|
|
121
|
+
const to = first(params[`${name}_to`]);
|
|
122
|
+
const range: { from?: Date; to?: Date } = {};
|
|
123
|
+
if (from) {
|
|
124
|
+
const d = fromIso(from);
|
|
125
|
+
if (d) range.from = d;
|
|
126
|
+
}
|
|
127
|
+
if (to) {
|
|
128
|
+
const d = fromIso(to);
|
|
129
|
+
if (d) range.to = d;
|
|
130
|
+
}
|
|
131
|
+
if (range.from || range.to) result[name] = range;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const raw = params[name];
|
|
136
|
+
if (raw === undefined) continue;
|
|
137
|
+
|
|
138
|
+
if (field.multiple || Array.isArray(raw)) {
|
|
139
|
+
const arr = (Array.isArray(raw) ? raw : [raw]).map(coerceScalar);
|
|
140
|
+
result[name] = arr;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const value = raw as string;
|
|
145
|
+
|
|
146
|
+
switch (field.type) {
|
|
147
|
+
case 'number':
|
|
148
|
+
if (value !== '' && !Number.isNaN(Number(value)))
|
|
149
|
+
result[name] = Number(value);
|
|
150
|
+
break;
|
|
151
|
+
case 'checkbox':
|
|
152
|
+
case 'switch':
|
|
153
|
+
result[name] = value === 'true';
|
|
154
|
+
break;
|
|
155
|
+
case 'select':
|
|
156
|
+
case 'radio':
|
|
157
|
+
result[name] = recoverOptionValue(field, value);
|
|
158
|
+
break;
|
|
159
|
+
case 'autocomplete':
|
|
160
|
+
result[name] = coerceScalar(value);
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
if (DATE_TYPES.has(field.type)) {
|
|
164
|
+
const d = fromIso(value);
|
|
165
|
+
if (d) result[name] = d;
|
|
166
|
+
} else {
|
|
167
|
+
result[name] = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useForm } from 'react-hook-form';
|
|
3
|
+
import type { Control, FieldValues } from 'react-hook-form';
|
|
4
|
+
import type { FilterFieldConfig, UrlSyncAdapter } from './types';
|
|
5
|
+
import { deserializeAll, serializeAll } from './urlSerialization';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default URL adapter backed by the browser History API. Suitable for
|
|
9
|
+
* router-less contexts (plain apps, Storybook). In an app whose router owns the
|
|
10
|
+
* URL (e.g. TanStack Router), pass a custom {@link UrlSyncAdapter} instead so
|
|
11
|
+
* the header does not fight the router.
|
|
12
|
+
*/
|
|
13
|
+
export const nativeAdapter: UrlSyncAdapter = {
|
|
14
|
+
read: () => {
|
|
15
|
+
if (typeof window === 'undefined') return {};
|
|
16
|
+
const usp = new URLSearchParams(window.location.search);
|
|
17
|
+
const out: Record<string, string | string[]> = {};
|
|
18
|
+
for (const key of usp.keys()) {
|
|
19
|
+
if (key in out) continue;
|
|
20
|
+
const all = usp.getAll(key);
|
|
21
|
+
out[key] = all.length > 1 ? all : all[0];
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
},
|
|
25
|
+
write: (params) => {
|
|
26
|
+
if (typeof window === 'undefined') return;
|
|
27
|
+
const usp = new URLSearchParams();
|
|
28
|
+
for (const [key, value] of Object.entries(params)) {
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
value.forEach((v) => {
|
|
31
|
+
usp.append(key, v);
|
|
32
|
+
});
|
|
33
|
+
else usp.append(key, value);
|
|
34
|
+
}
|
|
35
|
+
const query = usp.toString();
|
|
36
|
+
const url = query
|
|
37
|
+
? `${window.location.pathname}?${query}`
|
|
38
|
+
: window.location.pathname;
|
|
39
|
+
window.history.replaceState(null, '', url);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface UseDataTableHeaderUrlOptions {
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
fields: FilterFieldConfig[];
|
|
46
|
+
values: Record<string, unknown>;
|
|
47
|
+
adapter: UrlSyncAdapter;
|
|
48
|
+
/** Bulk hydrate used on mount. */
|
|
49
|
+
onHydrate: (next: Record<string, unknown>) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Two-way URL sync for the filter header:
|
|
54
|
+
* - On mount, reads params from the adapter and hydrates the external values.
|
|
55
|
+
* - On every values change, writes the serialized params back to the adapter.
|
|
56
|
+
*
|
|
57
|
+
* Holds no filter state of its own; the external `values` remain the single
|
|
58
|
+
* source of truth.
|
|
59
|
+
*/
|
|
60
|
+
export function useDataTableHeaderUrl({
|
|
61
|
+
enabled,
|
|
62
|
+
fields,
|
|
63
|
+
values,
|
|
64
|
+
adapter,
|
|
65
|
+
onHydrate,
|
|
66
|
+
}: UseDataTableHeaderUrlOptions): void {
|
|
67
|
+
const hydratedRef = useRef(false);
|
|
68
|
+
const lastWrittenRef = useRef<string | null>(null);
|
|
69
|
+
|
|
70
|
+
// Mount: read from URL -> hydrate external state (once).
|
|
71
|
+
// biome-ignore lint: _
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!enabled || hydratedRef.current) return;
|
|
74
|
+
hydratedRef.current = true;
|
|
75
|
+
const params = adapter.read();
|
|
76
|
+
const hydrated = deserializeAll(fields, params);
|
|
77
|
+
// Record what we're hydrating so the write-effect doesn't echo it back.
|
|
78
|
+
lastWrittenRef.current = JSON.stringify(serializeAll(fields, hydrated));
|
|
79
|
+
if (Object.keys(hydrated).length) onHydrate(hydrated);
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
}, [enabled]);
|
|
82
|
+
|
|
83
|
+
// Changes: write serialized values back to the URL (deduped).
|
|
84
|
+
// biome-ignore lint: _
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!enabled) return;
|
|
87
|
+
const params = serializeAll(fields, values);
|
|
88
|
+
const serialized = JSON.stringify(params);
|
|
89
|
+
if (serialized === lastWrittenRef.current) return;
|
|
90
|
+
lastWrittenRef.current = serialized;
|
|
91
|
+
adapter.write(params);
|
|
92
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
93
|
+
}, [enabled, values]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A throwaway react-hook-form control used solely to satisfy date field
|
|
98
|
+
* components that call `useWatch({ control })`. It holds NO filter data —
|
|
99
|
+
* nothing ever writes to it — so it does not violate the "no internal state"
|
|
100
|
+
* rule. Cross-field date constraints (`minDateFromField`/`maxDateFromField`)
|
|
101
|
+
* resolve against this empty form and therefore won't apply in filters; static
|
|
102
|
+
* `minDate`/`maxDate` still work.
|
|
103
|
+
*/
|
|
104
|
+
export function useFilterControl(): Control<FieldValues> {
|
|
105
|
+
const { control } = useForm<FieldValues>();
|
|
106
|
+
return control;
|
|
107
|
+
}
|