@svgrid/grid 2.2.30 → 2.2.32
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 +1 -1
- package/dist/SvAvatar.svelte +1 -1
- package/dist/SvCheckBox.svelte +2 -1
- package/dist/SvCircularProgress.svelte +1 -1
- package/dist/SvGrid.controller.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.js +358 -18
- package/dist/SvGrid.css +103 -3
- package/dist/SvGrid.svelte +162 -10
- package/dist/SvGrid.types.d.ts +81 -0
- package/dist/SvGridDropdown.svelte +38 -6
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvGridSelect.svelte +12 -3
- package/dist/SvGridSelect.svelte.d.ts +6 -0
- package/dist/SvGroupCell.svelte +9 -2
- package/dist/SvMultiSelect.svelte +12 -3
- package/dist/SvMultiSelect.svelte.d.ts +6 -0
- package/dist/SvPasswordInput.svelte +1 -1
- package/dist/SvProgress.svelte +1 -1
- package/dist/SvRating.svelte +1 -1
- package/dist/SvResult.svelte +1 -1
- package/dist/SvRichText.svelte +31 -16
- package/dist/SvRichText.svelte.d.ts +9 -0
- package/dist/SvSwitchButton.svelte +2 -1
- package/dist/SvToaster.svelte +1 -1
- package/dist/SvTree.svelte +9 -2
- package/dist/SvTree.svelte.d.ts +5 -0
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/build-api.js +16 -0
- package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-C6ixSGWW.js} +33 -33
- package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-Cyk3qpJ7.js} +33 -33
- package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-CdqaP61H.js} +2 -1
- package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-Dh-2ej8j.js} +92 -87
- package/dist/cdn/{src-BLJfdyL0.js → src-BgHjWF0Q.js} +4193 -3837
- package/dist/cdn/{src-DYDCiC0D.js → src-CgD-wiuS.js} +5956 -5602
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/cell-render.d.ts +1 -0
- package/dist/cell-render.js +115 -1
- package/dist/cell-values.js +5 -0
- package/dist/core.d.ts +80 -9
- package/dist/core.js +89 -0
- package/dist/editor-contract.d.ts +12 -5
- package/dist/editor-registry.d.ts +18 -7
- package/dist/editor-registry.js +10 -1
- package/dist/group-display.d.ts +39 -0
- package/dist/group-display.js +52 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/keyboard-handlers.js +16 -0
- package/dist/recurrence.js +3 -2
- package/dist/svgrid-wrapper.types.d.ts +8 -2
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +26 -0
- package/package.json +1 -1
- package/src/SvAccordion.test.ts +5 -5
- package/src/SvAvatar.svelte +1 -1
- package/src/SvButtonGroup.test.ts +6 -6
- package/src/SvCheckBox.svelte +2 -1
- package/src/SvCircularProgress.svelte +1 -1
- package/src/SvFileUpload.test.ts +1 -1
- package/src/SvGrid.controller.svelte.ts +367 -17
- package/src/SvGrid.css +103 -3
- package/src/SvGrid.svelte +162 -10
- package/src/SvGrid.types.ts +81 -0
- package/src/SvGridDropdown.svelte +38 -6
- package/src/SvGridSelect.svelte +12 -3
- package/src/SvGroupCell.svelte +9 -2
- package/src/SvMultiSelect.svelte +12 -3
- package/src/SvPasswordInput.svelte +1 -1
- package/src/SvProgress.svelte +1 -1
- package/src/SvRating.svelte +1 -1
- package/src/SvResult.svelte +1 -1
- package/src/SvRichText.svelte +31 -16
- package/src/SvSwitchButton.svelte +2 -1
- package/src/SvToaster.svelte +1 -1
- package/src/SvTree.svelte +9 -2
- package/src/SvTreeSelect.svelte +6 -2
- package/src/build-api.ts +15 -0
- package/src/builtin-editors.test.ts +3 -1
- package/src/cell-render.test.ts +27 -3
- package/src/cell-render.ts +122 -1
- package/src/cell-values.ts +4 -0
- package/src/chart-export.test.ts +2 -2
- package/src/clipboard.test.ts +0 -2
- package/src/core.ts +157 -8
- package/src/editing.test.ts +1 -1
- package/src/editor-contract.ts +12 -5
- package/src/editor-registry.grid.test.ts +48 -0
- package/src/editor-registry.test.ts +3 -1
- package/src/editor-registry.ts +23 -7
- package/src/facet-buckets.test.ts +33 -33
- package/src/group-display.test.ts +91 -1
- package/src/group-display.ts +83 -0
- package/src/index.ts +9 -0
- package/src/keyboard-handlers.ts +17 -0
- package/src/list-power.test.ts +2 -2
- package/src/menus.test.ts +4 -4
- package/src/named-views.coverage.test.ts +3 -3
- package/src/recurrence.ts +3 -2
- package/src/row-resize.test.ts +40 -40
- package/src/scheduler-model.ts +1 -1
- package/src/server-data-source.coverage.test.ts +1 -1
- package/src/server-group-model.test.ts +6 -6
- package/src/spreadsheet.test.ts +0 -1
- package/src/svgrid-wrapper.types.ts +11 -9
- package/src/svgrid.async-editor-options.test.ts +271 -0
- package/src/svgrid.auto-row-height.test.ts +204 -0
- package/src/svgrid.charting.test.ts +4 -4
- package/src/svgrid.comments-autocomplete.test.ts +8 -2
- package/src/svgrid.context-menu.test.ts +25 -9
- package/src/svgrid.filter-depth.test.ts +163 -0
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
- package/src/svgrid.filter-menu-scroll.test.ts +10 -2
- package/src/svgrid.grand-total.test.ts +188 -0
- package/src/svgrid.group-display-mode.test.ts +171 -0
- package/src/svgrid.group-footers.test.ts +121 -0
- package/src/svgrid.group-pagination.test.ts +153 -0
- package/src/svgrid.tree-data.test.ts +186 -0
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
- package/src/themes/index.ts +42 -0
- package/src/tree-edit.test.ts +4 -4
- package/src/tree-row-model.test.ts +169 -0
- package/src/ui-localization.test.ts +113 -0
- package/themes/ag-alpine.css +22 -0
- package/themes/antd.css +22 -0
- package/themes/atlassian.css +22 -0
- package/themes/bootstrap.css +22 -0
- package/themes/carbon.css +22 -0
- package/themes/catppuccin.css +22 -0
- package/themes/dracula.css +22 -0
- package/themes/ember.css +22 -0
- package/themes/excel.css +22 -0
- package/themes/fluent.css +22 -0
- package/themes/github.css +22 -0
- package/themes/linear.css +22 -0
- package/themes/material.css +22 -0
- package/themes/nord.css +22 -0
- package/themes/notion.css +22 -0
- package/themes/salesforce.css +22 -0
- package/themes/sap.css +22 -0
- package/themes/shadcn.css +22 -0
- package/themes/tailwind.css +22 -0
- package/themes/vercel.css +22 -0
package/src/core.ts
CHANGED
|
@@ -45,6 +45,11 @@ export type CellSpanParams<TData extends RowData = RowData> = {
|
|
|
45
45
|
value: unknown
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** The raw option list a column's `editorOptions` can supply. */
|
|
49
|
+
export type EditorOptionSource = ReadonlyArray<
|
|
50
|
+
string | number | { value: string | number; label?: string; color?: string }
|
|
51
|
+
>
|
|
52
|
+
|
|
48
53
|
/** Params passed to a column's `valueParser(...)` on edit commit. */
|
|
49
54
|
export type ValueParserParams<TData extends RowData = RowData> = {
|
|
50
55
|
/** The value after built-in per-`editorType` coercion. */
|
|
@@ -172,6 +177,16 @@ export function applyGroupAggregate<TData extends RowData>(
|
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
/**
|
|
181
|
+
* A column definition.
|
|
182
|
+
*
|
|
183
|
+
* `TFeatures` is a phantom parameter - it is threaded through nested
|
|
184
|
+
* `columns` groups but no member depends on it, so `{}`, `TableFeatures` and
|
|
185
|
+
* `typeof features` are all interchangeable here. It is deliberately left
|
|
186
|
+
* WITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot
|
|
187
|
+
* and silently type your data as `RowData`, losing every field-name check.
|
|
188
|
+
* Prefer {@link GridColumns} / {@link GridColumnDef} for the common case.
|
|
189
|
+
*/
|
|
175
190
|
export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
|
|
176
191
|
id?: string
|
|
177
192
|
field?: keyof TData & string
|
|
@@ -336,16 +351,20 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
|
|
|
336
351
|
*
|
|
337
352
|
* Pass a function `(row) => options` for row-dependent (cascading)
|
|
338
353
|
* options - e.g. City options that depend on Country in the same row.
|
|
354
|
+
*
|
|
355
|
+
* Either form may return a **Promise**, for options that come from the
|
|
356
|
+
* server. While it resolves, the editor shows a loading state and the cell
|
|
357
|
+
* renders its raw value.
|
|
358
|
+
*
|
|
359
|
+
* Results are cached so reopening an editor does not refetch: a static source
|
|
360
|
+
* per column, a per-row source per row AND per that row's data - so a cascade
|
|
361
|
+
* reloads by itself when the cell it depends on is edited. Call
|
|
362
|
+
* `api.refreshEditorOptions(columnId?)` when the list changes server-side.
|
|
339
363
|
*/
|
|
340
364
|
editorOptions?:
|
|
341
|
-
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
| ((
|
|
345
|
-
row: TData,
|
|
346
|
-
) => ReadonlyArray<
|
|
347
|
-
string | number | { value: string | number; label?: string; color?: string }
|
|
348
|
-
>)
|
|
365
|
+
| EditorOptionSource
|
|
366
|
+
| Promise<EditorOptionSource>
|
|
367
|
+
| ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
|
|
349
368
|
/** When true, list/chips allow multiple selections. Cell value becomes an array. */
|
|
350
369
|
editorMultiple?: boolean
|
|
351
370
|
/** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
|
|
@@ -409,6 +428,23 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
|
|
|
409
428
|
| ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
|
|
410
429
|
}
|
|
411
430
|
|
|
431
|
+
/**
|
|
432
|
+
* A column definition keyed only by your row type - the ergonomic form of
|
|
433
|
+
* {@link ColumnDef}, whose first parameter is a phantom feature bag that is
|
|
434
|
+
* almost always `{}`.
|
|
435
|
+
*
|
|
436
|
+
* ```ts
|
|
437
|
+
* const columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]
|
|
438
|
+
* ```
|
|
439
|
+
*
|
|
440
|
+
* Interchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,
|
|
441
|
+
* TData>` in both directions, so it mixes freely with existing code.
|
|
442
|
+
*/
|
|
443
|
+
export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>
|
|
444
|
+
|
|
445
|
+
/** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
|
|
446
|
+
export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
|
|
447
|
+
|
|
412
448
|
export type Column<TData extends RowData> = {
|
|
413
449
|
id: string
|
|
414
450
|
columnDef: ColumnDef<any, TData>
|
|
@@ -647,6 +683,119 @@ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<
|
|
|
647
683
|
return buildGroups(rows, 0, 0, 'group')
|
|
648
684
|
}
|
|
649
685
|
}
|
|
686
|
+
export type TreeRowModelOptions = {
|
|
687
|
+
/** Field holding each row's parent id. Rows with no parent are roots. */
|
|
688
|
+
parentField: string
|
|
689
|
+
/** Field holding the row's own id. Defaults to `'id'`. */
|
|
690
|
+
idField?: string
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Client-side tree data: nest the grid's own flat rows into a parent/child
|
|
695
|
+
* hierarchy that `createExpandedRowModel` then walks.
|
|
696
|
+
*
|
|
697
|
+
* This works on the rows the grid already built rather than on raw data, so
|
|
698
|
+
* tree rows keep their cells, editing, selection and formatting - they are real
|
|
699
|
+
* data rows that happen to have children, not synthetic banners like grouping's.
|
|
700
|
+
* That is also why the model is parent-id based: nested source arrays never
|
|
701
|
+
* become rows (the grid only builds rows for `data`), so nested input is
|
|
702
|
+
* flattened first with {@link flattenTreeData}. One code path, no duplicated
|
|
703
|
+
* row construction.
|
|
704
|
+
*
|
|
705
|
+
* Rows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable
|
|
706
|
+
* data row for a full-width group banner.
|
|
707
|
+
*/
|
|
708
|
+
export function createTreeRowModel<TData extends RowData>(
|
|
709
|
+
options: TreeRowModelOptions,
|
|
710
|
+
): RowModelFactory<TData> {
|
|
711
|
+
const { parentField, idField = 'id' } = options
|
|
712
|
+
return ({ table, rows }) => {
|
|
713
|
+
if (!rows.length) return rows
|
|
714
|
+
const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]
|
|
715
|
+
const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]
|
|
716
|
+
|
|
717
|
+
const present = new Set<unknown>()
|
|
718
|
+
for (const row of rows) present.add(keyOf(row))
|
|
719
|
+
|
|
720
|
+
const childrenByParent = new Map<unknown, Array<Row<TData>>>()
|
|
721
|
+
const roots: Array<Row<TData>> = []
|
|
722
|
+
for (const row of rows) {
|
|
723
|
+
const parent = parentOf(row)
|
|
724
|
+
// A row whose parent is absent (filtered out, or never existed) becomes a
|
|
725
|
+
// root rather than disappearing - silently dropping rows is worse than a
|
|
726
|
+
// shallower tree. Self-parenting is treated the same way.
|
|
727
|
+
if (parent == null || parent === keyOf(row) || !present.has(parent)) {
|
|
728
|
+
roots.push(row)
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
const list = childrenByParent.get(parent) ?? []
|
|
732
|
+
list.push(row)
|
|
733
|
+
childrenByParent.set(parent, list)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Guards a cycle in the parent chain from recursing forever.
|
|
737
|
+
const seen = new Set<unknown>()
|
|
738
|
+
const build = (row: Row<TData>, depth: number): Row<TData> => {
|
|
739
|
+
const key = keyOf(row)
|
|
740
|
+
const id = row.id
|
|
741
|
+
if (seen.has(key)) {
|
|
742
|
+
return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>
|
|
743
|
+
}
|
|
744
|
+
seen.add(key)
|
|
745
|
+
const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))
|
|
746
|
+
return {
|
|
747
|
+
...row,
|
|
748
|
+
depth,
|
|
749
|
+
subRows,
|
|
750
|
+
leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),
|
|
751
|
+
__treeRow: true,
|
|
752
|
+
getCanExpand: () => subRows.length > 0,
|
|
753
|
+
getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
|
|
754
|
+
toggleExpanded: () => {
|
|
755
|
+
table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
|
|
756
|
+
},
|
|
757
|
+
} as Row<TData>
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return roots.map((root) => build(root, 0))
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export type FlattenTreeOptions = {
|
|
765
|
+
/** Field holding an array of child objects. */
|
|
766
|
+
childrenField: string
|
|
767
|
+
/** Field holding each object's id. Defaults to `'id'`. */
|
|
768
|
+
idField?: string
|
|
769
|
+
/** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */
|
|
770
|
+
parentField?: string
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Flatten nested tree data into the flat parent-id shape `createTreeRowModel`
|
|
775
|
+
* consumes, stamping each child with its parent's id.
|
|
776
|
+
*
|
|
777
|
+
* Children are emitted directly after their parent so the natural order already
|
|
778
|
+
* matches the rendered tree. The `childrenField` array is left on the objects
|
|
779
|
+
* (harmless, and callers often still want it); only the parent link is added.
|
|
780
|
+
*/
|
|
781
|
+
export function flattenTreeData<T extends RowData>(
|
|
782
|
+
data: ReadonlyArray<T>,
|
|
783
|
+
options: FlattenTreeOptions,
|
|
784
|
+
): T[] {
|
|
785
|
+
const { childrenField, idField = 'id', parentField = '__parentId' } = options
|
|
786
|
+
const out: T[] = []
|
|
787
|
+
const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {
|
|
788
|
+
for (const node of nodes) {
|
|
789
|
+
const flat = { ...node, [parentField]: parentId } as T
|
|
790
|
+
out.push(flat)
|
|
791
|
+
const kids = (node as any)[childrenField]
|
|
792
|
+
if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
walk(data, null)
|
|
796
|
+
return out
|
|
797
|
+
}
|
|
798
|
+
|
|
650
799
|
export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
|
|
651
800
|
return ({ table, rows }) => {
|
|
652
801
|
const expanded: ExpandedState = table.getState().expanded ?? {}
|
package/src/editing.test.ts
CHANGED
|
@@ -154,7 +154,7 @@ describe('isCellEditable', () => {
|
|
|
154
154
|
data: [{ a: 'hello' }],
|
|
155
155
|
})
|
|
156
156
|
ed.isCellEditable(ctx.allColumns[0], ctx.allRows[0])
|
|
157
|
-
const cc = spy.mock.calls[0][0]
|
|
157
|
+
const cc = spy.mock.calls[0]![0]
|
|
158
158
|
expect(cc.getValue()).toBe('hello')
|
|
159
159
|
expect(cc.cell.getValue()).toBe('hello')
|
|
160
160
|
expect(cc.cell.getContext()).toBe(cc)
|
package/src/editor-contract.ts
CHANGED
|
@@ -68,16 +68,23 @@ export type SvEditorProps = {
|
|
|
68
68
|
* The interaction surface an editor exposes when embedded as a grid cell editor.
|
|
69
69
|
* Standalone usage ignores these; the grid supplies them so every editor shares
|
|
70
70
|
* one commit / cancel / move contract (Enter commits, Escape cancels, Tab moves)
|
|
71
|
-
* instead of each cell-editor re-implementing key handling.
|
|
72
|
-
*
|
|
71
|
+
* instead of each cell-editor re-implementing key handling.
|
|
72
|
+
*
|
|
73
|
+
* `CellEditorContext` (editor-registry.ts) extends this type, so what the grid
|
|
74
|
+
* hands a registered editor and what this contract promises are the same thing
|
|
75
|
+
* by construction rather than by convention.
|
|
73
76
|
*/
|
|
74
77
|
export type EditorInteraction<V = unknown> = {
|
|
75
|
-
/**
|
|
76
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Commit the value and stop editing (Enter, option pick, blur-commit).
|
|
80
|
+
* The argument is optional: an editor that has been reporting through
|
|
81
|
+
* `onChange` can just call `onCommit()` to commit what the grid already has.
|
|
82
|
+
*/
|
|
83
|
+
onCommit?: (value?: V) => void
|
|
77
84
|
/** Abandon the edit and restore the previous value (Escape). */
|
|
78
85
|
onCancel?: () => void
|
|
79
86
|
/** Commit, then move to the adjacent cell (Tab = 1, Shift+Tab = -1). */
|
|
80
|
-
onCommitAndMove?: (value: V, direction: 1 | -1) => void
|
|
87
|
+
onCommitAndMove?: (value: V | undefined, direction: 1 | -1) => void
|
|
81
88
|
/** Ask the surrounding popover/panel to close without committing. */
|
|
82
89
|
onRequestClose?: () => void
|
|
83
90
|
/** True while mounted inside a grid cell (lets an editor tune autofocus etc.). */
|
|
@@ -95,4 +95,52 @@ describe('registry wiring in SvGrid', () => {
|
|
|
95
95
|
destroy()
|
|
96
96
|
}
|
|
97
97
|
})
|
|
98
|
+
|
|
99
|
+
it('hands the editor the full EditorInteraction contract, not a subset', async () => {
|
|
100
|
+
// The contract type used to be documented but never supplied: the registry
|
|
101
|
+
// passed only value/onChange/onCommit/onCancel, so an editor written against
|
|
102
|
+
// `EditorInteraction` (Tab-to-move, dismiss-without-commit, in-cell tuning)
|
|
103
|
+
// silently got undefined for half of it.
|
|
104
|
+
let seen: Record<string, unknown> = {}
|
|
105
|
+
registerCellEditor('stars', {
|
|
106
|
+
component: SvRating,
|
|
107
|
+
props: (ctx) => {
|
|
108
|
+
seen = ctx as unknown as Record<string, unknown>
|
|
109
|
+
return { value: ctx.value }
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const { api, target, destroy } = await mountGrid()
|
|
114
|
+
try {
|
|
115
|
+
api.startEditing(0, 'score')
|
|
116
|
+
await tick()
|
|
117
|
+
await tick()
|
|
118
|
+
expect(typeof seen.onCommit).toBe('function')
|
|
119
|
+
expect(typeof seen.onCancel).toBe('function')
|
|
120
|
+
expect(typeof seen.onChange).toBe('function')
|
|
121
|
+
expect(typeof seen.onCommitAndMove).toBe('function')
|
|
122
|
+
expect(typeof seen.onRequestClose).toBe('function')
|
|
123
|
+
expect(seen.inCell).toBe(true)
|
|
124
|
+
expect(seen.rowId).toBeTruthy()
|
|
125
|
+
expect(seen.columnId).toBe('score')
|
|
126
|
+
void target
|
|
127
|
+
} finally {
|
|
128
|
+
destroy()
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('defaults to passing the whole contract when a registration has no props mapping', async () => {
|
|
133
|
+
// A registration with no `props` should still work for a contract-aware
|
|
134
|
+
// editor - that is the point of the default mapping.
|
|
135
|
+
registerCellEditor('stars', SvRating)
|
|
136
|
+
const { api, target, destroy } = await mountGrid()
|
|
137
|
+
try {
|
|
138
|
+
api.startEditing(0, 'score')
|
|
139
|
+
await tick()
|
|
140
|
+
await tick()
|
|
141
|
+
expect(target.querySelector('.sv-rating')).not.toBeNull()
|
|
142
|
+
} finally {
|
|
143
|
+
destroy()
|
|
144
|
+
}
|
|
145
|
+
})
|
|
98
146
|
})
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
const Fake = (() => {}) as unknown as Component<any>
|
|
16
16
|
const Fake2 = (() => {}) as unknown as Component<any>
|
|
17
17
|
|
|
18
|
+
// onCommitAndMove / onRequestClose / inCell are deliberately left off: the
|
|
19
|
+
// tests below assert that defaultEditorProps maps them to undefined.
|
|
18
20
|
function ctx(overrides: Partial<CellEditorContext> = {}): CellEditorContext {
|
|
19
21
|
return {
|
|
20
22
|
value: 42,
|
|
@@ -24,7 +26,7 @@ function ctx(overrides: Partial<CellEditorContext> = {}): CellEditorContext {
|
|
|
24
26
|
onCommit: () => {},
|
|
25
27
|
onCancel: () => {},
|
|
26
28
|
...overrides,
|
|
27
|
-
}
|
|
29
|
+
} as CellEditorContext
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
afterEach(() => {
|
package/src/editor-registry.ts
CHANGED
|
@@ -20,9 +20,20 @@
|
|
|
20
20
|
* mounting.
|
|
21
21
|
*/
|
|
22
22
|
import type { Component } from 'svelte'
|
|
23
|
+
import type { EditorInteraction } from './editor-contract'
|
|
23
24
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Context the grid hands a cell editor when it mounts one for an edit.
|
|
27
|
+
*
|
|
28
|
+
* Extends {@link EditorInteraction} - the shared commit / cancel / move contract
|
|
29
|
+
* the `Sv*` editors are written against - and narrows the parts the grid always
|
|
30
|
+
* supplies, so a registered component and a built-in editor speak the same
|
|
31
|
+
* language. `onCommitAndMove`, `onRequestClose` and `inCell` come from the
|
|
32
|
+
* contract and are always populated by the grid.
|
|
33
|
+
*/
|
|
34
|
+
export type CellEditorContext<T = unknown> = Required<
|
|
35
|
+
Pick<EditorInteraction<T>, 'onCommit' | 'onCancel' | 'onCommitAndMove' | 'onRequestClose' | 'inCell'>
|
|
36
|
+
> & {
|
|
26
37
|
/** The value currently being edited. */
|
|
27
38
|
value: T
|
|
28
39
|
/** Id of the row being edited. */
|
|
@@ -31,10 +42,6 @@ export type CellEditorContext<T = unknown> = {
|
|
|
31
42
|
columnId: string
|
|
32
43
|
/** Update the in-progress value WITHOUT ending the edit. */
|
|
33
44
|
onChange: (value: T) => void
|
|
34
|
-
/** Commit the value and stop editing (optionally pass a final value). */
|
|
35
|
-
onCommit: (value?: T) => void
|
|
36
|
-
/** Discard changes and stop editing (returns focus to the grid). */
|
|
37
|
-
onCancel: () => void
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/** How a registered component is mounted and wired for a cell edit. */
|
|
@@ -87,13 +94,22 @@ export function registeredCellEditorTypes(): string[] {
|
|
|
87
94
|
return [...registry.keys()]
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The default context → props mapping used when a registration has no `props`.
|
|
99
|
+
* Passes the whole {@link EditorInteraction} surface, so an editor written
|
|
100
|
+
* against the shared contract works when registered with no mapping at all.
|
|
101
|
+
* Svelte ignores props a component doesn't declare, so the extra keys are inert
|
|
102
|
+
* for a simple editor that only wants `value` + `onCommit`.
|
|
103
|
+
*/
|
|
91
104
|
export function defaultEditorProps(ctx: CellEditorContext): Record<string, unknown> {
|
|
92
105
|
return {
|
|
93
106
|
value: ctx.value,
|
|
94
107
|
onChange: ctx.onChange,
|
|
95
108
|
onCommit: ctx.onCommit,
|
|
96
109
|
onCancel: ctx.onCancel,
|
|
110
|
+
onCommitAndMove: ctx.onCommitAndMove,
|
|
111
|
+
onRequestClose: ctx.onRequestClose,
|
|
112
|
+
inCell: ctx.inCell,
|
|
97
113
|
}
|
|
98
114
|
}
|
|
99
115
|
|
|
@@ -62,11 +62,11 @@ describe('isBucketableColumn', () => {
|
|
|
62
62
|
const cols = makeColumns()
|
|
63
63
|
|
|
64
64
|
it('returns { isDate: false } for a number column', () => {
|
|
65
|
-
expect(isBucketableColumn(cols.num)).toEqual({ isDate: false })
|
|
65
|
+
expect(isBucketableColumn(cols.num!)).toEqual({ isDate: false })
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
it('returns { isDate: true } for a date column', () => {
|
|
69
|
-
expect(isBucketableColumn(cols.when)).toEqual({ isDate: true })
|
|
69
|
+
expect(isBucketableColumn(cols.when!)).toEqual({ isDate: true })
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
it('returns { isDate: true } for a datetime column', () => {
|
|
@@ -81,15 +81,15 @@ describe('isBucketableColumn', () => {
|
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
it('returns null for a text column', () => {
|
|
84
|
-
expect(isBucketableColumn(cols.id)).toBeNull()
|
|
84
|
+
expect(isBucketableColumn(cols.id!)).toBeNull()
|
|
85
85
|
})
|
|
86
86
|
|
|
87
87
|
it('returns null for a checkbox column', () => {
|
|
88
|
-
expect(isBucketableColumn(cols.flag)).toBeNull()
|
|
88
|
+
expect(isBucketableColumn(cols.flag!)).toBeNull()
|
|
89
89
|
})
|
|
90
90
|
|
|
91
91
|
it('returns null for a column with no editorType', () => {
|
|
92
|
-
expect(isBucketableColumn(cols.name)).toBeNull()
|
|
92
|
+
expect(isBucketableColumn(cols.name!)).toBeNull()
|
|
93
93
|
})
|
|
94
94
|
})
|
|
95
95
|
|
|
@@ -105,7 +105,7 @@ describe('buildBuckets - numeric', () => {
|
|
|
105
105
|
name: '',
|
|
106
106
|
flag: false,
|
|
107
107
|
}))
|
|
108
|
-
expect(buildBuckets(cols.num
|
|
108
|
+
expect(buildBuckets(cols.num!, false, data, accessor)).toBeNull()
|
|
109
109
|
})
|
|
110
110
|
|
|
111
111
|
it('builds 10 buckets when distinct values exceed the threshold', () => {
|
|
@@ -117,7 +117,7 @@ describe('buildBuckets - numeric', () => {
|
|
|
117
117
|
name: '',
|
|
118
118
|
flag: false,
|
|
119
119
|
}))
|
|
120
|
-
const buckets = buildBuckets(cols.num
|
|
120
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)
|
|
121
121
|
expect(buckets).not.toBeNull()
|
|
122
122
|
expect(buckets!).toHaveLength(10)
|
|
123
123
|
})
|
|
@@ -130,12 +130,12 @@ describe('buildBuckets - numeric', () => {
|
|
|
130
130
|
name: '',
|
|
131
131
|
flag: false,
|
|
132
132
|
}))
|
|
133
|
-
const buckets = buildBuckets(cols.num
|
|
134
|
-
expect(buckets[0]
|
|
135
|
-
expect(buckets[9]
|
|
133
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)!
|
|
134
|
+
expect(buckets[0]!.numericMin).toBe(0)
|
|
135
|
+
expect(buckets[9]!.numericMax).toBe(30)
|
|
136
136
|
// bucket i's max equals bucket i+1's min (contiguous, no gaps)
|
|
137
137
|
for (let i = 0; i < buckets.length - 1; i += 1) {
|
|
138
|
-
expect(buckets[i]
|
|
138
|
+
expect(buckets[i]!.numericMax).toBeCloseTo(buckets[i + 1]!.numericMin, 10)
|
|
139
139
|
}
|
|
140
140
|
})
|
|
141
141
|
|
|
@@ -147,10 +147,10 @@ describe('buildBuckets - numeric', () => {
|
|
|
147
147
|
name: '',
|
|
148
148
|
flag: false,
|
|
149
149
|
}))
|
|
150
|
-
const buckets = buildBuckets(cols.num
|
|
150
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)!
|
|
151
151
|
expect(buckets.filter((b) => b.isLast)).toHaveLength(1)
|
|
152
|
-
expect(buckets[9]
|
|
153
|
-
expect(buckets[0]
|
|
152
|
+
expect(buckets[9]!.isLast).toBe(true)
|
|
153
|
+
expect(buckets[0]!.isLast).toBe(false)
|
|
154
154
|
expect(buckets.every((b) => b.isDate === false)).toBe(true)
|
|
155
155
|
})
|
|
156
156
|
|
|
@@ -162,8 +162,8 @@ describe('buildBuckets - numeric', () => {
|
|
|
162
162
|
name: '',
|
|
163
163
|
flag: false,
|
|
164
164
|
}))
|
|
165
|
-
const buckets = buildBuckets(cols.num
|
|
166
|
-
expect(buckets[0]
|
|
165
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)!
|
|
166
|
+
expect(buckets[0]!.label).toContain('–')
|
|
167
167
|
})
|
|
168
168
|
|
|
169
169
|
it('ignores non-finite / blank values when computing min/max and distinct', () => {
|
|
@@ -180,10 +180,10 @@ describe('buildBuckets - numeric', () => {
|
|
|
180
180
|
{ id: 'b', num: undefined as unknown as number, when: '', name: '', flag: false },
|
|
181
181
|
{ id: 'c', num: 'not-a-number' as unknown as number, when: '', name: '', flag: false },
|
|
182
182
|
]
|
|
183
|
-
const buckets = buildBuckets(cols.num
|
|
183
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)!
|
|
184
184
|
// range is still 0..30 - the junk rows were skipped
|
|
185
|
-
expect(buckets[0]
|
|
186
|
-
expect(buckets[9]
|
|
185
|
+
expect(buckets[0]!.numericMin).toBe(0)
|
|
186
|
+
expect(buckets[9]!.numericMax).toBe(30)
|
|
187
187
|
})
|
|
188
188
|
|
|
189
189
|
it('returns null when all finite values are identical (min === max)', () => {
|
|
@@ -196,7 +196,7 @@ describe('buildBuckets - numeric', () => {
|
|
|
196
196
|
name: '',
|
|
197
197
|
flag: false,
|
|
198
198
|
}))
|
|
199
|
-
expect(buildBuckets(cols.num
|
|
199
|
+
expect(buildBuckets(cols.num!, false, data, accessor)).toBeNull()
|
|
200
200
|
})
|
|
201
201
|
|
|
202
202
|
it('returns null when there is no finite data at all', () => {
|
|
@@ -207,11 +207,11 @@ describe('buildBuckets - numeric', () => {
|
|
|
207
207
|
name: '',
|
|
208
208
|
flag: false,
|
|
209
209
|
}))
|
|
210
|
-
expect(buildBuckets(cols.num
|
|
210
|
+
expect(buildBuckets(cols.num!, false, data, accessor)).toBeNull()
|
|
211
211
|
})
|
|
212
212
|
|
|
213
213
|
it('returns null for an empty dataset', () => {
|
|
214
|
-
expect(buildBuckets(cols.num
|
|
214
|
+
expect(buildBuckets(cols.num!, false, [], accessor)).toBeNull()
|
|
215
215
|
})
|
|
216
216
|
|
|
217
217
|
it('handles negative ranges', () => {
|
|
@@ -222,9 +222,9 @@ describe('buildBuckets - numeric', () => {
|
|
|
222
222
|
name: '',
|
|
223
223
|
flag: false,
|
|
224
224
|
}))
|
|
225
|
-
const buckets = buildBuckets(cols.num
|
|
226
|
-
expect(buckets[0]
|
|
227
|
-
expect(buckets[9]
|
|
225
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)!
|
|
226
|
+
expect(buckets[0]!.numericMin).toBe(-20)
|
|
227
|
+
expect(buckets[9]!.numericMax).toBe(20)
|
|
228
228
|
})
|
|
229
229
|
})
|
|
230
230
|
|
|
@@ -241,11 +241,11 @@ describe('buildBuckets - date', () => {
|
|
|
241
241
|
name: '',
|
|
242
242
|
flag: false,
|
|
243
243
|
}))
|
|
244
|
-
const buckets = buildBuckets(cols.when
|
|
244
|
+
const buckets = buildBuckets(cols.when!, true, data, accessor)!
|
|
245
245
|
expect(buckets).toHaveLength(10)
|
|
246
246
|
expect(buckets.every((b) => b.isDate === true)).toBe(true)
|
|
247
247
|
// labels should look like dates (contain a 4-digit year)
|
|
248
|
-
expect(buckets[0]
|
|
248
|
+
expect(buckets[0]!.label).toMatch(/\d{4}/)
|
|
249
249
|
})
|
|
250
250
|
|
|
251
251
|
it('accepts Date instances directly', () => {
|
|
@@ -257,9 +257,9 @@ describe('buildBuckets - date', () => {
|
|
|
257
257
|
)
|
|
258
258
|
// accessor reads row[column.id]; column id is 'when'
|
|
259
259
|
const rows = data.map((d) => ({ when: d.when })) as unknown as Row[]
|
|
260
|
-
const buckets = buildBuckets(cols.when
|
|
260
|
+
const buckets = buildBuckets(cols.when!, true, rows, accessor)!
|
|
261
261
|
expect(buckets).toHaveLength(10)
|
|
262
|
-
expect(buckets[0]
|
|
262
|
+
expect(buckets[0]!.numericMin).toBe(start)
|
|
263
263
|
})
|
|
264
264
|
|
|
265
265
|
it('returns null when date values are too few', () => {
|
|
@@ -270,7 +270,7 @@ describe('buildBuckets - date', () => {
|
|
|
270
270
|
name: '',
|
|
271
271
|
flag: false,
|
|
272
272
|
}))
|
|
273
|
-
expect(buildBuckets(cols.when
|
|
273
|
+
expect(buildBuckets(cols.when!, true, data, accessor)).toBeNull()
|
|
274
274
|
})
|
|
275
275
|
|
|
276
276
|
it('skips invalid date strings', () => {
|
|
@@ -286,9 +286,9 @@ describe('buildBuckets - date', () => {
|
|
|
286
286
|
})),
|
|
287
287
|
{ id: 'bad', num: 0, when: 'not-a-date', name: '', flag: false },
|
|
288
288
|
]
|
|
289
|
-
const buckets = buildBuckets(cols.when
|
|
289
|
+
const buckets = buildBuckets(cols.when!, true, data, accessor)!
|
|
290
290
|
expect(buckets).toHaveLength(10)
|
|
291
|
-
expect(buckets[0]
|
|
291
|
+
expect(buckets[0]!.numericMin).toBe(start)
|
|
292
292
|
})
|
|
293
293
|
})
|
|
294
294
|
|
|
@@ -344,7 +344,7 @@ describe('isInBucket', () => {
|
|
|
344
344
|
name: '',
|
|
345
345
|
flag: false,
|
|
346
346
|
}))
|
|
347
|
-
const buckets = buildBuckets(cols.num
|
|
347
|
+
const buckets = buildBuckets(cols.num!, false, data, accessor)!
|
|
348
348
|
for (const v of [0, 1, 15, 50, 99, 100]) {
|
|
349
349
|
const hits = buckets.filter((b) => isInBucket(v, b))
|
|
350
350
|
expect(hits).toHaveLength(1)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { buildAutoGroupColumns, insertGroupFooters } from './group-display'
|
|
2
|
+
import { buildAutoGroupColumns, insertGroupFooters, paginateGroupedRows } from './group-display'
|
|
3
3
|
|
|
4
4
|
type R = { id: string; depth: number; group?: boolean; total?: number; footer?: boolean }
|
|
5
5
|
|
|
@@ -75,3 +75,93 @@ describe('buildAutoGroupColumns', () => {
|
|
|
75
75
|
expect(r.autoColumns.map((c) => c.field)).toEqual(['region', 'category'])
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
|
+
|
|
79
|
+
describe('paginateGroupedRows (#73)', () => {
|
|
80
|
+
// Ten groups of two rows each - the shape from the issue report, where
|
|
81
|
+
// slicing the flat list by pageSize leaves almost no data on a page.
|
|
82
|
+
const many: R[] = []
|
|
83
|
+
for (let g = 0; g < 10; g += 1) {
|
|
84
|
+
many.push({ id: `g${g}`, depth: 0, group: true })
|
|
85
|
+
many.push({ id: `g${g}r0`, depth: 1 })
|
|
86
|
+
many.push({ id: `g${g}r1`, depth: 1 })
|
|
87
|
+
}
|
|
88
|
+
const base = { getDepth: (r: R) => r.depth, isGroup: (r: R) => !!r.group }
|
|
89
|
+
|
|
90
|
+
it('counts only data rows against pageSize', () => {
|
|
91
|
+
const { rows, dataRowCount } = paginateGroupedRows(many, { ...base, pageIndex: 0, pageSize: 10 })
|
|
92
|
+
expect(dataRowCount).toBe(20)
|
|
93
|
+
// A naive slice would have yielded 10 rows TOTAL, only ~3 of them data.
|
|
94
|
+
expect(rows.filter((r) => !r.group)).toHaveLength(10)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('reprints the banner each page a group appears on', () => {
|
|
98
|
+
const page0 = paginateGroupedRows(many, { ...base, pageIndex: 0, pageSize: 3 }).rows
|
|
99
|
+
expect(page0.map((r) => r.id)).toEqual(['g0', 'g0r0', 'g0r1', 'g1', 'g1r0'])
|
|
100
|
+
// g1 is reprinted because its second row leads the next page.
|
|
101
|
+
const page1 = paginateGroupedRows(many, { ...base, pageIndex: 1, pageSize: 3 }).rows
|
|
102
|
+
expect(page1.map((r) => r.id)).toEqual(['g1', 'g1r1', 'g2', 'g2r0', 'g2r1'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('emits a banner once for a group spanning many rows on one page', () => {
|
|
106
|
+
const wide: R[] = [{ id: 'g', depth: 0, group: true }]
|
|
107
|
+
for (let i = 0; i < 5; i += 1) wide.push({ id: `r${i}`, depth: 1 })
|
|
108
|
+
const { rows } = paginateGroupedRows(wide, { ...base, pageIndex: 0, pageSize: 5 })
|
|
109
|
+
expect(rows.filter((r) => r.group)).toHaveLength(1)
|
|
110
|
+
expect(rows).toHaveLength(6)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('reprints the whole ancestor chain for nested groups', () => {
|
|
114
|
+
const nested: R[] = [
|
|
115
|
+
{ id: 'A', depth: 0, group: true },
|
|
116
|
+
{ id: 'A1', depth: 1, group: true },
|
|
117
|
+
{ id: 'x', depth: 2 },
|
|
118
|
+
{ id: 'y', depth: 2 },
|
|
119
|
+
]
|
|
120
|
+
const page1 = paginateGroupedRows(nested, { ...base, pageIndex: 1, pageSize: 1 }).rows
|
|
121
|
+
expect(page1.map((r) => r.id)).toEqual(['A', 'A1', 'y'])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('gives a COLLAPSED group its own page slot', () => {
|
|
125
|
+
// All groups collapsed: the flat model is banners only. Treating those as
|
|
126
|
+
// zero-cost headers left every page empty - a real bug caught by the DOM test.
|
|
127
|
+
const collapsed: R[] = []
|
|
128
|
+
for (let g = 0; g < 10; g += 1) collapsed.push({ id: `c${g}`, depth: 0, group: true })
|
|
129
|
+
const { rows, dataRowCount } = paginateGroupedRows(collapsed, {
|
|
130
|
+
...base,
|
|
131
|
+
isExpanded: () => false,
|
|
132
|
+
pageIndex: 0,
|
|
133
|
+
pageSize: 4,
|
|
134
|
+
})
|
|
135
|
+
expect(dataRowCount).toBe(10)
|
|
136
|
+
expect(rows.map((r) => r.id)).toEqual(['c0', 'c1', 'c2', 'c3'])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('mixes an expanded group with a collapsed sibling', () => {
|
|
140
|
+
const mixed: R[] = [
|
|
141
|
+
{ id: 'open', depth: 0, group: true },
|
|
142
|
+
{ id: 'o1', depth: 1 },
|
|
143
|
+
{ id: 'o2', depth: 1 },
|
|
144
|
+
{ id: 'shut', depth: 0, group: true },
|
|
145
|
+
]
|
|
146
|
+
const { rows, dataRowCount } = paginateGroupedRows(mixed, {
|
|
147
|
+
...base,
|
|
148
|
+
isExpanded: (r: R) => r.id === 'open',
|
|
149
|
+
pageIndex: 0,
|
|
150
|
+
pageSize: 5,
|
|
151
|
+
})
|
|
152
|
+
// 2 data rows + 1 collapsed group = 3 units; the open banner is free.
|
|
153
|
+
expect(dataRowCount).toBe(3)
|
|
154
|
+
expect(rows.map((r) => r.id)).toEqual(['open', 'o1', 'o2', 'shut'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('handles an out-of-range page', () => {
|
|
158
|
+
expect(paginateGroupedRows(many, { ...base, pageIndex: 99, pageSize: 10 }).rows).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('leaves ungrouped data untouched', () => {
|
|
162
|
+
const flat: R[] = [{ id: 'a', depth: 0 }, { id: 'b', depth: 0 }, { id: 'c', depth: 0 }]
|
|
163
|
+
const { rows, dataRowCount } = paginateGroupedRows(flat, { ...base, pageIndex: 1, pageSize: 2 })
|
|
164
|
+
expect(dataRowCount).toBe(3)
|
|
165
|
+
expect(rows.map((r) => r.id)).toEqual(['c'])
|
|
166
|
+
})
|
|
167
|
+
})
|