@jielga/tmdatagrid 2.3.0 → 2.5.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/dist/index.d.ts +289 -9
- package/dist/index.js +1418 -1280
- package/dist/index.js.map +1 -1
- package/docs/columns.md +8 -0
- package/docs/editors.md +31 -0
- package/docs/filtering.md +2 -1
- package/docs/grouping.md +1 -1
- package/docs/localization.md +3 -3
- package/docs/persistence.md +88 -0
- package/docs/typed-deep-links.md +169 -0
- package/package.json +1 -1
- package/skills/appearance/SKILL.md +1 -1
- package/skills/appearance/references/labels.md +3 -3
- package/skills/cell-selection/SKILL.md +1 -1
- package/skills/columns/SKILL.md +5 -2
- package/skills/columns/references/columns-api.md +1 -0
- package/skills/data/SKILL.md +1 -1
- package/skills/editing/SKILL.md +15 -2
- package/skills/editing/references/editing-api.md +2 -1
- package/skills/editing/references/editors-and-validation.md +24 -0
- package/skills/filtering/SKILL.md +3 -2
- package/skills/getting-started/SKILL.md +2 -2
- package/skills/grouping/SKILL.md +2 -2
- package/skills/migrating-to-2/SKILL.md +1 -1
- package/skills/options/SKILL.md +30 -6
- package/skills/options/references/typed-links.md +134 -0
- package/skills/rows/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing-components/SKILL.md +1 -1
- package/skills/testing-editing/SKILL.md +1 -1
- package/src/components/TMDataGridFilterPills.tsx +15 -1
- package/src/components/TMDataGridGroupColumn.tsx +19 -4
- package/src/components/TMDataGridHeaderFilterRow.tsx +7 -1
- package/src/core/editEngine.ts +101 -5
- package/src/core/filterOperators.ts +41 -18
- package/src/core/grouping.ts +11 -3
- package/src/core/labels.ts +4 -3
- package/src/core/persistence.ts +9 -1
- package/src/core/urlState.ts +439 -0
- package/src/core/valueText.ts +46 -0
- package/src/index.ts +15 -0
- package/src/useTMDataGrid.tsx +51 -8
|
@@ -6,6 +6,9 @@ import type {
|
|
|
6
6
|
TableFeatures,
|
|
7
7
|
} from "@tanstack/react-table";
|
|
8
8
|
import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
|
|
9
|
+
import type { TMDataGridOption } from "./columnOptions";
|
|
10
|
+
import type { TMDataGridLabels } from "./labels";
|
|
11
|
+
import { formatValueText } from "./valueText";
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* The value shape stored in `columnFilters` for every TMDataGrid column.
|
|
@@ -75,7 +78,7 @@ export const FILTER_OPERATOR_LABELS: Record<TMDataGridFilterOperator, string> =
|
|
|
75
78
|
isNotEmpty: "is not empty",
|
|
76
79
|
};
|
|
77
80
|
|
|
78
|
-
const STRING_OPERATORS
|
|
81
|
+
const STRING_OPERATORS = [
|
|
79
82
|
"contains",
|
|
80
83
|
"equals",
|
|
81
84
|
"notEquals",
|
|
@@ -83,9 +86,9 @@ const STRING_OPERATORS: readonly TMDataGridFilterOperator[] = [
|
|
|
83
86
|
"endsWith",
|
|
84
87
|
"isEmpty",
|
|
85
88
|
"isNotEmpty",
|
|
86
|
-
];
|
|
89
|
+
] as const satisfies readonly TMDataGridFilterOperator[];
|
|
87
90
|
|
|
88
|
-
const NUMBER_OPERATORS
|
|
91
|
+
const NUMBER_OPERATORS = [
|
|
89
92
|
"equals",
|
|
90
93
|
"notEquals",
|
|
91
94
|
"greaterThan",
|
|
@@ -95,12 +98,12 @@ const NUMBER_OPERATORS: readonly TMDataGridFilterOperator[] = [
|
|
|
95
98
|
"between",
|
|
96
99
|
"isEmpty",
|
|
97
100
|
"isNotEmpty",
|
|
98
|
-
];
|
|
101
|
+
] as const satisfies readonly TMDataGridFilterOperator[];
|
|
99
102
|
|
|
100
103
|
// `equals` doubles as a date's "is": it compares by calendar day whenever the
|
|
101
104
|
// cell holds a Date (see matchesFilter), so a separate `is` operator would be
|
|
102
105
|
// the same comparison under a second name.
|
|
103
|
-
const DATE_OPERATORS
|
|
106
|
+
const DATE_OPERATORS = [
|
|
104
107
|
"equals",
|
|
105
108
|
"notEquals",
|
|
106
109
|
"before",
|
|
@@ -110,23 +113,23 @@ const DATE_OPERATORS: readonly TMDataGridFilterOperator[] = [
|
|
|
110
113
|
"between",
|
|
111
114
|
"isEmpty",
|
|
112
115
|
"isNotEmpty",
|
|
113
|
-
];
|
|
116
|
+
] as const satisfies readonly TMDataGridFilterOperator[];
|
|
114
117
|
|
|
115
|
-
const BOOLEAN_OPERATORS
|
|
118
|
+
const BOOLEAN_OPERATORS = [
|
|
116
119
|
"equals",
|
|
117
120
|
"notEquals",
|
|
118
121
|
"isEmpty",
|
|
119
122
|
"isNotEmpty",
|
|
120
|
-
];
|
|
123
|
+
] as const satisfies readonly TMDataGridFilterOperator[];
|
|
121
124
|
|
|
122
125
|
// One set for both select types: the operators are about membership in the
|
|
123
126
|
// filter's set, which reads the same whether the cell holds one value or many.
|
|
124
|
-
const SELECT_OPERATORS
|
|
127
|
+
const SELECT_OPERATORS = [
|
|
125
128
|
"isAnyOf",
|
|
126
129
|
"isNoneOf",
|
|
127
130
|
"isEmpty",
|
|
128
131
|
"isNotEmpty",
|
|
129
|
-
];
|
|
132
|
+
] as const satisfies readonly TMDataGridFilterOperator[];
|
|
130
133
|
|
|
131
134
|
/** Operators that ignore the value input (the input is disabled for these). */
|
|
132
135
|
const VALUELESS_OPERATORS: readonly TMDataGridFilterOperator[] = [
|
|
@@ -143,16 +146,27 @@ const ARRAY_OPERATORS: readonly TMDataGridFilterOperator[] = [
|
|
|
143
146
|
/** Operators whose value is a `[min, max]` pair. */
|
|
144
147
|
const RANGE_OPERATORS: readonly TMDataGridFilterOperator[] = ["between"];
|
|
145
148
|
|
|
146
|
-
const OPERATORS_BY_TYPE
|
|
147
|
-
TMDataGridColumnType,
|
|
148
|
-
readonly TMDataGridFilterOperator[]
|
|
149
|
-
> = {
|
|
149
|
+
const OPERATORS_BY_TYPE = {
|
|
150
150
|
string: STRING_OPERATORS,
|
|
151
151
|
number: NUMBER_OPERATORS,
|
|
152
152
|
boolean: BOOLEAN_OPERATORS,
|
|
153
153
|
date: DATE_OPERATORS,
|
|
154
154
|
select: SELECT_OPERATORS,
|
|
155
155
|
multiSelect: SELECT_OPERATORS,
|
|
156
|
+
} as const satisfies Record<
|
|
157
|
+
TMDataGridColumnType,
|
|
158
|
+
readonly TMDataGridFilterOperator[]
|
|
159
|
+
>;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The operators each column type offers, as a type: the same lists the filter
|
|
163
|
+
* panel offers, so a typed link can never name an operator the panel lacks.
|
|
164
|
+
*
|
|
165
|
+
* @internal Feeds the typed links in urlState.ts; not exported from the
|
|
166
|
+
* package.
|
|
167
|
+
*/
|
|
168
|
+
export type OperatorsByColumnType = {
|
|
169
|
+
[TType in TMDataGridColumnType]: (typeof OPERATORS_BY_TYPE)[TType][number];
|
|
156
170
|
};
|
|
157
171
|
|
|
158
172
|
const DEFAULT_OPERATOR_BY_TYPE: Record<
|
|
@@ -307,30 +321,39 @@ export function activeColumnFilters<TData extends RowData>(
|
|
|
307
321
|
*
|
|
308
322
|
* The type's default operator is left implicit - "First name: Sofia" reads the
|
|
309
323
|
* way a person would say it - while any other operator is spelled out, since
|
|
310
|
-
* that is the part a reader cannot guess.
|
|
324
|
+
* that is the part a reader cannot guess. A value with an option reads as the
|
|
325
|
+
* option's label, and a boolean as `labels.booleanTrue` / `booleanFalse`.
|
|
311
326
|
*/
|
|
312
327
|
export function formatFilterLabel({
|
|
313
328
|
label,
|
|
314
329
|
type,
|
|
315
330
|
filter,
|
|
316
331
|
operatorLabels = FILTER_OPERATOR_LABELS,
|
|
332
|
+
options,
|
|
333
|
+
labels,
|
|
317
334
|
}: {
|
|
318
335
|
label: string;
|
|
319
336
|
type: TMDataGridColumnType;
|
|
320
337
|
filter: TMDataGridFilterValue;
|
|
321
338
|
/** Localized operator names - `labels.operators`. Defaults to English. */
|
|
322
339
|
operatorLabels?: Record<TMDataGridFilterOperator, string>;
|
|
340
|
+
/** The column's options, so a value reads as its option's label. */
|
|
341
|
+
options?: ReadonlyArray<TMDataGridOption>;
|
|
342
|
+
/** What a boolean value reads as. Defaults to English. */
|
|
343
|
+
labels?: Pick<TMDataGridLabels, "booleanTrue" | "booleanFalse">;
|
|
323
344
|
}): string {
|
|
324
345
|
const { operator, value } = filter;
|
|
325
346
|
if (!operatorNeedsValue(operator)) {
|
|
326
347
|
return `${label} ${operatorLabels[operator]}`;
|
|
327
348
|
}
|
|
349
|
+
const valueText = (entry: string) =>
|
|
350
|
+
formatValueText(String(entry).trim(), { type, options, labels });
|
|
328
351
|
const text =
|
|
329
352
|
operator === "between" && Array.isArray(value)
|
|
330
|
-
? value.map(
|
|
353
|
+
? value.map(valueText).join("–")
|
|
331
354
|
: Array.isArray(value)
|
|
332
|
-
? value.join(", ")
|
|
333
|
-
: String(value)
|
|
355
|
+
? value.map(valueText).join(", ")
|
|
356
|
+
: valueText(String(value));
|
|
334
357
|
if (operator === getDefaultOperator(type)) return `${label}: ${text}`;
|
|
335
358
|
return `${label} ${operatorLabels[operator]} ${text}`;
|
|
336
359
|
}
|
package/src/core/grouping.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Row, RowData } from "@tanstack/react-table";
|
|
2
2
|
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
3
|
+
import { formatValueText, type TMDataGridValueTextOptions } from "./valueText";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The data rows under a group row, at any depth.
|
|
@@ -29,14 +30,21 @@ const BLANK_GROUP_LABEL = "(Blank)";
|
|
|
29
30
|
* Deliberately not the grouped column's own `cell` renderer: that renderer is
|
|
30
31
|
* written for a data row and is free to reach into `row.original`, which on a
|
|
31
32
|
* group row is the first leaf's record rather than anything about the group.
|
|
33
|
+
* `options` carries what the chrome knows of the column instead - its type,
|
|
34
|
+
* its option labels and the boolean labels.
|
|
32
35
|
*/
|
|
33
36
|
export function formatGroupValue(
|
|
34
37
|
value: unknown,
|
|
35
38
|
blankLabel = BLANK_GROUP_LABEL,
|
|
39
|
+
options?: TMDataGridValueTextOptions,
|
|
36
40
|
): string {
|
|
37
|
-
if (
|
|
41
|
+
if (
|
|
42
|
+
value === null ||
|
|
43
|
+
value === undefined ||
|
|
44
|
+
value === "" ||
|
|
45
|
+
(Array.isArray(value) && value.length === 0)
|
|
46
|
+
) {
|
|
38
47
|
return blankLabel;
|
|
39
48
|
}
|
|
40
|
-
|
|
41
|
-
return String(value);
|
|
49
|
+
return formatValueText(value, options);
|
|
42
50
|
}
|
package/src/core/labels.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
FILTER_OPERATOR_LABELS,
|
|
3
3
|
type TMDataGridFilterOperator,
|
|
4
4
|
} from "./filterOperators";
|
|
5
|
+
import { BOOLEAN_LABELS_EN } from "./valueText";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Every user-facing string in the grid - menu items, panels, tooltips, the
|
|
@@ -55,7 +56,7 @@ export type TMDataGridLabels = {
|
|
|
55
56
|
filterTo: string;
|
|
56
57
|
/** `DgTriStateFilter`'s no-filter segment. */
|
|
57
58
|
filterAll: string;
|
|
58
|
-
/** What a boolean
|
|
59
|
+
/** What a boolean `true` reads as - its filter choice, filter pill and group row. */
|
|
59
60
|
booleanTrue: string;
|
|
60
61
|
booleanFalse: string;
|
|
61
62
|
addFilter: string;
|
|
@@ -225,8 +226,8 @@ export const TMDATAGRID_LABELS_EN: TMDataGridLabels = {
|
|
|
225
226
|
filterFrom: "From",
|
|
226
227
|
filterTo: "To",
|
|
227
228
|
filterAll: "All",
|
|
228
|
-
booleanTrue:
|
|
229
|
-
booleanFalse:
|
|
229
|
+
booleanTrue: BOOLEAN_LABELS_EN.booleanTrue,
|
|
230
|
+
booleanFalse: BOOLEAN_LABELS_EN.booleanFalse,
|
|
230
231
|
addFilter: "Add filter",
|
|
231
232
|
clearAllFilters: "Clear all",
|
|
232
233
|
closeFilters: "Close filters",
|
package/src/core/persistence.ts
CHANGED
|
@@ -515,7 +515,15 @@ export function collectUrlExcludedColumnIds(
|
|
|
515
515
|
return into;
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Visits every leaf column definition under `defs` with the id TanStack gives
|
|
520
|
+
* it: `id`, else the `accessorKey` with dots replaced by underscores, else a
|
|
521
|
+
* string `header`.
|
|
522
|
+
*
|
|
523
|
+
* @internal Shared by persistence and the typed links in urlState.ts; not
|
|
524
|
+
* exported from the package.
|
|
525
|
+
*/
|
|
526
|
+
export function forEachLeafColumnDef(
|
|
519
527
|
defs: ReadonlyArray<unknown>,
|
|
520
528
|
visit: (id: string, record: Record<string, unknown>) => void,
|
|
521
529
|
): void {
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import type { TMDataGridColumnType } from "./filterOperators";
|
|
2
|
+
import type {
|
|
3
|
+
OperatorsByColumnType,
|
|
4
|
+
TMDataGridColumnFilter,
|
|
5
|
+
TMDataGridFilterOperator,
|
|
6
|
+
TMDataGridFilterValue,
|
|
7
|
+
} from "./filterOperators";
|
|
8
|
+
import {
|
|
9
|
+
DATA_STATE_SLICES,
|
|
10
|
+
forEachLeafColumnDef,
|
|
11
|
+
type TMDataGridDataSlice,
|
|
12
|
+
type TMDataGridPersistence,
|
|
13
|
+
type TMDataGridPersistKey,
|
|
14
|
+
type TMDataGridUrlStorage,
|
|
15
|
+
} from "./persistence";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Typed deep links.
|
|
19
|
+
*
|
|
20
|
+
* `createTMDataGridColumnHelper` returns columns that carry, in their type
|
|
21
|
+
* only, what a link needs to know about them: the id, the `meta.type`, the
|
|
22
|
+
* value type, the offered operators, and whether the column can be filtered,
|
|
23
|
+
* sorted and put in the URL. `createTMDataGridUrlState` reads that from the
|
|
24
|
+
* grid's own columns, so a link can name only what the grid can show: a
|
|
25
|
+
* filter on a column the grid lacks, an operator its type does not offer or a
|
|
26
|
+
* value of the wrong type is a compile error, not a link that silently opens
|
|
27
|
+
* unfiltered.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here is visible at runtime except the definition object. The
|
|
30
|
+
* column brand is a type, never a property.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// The column brand
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a column built with `createTMDataGridColumnHelper` tells a typed link
|
|
39
|
+
* about itself. Read from literal values in the column definition: a value
|
|
40
|
+
* that is not a literal (`enableSorting: isAdmin`) reads as "not allowed".
|
|
41
|
+
*/
|
|
42
|
+
export type TMDataGridColumnInfo = {
|
|
43
|
+
/** The column id, by TanStack's rule: `id`, else the accessor key with `.` replaced by `_`. */
|
|
44
|
+
id: string;
|
|
45
|
+
/** `meta.type`, `"string"` when unset. */
|
|
46
|
+
type: TMDataGridColumnType;
|
|
47
|
+
/** The value type the accessor returns. */
|
|
48
|
+
value: unknown;
|
|
49
|
+
/** `meta.filter.operators`, every operator when unset. */
|
|
50
|
+
operators: TMDataGridFilterOperator;
|
|
51
|
+
/** `enableColumnFilter` is not `false`. */
|
|
52
|
+
filterable: boolean;
|
|
53
|
+
/** `enableSorting` is not `false`. */
|
|
54
|
+
sortable: boolean;
|
|
55
|
+
/** `meta.filter.url` is not `false`. */
|
|
56
|
+
url: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
declare const columnInfo: unique symbol;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The type-only brand a column helper column carries. Never present at
|
|
63
|
+
* runtime, so spreading, cloning or serializing a column is unaffected.
|
|
64
|
+
*/
|
|
65
|
+
export type TMDataGridTypedColumn<TInfo extends TMDataGridColumnInfo> = {
|
|
66
|
+
readonly [columnInfo]: TInfo;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The info of every leaf column in `TColumn`, a group's children included. */
|
|
70
|
+
export type TMDataGridColumnInfoOf<TColumn> =
|
|
71
|
+
TColumn extends TMDataGridTypedColumn<infer TInfo> ? TInfo : never;
|
|
72
|
+
|
|
73
|
+
/** `"address.city"` becomes `"address_city"`, as TanStack derives the id. */
|
|
74
|
+
type DotsToUnderscores<S extends string> = S extends `${infer Head}.${infer Tail}`
|
|
75
|
+
? `${Head}_${DotsToUnderscores<Tail>}`
|
|
76
|
+
: S;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The column id the helper's `accessor` call produces: the explicit `id`,
|
|
80
|
+
* else the accessor key. An accessor function without an `id` gets `never`,
|
|
81
|
+
* which no link can name.
|
|
82
|
+
*/
|
|
83
|
+
export type TMDataGridAccessorColumnId<TAccessor, TId extends string> = [TId] extends [never]
|
|
84
|
+
? TAccessor extends string
|
|
85
|
+
? DotsToUnderscores<TAccessor>
|
|
86
|
+
: never
|
|
87
|
+
: TId;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The column options whose literal values a typed link reads. Intersected into
|
|
91
|
+
* the column helper's parameter so each one is inferred as a literal.
|
|
92
|
+
*
|
|
93
|
+
* @internal
|
|
94
|
+
*/
|
|
95
|
+
export type TMDataGridColumnInfoHints<
|
|
96
|
+
TId extends string,
|
|
97
|
+
TType extends TMDataGridColumnType,
|
|
98
|
+
TOperator extends TMDataGridFilterOperator,
|
|
99
|
+
TFilterable extends boolean,
|
|
100
|
+
TSortable extends boolean,
|
|
101
|
+
TUrl extends boolean,
|
|
102
|
+
> = {
|
|
103
|
+
id?: TId;
|
|
104
|
+
enableColumnFilter?: TFilterable;
|
|
105
|
+
enableSorting?: TSortable;
|
|
106
|
+
meta?: {
|
|
107
|
+
type?: TType;
|
|
108
|
+
filter?: { operators?: ReadonlyArray<TOperator>; url?: TUrl };
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Link input
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** A `Date`, or an ISO `YYYY-MM-DD` string. */
|
|
117
|
+
export type TMDataGridDateInput = Date | string;
|
|
118
|
+
|
|
119
|
+
/** One choice of a select column: the value, or an element of an array value. */
|
|
120
|
+
type OptionOf<TValue> =
|
|
121
|
+
NonNullable<TValue> extends ReadonlyArray<infer TElement> ? TElement : NonNullable<TValue>;
|
|
122
|
+
|
|
123
|
+
type ScalarInput = {
|
|
124
|
+
string: string;
|
|
125
|
+
number: number;
|
|
126
|
+
date: TMDataGridDateInput;
|
|
127
|
+
boolean: boolean;
|
|
128
|
+
select: string;
|
|
129
|
+
multiSelect: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type RangeEnd<TType extends TMDataGridColumnType> = TType extends "date"
|
|
133
|
+
? TMDataGridDateInput
|
|
134
|
+
: TType extends "number"
|
|
135
|
+
? number
|
|
136
|
+
: string;
|
|
137
|
+
|
|
138
|
+
/** One filter on one column: an operator, and the value that operator takes. */
|
|
139
|
+
type FilterInputFor<
|
|
140
|
+
TType extends TMDataGridColumnType,
|
|
141
|
+
TValue,
|
|
142
|
+
TOperator extends TMDataGridFilterOperator,
|
|
143
|
+
> = TOperator extends "isEmpty" | "isNotEmpty"
|
|
144
|
+
? { operator: TOperator }
|
|
145
|
+
: TOperator extends "isAnyOf" | "isNoneOf"
|
|
146
|
+
? { operator: TOperator; value: ReadonlyArray<OptionOf<TValue>> }
|
|
147
|
+
: TOperator extends "between"
|
|
148
|
+
? {
|
|
149
|
+
operator: TOperator;
|
|
150
|
+
/** `null` leaves that end open. */
|
|
151
|
+
value: readonly [from: RangeEnd<TType> | null, to: RangeEnd<TType> | null];
|
|
152
|
+
}
|
|
153
|
+
: { operator: TOperator; value: ScalarInput[TType] };
|
|
154
|
+
|
|
155
|
+
/** Columns a link may filter: filterable, and not kept out of the URL. */
|
|
156
|
+
type LinkFilterable<TInfo extends TMDataGridColumnInfo> = Extract<
|
|
157
|
+
TInfo,
|
|
158
|
+
{ filterable: true; url: true }
|
|
159
|
+
>;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `filters` a link accepts: one optional entry per filterable column.
|
|
163
|
+
* With no filterable column it accepts no entry at all - an empty object type
|
|
164
|
+
* would let any key through, since TypeScript skips the unknown-key check
|
|
165
|
+
* against `{}`.
|
|
166
|
+
*/
|
|
167
|
+
export type TMDataGridUrlFiltersInput<TInfo extends TMDataGridColumnInfo> = [
|
|
168
|
+
LinkFilterable<TInfo>,
|
|
169
|
+
] extends [never]
|
|
170
|
+
? Record<string, never>
|
|
171
|
+
: {
|
|
172
|
+
[TColumn in LinkFilterable<TInfo> as TColumn["id"]]?: FilterInputFor<
|
|
173
|
+
TColumn["type"],
|
|
174
|
+
TColumn["value"],
|
|
175
|
+
Extract<OperatorsByColumnType[TColumn["type"]], TColumn["operators"]>
|
|
176
|
+
>;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The ids a link may sort by. */
|
|
180
|
+
export type TMDataGridUrlSortableId<TInfo extends TMDataGridColumnInfo> = Extract<
|
|
181
|
+
TInfo,
|
|
182
|
+
{ sortable: true }
|
|
183
|
+
>["id"];
|
|
184
|
+
|
|
185
|
+
type Carries<
|
|
186
|
+
TSlices extends ReadonlyArray<TMDataGridDataSlice>,
|
|
187
|
+
TSlice extends TMDataGridDataSlice,
|
|
188
|
+
> = TSlice extends TSlices[number] ? true : false;
|
|
189
|
+
|
|
190
|
+
/** What a link may set: only the slices the definition carries, only the grid's columns. */
|
|
191
|
+
export type TMDataGridUrlInput<
|
|
192
|
+
TInfo extends TMDataGridColumnInfo,
|
|
193
|
+
TSlices extends ReadonlyArray<TMDataGridDataSlice>,
|
|
194
|
+
> = (Carries<TSlices, "columnFilters"> extends true
|
|
195
|
+
? { filters?: TMDataGridUrlFiltersInput<TInfo> }
|
|
196
|
+
: unknown) &
|
|
197
|
+
(Carries<TSlices, "globalFilter"> extends true ? { globalFilter?: string } : unknown) &
|
|
198
|
+
(Carries<TSlices, "sorting"> extends true
|
|
199
|
+
? {
|
|
200
|
+
sorting?: ReadonlyArray<{
|
|
201
|
+
id: TMDataGridUrlSortableId<TInfo>;
|
|
202
|
+
/** Defaults to `false`. */
|
|
203
|
+
desc?: boolean;
|
|
204
|
+
}>;
|
|
205
|
+
}
|
|
206
|
+
: unknown) &
|
|
207
|
+
(Carries<TSlices, "pagination"> extends true
|
|
208
|
+
? { pagination?: { pageIndex: number; pageSize: number } }
|
|
209
|
+
: unknown);
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// The payload and the definition
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/** What the grid keeps in its URL param: the data slices, in the grid's own state shape. */
|
|
216
|
+
export type TMDataGridUrlPayload = {
|
|
217
|
+
columnFilters?: Array<TMDataGridColumnFilter>;
|
|
218
|
+
/** `null` is a search the user cleared, as opposed to one the link does not set. */
|
|
219
|
+
globalFilter?: string | null;
|
|
220
|
+
sorting?: Array<{ id: string; desc: boolean }>;
|
|
221
|
+
pagination?: { pageIndex: number; pageSize: number };
|
|
222
|
+
expanded?: true | Record<string, boolean>;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A router's search, read and written as values rather than strings, for
|
|
227
|
+
* routers that keep objects in their search params and encode them
|
|
228
|
+
* themselves, such as TanStack Router.
|
|
229
|
+
*/
|
|
230
|
+
export type TMDataGridUrlSearchStorage = {
|
|
231
|
+
/** The param's current value in the router's search, or `undefined`. */
|
|
232
|
+
get(name: string): unknown;
|
|
233
|
+
/** Sets the param to `value`, or removes it on `undefined`. */
|
|
234
|
+
set(name: string, value: TMDataGridUrlPayload | undefined): void;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export type TMDataGridUrlState<
|
|
238
|
+
TKey extends string,
|
|
239
|
+
TSlices extends ReadonlyArray<TMDataGridDataSlice>,
|
|
240
|
+
TInfo extends TMDataGridColumnInfo,
|
|
241
|
+
> = {
|
|
242
|
+
/** The URL search param. */
|
|
243
|
+
readonly key: TKey;
|
|
244
|
+
/** The data slices the param carries. */
|
|
245
|
+
readonly slices: TSlices;
|
|
246
|
+
/** For `persist.urlKey`. */
|
|
247
|
+
readonly urlKey: TMDataGridPersistKey<TMDataGridDataSlice>;
|
|
248
|
+
/** The param's value for a link, or `undefined` when the input sets nothing. */
|
|
249
|
+
payload(input: TMDataGridUrlInput<TInfo, TSlices>): TMDataGridUrlPayload | undefined;
|
|
250
|
+
/** The search object for a router link, `{ [key]: payload }`. */
|
|
251
|
+
search(input: TMDataGridUrlInput<TInfo, TSlices>): {
|
|
252
|
+
[K in TKey]: TMDataGridUrlPayload | undefined;
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* The search params for a plain link, encoded the way the grid's default
|
|
256
|
+
* `serialize` encodes them. Pass the `serialize` you gave `persist`, if any.
|
|
257
|
+
*/
|
|
258
|
+
searchParams(
|
|
259
|
+
input: TMDataGridUrlInput<TInfo, TSlices>,
|
|
260
|
+
options?: { serialize?: (payload: TMDataGridUrlPayload) => string },
|
|
261
|
+
): URLSearchParams;
|
|
262
|
+
/**
|
|
263
|
+
* A route search validator: keeps the param when it is an object and drops
|
|
264
|
+
* anything else. The grid checks every slice itself when it reads the param.
|
|
265
|
+
*/
|
|
266
|
+
validateSearch(search: Record<string, unknown>): {
|
|
267
|
+
[K in TKey]?: TMDataGridUrlPayload;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* `urlKey`, plus a `urlStorage` over `storage` when one is given. Without
|
|
271
|
+
* one, the grid reads and writes the browser's URL itself.
|
|
272
|
+
*/
|
|
273
|
+
persist(
|
|
274
|
+
storage?: TMDataGridUrlSearchStorage,
|
|
275
|
+
): Pick<TMDataGridPersistence, "urlKey" | "urlStorage">;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Defines a grid's deep links from its columns. `columns` is the array the
|
|
280
|
+
* grid renders, built with `createTMDataGridColumnHelper`; it decides which
|
|
281
|
+
* filters, operators, values and sorts a link may name.
|
|
282
|
+
*/
|
|
283
|
+
export function createTMDataGridUrlState<
|
|
284
|
+
const TKey extends string,
|
|
285
|
+
TColumns extends ReadonlyArray<unknown>,
|
|
286
|
+
const TSlices extends ReadonlyArray<TMDataGridDataSlice> = typeof DATA_STATE_SLICES,
|
|
287
|
+
>(options: {
|
|
288
|
+
/** The URL search param. */
|
|
289
|
+
key: TKey;
|
|
290
|
+
/** The grid's columns. */
|
|
291
|
+
columns: TColumns;
|
|
292
|
+
/** The data slices the param carries. Defaults to every data slice. */
|
|
293
|
+
slices?: TSlices;
|
|
294
|
+
}): TMDataGridUrlState<TKey, TSlices, TMDataGridColumnInfoOf<TColumns[number]>> {
|
|
295
|
+
const { key, columns } = options;
|
|
296
|
+
const slices = (options.slices ?? DATA_STATE_SLICES) as TSlices;
|
|
297
|
+
const urlKey: TMDataGridPersistKey<TMDataGridDataSlice> = options.slices
|
|
298
|
+
? [key, options.slices]
|
|
299
|
+
: key;
|
|
300
|
+
|
|
301
|
+
const columnTypes = new Map<string, TMDataGridColumnType>();
|
|
302
|
+
forEachLeafColumnDef(columns, (id, record) => {
|
|
303
|
+
const type = (record.meta as { type?: TMDataGridColumnType } | undefined)?.type;
|
|
304
|
+
columnTypes.set(id, type ?? "string");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const payload = (input: LooseUrlInput): TMDataGridUrlPayload | undefined =>
|
|
308
|
+
toPayload(input, columnTypes, slices);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
key,
|
|
312
|
+
slices,
|
|
313
|
+
urlKey,
|
|
314
|
+
payload: (input) => payload(input as LooseUrlInput),
|
|
315
|
+
search: (input) =>
|
|
316
|
+
({ [key]: payload(input as LooseUrlInput) }) as {
|
|
317
|
+
[K in TKey]: TMDataGridUrlPayload | undefined;
|
|
318
|
+
},
|
|
319
|
+
searchParams: (input, { serialize = JSON.stringify } = {}) => {
|
|
320
|
+
const value = payload(input as LooseUrlInput);
|
|
321
|
+
const params = new URLSearchParams();
|
|
322
|
+
if (value !== undefined) params.set(key, serialize(value));
|
|
323
|
+
return params;
|
|
324
|
+
},
|
|
325
|
+
validateSearch: (search) => {
|
|
326
|
+
const value = search[key];
|
|
327
|
+
return (isPlainObject(value) ? { [key]: value } : {}) as {
|
|
328
|
+
[K in TKey]?: TMDataGridUrlPayload;
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
persist: (storage) =>
|
|
332
|
+
storage ? { urlKey, urlStorage: toUrlStorage(storage) } : { urlKey },
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Runtime
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
type LooseFilterInput = { operator: TMDataGridFilterOperator; value?: unknown };
|
|
341
|
+
|
|
342
|
+
type LooseUrlInput = {
|
|
343
|
+
filters?: Record<string, LooseFilterInput | undefined>;
|
|
344
|
+
globalFilter?: string;
|
|
345
|
+
sorting?: ReadonlyArray<{ id: string; desc?: boolean }>;
|
|
346
|
+
pagination?: { pageIndex: number; pageSize: number };
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The input in the grid's own state shape. A slice the definition does not
|
|
351
|
+
* carry is left out even when an untyped caller passes it, since the grid
|
|
352
|
+
* would ignore it in the URL anyway.
|
|
353
|
+
*/
|
|
354
|
+
function toPayload(
|
|
355
|
+
input: LooseUrlInput,
|
|
356
|
+
columnTypes: ReadonlyMap<string, TMDataGridColumnType>,
|
|
357
|
+
slices: ReadonlyArray<TMDataGridDataSlice>,
|
|
358
|
+
): TMDataGridUrlPayload | undefined {
|
|
359
|
+
const payload: TMDataGridUrlPayload = {};
|
|
360
|
+
|
|
361
|
+
if (input.filters && slices.includes("columnFilters")) {
|
|
362
|
+
const columnFilters = Object.entries(input.filters).flatMap(([id, filter]) =>
|
|
363
|
+
filter ? [{ id, value: toFilterValue(filter, columnTypes.get(id)) }] : [],
|
|
364
|
+
);
|
|
365
|
+
if (columnFilters.length > 0) payload.columnFilters = columnFilters;
|
|
366
|
+
}
|
|
367
|
+
if (input.globalFilter && slices.includes("globalFilter")) {
|
|
368
|
+
payload.globalFilter = input.globalFilter;
|
|
369
|
+
}
|
|
370
|
+
if (input.sorting && input.sorting.length > 0 && slices.includes("sorting")) {
|
|
371
|
+
payload.sorting = input.sorting.map(({ id, desc = false }) => ({ id, desc }));
|
|
372
|
+
}
|
|
373
|
+
if (input.pagination && slices.includes("pagination")) {
|
|
374
|
+
payload.pagination = {
|
|
375
|
+
pageIndex: input.pagination.pageIndex,
|
|
376
|
+
pageSize: input.pagination.pageSize,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return Object.keys(payload).length === 0 ? undefined : payload;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Native values in, the grid's filter envelope out: every value a string. */
|
|
384
|
+
function toFilterValue(
|
|
385
|
+
filter: LooseFilterInput,
|
|
386
|
+
type: TMDataGridColumnType | undefined,
|
|
387
|
+
): TMDataGridFilterValue {
|
|
388
|
+
const { operator, value } = filter;
|
|
389
|
+
if (operator === "isEmpty" || operator === "isNotEmpty") {
|
|
390
|
+
return { operator, value: "" };
|
|
391
|
+
}
|
|
392
|
+
if (operator === "isAnyOf" || operator === "isNoneOf") {
|
|
393
|
+
return { operator, value: (value as ReadonlyArray<unknown>).map(String) };
|
|
394
|
+
}
|
|
395
|
+
if (operator === "between") {
|
|
396
|
+
const [from, to] = value as readonly [unknown, unknown];
|
|
397
|
+
return { operator, value: [toFilterString(from, type), toFilterString(to, type)] };
|
|
398
|
+
}
|
|
399
|
+
return { operator, value: toFilterString(value, type) };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function toFilterString(value: unknown, type: TMDataGridColumnType | undefined): string {
|
|
403
|
+
if (value === null || value === undefined) return "";
|
|
404
|
+
if (value instanceof Date) return localIsoDay(value);
|
|
405
|
+
// The date controls write `YYYY-MM-DD`; a full ISO timestamp is cut to it.
|
|
406
|
+
if (type === "date" && typeof value === "string") return value.slice(0, 10);
|
|
407
|
+
return String(value);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The calendar day in local time. `toISOString()` would give the UTC day. */
|
|
411
|
+
function localIsoDay(date: Date): string {
|
|
412
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
413
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
414
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The grid reads and writes its param as the string `serialize` produced
|
|
419
|
+
* (JSON by default); a router like TanStack Router keeps the parsed object and
|
|
420
|
+
* encodes it itself. Both directions go through JSON, so the grid's "the param
|
|
421
|
+
* is unchanged, skip the write" comparison keeps holding.
|
|
422
|
+
*/
|
|
423
|
+
function toUrlStorage(storage: TMDataGridUrlSearchStorage): TMDataGridUrlStorage {
|
|
424
|
+
return {
|
|
425
|
+
get: (name) => {
|
|
426
|
+
const value = storage.get(name);
|
|
427
|
+
if (value === undefined || value === null) return null;
|
|
428
|
+
// A router that left the value as text hands back what the grid wrote.
|
|
429
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
430
|
+
},
|
|
431
|
+
set: (name, value) => {
|
|
432
|
+
storage.set(name, value === null ? undefined : (JSON.parse(value) as TMDataGridUrlPayload));
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
438
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
439
|
+
}
|