@lavalogic/scoria 0.38.5 → 0.38.6

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.
@@ -32,18 +32,9 @@
32
32
  // strings) and from `UNSAVED_OPTION`.
33
33
  const DEFAULT_OPTION = ' default';
34
34
 
35
- // Sentinel value for the *transient* "…(unsaved)" option shown while
36
- // the table state is dirty. It is deliberately distinct from
37
- // `DEFAULT_OPTION` and from every real saved-view id so the transient
38
- // option never coincides with a selectable option: selecting any real
39
- // option (Default included) from a dirty state is then always seen as
40
- // a genuine change by the underlying select, so the handler fires.
41
- const UNSAVED_OPTION = ' unsaved';
42
-
43
35
  // The view-state slice for `kind`, read live from the context.
44
36
  const activeView = $derived(tableContext._viewState.activeView(kind));
45
37
  const savedViews = $derived(tableContext._viewState.savedViews(kind));
46
- const isDirty = $derived(tableContext._viewState.isDirty(kind));
47
38
 
48
39
  // Dropdown label: 'Default' / 'Custom (unsaved)' / '<name>' / '<name>
49
40
  // (unsaved)'. Used as the displayed value of the select so it reflects
@@ -68,19 +59,18 @@
68
59
  ...savedViews.map((view) => ({ label: view.name, value: view.id })),
69
60
  ]);
70
61
 
71
- // The value handed to `SingleSelect`. `SingleSelect` displays
72
- // `value.label` directly (via its `selection` snippet), so when the
73
- // state is dirty we feed it a transient option carrying the
74
- // "…(unsaved)" `dropdownLabel` and the `UNSAVED_OPTION` sentinel
75
- // value - this shows the dirty label WITHOUT having to inject it into
76
- // `options`, so Default + saved views all stay selectable. When the
77
- // state is clean the value is the matching real option so it
78
- // highlights correctly in the list.
79
- const selectedOption: SelectOption<string> = $derived(
80
- isDirty
81
- ? { label: dropdownLabel, value: UNSAVED_OPTION }
82
- : (options.find((o) => o.value === activeValue) ?? options[0])
83
- );
62
+ // The value handed to `SingleSelect`. The `value` is always the active
63
+ // option's real value (the Default sentinel or a saved-view id), so it
64
+ // matches an entry in `options` and `SingleSelect` keeps the selection
65
+ // rendered. The `label` is `dropdownLabel`, which already encodes the
66
+ // dirty state ("…(unsaved)"); `SingleSelect` shows it verbatim via its
67
+ // `selection` snippet. An earlier approach fed a transient value that
68
+ // was absent from `options`, which made svelecte drop the selection
69
+ // and fall back to its placeholder.
70
+ const selectedOption: SelectOption<string> = $derived({
71
+ label: dropdownLabel,
72
+ value: activeValue,
73
+ });
84
74
 
85
75
  /** Apply the option the user picked from the dropdown. */
86
76
  function selectOption(option: SelectOption<string> | null): void {
@@ -37,6 +37,31 @@ const LAYOUT_PERSIST_DEBOUNCE_MS = 250;
37
37
  * auto-persist effects behave consistently.
38
38
  */
39
39
  const FILTER_PERSIST_DEBOUNCE_MS = 250;
40
+ /**
41
+ * `JSON.stringify` with object keys sorted recursively, so two
42
+ * structurally-equal values produce an identical string regardless of
43
+ * key insertion order. Array element order is preserved (it is
44
+ * significant for `columnOrder` and pinning entries).
45
+ *
46
+ * Used for the layout / filter dirty comparison: a baseline parsed from a
47
+ * backend JSON column has arbitrary key order, while `_captureLayout` /
48
+ * `_captureFilter` build their objects in a fixed order - a plain
49
+ * `JSON.stringify` compare would then report a false "dirty" the moment a
50
+ * saved view is applied.
51
+ */
52
+ function stableStringify(value) {
53
+ if (value === null || typeof value !== 'object') {
54
+ return JSON.stringify(value) ?? 'null';
55
+ }
56
+ if (Array.isArray(value)) {
57
+ return `[${value.map(stableStringify).join(',')}]`;
58
+ }
59
+ const record = value;
60
+ return `{${Object.keys(record)
61
+ .sort()
62
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
63
+ .join(',')}}`;
64
+ }
40
65
  /**
41
66
  * The complete set of filter-mode operator strings scoria recognises -
42
67
  * the union of every `StringFilterMode` and `NumberDateFilterMode`
@@ -412,13 +437,14 @@ export class TableContext {
412
437
  this._layoutPersistTimer = undefined;
413
438
  this._preferences.persistLayout(snapshot);
414
439
  // Dirty tracking: the layout is dirty when it diverges
415
- // from the active view's baseline. `_captureLayout`
416
- // builds its object deterministically, so a stable
417
- // `JSON.stringify` comparison is sufficient. When no
418
- // baseline has been captured yet (should not happen
419
- // past boot) treat the layout as clean.
440
+ // from the active view's baseline. The baseline can be a
441
+ // layout parsed from a backend JSON column (arbitrary key
442
+ // order), so the comparison must be key-order-insensitive
443
+ // - a plain `JSON.stringify` compare flagged every applied
444
+ // saved view as dirty. When no baseline has been captured
445
+ // yet (should not happen past boot) treat it as clean.
420
446
  const differs = this._viewBaseline !== undefined &&
421
- JSON.stringify(snapshot) !== JSON.stringify(this._viewBaseline);
447
+ stableStringify(snapshot) !== stableStringify(this._viewBaseline);
422
448
  this._viewState.markDirty('layout', differs);
423
449
  this._persistActiveView();
424
450
  }, LAYOUT_PERSIST_DEBOUNCE_MS);
@@ -449,13 +475,13 @@ export class TableContext {
449
475
  this._filterPersistTimer = undefined;
450
476
  this._preferences.persistFilter(snapshot);
451
477
  // Dirty tracking: the filter is dirty when it diverges from
452
- // the active filter view's baseline. `_captureFilter` builds
453
- // its object deterministically (sorted entries / sorted mode
454
- // keys), so a stable `JSON.stringify` comparison is
455
- // sufficient. When no baseline has been captured yet (should
456
- // not happen past boot) treat the filter as clean.
478
+ // the active filter view's baseline. The baseline can be a
479
+ // filter parsed from a backend JSON column (arbitrary key
480
+ // order), so the comparison is key-order-insensitive via
481
+ // `stableStringify`. When no baseline has been captured yet
482
+ // (should not happen past boot) treat the filter as clean.
457
483
  const differs = this._filterBaseline !== undefined &&
458
- JSON.stringify(snapshot) !== JSON.stringify(this._filterBaseline);
484
+ stableStringify(snapshot) !== stableStringify(this._filterBaseline);
459
485
  this._viewState.markDirty('filter', differs);
460
486
  this._persistActiveFilterView();
461
487
  }, FILTER_PERSIST_DEBOUNCE_MS);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.38.5",
4
+ "version": "0.38.6",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },