@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.
@@ -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
- Additional parameters from the `inputs` slot are merged into the request.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.132",
3
+ "version": "1.1.134",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -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-600);
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
- // get all inputs that are in the inputs slot and add them to the
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
- // Add any extra request params
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.assign(requestData, requestParams);
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;