gbs-add-block 1.2.7 → 1.2.8
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/README.md +3 -3
- package/index.cjs +9 -9
- package/package.json +1 -1
- package/source/components/datagridbeta/README.md +232 -0
- package/source/components/datagridbeta/__tests__/core.test.ts +341 -0
- package/source/components/datagridbeta/__tests__/export.test.ts +70 -0
- package/source/components/datagridbeta/core/columnHelper.ts +21 -0
- package/source/components/datagridbeta/core/columns.ts +288 -0
- package/source/components/datagridbeta/core/filtering.ts +237 -0
- package/source/components/datagridbeta/core/grid.ts +616 -0
- package/source/components/datagridbeta/core/index.ts +14 -0
- package/source/components/datagridbeta/core/rows.ts +64 -0
- package/source/components/datagridbeta/core/sorting.ts +111 -0
- package/source/components/datagridbeta/core/state.ts +82 -0
- package/source/components/datagridbeta/core/store.ts +52 -0
- package/source/components/datagridbeta/core/types.ts +266 -0
- package/source/components/datagridbeta/core/values.ts +98 -0
- package/source/components/datagridbeta/core/virtual.ts +85 -0
- package/source/components/datagridbeta/export/csv.ts +32 -0
- package/source/components/datagridbeta/export/download.ts +12 -0
- package/source/components/datagridbeta/export/pdf.ts +61 -0
- package/source/components/datagridbeta/export/table.ts +73 -0
- package/source/components/datagridbeta/export/xlsx.ts +145 -0
- package/source/components/datagridbeta/export/zip.ts +87 -0
- package/source/components/datagridbeta/index.ts +7 -0
- package/source/components/datagridbeta/react/Cell.tsx +282 -0
- package/source/components/datagridbeta/react/ColumnMenu.tsx +135 -0
- package/source/components/datagridbeta/react/DataGrid.tsx +296 -0
- package/source/components/datagridbeta/react/FilterForm.tsx +142 -0
- package/source/components/datagridbeta/react/HeaderRow.tsx +306 -0
- package/source/components/datagridbeta/react/Pagination.tsx +107 -0
- package/source/components/datagridbeta/react/Popover.tsx +79 -0
- package/source/components/datagridbeta/react/Row.tsx +85 -0
- package/source/components/datagridbeta/react/Toolbar.tsx +266 -0
- package/source/components/datagridbeta/react/Viewport.tsx +167 -0
- package/source/components/datagridbeta/react/context.ts +57 -0
- package/source/components/datagridbeta/react/hooks.ts +80 -0
- package/source/components/datagridbeta/react/icons.tsx +72 -0
- package/source/components/datagridbeta/react/keyboard.ts +110 -0
- package/source/components/datagridbeta/react/locale.ts +128 -0
- package/source/components/datagridbeta/styles.css +717 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeLayout, resolveColumns } from "../core/columns";
|
|
3
|
+
import { createInitialState } from "../core/state";
|
|
4
|
+
import type { ColumnDef } from "../core/types";
|
|
5
|
+
import { createFormatters } from "../core/values";
|
|
6
|
+
import { toCsv } from "../export/csv";
|
|
7
|
+
import { buildPrintHtml } from "../export/pdf";
|
|
8
|
+
import { buildExportTable } from "../export/table";
|
|
9
|
+
import { columnName, createXlsxBlob, escapeXml } from "../export/xlsx";
|
|
10
|
+
import { crc32 } from "../export/zip";
|
|
11
|
+
|
|
12
|
+
interface Person {
|
|
13
|
+
id: number;
|
|
14
|
+
name: string;
|
|
15
|
+
age: number | null;
|
|
16
|
+
joined: string;
|
|
17
|
+
active: boolean;
|
|
18
|
+
status: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const defs: ColumnDef<Person>[] = [
|
|
22
|
+
{ field: "id", type: "number" },
|
|
23
|
+
{ field: "name" },
|
|
24
|
+
{ field: "age", type: "number" },
|
|
25
|
+
{ field: "joined", type: "date" },
|
|
26
|
+
{ field: "active", type: "boolean" },
|
|
27
|
+
{ field: "status", options: [{ label: "Active", value: "active" }, { label: "New", value: "new" }] },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const people: Person[] = [
|
|
31
|
+
{ id: 1, name: '=HYPERLINK("x"), "quoted"', age: 34, joined: "2024-01-15", active: true, status: "active" },
|
|
32
|
+
{ id: 2, name: "bob", age: null, joined: "2023-06-01", active: false, status: "new" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const columns = resolveColumns(defs);
|
|
36
|
+
const layout = computeLayout(columns, createInitialState({ data: people, columns: defs }));
|
|
37
|
+
const table = buildExportTable(people, layout.columns, createFormatters("en-US"));
|
|
38
|
+
|
|
39
|
+
describe("export", () => {
|
|
40
|
+
it("escapes CSV fields and neutralizes formulas", () => {
|
|
41
|
+
const [header, first, second] = toCsv(table).split("\r\n");
|
|
42
|
+
expect(header).toBe("Id,Name,Age,Joined,Active,Status");
|
|
43
|
+
expect(first).toBe(`1,"'=HYPERLINK(""x""), ""quoted""",34,"Jan 15, 2024",Yes,Active`);
|
|
44
|
+
expect(second).toBe(`2,bob,,"Jun 1, 2023",No,New`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("writes a typed, escaped XLSX package", async () => {
|
|
48
|
+
const bytes = new Uint8Array(await createXlsxBlob(table, "People & Co").arrayBuffer());
|
|
49
|
+
expect(String.fromCharCode(...bytes.slice(0, 4))).toBe("PK\x03\x04");
|
|
50
|
+
|
|
51
|
+
const text = new TextDecoder().decode(bytes);
|
|
52
|
+
expect(text).toContain(""quoted"");
|
|
53
|
+
expect(text).toContain('<c r="C2"><v>34</v></c>');
|
|
54
|
+
expect(text).toContain('<c r="E3" t="b"><v>0</v></c>');
|
|
55
|
+
expect(text).toContain('<c r="D2" s="2">');
|
|
56
|
+
expect(text).toContain('name="People & Co"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("names columns, escapes XML and checksums like the spec", () => {
|
|
60
|
+
expect([0, 25, 26, 701, 702].map(columnName)).toEqual(["A", "Z", "AA", "ZZ", "AAA"]);
|
|
61
|
+
expect(escapeXml("a<b>&")).toBe("a<b>&");
|
|
62
|
+
expect(crc32([new TextEncoder().encode("hello")])).toBe(0x3610a686);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("escapes HTML in the print view", () => {
|
|
66
|
+
const html = buildPrintHtml(table, { title: "<Report>", orientation: "landscape", paperSize: "A4" });
|
|
67
|
+
expect(html).toContain("<Report>");
|
|
68
|
+
expect(html).not.toContain("<Report>");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ColumnDef } from "./types";
|
|
2
|
+
|
|
3
|
+
type Base<T, V> = Omit<ColumnDef<T, V>, "id" | "field" | "accessor">;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Typed column builders: `value` in `cell`, `format`, `validate` and friends is
|
|
7
|
+
* inferred from the field or accessor.
|
|
8
|
+
*/
|
|
9
|
+
export function createColumnHelper<T>() {
|
|
10
|
+
return {
|
|
11
|
+
field<K extends Extract<keyof T, string>>(field: K, def: Base<T, T[K]> & { id?: string } = {}): ColumnDef<T> {
|
|
12
|
+
return { ...def, field } as unknown as ColumnDef<T>;
|
|
13
|
+
},
|
|
14
|
+
accessor<V>(id: string, accessor: (row: T) => V, def: Base<T, V> = {}): ColumnDef<T> {
|
|
15
|
+
return { ...def, id, accessor } as unknown as ColumnDef<T>;
|
|
16
|
+
},
|
|
17
|
+
display(id: string, def: Base<T, undefined>): ColumnDef<T> {
|
|
18
|
+
return { ...def, id } as unknown as ColumnDef<T>;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnDef,
|
|
3
|
+
ColumnLayout,
|
|
4
|
+
ColumnLayoutItem,
|
|
5
|
+
ColumnPinningState,
|
|
6
|
+
GridState,
|
|
7
|
+
PinSide,
|
|
8
|
+
ResolvedColumn,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
export const SELECTION_COLUMN_ID = "__select__";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_WIDTH = 160;
|
|
14
|
+
const DEFAULT_MIN_WIDTH = 60;
|
|
15
|
+
const DEFAULT_MAX_WIDTH = 1200;
|
|
16
|
+
|
|
17
|
+
type LayoutState = Pick<
|
|
18
|
+
GridState,
|
|
19
|
+
"columnOrder" | "columnVisibility" | "columnSizing" | "columnPinning"
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
export function getColumnId<T>(def: ColumnDef<T>): string {
|
|
23
|
+
const id = def.id ?? def.field;
|
|
24
|
+
if (!id) {
|
|
25
|
+
throw new Error("[DataGrid] Every column needs an `id` or a `field`.");
|
|
26
|
+
}
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function humanize(id: string): string {
|
|
31
|
+
const spaced = id
|
|
32
|
+
.replace(/[_-]+/g, " ")
|
|
33
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
34
|
+
.trim();
|
|
35
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
39
|
+
Math.min(max, Math.max(min, value));
|
|
40
|
+
|
|
41
|
+
export function resolveColumns<T>(
|
|
42
|
+
defs: readonly ColumnDef<T>[],
|
|
43
|
+
): ResolvedColumn<T>[] {
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
|
|
46
|
+
return defs.map((def) => {
|
|
47
|
+
const id = getColumnId(def);
|
|
48
|
+
if (seen.has(id)) {
|
|
49
|
+
throw new Error(`[DataGrid] Duplicate column id "${id}".`);
|
|
50
|
+
}
|
|
51
|
+
seen.add(id);
|
|
52
|
+
|
|
53
|
+
const { field, accessor } = def;
|
|
54
|
+
const hasValue = accessor !== undefined || field !== undefined;
|
|
55
|
+
const getValue: (row: T) => unknown = accessor
|
|
56
|
+
? (row) => accessor(row)
|
|
57
|
+
: field
|
|
58
|
+
? (row) => row[field]
|
|
59
|
+
: () => undefined;
|
|
60
|
+
const type = def.type ?? "string";
|
|
61
|
+
const minWidth = def.minWidth ?? DEFAULT_MIN_WIDTH;
|
|
62
|
+
const maxWidth = def.maxWidth ?? DEFAULT_MAX_WIDTH;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
def,
|
|
67
|
+
header: def.header ?? humanize(id),
|
|
68
|
+
type,
|
|
69
|
+
hasValue,
|
|
70
|
+
getValue,
|
|
71
|
+
width: clamp(def.width ?? DEFAULT_WIDTH, minWidth, maxWidth),
|
|
72
|
+
minWidth,
|
|
73
|
+
maxWidth,
|
|
74
|
+
align:
|
|
75
|
+
def.align ??
|
|
76
|
+
(type === "number" ? "end" : type === "boolean" ? "center" : "start"),
|
|
77
|
+
sortable: hasValue && def.sortable !== false,
|
|
78
|
+
filterable: hasValue && def.filterable !== false,
|
|
79
|
+
resizable: def.resizable !== false,
|
|
80
|
+
hideable: def.hideable !== false,
|
|
81
|
+
reorderable: def.reorderable !== false,
|
|
82
|
+
pinnable: def.pinnable !== false,
|
|
83
|
+
searchable: hasValue && def.searchable !== false,
|
|
84
|
+
exportable:
|
|
85
|
+
(hasValue || def.exportValue !== undefined) && def.exportable !== false,
|
|
86
|
+
internal: false,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSelectionColumn<T>(): ResolvedColumn<T> {
|
|
92
|
+
return {
|
|
93
|
+
id: SELECTION_COLUMN_ID,
|
|
94
|
+
def: { id: SELECTION_COLUMN_ID },
|
|
95
|
+
header: "",
|
|
96
|
+
type: "boolean",
|
|
97
|
+
hasValue: false,
|
|
98
|
+
getValue: () => undefined,
|
|
99
|
+
width: 44,
|
|
100
|
+
minWidth: 44,
|
|
101
|
+
maxWidth: 44,
|
|
102
|
+
align: "center",
|
|
103
|
+
sortable: false,
|
|
104
|
+
filterable: false,
|
|
105
|
+
resizable: false,
|
|
106
|
+
hideable: false,
|
|
107
|
+
reorderable: false,
|
|
108
|
+
pinnable: false,
|
|
109
|
+
searchable: false,
|
|
110
|
+
exportable: false,
|
|
111
|
+
internal: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Columns in user order: ids from `order` first, the rest in definition order. */
|
|
116
|
+
export function orderColumns<T>(
|
|
117
|
+
columns: readonly ResolvedColumn<T>[],
|
|
118
|
+
order: readonly string[],
|
|
119
|
+
): ResolvedColumn<T>[] {
|
|
120
|
+
if (order.length === 0) return [...columns];
|
|
121
|
+
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
122
|
+
const result: ResolvedColumn<T>[] = [];
|
|
123
|
+
const used = new Set<string>();
|
|
124
|
+
for (const id of order) {
|
|
125
|
+
const column = byId.get(id);
|
|
126
|
+
if (column && !used.has(id)) {
|
|
127
|
+
result.push(column);
|
|
128
|
+
used.add(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const column of columns) {
|
|
132
|
+
if (!used.has(column.id)) result.push(column);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getColumnWidth<T>(
|
|
138
|
+
column: ResolvedColumn<T>,
|
|
139
|
+
sizing: Record<string, number>,
|
|
140
|
+
): number {
|
|
141
|
+
const width = sizing[column.id];
|
|
142
|
+
return width === undefined
|
|
143
|
+
? column.width
|
|
144
|
+
: clamp(width, column.minWidth, column.maxWidth);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function computeLayout<T>(
|
|
148
|
+
columns: readonly ResolvedColumn<T>[],
|
|
149
|
+
state: LayoutState,
|
|
150
|
+
leading: readonly ResolvedColumn<T>[] = [],
|
|
151
|
+
): ColumnLayout<T> {
|
|
152
|
+
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
153
|
+
const isVisible = (c: ResolvedColumn<T> | undefined): c is ResolvedColumn<T> =>
|
|
154
|
+
c !== undefined && state.columnVisibility[c.id] !== false;
|
|
155
|
+
|
|
156
|
+
const pinnedIds = new Set([
|
|
157
|
+
...state.columnPinning.left,
|
|
158
|
+
...state.columnPinning.right,
|
|
159
|
+
]);
|
|
160
|
+
const leftColumns = [
|
|
161
|
+
...leading,
|
|
162
|
+
...state.columnPinning.left.map((id) => byId.get(id)).filter(isVisible),
|
|
163
|
+
];
|
|
164
|
+
const rightColumns = state.columnPinning.right
|
|
165
|
+
.map((id) => byId.get(id))
|
|
166
|
+
.filter(isVisible);
|
|
167
|
+
const centerColumns = orderColumns(columns, state.columnOrder).filter(
|
|
168
|
+
(c) => !pinnedIds.has(c.id) && isVisible(c),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
let visibleIndex = 0;
|
|
172
|
+
const build = (
|
|
173
|
+
section: ResolvedColumn<T>[],
|
|
174
|
+
pinned: PinSide | false,
|
|
175
|
+
): [ColumnLayoutItem<T>[], number] => {
|
|
176
|
+
let offset = 0;
|
|
177
|
+
const items = section.map((column) => {
|
|
178
|
+
const width = getColumnWidth(column, state.columnSizing);
|
|
179
|
+
const index = visibleIndex++;
|
|
180
|
+
const style: Record<string, string> = { width: `var(--dg-c${index}-w)` };
|
|
181
|
+
if (pinned === "left") style.insetInlineStart = `var(--dg-c${index}-o)`;
|
|
182
|
+
if (pinned === "right") style.insetInlineEnd = `var(--dg-c${index}-o)`;
|
|
183
|
+
const item = { column, width, pinned, offset, visibleIndex: index, style, pinEdge: false };
|
|
184
|
+
offset += width;
|
|
185
|
+
return item;
|
|
186
|
+
});
|
|
187
|
+
return [items, offset];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const [left, leftWidth] = build(leftColumns, "left");
|
|
191
|
+
const [center, centerWidth] = build(centerColumns, false);
|
|
192
|
+
const [right, rightWidth] = build(rightColumns, "right");
|
|
193
|
+
const lastLeft = left.at(-1);
|
|
194
|
+
if (lastLeft) lastLeft.pinEdge = true;
|
|
195
|
+
if (right[0]) right[0].pinEdge = true;
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
columns: [...left, ...center, ...right],
|
|
199
|
+
left,
|
|
200
|
+
center,
|
|
201
|
+
right,
|
|
202
|
+
leftWidth,
|
|
203
|
+
centerWidth,
|
|
204
|
+
rightWidth,
|
|
205
|
+
totalWidth: leftWidth + centerWidth + rightWidth,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* CSS custom properties that size and position every column. Resizing only
|
|
211
|
+
* rewrites these variables, so cells never re-render during a drag.
|
|
212
|
+
*/
|
|
213
|
+
export function getLayoutVars<T>(
|
|
214
|
+
layout: ColumnLayout<T>,
|
|
215
|
+
overrides?: Record<string, number>,
|
|
216
|
+
): Record<string, string> {
|
|
217
|
+
const widthOf = (item: ColumnLayoutItem<T>) =>
|
|
218
|
+
overrides?.[item.column.id] ?? item.width;
|
|
219
|
+
const vars: Record<string, string> = {};
|
|
220
|
+
let total = 0;
|
|
221
|
+
|
|
222
|
+
let leftOffset = 0;
|
|
223
|
+
for (const item of layout.left) {
|
|
224
|
+
const width = widthOf(item);
|
|
225
|
+
vars[`--dg-c${item.visibleIndex}-w`] = `${width}px`;
|
|
226
|
+
vars[`--dg-c${item.visibleIndex}-o`] = `${leftOffset}px`;
|
|
227
|
+
leftOffset += width;
|
|
228
|
+
}
|
|
229
|
+
for (const item of layout.center) {
|
|
230
|
+
vars[`--dg-c${item.visibleIndex}-w`] = `${widthOf(item)}px`;
|
|
231
|
+
total += widthOf(item);
|
|
232
|
+
}
|
|
233
|
+
let rightOffset = 0;
|
|
234
|
+
for (let i = layout.right.length - 1; i >= 0; i--) {
|
|
235
|
+
const item = layout.right[i];
|
|
236
|
+
const width = widthOf(item);
|
|
237
|
+
vars[`--dg-c${item.visibleIndex}-w`] = `${width}px`;
|
|
238
|
+
vars[`--dg-c${item.visibleIndex}-o`] = `${rightOffset}px`;
|
|
239
|
+
rightOffset += width;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
total += leftOffset + rightOffset;
|
|
243
|
+
vars["--dg-total-width"] = `${total}px`;
|
|
244
|
+
return vars;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function pinColumn(
|
|
248
|
+
pinning: ColumnPinningState,
|
|
249
|
+
columnId: string,
|
|
250
|
+
side: PinSide | false,
|
|
251
|
+
): ColumnPinningState {
|
|
252
|
+
const left = pinning.left.filter((id) => id !== columnId);
|
|
253
|
+
const right = pinning.right.filter((id) => id !== columnId);
|
|
254
|
+
if (side === "left") left.push(columnId);
|
|
255
|
+
if (side === "right") right.unshift(columnId);
|
|
256
|
+
return { left, right };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Moves a column next to another one, adopting the target's pin section. */
|
|
260
|
+
export function moveColumn<T>(
|
|
261
|
+
columns: readonly ResolvedColumn<T>[],
|
|
262
|
+
state: Pick<GridState, "columnOrder" | "columnPinning">,
|
|
263
|
+
columnId: string,
|
|
264
|
+
targetId: string,
|
|
265
|
+
placement: "before" | "after",
|
|
266
|
+
): Pick<GridState, "columnOrder" | "columnPinning"> {
|
|
267
|
+
if (columnId === targetId) return state;
|
|
268
|
+
|
|
269
|
+
const insert = (ids: string[]) => {
|
|
270
|
+
const next = ids.filter((id) => id !== columnId);
|
|
271
|
+
const targetIndex = next.indexOf(targetId);
|
|
272
|
+
if (targetIndex === -1) return next;
|
|
273
|
+
next.splice(placement === "before" ? targetIndex : targetIndex + 1, 0, columnId);
|
|
274
|
+
return next;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const { left, right } = state.columnPinning;
|
|
278
|
+
const stripped = pinColumn(state.columnPinning, columnId, false);
|
|
279
|
+
|
|
280
|
+
if (left.includes(targetId)) {
|
|
281
|
+
return { ...state, columnPinning: { ...stripped, left: insert(stripped.left) } };
|
|
282
|
+
}
|
|
283
|
+
if (right.includes(targetId)) {
|
|
284
|
+
return { ...state, columnPinning: { ...stripped, right: insert(stripped.right) } };
|
|
285
|
+
}
|
|
286
|
+
const order = orderColumns(columns, state.columnOrder).map((c) => c.id);
|
|
287
|
+
return { columnOrder: insert(order), columnPinning: stripped };
|
|
288
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnFilter,
|
|
3
|
+
FilterOperator,
|
|
4
|
+
FilterValue,
|
|
5
|
+
GridRow,
|
|
6
|
+
ResolvedColumn,
|
|
7
|
+
} from "./types";
|
|
8
|
+
import {
|
|
9
|
+
formatCellValue,
|
|
10
|
+
isEmptyValue,
|
|
11
|
+
startOfDay,
|
|
12
|
+
startOfNextDay,
|
|
13
|
+
toTime,
|
|
14
|
+
type Formatters,
|
|
15
|
+
} from "./values";
|
|
16
|
+
|
|
17
|
+
const STRING_OPERATORS: FilterOperator[] = [
|
|
18
|
+
"contains",
|
|
19
|
+
"notContains",
|
|
20
|
+
"equals",
|
|
21
|
+
"notEquals",
|
|
22
|
+
"startsWith",
|
|
23
|
+
"endsWith",
|
|
24
|
+
"isEmpty",
|
|
25
|
+
"isNotEmpty",
|
|
26
|
+
];
|
|
27
|
+
const NUMBER_OPERATORS: FilterOperator[] = [
|
|
28
|
+
"equals",
|
|
29
|
+
"notEquals",
|
|
30
|
+
"gt",
|
|
31
|
+
"gte",
|
|
32
|
+
"lt",
|
|
33
|
+
"lte",
|
|
34
|
+
"between",
|
|
35
|
+
"isEmpty",
|
|
36
|
+
"isNotEmpty",
|
|
37
|
+
];
|
|
38
|
+
const DATE_OPERATORS: FilterOperator[] = [
|
|
39
|
+
"equals",
|
|
40
|
+
"before",
|
|
41
|
+
"after",
|
|
42
|
+
"between",
|
|
43
|
+
"isEmpty",
|
|
44
|
+
"isNotEmpty",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function getFilterOperators<T>(column: ResolvedColumn<T>): FilterOperator[] {
|
|
48
|
+
if (column.def.options) return ["in", "isEmpty", "isNotEmpty"];
|
|
49
|
+
switch (column.type) {
|
|
50
|
+
case "number":
|
|
51
|
+
return NUMBER_OPERATORS;
|
|
52
|
+
case "date":
|
|
53
|
+
return DATE_OPERATORS;
|
|
54
|
+
case "boolean":
|
|
55
|
+
return ["equals"];
|
|
56
|
+
default:
|
|
57
|
+
return STRING_OPERATORS;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hasValue = (value: FilterValue | undefined) =>
|
|
62
|
+
value !== undefined &&
|
|
63
|
+
value !== null &&
|
|
64
|
+
value !== "" &&
|
|
65
|
+
!(Array.isArray(value) && value.length === 0);
|
|
66
|
+
|
|
67
|
+
/** A filter with no usable value (e.g. an empty text box) matches everything. */
|
|
68
|
+
export function isFilterActive(filter: ColumnFilter): boolean {
|
|
69
|
+
switch (filter.operator) {
|
|
70
|
+
case "isEmpty":
|
|
71
|
+
case "isNotEmpty":
|
|
72
|
+
return true;
|
|
73
|
+
case "between":
|
|
74
|
+
return hasValue(filter.value) || hasValue(filter.value2);
|
|
75
|
+
default:
|
|
76
|
+
return hasValue(filter.value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const toNumber = (value: unknown): number | null => {
|
|
81
|
+
if (value === null || value === undefined || value === "") return null;
|
|
82
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
83
|
+
return Number.isNaN(n) ? null : n;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type Predicate<T> = (row: T) => boolean;
|
|
87
|
+
|
|
88
|
+
export function compileFilter<T>(
|
|
89
|
+
column: ResolvedColumn<T>,
|
|
90
|
+
filter: ColumnFilter,
|
|
91
|
+
formatters: Formatters,
|
|
92
|
+
): Predicate<T> | null {
|
|
93
|
+
if (!isFilterActive(filter)) return null;
|
|
94
|
+
|
|
95
|
+
const { getValue } = column;
|
|
96
|
+
const { filterFn } = column.def;
|
|
97
|
+
if (filterFn) return (row) => filterFn(getValue(row), filter, row);
|
|
98
|
+
|
|
99
|
+
const { operator } = filter;
|
|
100
|
+
if (operator === "isEmpty") return (row) => isEmptyValue(getValue(row));
|
|
101
|
+
if (operator === "isNotEmpty") return (row) => !isEmptyValue(getValue(row));
|
|
102
|
+
|
|
103
|
+
if (operator === "in") {
|
|
104
|
+
const values = Array.isArray(filter.value) ? filter.value : [filter.value];
|
|
105
|
+
const allowed = new Set(values.map(String));
|
|
106
|
+
return (row) => allowed.has(String(getValue(row)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
switch (column.type) {
|
|
110
|
+
case "number": {
|
|
111
|
+
const a = toNumber(filter.value);
|
|
112
|
+
const b = toNumber(filter.value2);
|
|
113
|
+
return (row) => {
|
|
114
|
+
const v = toNumber(getValue(row));
|
|
115
|
+
if (v === null) return operator === "notEquals";
|
|
116
|
+
switch (operator) {
|
|
117
|
+
case "equals": return a === null || v === a;
|
|
118
|
+
case "notEquals": return a === null || v !== a;
|
|
119
|
+
case "gt": return a === null || v > a;
|
|
120
|
+
case "gte": return a === null || v >= a;
|
|
121
|
+
case "lt": return a === null || v < a;
|
|
122
|
+
case "lte": return a === null || v <= a;
|
|
123
|
+
case "between": return (a === null || v >= a) && (b === null || v <= b);
|
|
124
|
+
default: return true;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "date": {
|
|
130
|
+
const a = toTime(filter.value);
|
|
131
|
+
const b = toTime(filter.value2);
|
|
132
|
+
const aStart = a === null ? null : startOfDay(a);
|
|
133
|
+
const aEnd = a === null ? null : startOfNextDay(a);
|
|
134
|
+
const bEnd = b === null ? null : startOfNextDay(b);
|
|
135
|
+
return (row) => {
|
|
136
|
+
const t = toTime(getValue(row));
|
|
137
|
+
if (t === null) return false;
|
|
138
|
+
switch (operator) {
|
|
139
|
+
case "equals": return aStart === null || (t >= aStart && t < aEnd!);
|
|
140
|
+
case "before": return aStart === null || t < aStart;
|
|
141
|
+
case "after": return aEnd === null || t >= aEnd;
|
|
142
|
+
case "between":
|
|
143
|
+
return (aStart === null || t >= aStart) && (bEnd === null || t < bEnd);
|
|
144
|
+
default: return true;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case "boolean": {
|
|
150
|
+
const wanted = filter.value === true || filter.value === "true";
|
|
151
|
+
return (row) => Boolean(getValue(row)) === wanted;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
default: {
|
|
155
|
+
const needle = String(filter.value).toLocaleLowerCase();
|
|
156
|
+
const text = (row: T) =>
|
|
157
|
+
formatCellValue(column, getValue(row), row, formatters).toLocaleLowerCase();
|
|
158
|
+
switch (operator) {
|
|
159
|
+
case "contains": return (row) => text(row).includes(needle);
|
|
160
|
+
case "notContains": return (row) => !text(row).includes(needle);
|
|
161
|
+
case "equals": return (row) => text(row) === needle;
|
|
162
|
+
case "notEquals": return (row) => text(row) !== needle;
|
|
163
|
+
case "startsWith": return (row) => text(row).startsWith(needle);
|
|
164
|
+
case "endsWith": return (row) => text(row).endsWith(needle);
|
|
165
|
+
default: return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Search text per row, built once per (columns, formatters) and reused across
|
|
173
|
+
* keystrokes. Rows are rebuilt when `data` changes, which invalidates the cache.
|
|
174
|
+
*/
|
|
175
|
+
const searchCache = new WeakMap<
|
|
176
|
+
readonly ResolvedColumn<unknown>[],
|
|
177
|
+
{ formatters: Formatters; text: WeakMap<object, string> }
|
|
178
|
+
>();
|
|
179
|
+
|
|
180
|
+
function getSearchText<T>(
|
|
181
|
+
row: GridRow<T>,
|
|
182
|
+
columns: readonly ResolvedColumn<T>[],
|
|
183
|
+
formatters: Formatters,
|
|
184
|
+
): string {
|
|
185
|
+
const key = columns as readonly ResolvedColumn<unknown>[];
|
|
186
|
+
let entry = searchCache.get(key);
|
|
187
|
+
if (!entry || entry.formatters !== formatters) {
|
|
188
|
+
entry = { formatters, text: new WeakMap() };
|
|
189
|
+
searchCache.set(key, entry);
|
|
190
|
+
}
|
|
191
|
+
let text = entry.text.get(row);
|
|
192
|
+
if (text === undefined) {
|
|
193
|
+
const parts: string[] = [];
|
|
194
|
+
for (const column of columns) {
|
|
195
|
+
if (!column.searchable) continue;
|
|
196
|
+
const value = column.getValue(row.original);
|
|
197
|
+
parts.push(formatCellValue(column, value, row.original, formatters));
|
|
198
|
+
}
|
|
199
|
+
text = parts.join("").toLocaleLowerCase();
|
|
200
|
+
entry.text.set(row, text);
|
|
201
|
+
}
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Applies column filters (AND) and the global search (every term must match). */
|
|
206
|
+
export function filterRows<T>(
|
|
207
|
+
rows: GridRow<T>[],
|
|
208
|
+
columns: readonly ResolvedColumn<T>[],
|
|
209
|
+
filters: readonly ColumnFilter[],
|
|
210
|
+
globalFilter: string,
|
|
211
|
+
formatters: Formatters,
|
|
212
|
+
): GridRow<T>[] {
|
|
213
|
+
const byId = new Map(columns.map((c) => [c.id, c]));
|
|
214
|
+
const predicates: Predicate<T>[] = [];
|
|
215
|
+
for (const filter of filters) {
|
|
216
|
+
const column = byId.get(filter.columnId);
|
|
217
|
+
const predicate = column && compileFilter(column, filter, formatters);
|
|
218
|
+
if (predicate) predicates.push(predicate);
|
|
219
|
+
}
|
|
220
|
+
const terms = globalFilter.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
221
|
+
|
|
222
|
+
// Returning the same array keeps downstream memoization intact.
|
|
223
|
+
if (predicates.length === 0 && terms.length === 0) return rows;
|
|
224
|
+
|
|
225
|
+
return rows.filter((row) => {
|
|
226
|
+
for (const predicate of predicates) {
|
|
227
|
+
if (!predicate(row.original)) return false;
|
|
228
|
+
}
|
|
229
|
+
if (terms.length > 0) {
|
|
230
|
+
const text = getSearchText(row, columns, formatters);
|
|
231
|
+
for (const term of terms) {
|
|
232
|
+
if (!text.includes(term)) return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
});
|
|
237
|
+
}
|