@happyvertical/smrt-svelte 0.51.4 → 0.51.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.
- package/AGENTS.md +1 -0
- package/README.md +39 -1
- package/dist/components/workspace/README.md +25 -0
- package/dist/web/__tests__/assistant-dock.integration.svelte.test.js +204 -0
- package/dist/web/__tests__/list-data-surface.fixture.svelte +59 -0
- package/dist/web/__tests__/list-data-surface.fixture.svelte.d.ts +14 -0
- package/dist/web/__tests__/list-data-surface.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/list-data-surface.svelte.test.js +106 -0
- package/dist/web/__tests__/list-data-surface.test.js +651 -0
- package/dist/web/index.d.ts +1 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +1 -0
- package/dist/web/list-data-surface.svelte.d.ts +133 -0
- package/dist/web/list-data-surface.svelte.d.ts.map +1 -0
- package/dist/web/list-data-surface.svelte.js +308 -0
- package/package.json +13 -13
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mountListDataSurface` (#2906) — a one-liner to register an existing,
|
|
3
|
+
* hand-rolled list component as a mounted {@link DataSurfaceDescriptor}
|
|
4
|
+
* without adopting the `DataTable` component.
|
|
5
|
+
*
|
|
6
|
+
* `registerContentListDataSurface` in `@happyvertical/smrt-content/svelte`
|
|
7
|
+
* proves the pattern: mirror a headless `DataTableController` into the
|
|
8
|
+
* registry, translate visible commands back into controller dispatches, and
|
|
9
|
+
* bump a monotonic revision whenever the controller or app-owned context
|
|
10
|
+
* state changes. That logic is entangled with ContentList's view-mode
|
|
11
|
+
* concept and `packages/content` has no dependency on this package, so this
|
|
12
|
+
* module is a same-behavior PORT of that translation/registration logic —
|
|
13
|
+
* not a shared import — generalized off the view-mode concept so any custom
|
|
14
|
+
* list markup can register in one call instead of hand-mirroring the
|
|
15
|
+
* registry contract (the exact duplication that motivated this issue: an
|
|
16
|
+
* application page manually driving a headless `DataTableController` and
|
|
17
|
+
* calling `registry.register` itself). The two copies must be kept in sync
|
|
18
|
+
* by hand until they are unified behind a shared implementation in
|
|
19
|
+
* `@happyvertical/smrt-ui/data` (both packages already depend on it) —
|
|
20
|
+
* tracked as a follow-up (#2917). They are NOT currently identical: this
|
|
21
|
+
* copy denies a controlled controller's table command that fails to settle
|
|
22
|
+
* (see `applyControlledState` below), calls `registry.register` before
|
|
23
|
+
* subscribing to the controller to avoid leaking a subscription on a
|
|
24
|
+
* throwing register, and ignores `update()` once `destroy()` has run so a
|
|
25
|
+
* late-resolving page callback cannot resurrect this identity's entry in the
|
|
26
|
+
* registry-scoped revision map after a later mount has taken it over;
|
|
27
|
+
* `registerContentListDataSurface` predates all three fixes. #2917 should
|
|
28
|
+
* adopt them rather than treat this copy as the odd one out.
|
|
29
|
+
*
|
|
30
|
+
* Call during component initialization (top level of `<script>`, or inside
|
|
31
|
+
* an `$effect`); `destroy()` unregisters and unsubscribes, so tearing it down
|
|
32
|
+
* on unmount is the caller's responsibility, exactly like
|
|
33
|
+
* `registerContentListDataSurface`. Nothing here touches `window` or
|
|
34
|
+
* `document` at module scope, so it is safe under `ssr = false` SPAs and
|
|
35
|
+
* during SSR (the registry itself is a plain in-memory object).
|
|
36
|
+
*/
|
|
37
|
+
import type { DataSurfaceDescriptor, DataSurfaceJsonValue, DataSurfaceRegistry, DataTableCommand, DataTableControlledStateApplier, DataTableController } from '@happyvertical/smrt-ui/data';
|
|
38
|
+
/**
|
|
39
|
+
* Arbitrary, JSON-safe application state folded into every snapshot.
|
|
40
|
+
*
|
|
41
|
+
* `table` is reserved: the mounted controller's own snapshot is always
|
|
42
|
+
* published under that key, so a context carrying it is rejected at mount
|
|
43
|
+
* and on every `update()` rather than silently discarded. The registry's own
|
|
44
|
+
* boundary-safe check further rejects transport-reserved keys such as
|
|
45
|
+
* `token`, `where`, or `tenantId` — see `boundarySafeObject` in
|
|
46
|
+
* `@happyvertical/smrt-ui/data`.
|
|
47
|
+
*/
|
|
48
|
+
export type ListDataSurfaceContext = Readonly<Record<string, DataSurfaceJsonValue>>;
|
|
49
|
+
/**
|
|
50
|
+
* A {@link ListDataSurfaceContext} update: same shape, but a key may also be
|
|
51
|
+
* `undefined` to delete it from the published state (see
|
|
52
|
+
* {@link ListDataSurfaceHandle.update}). Kept distinct from
|
|
53
|
+
* `ListDataSurfaceContext` itself so the initial `context` option — which
|
|
54
|
+
* has no delete affordance — stays exactly JSON-safe.
|
|
55
|
+
*/
|
|
56
|
+
export type ListDataSurfaceContextPatch = Readonly<Record<string, DataSurfaceJsonValue | undefined>>;
|
|
57
|
+
export interface ListDataSurfaceControlResult {
|
|
58
|
+
ok: boolean;
|
|
59
|
+
}
|
|
60
|
+
export interface MountListDataSurfaceOptions {
|
|
61
|
+
registry: DataSurfaceRegistry;
|
|
62
|
+
descriptor: DataSurfaceDescriptor;
|
|
63
|
+
/** The headless controller the page already mirrors search/filters/sort/page/selection from. */
|
|
64
|
+
controller: DataTableController;
|
|
65
|
+
/** App-owned state (freshness, fingerprints, …) folded into `state` alongside the table snapshot. */
|
|
66
|
+
context?: ListDataSurfaceContext;
|
|
67
|
+
/** Carries the mounted identity's monotonic revision across re-registration. */
|
|
68
|
+
initialRevision?: number;
|
|
69
|
+
onRevision?: (revision: number) => void;
|
|
70
|
+
/**
|
|
71
|
+
* App-owned constraints that must hold before a visible table command is
|
|
72
|
+
* acknowledged. Returning false denies the command rather than publishing
|
|
73
|
+
* a transient state a later effect would correct.
|
|
74
|
+
*/
|
|
75
|
+
acceptsTableCommand?: (command: DataTableCommand) => boolean;
|
|
76
|
+
/**
|
|
77
|
+
* REQUIRED when `controller` is controlled (`controller.isControlled()`):
|
|
78
|
+
* a controlled controller's `dispatch()` only proposes state via its
|
|
79
|
+
* `onStateChange` callback — it never applies it or notifies subscribers,
|
|
80
|
+
* so without this the registry would acknowledge `ok: true` while nothing
|
|
81
|
+
* actually changed. Mirrors `DataTable`'s own controlled-table contract
|
|
82
|
+
* (`DataTableDataSurfaceOptions.applyControlledState`): settle the
|
|
83
|
+
* candidate state (typically by awaiting whatever the page's
|
|
84
|
+
* `onStateChange` triggered) and return the state that was actually
|
|
85
|
+
* applied, or `undefined` to deny. The command is denied whenever the
|
|
86
|
+
* controller's post-settle state does not match what was applied.
|
|
87
|
+
*/
|
|
88
|
+
applyControlledState?: DataTableControlledStateApplier;
|
|
89
|
+
/** Non-table visible commands (`refresh`/`retry`/`focus`/`reveal`/`highlight`) the page implements. */
|
|
90
|
+
refresh?: () => boolean | Promise<boolean>;
|
|
91
|
+
retry?: () => boolean | Promise<boolean>;
|
|
92
|
+
focus?: () => void;
|
|
93
|
+
reveal?: () => void;
|
|
94
|
+
highlight?: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* Escape hatch for custom controls beyond the fixed set above — a page can
|
|
97
|
+
* expose any additional `controlId` its markup understands, PROVIDED it is
|
|
98
|
+
* not one of `DATA_TABLE_SURFACE_CONTROL_IDS` (`@happyvertical/smrt-ui/data`,
|
|
99
|
+
* e.g. `set-filters`, `reset`, `set-page`): those ids are always
|
|
100
|
+
* intercepted first (translated and dispatched to `controller`, or denied
|
|
101
|
+
* outright if the payload fails to translate — see `TABLE_CONTROL_IDS`
|
|
102
|
+
* below) and never reach `onControl`, even if a descriptor declares one of
|
|
103
|
+
* them with a custom label. A descriptor control id must avoid that set to
|
|
104
|
+
* be reachable here. Only invoked when the command's `controlId` is
|
|
105
|
+
* neither a fixed control (`refresh`/`retry`/`focus`/`reveal`/`highlight`
|
|
106
|
+
* — a fixed control with no matching callback is denied directly, never
|
|
107
|
+
* forwarded here) nor a table-control id. Returning `false`/`{ ok: false }`
|
|
108
|
+
* denies the command; returning `true`, `{ ok: true }`, or nothing
|
|
109
|
+
* (`void`) is treated as success. A thrown error propagates out of
|
|
110
|
+
* `execute` and the registry reports it as `execution_failed` — it is
|
|
111
|
+
* never silently treated as success.
|
|
112
|
+
*/
|
|
113
|
+
onControl?: (controlId: string, payload: DataSurfaceJsonValue | undefined) => boolean | void | ListDataSurfaceControlResult | Promise<boolean | void | ListDataSurfaceControlResult>;
|
|
114
|
+
}
|
|
115
|
+
export interface ListDataSurfaceHandle {
|
|
116
|
+
/**
|
|
117
|
+
* Merge new app-owned context state over what is already published,
|
|
118
|
+
* bumping the revision if the result changed. Keys `next` does not
|
|
119
|
+
* mention are retained; pass a key explicitly as `undefined` to drop it.
|
|
120
|
+
*/
|
|
121
|
+
update(context: ListDataSurfaceContextPatch): void;
|
|
122
|
+
/** Unsubscribe from the controller and unregister from the registry. */
|
|
123
|
+
destroy(): void;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Register an existing, headless-controller-backed list as a mounted
|
|
127
|
+
* `DataSurfaceDescriptor`. Mirrors `registerContentListDataSurface`'s
|
|
128
|
+
* contract (stable identity independent of any view-mode churn, monotonic
|
|
129
|
+
* per-identity revision, visible-command translation) without requiring the
|
|
130
|
+
* ContentList view-mode concept, so any custom list can adopt it directly.
|
|
131
|
+
*/
|
|
132
|
+
export declare function mountListDataSurface(options: MountListDataSurfaceOptions): ListDataSurfaceHandle;
|
|
133
|
+
//# sourceMappingURL=list-data-surface.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-data-surface.svelte.d.ts","sourceRoot":"","sources":["../../src/web/list-data-surface.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,KAAK,EACV,qBAAqB,EAErB,oBAAoB,EACpB,mBAAmB,EAGnB,gBAAgB,EAChB,+BAA+B,EAC/B,mBAAmB,EAEpB,MAAM,6BAA6B,CAAC;AAOrC;;;;;;;;;GASG;AACH,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,oBAAoB,CAAC,CACrC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,2BAA2B,GAAG,QAAQ,CAChD,MAAM,CAAC,MAAM,EAAE,oBAAoB,GAAG,SAAS,CAAC,CACjD,CAAC;AAEF,MAAM,WAAW,4BAA4B;IAC3C,EAAE,EAAE,OAAO,CAAC;CACb;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,UAAU,EAAE,qBAAqB,CAAC;IAClC,gGAAgG;IAChG,UAAU,EAAE,mBAAmB,CAAC;IAChC,qGAAqG;IACrG,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC,gFAAgF;IAChF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC;IAC7D;;;;;;;;;;;OAWG;IACH,oBAAoB,CAAC,EAAE,+BAA+B,CAAC;IACvD,uGAAuG;IACvG,OAAO,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,EAAE,CACV,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,oBAAoB,GAAG,SAAS,KAEvC,OAAO,GACP,IAAI,GACJ,4BAA4B,GAC5B,OAAO,CAAC,OAAO,GAAG,IAAI,GAAG,4BAA4B,CAAC,CAAC;CAC5D;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,2BAA2B,GAAG,IAAI,CAAC;IACnD,wEAAwE;IACxE,OAAO,IAAI,IAAI,CAAC;CACjB;AAuID;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,2BAA2B,GACnC,qBAAqB,CAuKvB"}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mountListDataSurface` (#2906) — a one-liner to register an existing,
|
|
3
|
+
* hand-rolled list component as a mounted {@link DataSurfaceDescriptor}
|
|
4
|
+
* without adopting the `DataTable` component.
|
|
5
|
+
*
|
|
6
|
+
* `registerContentListDataSurface` in `@happyvertical/smrt-content/svelte`
|
|
7
|
+
* proves the pattern: mirror a headless `DataTableController` into the
|
|
8
|
+
* registry, translate visible commands back into controller dispatches, and
|
|
9
|
+
* bump a monotonic revision whenever the controller or app-owned context
|
|
10
|
+
* state changes. That logic is entangled with ContentList's view-mode
|
|
11
|
+
* concept and `packages/content` has no dependency on this package, so this
|
|
12
|
+
* module is a same-behavior PORT of that translation/registration logic —
|
|
13
|
+
* not a shared import — generalized off the view-mode concept so any custom
|
|
14
|
+
* list markup can register in one call instead of hand-mirroring the
|
|
15
|
+
* registry contract (the exact duplication that motivated this issue: an
|
|
16
|
+
* application page manually driving a headless `DataTableController` and
|
|
17
|
+
* calling `registry.register` itself). The two copies must be kept in sync
|
|
18
|
+
* by hand until they are unified behind a shared implementation in
|
|
19
|
+
* `@happyvertical/smrt-ui/data` (both packages already depend on it) —
|
|
20
|
+
* tracked as a follow-up (#2917). They are NOT currently identical: this
|
|
21
|
+
* copy denies a controlled controller's table command that fails to settle
|
|
22
|
+
* (see `applyControlledState` below), calls `registry.register` before
|
|
23
|
+
* subscribing to the controller to avoid leaking a subscription on a
|
|
24
|
+
* throwing register, and ignores `update()` once `destroy()` has run so a
|
|
25
|
+
* late-resolving page callback cannot resurrect this identity's entry in the
|
|
26
|
+
* registry-scoped revision map after a later mount has taken it over;
|
|
27
|
+
* `registerContentListDataSurface` predates all three fixes. #2917 should
|
|
28
|
+
* adopt them rather than treat this copy as the odd one out.
|
|
29
|
+
*
|
|
30
|
+
* Call during component initialization (top level of `<script>`, or inside
|
|
31
|
+
* an `$effect`); `destroy()` unregisters and unsubscribes, so tearing it down
|
|
32
|
+
* on unmount is the caller's responsibility, exactly like
|
|
33
|
+
* `registerContentListDataSurface`. Nothing here touches `window` or
|
|
34
|
+
* `document` at module scope, so it is safe under `ssr = false` SPAs and
|
|
35
|
+
* during SSR (the registry itself is a plain in-memory object).
|
|
36
|
+
*/
|
|
37
|
+
import { DATA_TABLE_SURFACE_CONTROL_IDS, dataTableCommandFromDataSurfaceCommand, dataTableRowIdKey, } from '@happyvertical/smrt-ui/data';
|
|
38
|
+
const revisionsByRegistry = new WeakMap();
|
|
39
|
+
function identityKey(descriptor) {
|
|
40
|
+
const { identity } = descriptor;
|
|
41
|
+
return JSON.stringify([
|
|
42
|
+
identity.kind,
|
|
43
|
+
identity.surfaceId,
|
|
44
|
+
identity.subject?.type,
|
|
45
|
+
identity.subject?.id,
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
function selectionReference(selection) {
|
|
49
|
+
if (selection.scope === 'page')
|
|
50
|
+
return { scope: 'current-page' };
|
|
51
|
+
if (selection.scope === 'allMatching') {
|
|
52
|
+
return {
|
|
53
|
+
scope: 'all-matching',
|
|
54
|
+
queryFingerprint: selection.queryFingerprint,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return selection.rowIds.length > 0
|
|
58
|
+
? { scope: 'explicit-ids', rowIds: selection.rowIds }
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
const RESERVED_CONTEXT_KEYS = ['table'];
|
|
62
|
+
/**
|
|
63
|
+
* A command declaring a `controlId` from the canonical
|
|
64
|
+
* `DATA_TABLE_SURFACE_CONTROL_IDS` (`@happyvertical/smrt-ui/data`) that still
|
|
65
|
+
* fails to translate (an unparseable payload — e.g. `set-filters` with a
|
|
66
|
+
* non-array `filters`) must be denied directly, never forwarded to
|
|
67
|
+
* `onControl`: the descriptor and `acceptsTableCommand` gate never ran for
|
|
68
|
+
* it, so treating it as a generic custom control would let an `onControl`
|
|
69
|
+
* catch-all silently acknowledge a table mutation that was never applied.
|
|
70
|
+
* Sourced from the same upstream enumeration `dataTableCommandFromDataSurfaceCommand`
|
|
71
|
+
* is derived from — never a local copy of the id list — so an upstream
|
|
72
|
+
* addition to that switch is covered here automatically.
|
|
73
|
+
*/
|
|
74
|
+
const TABLE_CONTROL_IDS = new Set(DATA_TABLE_SURFACE_CONTROL_IDS);
|
|
75
|
+
function assertNoReservedContextKeys(context) {
|
|
76
|
+
for (const key of RESERVED_CONTEXT_KEYS) {
|
|
77
|
+
if (key in context) {
|
|
78
|
+
throw new TypeError(`ListDataSurfaceContext must not use the reserved key "${key}" — the mounted controller's own snapshot is always published under it.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function allowsFilterOperator(column, operator) {
|
|
83
|
+
if (!column)
|
|
84
|
+
return false;
|
|
85
|
+
const canonical = column.operators?.filter;
|
|
86
|
+
const alias = column.filterOperators;
|
|
87
|
+
if (canonical && alias)
|
|
88
|
+
return canonical.includes(operator) && alias.includes(operator);
|
|
89
|
+
return canonical?.includes(operator) ?? alias?.includes(operator) ?? false;
|
|
90
|
+
}
|
|
91
|
+
function commandAllowed(command, descriptor, controller) {
|
|
92
|
+
const readable = new Set(descriptor.columns
|
|
93
|
+
.filter((column) => column.capabilities.includes('read'))
|
|
94
|
+
.map((column) => column.id));
|
|
95
|
+
const filterable = new Set(descriptor.query.filterableColumnIds ?? []);
|
|
96
|
+
const sortable = new Set(descriptor.query.sortableColumnIds ?? []);
|
|
97
|
+
switch (command.type) {
|
|
98
|
+
case 'setFilters':
|
|
99
|
+
return command.filters.every((filter) => {
|
|
100
|
+
const column = descriptor.columns.find((candidate) => candidate.id === filter.columnId);
|
|
101
|
+
return (filterable.has(filter.columnId) &&
|
|
102
|
+
allowsFilterOperator(column, filter.operator));
|
|
103
|
+
});
|
|
104
|
+
case 'setSorting':
|
|
105
|
+
return command.sorting.every((sort) => sortable.has(sort.columnId));
|
|
106
|
+
case 'toggleSorting':
|
|
107
|
+
return sortable.has(command.columnId);
|
|
108
|
+
case 'setSelectedRows':
|
|
109
|
+
return command.rowIds.length <= descriptor.limits.maxSelectionSize;
|
|
110
|
+
case 'toggleRowSelection': {
|
|
111
|
+
const selection = controller.snapshot().state.selection;
|
|
112
|
+
if (selection.scope === 'allMatching')
|
|
113
|
+
return false;
|
|
114
|
+
const selected = selection.rowIds.some((rowId) => dataTableRowIdKey(rowId) === dataTableRowIdKey(command.rowId));
|
|
115
|
+
return (selected || selection.rowIds.length < descriptor.limits.maxSelectionSize);
|
|
116
|
+
}
|
|
117
|
+
case 'setPageSize':
|
|
118
|
+
return (command.pageSize === null ||
|
|
119
|
+
(Number.isSafeInteger(command.pageSize) && command.pageSize > 0));
|
|
120
|
+
case 'setColumnOrder':
|
|
121
|
+
return command.columnIds.every((columnId) => readable.has(columnId));
|
|
122
|
+
case 'setColumnVisibility':
|
|
123
|
+
return command.columns.every((column) => readable.has(column.columnId));
|
|
124
|
+
default:
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** `undefined` (a `void` return) means the handler ran and succeeded. */
|
|
129
|
+
function normalizeControlResult(result) {
|
|
130
|
+
if (result === undefined)
|
|
131
|
+
return { ok: true };
|
|
132
|
+
if (typeof result === 'boolean')
|
|
133
|
+
return { ok: result };
|
|
134
|
+
return { ok: result.ok };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Register an existing, headless-controller-backed list as a mounted
|
|
138
|
+
* `DataSurfaceDescriptor`. Mirrors `registerContentListDataSurface`'s
|
|
139
|
+
* contract (stable identity independent of any view-mode churn, monotonic
|
|
140
|
+
* per-identity revision, visible-command translation) without requiring the
|
|
141
|
+
* ContentList view-mode concept, so any custom list can adopt it directly.
|
|
142
|
+
*/
|
|
143
|
+
export function mountListDataSurface(options) {
|
|
144
|
+
let revisions = revisionsByRegistry.get(options.registry);
|
|
145
|
+
if (!revisions) {
|
|
146
|
+
revisions = new Map();
|
|
147
|
+
revisionsByRegistry.set(options.registry, revisions);
|
|
148
|
+
}
|
|
149
|
+
const key = identityKey(options.descriptor);
|
|
150
|
+
const previousRevision = revisions.get(key);
|
|
151
|
+
let revision = Math.max(options.initialRevision ?? 0, previousRevision === undefined ? 0 : previousRevision + 1);
|
|
152
|
+
// Do NOT commit `revision` to the shared per-identity map, or subscribe to
|
|
153
|
+
// the controller, until `registry.register` below succeeds. Both a bad
|
|
154
|
+
// descriptor (duplicate identity, unknown column/control ids, …) and a
|
|
155
|
+
// reserved context key throw synchronously; committing shared state first
|
|
156
|
+
// would leave an orphaned controller subscriber writing a phantom revision
|
|
157
|
+
// counter for an identity this call never actually owns.
|
|
158
|
+
let context = { ...(options.context ?? {}) };
|
|
159
|
+
assertNoReservedContextKeys(context);
|
|
160
|
+
let contextSignature = JSON.stringify(context);
|
|
161
|
+
const advanceRevision = () => {
|
|
162
|
+
revision += 1;
|
|
163
|
+
revisions.set(key, revision);
|
|
164
|
+
options.onRevision?.(revision);
|
|
165
|
+
};
|
|
166
|
+
let destroyed = false;
|
|
167
|
+
const updateContext = (next) => {
|
|
168
|
+
// A page callback captured before unmount (e.g. an async `refresh` that
|
|
169
|
+
// calls `handle.update()` after its promise resolves) must not resurrect
|
|
170
|
+
// this identity's entry in the registry-scoped, cross-mount `revisions`
|
|
171
|
+
// map after `destroy()` — doing so would corrupt the monotonic revision
|
|
172
|
+
// a LATER, unrelated mount of the same identity seeds from.
|
|
173
|
+
if (destroyed)
|
|
174
|
+
return;
|
|
175
|
+
// Genuinely "fold in": keys already published that `next` does not
|
|
176
|
+
// mention are retained, matching this function's own documented
|
|
177
|
+
// contract. A caller that wants a key gone passes it explicitly as
|
|
178
|
+
// `undefined`; the registry rejects a literal `undefined` value (it is
|
|
179
|
+
// not JSON-safe), so that case deletes the key outright instead.
|
|
180
|
+
const draft = {
|
|
181
|
+
...context,
|
|
182
|
+
...next,
|
|
183
|
+
};
|
|
184
|
+
for (const patchKey of Object.keys(next)) {
|
|
185
|
+
if (next[patchKey] === undefined)
|
|
186
|
+
delete draft[patchKey];
|
|
187
|
+
}
|
|
188
|
+
const merged = draft;
|
|
189
|
+
assertNoReservedContextKeys(merged);
|
|
190
|
+
// `merged` may still carry a transport-reserved key (`tenantId`, `token`,
|
|
191
|
+
// `where`, …) that only the registry's own boundary-safety check knows
|
|
192
|
+
// about (`FORBIDDEN_BOUNDARY_KEYS` in `@happyvertical/smrt-ui/data`,
|
|
193
|
+
// in-repo upstream — no local copy of that list here). Validate eagerly
|
|
194
|
+
// by forcing the same read `registry.register` already performed at
|
|
195
|
+
// mount, so a bad key throws synchronously at THIS call site instead of
|
|
196
|
+
// being committed and only failing later on an unrelated `inspect()`/
|
|
197
|
+
// `execute()` call against the now-poisoned surface. Roll back on
|
|
198
|
+
// failure so the surface is left exactly as it was.
|
|
199
|
+
const previousContext = context;
|
|
200
|
+
context = merged;
|
|
201
|
+
try {
|
|
202
|
+
options.registry.inspect(options.descriptor.identity);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
context = previousContext;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const signature = JSON.stringify(merged);
|
|
209
|
+
if (signature !== contextSignature) {
|
|
210
|
+
advanceRevision();
|
|
211
|
+
contextSignature = signature;
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const unregister = options.registry.register({
|
|
215
|
+
descriptor: options.descriptor,
|
|
216
|
+
getSnapshot: () => {
|
|
217
|
+
const table = options.controller.snapshot();
|
|
218
|
+
return {
|
|
219
|
+
revision,
|
|
220
|
+
state: {
|
|
221
|
+
...context,
|
|
222
|
+
table: table,
|
|
223
|
+
},
|
|
224
|
+
selection: selectionReference(table.state.selection),
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
execute: async (command) => {
|
|
228
|
+
const tableCommand = dataTableCommandFromDataSurfaceCommand(command);
|
|
229
|
+
if (tableCommand) {
|
|
230
|
+
if (!commandAllowed(tableCommand, options.descriptor, options.controller) ||
|
|
231
|
+
options.acceptsTableCommand?.(tableCommand) === false)
|
|
232
|
+
return { ok: false };
|
|
233
|
+
const transition = options.controller.dispatch(tableCommand);
|
|
234
|
+
if (options.controller.isControlled() && transition.changed) {
|
|
235
|
+
// A controlled controller's dispatch() only proposed state via
|
|
236
|
+
// onStateChange — it never applied it. Settle it (or deny) exactly
|
|
237
|
+
// like DataTable's own controlled-table contract.
|
|
238
|
+
const settled = await options.applyControlledState?.(transition.next.state, tableCommand);
|
|
239
|
+
if (settled)
|
|
240
|
+
options.controller.replaceState(settled);
|
|
241
|
+
if (JSON.stringify(options.controller.getState()) !==
|
|
242
|
+
JSON.stringify(transition.next.state)) {
|
|
243
|
+
return { ok: false };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
switch (command.controlId) {
|
|
249
|
+
case 'refresh':
|
|
250
|
+
if (!options.refresh)
|
|
251
|
+
return { ok: false };
|
|
252
|
+
if ((await options.refresh()) === false)
|
|
253
|
+
return { ok: false };
|
|
254
|
+
return;
|
|
255
|
+
case 'retry':
|
|
256
|
+
if (!options.retry)
|
|
257
|
+
return { ok: false };
|
|
258
|
+
if ((await options.retry()) === false)
|
|
259
|
+
return { ok: false };
|
|
260
|
+
return;
|
|
261
|
+
case 'focus':
|
|
262
|
+
if (!options.focus)
|
|
263
|
+
return { ok: false };
|
|
264
|
+
options.focus();
|
|
265
|
+
return;
|
|
266
|
+
case 'reveal':
|
|
267
|
+
if (!options.reveal)
|
|
268
|
+
return { ok: false };
|
|
269
|
+
options.reveal();
|
|
270
|
+
return;
|
|
271
|
+
case 'highlight':
|
|
272
|
+
if (!options.highlight)
|
|
273
|
+
return { ok: false };
|
|
274
|
+
options.highlight();
|
|
275
|
+
return;
|
|
276
|
+
default: {
|
|
277
|
+
// A declared table-control id that failed to translate (e.g. an
|
|
278
|
+
// unparseable payload) must be denied directly — it never reached
|
|
279
|
+
// `commandAllowed`/`acceptsTableCommand`, so treating it as a
|
|
280
|
+
// generic custom control would let `onControl` silently
|
|
281
|
+
// acknowledge a table mutation that was never applied.
|
|
282
|
+
if (TABLE_CONTROL_IDS.has(command.controlId))
|
|
283
|
+
return { ok: false };
|
|
284
|
+
if (!options.onControl)
|
|
285
|
+
return { ok: false };
|
|
286
|
+
const result = normalizeControlResult(await options.onControl(command.controlId, command.payload));
|
|
287
|
+
return result.ok ? undefined : { ok: false };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
// Registration succeeded — now, and only now, commit the shared per-identity
|
|
293
|
+
// revision and subscribe to the controller (see the note above).
|
|
294
|
+
revisions.set(key, revision);
|
|
295
|
+
const unsubscribe = options.controller.subscribe((transition) => {
|
|
296
|
+
if (transition.changed)
|
|
297
|
+
advanceRevision();
|
|
298
|
+
});
|
|
299
|
+
options.onRevision?.(revision);
|
|
300
|
+
return {
|
|
301
|
+
update: updateContext,
|
|
302
|
+
destroy() {
|
|
303
|
+
destroyed = true;
|
|
304
|
+
unsubscribe();
|
|
305
|
+
unregister();
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-svelte",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.6",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
|
|
6
6
|
"type": "module",
|
|
@@ -121,10 +121,10 @@
|
|
|
121
121
|
},
|
|
122
122
|
"dependencies": {
|
|
123
123
|
"@happyvertical/logger": "^0.89.11",
|
|
124
|
-
"@happyvertical/smrt-languages": "0.51.
|
|
125
|
-
"@happyvertical/smrt-types": "0.51.
|
|
126
|
-
"@happyvertical/smrt-ui": "0.51.
|
|
127
|
-
"@happyvertical/smrt-web": "0.51.
|
|
124
|
+
"@happyvertical/smrt-languages": "0.51.6",
|
|
125
|
+
"@happyvertical/smrt-types": "0.51.6",
|
|
126
|
+
"@happyvertical/smrt-ui": "0.51.6",
|
|
127
|
+
"@happyvertical/smrt-web": "0.51.6",
|
|
128
128
|
"@tanstack/db": "^0.6.14",
|
|
129
129
|
"@tanstack/svelte-db": "^0.1.91",
|
|
130
130
|
"esm-env": "^1.2.2"
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
}
|
|
156
156
|
},
|
|
157
157
|
"devDependencies": {
|
|
158
|
-
"@happyvertical/smrt-agents": "0.51.
|
|
159
|
-
"@happyvertical/smrt-chat": "0.51.
|
|
160
|
-
"@happyvertical/smrt-content": "0.51.
|
|
161
|
-
"@happyvertical/smrt-core": "0.51.
|
|
162
|
-
"@happyvertical/smrt-reports": "0.51.
|
|
163
|
-
"@happyvertical/smrt-scanner": "0.51.
|
|
164
|
-
"@happyvertical/smrt-tenancy": "0.51.
|
|
165
|
-
"@happyvertical/smrt-users": "0.51.
|
|
158
|
+
"@happyvertical/smrt-agents": "0.51.6",
|
|
159
|
+
"@happyvertical/smrt-chat": "0.51.6",
|
|
160
|
+
"@happyvertical/smrt-content": "0.51.6",
|
|
161
|
+
"@happyvertical/smrt-core": "0.51.6",
|
|
162
|
+
"@happyvertical/smrt-reports": "0.51.6",
|
|
163
|
+
"@happyvertical/smrt-scanner": "0.51.6",
|
|
164
|
+
"@happyvertical/smrt-tenancy": "0.51.6",
|
|
165
|
+
"@happyvertical/smrt-users": "0.51.6",
|
|
166
166
|
"@sveltejs/package": "^2.5.8",
|
|
167
167
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
168
168
|
"@testing-library/jest-dom": "^6.9.1",
|