@kubex/zinc 1.1.132 → 1.1.134
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/custom-elements.json +200 -78
- package/dist/vscode.html-custom-data.json +17 -12
- package/dist/web-types.json +35 -25
- package/dist/zn.d.ts +44 -1
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +374 -374
- package/docs/pages/components/data-table-filter.md +4 -0
- package/docs/pages/components/data-table.md +44 -3
- package/package.json +1 -1
- package/scss/themes/_light.scss +1 -1
- package/src/components/data-table/data-table.component.ts +219 -3
- package/src/components/data-table/data-table.test.ts +205 -0
- package/src/components/data-table-filter/data-table-filter.component.ts +45 -0
- package/src/components/data-table-filter/data-table-filter.test.ts +42 -0
- package/src/components/data-table-search/data-table-search.component.ts +16 -13
- package/src/components/data-table-search/data-table-search.test.ts +24 -0
- package/src/components/select/select.scss +4 -0
- package/src/components/toggle/toggle.scss +10 -0
|
@@ -35,6 +35,10 @@ Picking a filter from `+ Add filter` opens its pill straight away, with a text f
|
|
|
35
35
|
|
|
36
36
|
The component emits `zn-filter-change` and exposes the query on `value`, encoded exactly as `zn-query-builder` encodes it — base64 of `[{key, comparator, value}]` — so a backend built against the query builder needs no changes.
|
|
37
37
|
|
|
38
|
+
Setting `value` round-trips: assigning that same base64 string back — for example restoring it from a shared URL via `zn-data-table`'s [`sharable`](/components/data-table#shareable-url-state) sync — rebuilds the active pills, so the bar reopens in the state it was encoded in. Restoration needs the `filters` schema present to resolve each key, and it stands down once any filter already holds a value, so a shared filter still wins over an empty `default-filters` pill while a filter the user is mid-way through setting is never clobbered.
|
|
39
|
+
|
|
40
|
+
One caveat: a restored **date** filter shows its raw encoded value on the pill (a timestamp, or a minutes-from-now offset) rather than a formatted date, because the human-readable label is not part of the encoded query. The filter still applies correctly; only the pill text differs until the value is re-picked.
|
|
41
|
+
|
|
38
42
|
### Long Option Lists
|
|
39
43
|
|
|
40
44
|
An option list caps its height at 320px and scrolls inside the panel rather than running off the
|
|
@@ -643,6 +643,41 @@ Prevent automatic data loading on mount with `no-initial-load`. Call the `refres
|
|
|
643
643
|
</script>
|
|
644
644
|
```
|
|
645
645
|
|
|
646
|
+
### Shareable URL State
|
|
647
|
+
|
|
648
|
+
Set `sharable` to mirror the table's **non-default** state to the URL query string and restore it on load, so a view can be copied straight from the address bar and reproduced elsewhere. What syncs: the search text, the filter, the sort column and direction, the current page, the page size, and any search `fields` values.
|
|
649
|
+
|
|
650
|
+
Only state that differs from its initial/default value is written, so the URL stays clean — the default sort and direction, page 1, the default page size and an empty search/filter/fields are all omitted. Unrelated query parameters (for example `utm_*`) are left untouched. Updates use `history.replaceState`, so they never add browser-history entries.
|
|
651
|
+
|
|
652
|
+
On load, the table seeds its state from the query string before the first request. A URL parameter is only adopted as a field value when a matching `[name="…"]` control exists in the table's light DOM; anything else is left alone as an unrelated parameter. When combined with `no-initial-load`, a URL that carries relevant parameters forces the first load anyway, so a shared link never lands on an empty table.
|
|
653
|
+
|
|
654
|
+
```html
|
|
655
|
+
<zn-data-table
|
|
656
|
+
sharable
|
|
657
|
+
standalone
|
|
658
|
+
caption="Users"
|
|
659
|
+
data-uri="/api/users"
|
|
660
|
+
headers='[
|
|
661
|
+
{"key":"name","label":"Name", "sortable":true},
|
|
662
|
+
{"key":"email","label":"Email", "sortable":true},
|
|
663
|
+
{"key":"status","label":"Status", "sortable":true}
|
|
664
|
+
]'>
|
|
665
|
+
|
|
666
|
+
<zn-data-table-search slot="search" placeholder="Search users..."></zn-data-table-search>
|
|
667
|
+
|
|
668
|
+
<zn-data-table-filter
|
|
669
|
+
slot="filter"
|
|
670
|
+
default-filters="status"
|
|
671
|
+
filters='[
|
|
672
|
+
{"id":"status","name":"Status","options":{"active":"Active","inactive":"Inactive"},"operators":["eq"]}
|
|
673
|
+
]'>
|
|
674
|
+
</zn-data-table-filter>
|
|
675
|
+
|
|
676
|
+
</zn-data-table>
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Opening `/users?search=acme&status=<encoded>&page=2` reproduces that exact search, filter and page; sorting a column or paging then rewrites the query in place.
|
|
680
|
+
|
|
646
681
|
### Standalone Mode
|
|
647
682
|
|
|
648
683
|
Use `standalone` when the table supplies its own panel rather than sitting inside one. It renders a `zn-panel`, passing `caption` through as the panel's caption, the header controls into its `actions` slot and the pagination into its `footer` slot, with the rows flush against the panel edges. Columns scroll under the panel's edges, so the border stays put on a wide table.
|
|
@@ -889,7 +924,7 @@ The data table expects responses in the following format:
|
|
|
889
924
|
|
|
890
925
|
## Request Format
|
|
891
926
|
|
|
892
|
-
For POST requests, the table sends:
|
|
927
|
+
For POST requests, the table sends a JSON body:
|
|
893
928
|
|
|
894
929
|
```json
|
|
895
930
|
{
|
|
@@ -898,8 +933,14 @@ For POST requests, the table sends:
|
|
|
898
933
|
"sortColumn": "name",
|
|
899
934
|
"sortDirection": "asc",
|
|
900
935
|
"filter": "",
|
|
901
|
-
"search": "search term"
|
|
936
|
+
"search": "search term",
|
|
937
|
+
"searchFields": {
|
|
938
|
+
"q": "search term",
|
|
939
|
+
"status": "open"
|
|
940
|
+
}
|
|
902
941
|
}
|
|
903
942
|
```
|
|
904
943
|
|
|
905
|
-
|
|
944
|
+
Field values from a slotted `zn-data-table-search`'s [`fields` slot](/components/data-table-search#filter-fields) are wrapped under `searchFields`, keeping them out of the request root so the backend can bind them to a single map rather than arbitrary top-level keys. `q` mirrors the `search` text, and the root `search` key is retained for back-compatibility. Empty values are dropped, and `searchFields` is `null` when nothing meaningful remains — no search text and no field values — so the backend can treat that as "no search".
|
|
945
|
+
|
|
946
|
+
Parameters from the `inputs` slot are context/system values (a CSRF token, a package name, and the like) sent with every request. They are merged at the **root** of the body, not inside `searchFields`.
|
package/package.json
CHANGED
package/scss/themes/_light.scss
CHANGED
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
--zn-input-color-focus: var(--zn-color-neutral-700);
|
|
247
247
|
--zn-input-color-disabled: var(--zn-color-neutral-800);
|
|
248
248
|
--zn-input-icon-color: var(--zn-color-neutral-500);
|
|
249
|
-
--zn-input-icon-color-hover: var(--zn-color-neutral-
|
|
249
|
+
--zn-input-icon-color-hover: var(--zn-color-neutral-800);
|
|
250
250
|
--zn-input-icon-color-focus: var(--zn-color-neutral-600);
|
|
251
251
|
--zn-input-placeholder-color: var(--zn-text-input-placeholder-color);
|
|
252
252
|
--zn-input-placeholder-color-disabled: var(--zn-color-neutral-600);
|
|
@@ -151,6 +151,9 @@ interface DataRequest {
|
|
|
151
151
|
sortDirection: string;
|
|
152
152
|
filter: string;
|
|
153
153
|
search: string;
|
|
154
|
+
// Search-related fields (from the search component's `fields` slot) plus `q` (the search text),
|
|
155
|
+
// wrapped so the backend can bind them to a single map instead of arbitrary root-level keys.
|
|
156
|
+
searchFields?: Record<string, any> | null;
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
type AllowedInputElement =
|
|
@@ -269,6 +272,12 @@ export default class ZnDataTable extends ZincElement {
|
|
|
269
272
|
|
|
270
273
|
@property({attribute: "no-initial-load", type: Boolean}) noInitialLoad: boolean = false;
|
|
271
274
|
|
|
275
|
+
/**
|
|
276
|
+
* When set, the table's non-default state (search, filter, sort, page, per-page and field values)
|
|
277
|
+
*is mirrored to the URL query string and restored from it on load, so the view is shareable.
|
|
278
|
+
*/
|
|
279
|
+
@property({attribute: 'sharable', type: Boolean}) sharable: boolean = false;
|
|
280
|
+
|
|
272
281
|
@property({attribute: 'group-by'}) groupBy = '';
|
|
273
282
|
|
|
274
283
|
@property() groups = '';
|
|
@@ -282,6 +291,11 @@ export default class ZnDataTable extends ZincElement {
|
|
|
282
291
|
private _lastLoadHadRows = false;
|
|
283
292
|
private _lastTableContent: TemplateResult = html``;
|
|
284
293
|
|
|
294
|
+
// Sharable (URL <-> state) sync
|
|
295
|
+
private _sharableInitialised = false;
|
|
296
|
+
private _sharableDefaults: Record<string, string> | null = null;
|
|
297
|
+
private readonly _urlManagedKeys = new Set<string>(['search', 'filter', 'sortColumn', 'sortDirection', 'page', 'perPage']);
|
|
298
|
+
|
|
285
299
|
private readonly resizeObserver = new ResizeController(this, {
|
|
286
300
|
target: null,
|
|
287
301
|
callback: () => {
|
|
@@ -320,6 +334,10 @@ export default class ZnDataTable extends ZincElement {
|
|
|
320
334
|
|
|
321
335
|
private _dataTask = new Task(this, {
|
|
322
336
|
task: async ([dataUri, requestParams], {signal}) => {
|
|
337
|
+
// Every request path funnels through the task, so this is the single place to mirror the
|
|
338
|
+
// current (non-default) state to the URL when `sharable` is set.
|
|
339
|
+
this._updateSharableUrl();
|
|
340
|
+
|
|
323
341
|
if (dataUri === undefined || this.noInitialLoad && this._initialLoad) {
|
|
324
342
|
return {rows: [], page: 1, perPage: this.itemsPerPage, total: 0};
|
|
325
343
|
}
|
|
@@ -338,7 +356,8 @@ export default class ZnDataTable extends ZincElement {
|
|
|
338
356
|
search: this.search,
|
|
339
357
|
};
|
|
340
358
|
|
|
341
|
-
//
|
|
359
|
+
// Inputs-slot values are context/system params (e.g. csrf token, package name) sent with
|
|
360
|
+
// every request - they stay at the root of the payload.
|
|
342
361
|
const inputs = this.hasSlotController.getSlots(ActionSlots.inputs.valueOf());
|
|
343
362
|
const params: Record<string, any> = {};
|
|
344
363
|
if (inputs) {
|
|
@@ -355,11 +374,25 @@ export default class ZnDataTable extends ZincElement {
|
|
|
355
374
|
Object.assign(requestData, params);
|
|
356
375
|
}
|
|
357
376
|
|
|
358
|
-
//
|
|
377
|
+
// Search-related fields (from <zn-data-table-search>'s `fields` slot, delivered via
|
|
378
|
+
// requestParams) are wrapped under `searchFields` so the backend can bind them to a single
|
|
379
|
+
// map rather than arbitrary root-level keys. `q` mirrors the search text; the root `search`
|
|
380
|
+
// key is still sent for back-compatibility. Empty values are dropped, and `searchFields` is
|
|
381
|
+
// null when nothing meaningful remains so the backend can treat it as "no search".
|
|
382
|
+
const searchFields: Record<string, any> = {};
|
|
359
383
|
if (requestParams && typeof requestParams === 'object') {
|
|
360
|
-
Object.
|
|
384
|
+
for (const [key, value] of Object.entries(requestParams as Record<string, unknown>)) {
|
|
385
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
386
|
+
searchFields[key] = value;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (this.search || Object.keys(searchFields).length > 0) {
|
|
391
|
+
searchFields.q = this.search;
|
|
361
392
|
}
|
|
362
393
|
|
|
394
|
+
requestData.searchFields = Object.keys(searchFields).length > 0 ? searchFields : null;
|
|
395
|
+
|
|
363
396
|
// This is also used for Rubix, so it may not work for your application.
|
|
364
397
|
const response = await fetch(dataUri, {
|
|
365
398
|
method: this.method,
|
|
@@ -439,6 +472,177 @@ export default class ZnDataTable extends ZincElement {
|
|
|
439
472
|
this._deselectedColumns = deselected;
|
|
440
473
|
}
|
|
441
474
|
|
|
475
|
+
// Reserved (non-field) URL param names that map onto dedicated table state.
|
|
476
|
+
private static readonly _sharableKnownKeys = ['search', 'filter', 'sortColumn', 'sortDirection', 'page', 'perPage'];
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Snapshot the initial (attribute-provided) value of every managed key, so the URL writer can
|
|
480
|
+
* omit any key still holding its default - e.g. the table's default sort/direction, page 1 or the
|
|
481
|
+
* default page size. Captured once, before the URL is read, so URL values are treated as deltas.
|
|
482
|
+
*/
|
|
483
|
+
private _captureSharableDefaults() {
|
|
484
|
+
if (this._sharableDefaults) return;
|
|
485
|
+
this._sharableDefaults = {
|
|
486
|
+
search: this.search || '',
|
|
487
|
+
filter: this.filter || '',
|
|
488
|
+
sortColumn: this.sortColumn || '',
|
|
489
|
+
sortDirection: this.sortDirection || '',
|
|
490
|
+
page: String(this.page),
|
|
491
|
+
perPage: String(this.itemsPerPage),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Seed the table state from the URL query string (raw param names) when `sharable` is set. Runs
|
|
497
|
+
* once, before the first render, so the initial data request already carries the shared state.
|
|
498
|
+
*/
|
|
499
|
+
private _readSharableState() {
|
|
500
|
+
if (!this.sharable || this._sharableInitialised) return;
|
|
501
|
+
if (typeof window === 'undefined') return;
|
|
502
|
+
this._sharableInitialised = true;
|
|
503
|
+
|
|
504
|
+
const params = new URLSearchParams(window.location.search);
|
|
505
|
+
let hasRelevant = false;
|
|
506
|
+
|
|
507
|
+
if (params.has('search')) {
|
|
508
|
+
this.search = params.get('search')!;
|
|
509
|
+
hasRelevant = true;
|
|
510
|
+
}
|
|
511
|
+
if (params.has('filter')) {
|
|
512
|
+
this.filter = params.get('filter')!;
|
|
513
|
+
hasRelevant = true;
|
|
514
|
+
}
|
|
515
|
+
if (params.has('sortColumn')) {
|
|
516
|
+
this.sortColumn = params.get('sortColumn')!;
|
|
517
|
+
hasRelevant = true;
|
|
518
|
+
}
|
|
519
|
+
if (params.has('sortDirection')) {
|
|
520
|
+
this.sortDirection = params.get('sortDirection')!;
|
|
521
|
+
hasRelevant = true;
|
|
522
|
+
}
|
|
523
|
+
if (params.has('page')) {
|
|
524
|
+
const page = parseInt(params.get('page')!, 10);
|
|
525
|
+
if (!isNaN(page) && page > 0) this.page = page;
|
|
526
|
+
hasRelevant = true;
|
|
527
|
+
}
|
|
528
|
+
if (params.has('perPage')) {
|
|
529
|
+
const perPage = parseInt(params.get('perPage')!, 10);
|
|
530
|
+
if (!isNaN(perPage) && perPage > 0) this.itemsPerPage = perPage;
|
|
531
|
+
hasRelevant = true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Any remaining param is adopted as an extra field value only when it maps to an actual field in
|
|
535
|
+
// the table. Unrelated params (e.g. utm_*) are left untouched so they survive the round-trip.
|
|
536
|
+
const known = new Set(ZnDataTable._sharableKnownKeys);
|
|
537
|
+
const extras: Record<string, any> = {};
|
|
538
|
+
params.forEach((value, key) => {
|
|
539
|
+
if (known.has(key) || !this._hasFieldNamed(key)) return;
|
|
540
|
+
hasRelevant = true;
|
|
541
|
+
this._urlManagedKeys.add(key);
|
|
542
|
+
extras[key] = value;
|
|
543
|
+
});
|
|
544
|
+
if (Object.keys(extras).length > 0) {
|
|
545
|
+
this.requestParams = {...this.requestParams, ...extras};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// A shared link must render results even when no-initial-load is set - otherwise the recipient
|
|
549
|
+
// would land on an empty table. Force the first load when the URL carries relevant params.
|
|
550
|
+
if (hasRelevant && this.noInitialLoad) {
|
|
551
|
+
this._initialLoad = false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Push the shared state back into the actual DOM fields so the UI reflects it. The search value
|
|
557
|
+
* lives on the slotted <zn-data-table-search>, the filter on <zn-data-table-filter>, and extra
|
|
558
|
+
* field params on the search/inputs fields (all light-DOM descendants).
|
|
559
|
+
*/
|
|
560
|
+
private _populateSharableFields() {
|
|
561
|
+
if (this.search) {
|
|
562
|
+
const searchEl = this.querySelector('zn-data-table-search') as (Element & { value?: unknown }) | null;
|
|
563
|
+
if (searchEl) searchEl.value = this.search;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (this.filter) {
|
|
567
|
+
const filterEl = this.querySelector('zn-data-table-filter') as (Element & { value?: unknown }) | null;
|
|
568
|
+
if (filterEl) filterEl.value = this.filter;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const known = new Set(ZnDataTable._sharableKnownKeys);
|
|
572
|
+
Object.entries(this.requestParams).forEach(([name, value]) => {
|
|
573
|
+
if (known.has(name) || name === 'searchUri' || value === undefined || value === null) return;
|
|
574
|
+
this._setSharableFieldValue(name, String(value));
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private _hasFieldNamed(name: string): boolean {
|
|
579
|
+
try {
|
|
580
|
+
const selector = `[name="${window.CSS && CSS.escape ? CSS.escape(name) : name}"]`;
|
|
581
|
+
return this.querySelector(selector) !== null;
|
|
582
|
+
} catch {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private _setSharableFieldValue(name: string, value: string) {
|
|
588
|
+
let selector: string;
|
|
589
|
+
try {
|
|
590
|
+
selector = `[name="${window.CSS && CSS.escape ? CSS.escape(name) : name}"]`;
|
|
591
|
+
} catch {
|
|
592
|
+
selector = `[name="${name}"]`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
this.querySelectorAll(selector).forEach((field) => {
|
|
596
|
+
(field as Element & { value?: unknown }).value = value;
|
|
597
|
+
// Custom elements may read the value from the attribute (and it survives an upgrade).
|
|
598
|
+
if (field.tagName.includes('-')) {
|
|
599
|
+
field.setAttribute('value', value);
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Mirror the current table state to the URL query string (raw param names) via replaceState.
|
|
606
|
+
* A key is written only when its value differs from the captured default, so the default sort,
|
|
607
|
+
* page 1, the default page size and empty search/filter/fields never clutter the URL. Unrelated
|
|
608
|
+
* params (e.g. utm_*) are preserved.
|
|
609
|
+
*/
|
|
610
|
+
private _updateSharableUrl() {
|
|
611
|
+
if (!this.sharable || !window?.history) return;
|
|
612
|
+
const defaults: Record<string, string> = this._sharableDefaults ?? {};
|
|
613
|
+
|
|
614
|
+
const params = new URLSearchParams(window.location.search);
|
|
615
|
+
|
|
616
|
+
// Drop every key the table manages, then re-add only the ones that differ from their default.
|
|
617
|
+
this._urlManagedKeys.forEach((key) => params.delete(key));
|
|
618
|
+
|
|
619
|
+
const setParam = (key: string, value: unknown) => {
|
|
620
|
+
this._urlManagedKeys.add(key);
|
|
621
|
+
const str = value === undefined || value === null ? '' : String(value);
|
|
622
|
+
if (str === '' || str === (defaults[key] || '')) return;
|
|
623
|
+
params.set(key, str);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// Extra field values (searchFields) - their default is empty, so setParam writes them only when set.
|
|
627
|
+
const known = new Set(ZnDataTable._sharableKnownKeys);
|
|
628
|
+
Object.entries(this.requestParams).forEach(([key, value]) => {
|
|
629
|
+
if (known.has(key) || key === 'searchUri') return;
|
|
630
|
+
setParam(key, value);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
setParam('search', this.search);
|
|
634
|
+
setParam('filter', this.filter);
|
|
635
|
+
setParam('sortColumn', this.sortColumn);
|
|
636
|
+
setParam('sortDirection', this.sortDirection);
|
|
637
|
+
setParam('page', this.page);
|
|
638
|
+
setParam('perPage', this.itemsPerPage);
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
const queryString = params.toString();
|
|
642
|
+
const newUrl = `${window.location.pathname}${queryString ? `?${queryString}` : ''}${window.location.hash}`;
|
|
643
|
+
window.history.replaceState(window.history.state, '', newUrl);
|
|
644
|
+
}
|
|
645
|
+
|
|
442
646
|
render() {
|
|
443
647
|
this.applyColumnDefaults();
|
|
444
648
|
|
|
@@ -557,6 +761,16 @@ export default class ZnDataTable extends ZincElement {
|
|
|
557
761
|
this.addEventListener('zn-filter-change', this.filterChangeListener);
|
|
558
762
|
this.addEventListener('zn-clear', this.filterClearListener);
|
|
559
763
|
this.addEventListener('zn-search-change', this.searchChangeListener);
|
|
764
|
+
// Capture defaults before reading the URL so URL values are treated as deltas, then seed state.
|
|
765
|
+
this._captureSharableDefaults();
|
|
766
|
+
this._readSharableState();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
protected firstUpdated() {
|
|
770
|
+
// Push the shared state back into the slotted fields once they exist in the DOM.
|
|
771
|
+
if (this.sharable) {
|
|
772
|
+
this._populateSharableFields();
|
|
773
|
+
}
|
|
560
774
|
}
|
|
561
775
|
|
|
562
776
|
protected updated(changed: PropertyValues) {
|
|
@@ -1234,6 +1448,8 @@ export default class ZnDataTable extends ZincElement {
|
|
|
1234
1448
|
|
|
1235
1449
|
if (this.localSort) {
|
|
1236
1450
|
this._rows = this.sortLocalData(this._rows as Row[]);
|
|
1451
|
+
// Local sort never runs the data task (our URL-sync choke point), so mirror it here.
|
|
1452
|
+
this._updateSharableUrl();
|
|
1237
1453
|
this.requestUpdate();
|
|
1238
1454
|
} else {
|
|
1239
1455
|
this._dataTask.run().then(r => r);
|
|
@@ -322,4 +322,209 @@ describe('<zn-data-table>', () => {
|
|
|
322
322
|
expect(link).to.exist;
|
|
323
323
|
expect(link?.hasAttribute('title')).to.be.false;
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
// Search-component field values are nested under `searchFields` in the POST body, with `q`
|
|
327
|
+
// mirroring the search text, while the root `search` key is retained for back-compatibility.
|
|
328
|
+
describe('searchFields request wrapping', () => {
|
|
329
|
+
const rowResponse = () => new Response(JSON.stringify({
|
|
330
|
+
rows: [{id: '1', cells: [{text: 'Row', column: 'name'}]}],
|
|
331
|
+
page: 1,
|
|
332
|
+
perPage: 10,
|
|
333
|
+
total: 1,
|
|
334
|
+
}), {status: 200, headers: {'Content-Type': 'application/json'}});
|
|
335
|
+
|
|
336
|
+
it('wraps search-component field values under searchFields with q mirroring the search text', async () => {
|
|
337
|
+
const originalFetch = window.fetch;
|
|
338
|
+
const bodies: Record<string, unknown>[] = [];
|
|
339
|
+
window.fetch = (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
340
|
+
if (options?.body) bodies.push(JSON.parse(options.body as string) as Record<string, unknown>);
|
|
341
|
+
return Promise.resolve(rowResponse());
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const el = await fixture<ZnDataTable>(html`
|
|
346
|
+
<zn-data-table data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
|
|
347
|
+
await waitUntil(() => bodies.length > 0);
|
|
348
|
+
|
|
349
|
+
// Mirror what the search component's zn-search-change listener does: set the search text and
|
|
350
|
+
// merge its field values into requestParams, then reload.
|
|
351
|
+
el.search = 'foo';
|
|
352
|
+
el.requestParams = {status: 'open'};
|
|
353
|
+
el.refresh();
|
|
354
|
+
await waitUntil(() => bodies.some(b => b.search === 'foo'));
|
|
355
|
+
|
|
356
|
+
const body = bodies[bodies.length - 1];
|
|
357
|
+
expect(body.search).to.equal('foo');
|
|
358
|
+
expect(body.searchFields).to.deep.equal({status: 'open', q: 'foo'});
|
|
359
|
+
} finally {
|
|
360
|
+
window.fetch = originalFetch;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('sends searchFields as null when no search or field values are set', async () => {
|
|
365
|
+
const originalFetch = window.fetch;
|
|
366
|
+
const bodies: Record<string, unknown>[] = [];
|
|
367
|
+
window.fetch = (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
368
|
+
if (options?.body) bodies.push(JSON.parse(options.body as string) as Record<string, unknown>);
|
|
369
|
+
return Promise.resolve(rowResponse());
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
await fixture<ZnDataTable>(html`
|
|
374
|
+
<zn-data-table data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
|
|
375
|
+
await waitUntil(() => bodies.length > 0);
|
|
376
|
+
|
|
377
|
+
expect(bodies[bodies.length - 1].searchFields).to.be.null;
|
|
378
|
+
} finally {
|
|
379
|
+
window.fetch = originalFetch;
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('keeps inputs-slot params at the request root, not inside searchFields', async () => {
|
|
384
|
+
const originalFetch = window.fetch;
|
|
385
|
+
const bodies: Record<string, unknown>[] = [];
|
|
386
|
+
window.fetch = (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
387
|
+
if (options?.body) bodies.push(JSON.parse(options.body as string) as Record<string, unknown>);
|
|
388
|
+
return Promise.resolve(rowResponse());
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const el = await fixture<ZnDataTable>(html`
|
|
393
|
+
<zn-data-table data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'>
|
|
394
|
+
<input slot="inputs" name="csrf" value="tok">
|
|
395
|
+
</zn-data-table>`);
|
|
396
|
+
await waitUntil(() => bodies.length > 0);
|
|
397
|
+
el.refresh();
|
|
398
|
+
await waitUntil(() => bodies.length > 1);
|
|
399
|
+
|
|
400
|
+
const body = bodies[bodies.length - 1];
|
|
401
|
+
expect(body.csrf).to.equal('tok');
|
|
402
|
+
expect(body.searchFields).to.be.null;
|
|
403
|
+
} finally {
|
|
404
|
+
window.fetch = originalFetch;
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// `sharable` mirrors non-default state to the URL query string and restores it on load. These
|
|
410
|
+
// tests mutate window.location/history, so each one restores the original URL in a finally block
|
|
411
|
+
// and preserves the pre-existing query (web-test-runner keeps its session id there).
|
|
412
|
+
describe('sharable URL state', () => {
|
|
413
|
+
const okResponse = () => new Response(JSON.stringify({
|
|
414
|
+
rows: [{id: '1', cells: [{text: 'Row', column: 'name'}]}],
|
|
415
|
+
page: 1,
|
|
416
|
+
perPage: 10,
|
|
417
|
+
total: 1,
|
|
418
|
+
}), {status: 200, headers: {'Content-Type': 'application/json'}});
|
|
419
|
+
|
|
420
|
+
const MANAGED = ['search', 'filter', 'sortColumn', 'sortDirection', 'page', 'perPage'];
|
|
421
|
+
|
|
422
|
+
// Start from the live query (keeping unmanaged params like the WTR session id) with every
|
|
423
|
+
// managed key cleared, then overlay whatever the test wants to seed.
|
|
424
|
+
const seedUrl = (overlay: Record<string, string> = {}) => {
|
|
425
|
+
const params = new URLSearchParams(window.location.search);
|
|
426
|
+
MANAGED.forEach(key => params.delete(key));
|
|
427
|
+
Object.entries(overlay).forEach(([key, value]) => params.set(key, value));
|
|
428
|
+
const query = params.toString();
|
|
429
|
+
window.history.replaceState(window.history.state, '', `${window.location.pathname}${query ? `?${query}` : ''}`);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
it('seeds the table state from the URL query string on load', async () => {
|
|
433
|
+
const originalFetch = window.fetch;
|
|
434
|
+
const originalUrl = window.location.href;
|
|
435
|
+
const bodies: Record<string, unknown>[] = [];
|
|
436
|
+
window.fetch = (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
437
|
+
if (options?.body) bodies.push(JSON.parse(options.body as string) as Record<string, unknown>);
|
|
438
|
+
return Promise.resolve(okResponse());
|
|
439
|
+
};
|
|
440
|
+
seedUrl({search: 'foo', page: '2'});
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
await fixture<ZnDataTable>(html`
|
|
444
|
+
<zn-data-table sharable data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
|
|
445
|
+
await waitUntil(() => bodies.length > 0);
|
|
446
|
+
|
|
447
|
+
// The very first request already carries the shared state.
|
|
448
|
+
expect(bodies[0].search).to.equal('foo');
|
|
449
|
+
expect(bodies[0].page).to.equal(2);
|
|
450
|
+
} finally {
|
|
451
|
+
window.fetch = originalFetch;
|
|
452
|
+
window.history.replaceState(window.history.state, '', originalUrl);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('writes only changed state to the URL, omitting keys still at their default', async () => {
|
|
457
|
+
const originalFetch = window.fetch;
|
|
458
|
+
const originalUrl = window.location.href;
|
|
459
|
+
window.fetch = () => Promise.resolve(okResponse());
|
|
460
|
+
seedUrl();
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const el = await fixture<ZnDataTable>(html`
|
|
464
|
+
<zn-data-table sharable data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
|
|
465
|
+
await waitUntil(() => el.shadowRoot!.querySelector('tbody tr.table__row--data'));
|
|
466
|
+
|
|
467
|
+
el.search = 'bar';
|
|
468
|
+
el.refresh();
|
|
469
|
+
await waitUntil(() => window.location.search.includes('search=bar'));
|
|
470
|
+
|
|
471
|
+
const params = new URLSearchParams(window.location.search);
|
|
472
|
+
expect(params.get('search')).to.equal('bar');
|
|
473
|
+
// Page 1 and the unset/default sort are omitted rather than written out.
|
|
474
|
+
expect(params.has('page')).to.be.false;
|
|
475
|
+
expect(params.has('sortColumn')).to.be.false;
|
|
476
|
+
expect(params.has('perPage')).to.be.false;
|
|
477
|
+
} finally {
|
|
478
|
+
window.fetch = originalFetch;
|
|
479
|
+
window.history.replaceState(window.history.state, '', originalUrl);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('preserves unrelated query params when syncing state', async () => {
|
|
484
|
+
const originalFetch = window.fetch;
|
|
485
|
+
const originalUrl = window.location.href;
|
|
486
|
+
window.fetch = () => Promise.resolve(okResponse());
|
|
487
|
+
seedUrl({utm_source: 'news'});
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const el = await fixture<ZnDataTable>(html`
|
|
491
|
+
<zn-data-table sharable data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
|
|
492
|
+
await waitUntil(() => el.shadowRoot!.querySelector('tbody tr.table__row--data'));
|
|
493
|
+
|
|
494
|
+
el.search = 'bar';
|
|
495
|
+
el.refresh();
|
|
496
|
+
await waitUntil(() => window.location.search.includes('search=bar'));
|
|
497
|
+
|
|
498
|
+
const params = new URLSearchParams(window.location.search);
|
|
499
|
+
expect(params.get('utm_source')).to.equal('news');
|
|
500
|
+
expect(params.get('search')).to.equal('bar');
|
|
501
|
+
} finally {
|
|
502
|
+
window.fetch = originalFetch;
|
|
503
|
+
window.history.replaceState(window.history.state, '', originalUrl);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('forces the first load under no-initial-load when the URL carries relevant params', async () => {
|
|
508
|
+
const originalFetch = window.fetch;
|
|
509
|
+
const originalUrl = window.location.href;
|
|
510
|
+
const bodies: Record<string, unknown>[] = [];
|
|
511
|
+
window.fetch = (_url: RequestInfo | URL, options?: RequestInit) => {
|
|
512
|
+
if (options?.body) bodies.push(JSON.parse(options.body as string) as Record<string, unknown>);
|
|
513
|
+
return Promise.resolve(okResponse());
|
|
514
|
+
};
|
|
515
|
+
seedUrl({search: 'foo'});
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
await fixture<ZnDataTable>(html`
|
|
519
|
+
<zn-data-table sharable no-initial-load data-uri="/test-data" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
|
|
520
|
+
// Despite no-initial-load, a shared link must render results, so the first load fires.
|
|
521
|
+
await waitUntil(() => bodies.length > 0);
|
|
522
|
+
|
|
523
|
+
expect(bodies[0].search).to.equal('foo');
|
|
524
|
+
} finally {
|
|
525
|
+
window.fetch = originalFetch;
|
|
526
|
+
window.history.replaceState(window.history.state, '', originalUrl);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
});
|
|
325
530
|
});
|
|
@@ -148,6 +148,13 @@ export default class ZnDataTableFilter extends ZincElement implements ZincFormCo
|
|
|
148
148
|
protected willUpdate(changed: PropertyValues) {
|
|
149
149
|
super.willUpdate(changed);
|
|
150
150
|
|
|
151
|
+
// Rebuild the active filters from an externally-set value (e.g. restored from a shareable URL)
|
|
152
|
+
// once both the value and the filters schema are available. Must run before the default-filters
|
|
153
|
+
// seeding so a shared filter wins over defaults.
|
|
154
|
+
if (changed.has('value') || changed.has('filters')) {
|
|
155
|
+
this.hydrateFromValue();
|
|
156
|
+
}
|
|
157
|
+
|
|
151
158
|
if (changed.has('filters') && this._active.length === 0) {
|
|
152
159
|
this.defaultFilters
|
|
153
160
|
.split(',')
|
|
@@ -279,6 +286,44 @@ export default class ZnDataTableFilter extends ZincElement implements ZincFormCo
|
|
|
279
286
|
this.emit('zn-filter-change');
|
|
280
287
|
}
|
|
281
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Rebuild the active filters from an encoded `value` (the inverse of emitChange), so a filter
|
|
291
|
+
* restored from a shareable URL shows its pills. Runs only while there are no active filters and
|
|
292
|
+
* needs the filters schema present to resolve each key. Sets `_active` directly so it neither
|
|
293
|
+
* re-emits nor overwrites the value it just read.
|
|
294
|
+
*/
|
|
295
|
+
private hydrateFromValue() {
|
|
296
|
+
// Guard on valued filters (not just any pill) so default-filters' empty pills don't block a
|
|
297
|
+
// shared filter, and so a user's in-progress filter is never clobbered by a later value change.
|
|
298
|
+
if (!this.value || this.filters.length === 0 || this.activeCount > 0) return;
|
|
299
|
+
|
|
300
|
+
let decoded: unknown;
|
|
301
|
+
try {
|
|
302
|
+
decoded = JSON.parse(atob(this.value));
|
|
303
|
+
} catch {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!Array.isArray(decoded)) return;
|
|
307
|
+
|
|
308
|
+
const active: ActiveFilter[] = [];
|
|
309
|
+
decoded.forEach((entry) => {
|
|
310
|
+
if (typeof entry !== 'object' || entry === null) return;
|
|
311
|
+
const cond = entry as {key?: string; comparator?: string; value?: unknown};
|
|
312
|
+
if (typeof cond.key !== 'string') return;
|
|
313
|
+
|
|
314
|
+
const filter = this.definition(cond.key);
|
|
315
|
+
if (!filter) return;
|
|
316
|
+
|
|
317
|
+
const comparator = (cond.comparator as QueryBuilderOperators) || this.comparatorFor(filter);
|
|
318
|
+
// Multi-value filters serialize as a joined string; tolerate an array form too.
|
|
319
|
+
const value = Array.isArray(cond.value) ? cond.value.join(' ') : String(cond.value ?? '');
|
|
320
|
+
|
|
321
|
+
active.push({key: cond.key, comparator, value});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (active.length > 0) this._active = active;
|
|
325
|
+
}
|
|
326
|
+
|
|
282
327
|
private handleDateInput(filter: QueryBuilderItem, active: ActiveFilter, event: Event) {
|
|
283
328
|
const picker = event.target as ZnDatepicker;
|
|
284
329
|
const timestamp = picker.timestamp;
|