@object-ui/core 16.1.0 → 17.1.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +1355 -4
  2. package/dist/actions/ActionRunner.d.ts +82 -14
  3. package/dist/actions/ActionRunner.js +164 -81
  4. package/dist/actions/actionKeys.d.ts +118 -0
  5. package/dist/actions/actionKeys.js +243 -0
  6. package/dist/actions/index.d.ts +1 -0
  7. package/dist/actions/index.js +1 -0
  8. package/dist/adapters/ApiDataSource.js +10 -1
  9. package/dist/adapters/ValueDataSource.d.ts +2 -2
  10. package/dist/evaluator/fieldRules.d.ts +0 -2
  11. package/dist/evaluator/fieldRules.js +3 -2
  12. package/dist/evaluator/optionLint.js +5 -2
  13. package/dist/evaluator/optionRules.d.ts +13 -6
  14. package/dist/evaluator/optionRules.js +0 -23
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +7 -0
  17. package/dist/registry/Registry.d.ts +81 -2
  18. package/dist/registry/Registry.js +102 -2
  19. package/dist/registry/public-blocks.js +52 -1
  20. package/dist/theme/ThemeEngine.js +0 -12
  21. package/dist/utils/chart-series.d.ts +28 -0
  22. package/dist/utils/chart-series.js +55 -0
  23. package/dist/utils/column-identity.d.ts +110 -0
  24. package/dist/utils/column-identity.js +210 -0
  25. package/dist/utils/dashboard-filters.d.ts +2 -2
  26. package/dist/utils/expand-fields.js +8 -8
  27. package/dist/utils/filter-converter.d.ts +47 -0
  28. package/dist/utils/filter-converter.js +94 -7
  29. package/dist/utils/filter-tokens.d.ts +101 -0
  30. package/dist/utils/filter-tokens.js +173 -0
  31. package/dist/utils/managedBy.d.ts +21 -3
  32. package/dist/utils/managedBy.js +45 -4
  33. package/dist/utils/normalize-list-view.d.ts +99 -0
  34. package/dist/utils/normalize-list-view.js +255 -0
  35. package/dist/utils/sort-values.d.ts +52 -0
  36. package/dist/utils/sort-values.js +125 -0
  37. package/dist/validation/index.d.ts +9 -1
  38. package/dist/validation/index.js +9 -1
  39. package/dist/validation/validators/index.d.ts +7 -1
  40. package/dist/validation/validators/index.js +7 -1
  41. package/dist/validation/validators/object-validation-engine.d.ts +151 -31
  42. package/dist/validation/validators/object-validation-engine.js +275 -142
  43. package/package.json +4 -4
@@ -0,0 +1,173 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import { resolveDateMacros } from './date-macros.js';
9
+ /**
10
+ * Session-scoped filter placeholders — the sibling vocabulary to
11
+ * `{date-macros}`.
12
+ *
13
+ * Filter values travel as JSON, so a user-scoped slice cannot call
14
+ * `currentUser().id` inline; it writes a placeholder that this module expands
15
+ * immediately before the filter reaches the data source:
16
+ *
17
+ * { owner_id: '{current_user_id}' }
18
+ *
19
+ * ## Why this module exists (framework #3574)
20
+ *
21
+ * There used to be no shared resolver. Three ad-hoc implementations had grown
22
+ * up independently — one in `ObjectView` for list views, one in
23
+ * `ObjectDataPage` for URL filter triples, one in `NavigationRenderer` for
24
+ * hrefs — and each handled only the shape its own surface happened to use.
25
+ * Dashboard widgets never got one at all, so `{current_user_id}` in a widget
26
+ * filter reached SQL as a literal, matched no row, and the widget rendered
27
+ * `0` with no error in the console or the server log.
28
+ *
29
+ * The failure mode is the point: a metric reading `0` is indistinguishable
30
+ * from a metric that is legitimately zero, so the bug survived review and
31
+ * shipped. Any *new* surface that resolves placeholders itself will
32
+ * eventually reproduce it, which is why resolution lives here, once, and
33
+ * every surface calls {@link resolveFilterPlaceholders}.
34
+ *
35
+ * ## Presentation scope, NOT a security boundary
36
+ *
37
+ * These tokens scope what a surface *shows*. They do not decide what a caller
38
+ * is *allowed* to read — that is RLS, enforced server-side against a
39
+ * different vocabulary rooted at `current_user` (`owner_id = current_user.id`).
40
+ * Dropping a `{current_user_id}` filter widens a view; it must never widen
41
+ * access. Never use a context token as an access control.
42
+ *
43
+ * ## Contract source
44
+ *
45
+ * The canonical vocabulary is published as part of the platform contract at
46
+ * `@objectstack/spec` → `CONTEXT_TOKENS` / `classifyFilterToken`. It is
47
+ * mirrored here — exactly as `date-macros.ts` mirrors `DATE_MACRO_TOKENS` —
48
+ * because the installed `@objectstack/spec` predates that export. Keep the two
49
+ * in sync; the duplication is temporary until the next coordinated release.
50
+ */
51
+ /** The complete set of session-scoped filter tokens. */
52
+ export const CONTEXT_TOKENS = ['current_user_id', 'current_org_id'];
53
+ /**
54
+ * Near-miss spellings → the token the author meant.
55
+ *
56
+ * Every entry is a real authoring mistake, and each is a correct spelling
57
+ * *somewhere else* in the platform, which is exactly why authors reach for it:
58
+ * `current_user` is the RLS expression root, `{user_id}` is valid
59
+ * `titleFormat` field interpolation, `organization_id` is a real column name.
60
+ *
61
+ * Mirrors `CONTEXT_TOKEN_SUGGESTIONS` in `@objectstack/spec`. Used only to
62
+ * make the runtime warning actionable; the authoring-time gate
63
+ * (`validateFilterTokens` in `@objectstack/lint`) is what actually prevents
64
+ * these from shipping.
65
+ */
66
+ const CONTEXT_TOKEN_SUGGESTIONS = {
67
+ current_user: 'current_user_id',
68
+ current_user_email: 'current_user_id',
69
+ user_id: 'current_user_id',
70
+ userid: 'current_user_id',
71
+ me: 'current_user_id',
72
+ current_organization_id: 'current_org_id',
73
+ org_id: 'current_org_id',
74
+ organization_id: 'current_org_id',
75
+ current_tenant_id: 'current_org_id',
76
+ };
77
+ /** Whole-string placeholder: `{token}` or `${token}`, anchored. */
78
+ const WHOLE_TOKEN_RE = /^\$?\{([a-zA-Z0-9_]+)\}$/;
79
+ function isContextToken(token) {
80
+ return CONTEXT_TOKENS.includes(token);
81
+ }
82
+ /**
83
+ * Expand `{current_user_id}` / `{current_org_id}` inside a filter.
84
+ *
85
+ * Walks arrays and plain objects recursively, which is what makes one
86
+ * resolver cover both platform filter shapes: the MongoDB-style object a
87
+ * dashboard widget carries (`{ owner_id: '{current_user_id}' }`) and the
88
+ * condition/triple arrays a list view carries (`[{ field, operator, value }]`
89
+ * / `[['owner','=','…']]`). The predecessor helper in `ObjectView` bailed on
90
+ * non-arrays (`if (!Array.isArray(filter)) return filter`), which is why
91
+ * lifting it into the dashboard would have been a silent no-op.
92
+ *
93
+ * Only whole-string placeholders are substituted — an id is an opaque value,
94
+ * so embedding it in a larger string (`'user-{current_user_id}'`) is never
95
+ * what an author means, and substituting there would silently produce a value
96
+ * that matches nothing. Unknown tokens are passed through untouched so that
97
+ * date macros and nav-only `AppContextSelector` ids survive this pass.
98
+ *
99
+ * An unresolvable *known* token is deliberately left as-is rather than
100
+ * dropped: leaving it yields an empty result, whereas dropping the clause
101
+ * would widen the result set — and a filter that silently widens is far worse
102
+ * than one that silently narrows.
103
+ */
104
+ export function resolveContextTokens(filter, scope = {}) {
105
+ if (filter == null)
106
+ return filter;
107
+ const { currentUserId, currentOrgId } = scope;
108
+ const warn = scope.onUnresolved === null
109
+ ? () => { }
110
+ : scope.onUnresolved ??
111
+ ((message) => {
112
+ // eslint-disable-next-line no-console
113
+ console.warn(`[object-ui] ${message}`);
114
+ });
115
+ const values = {
116
+ current_user_id: currentUserId,
117
+ current_org_id: currentOrgId,
118
+ };
119
+ const walk = (value) => {
120
+ if (value == null)
121
+ return value;
122
+ if (typeof value === 'string') {
123
+ const m = value.match(WHOLE_TOKEN_RE);
124
+ if (!m)
125
+ return value;
126
+ const token = m[1];
127
+ if (isContextToken(token)) {
128
+ const resolved = values[token];
129
+ if (resolved != null && resolved !== '')
130
+ return resolved;
131
+ warn(`Filter placeholder "{${token}}" could not be resolved — no ${token === 'current_user_id' ? 'signed-in user' : 'active organization'} in scope. The filter will match no records.`);
132
+ return value;
133
+ }
134
+ // Not ours. Warn only for near-misses, which resolve in no vocabulary
135
+ // at all and would otherwise fail silently; genuine date macros and
136
+ // nav context-selector ids must pass through quietly.
137
+ const suggestion = CONTEXT_TOKEN_SUGGESTIONS[token.toLowerCase()];
138
+ if (suggestion) {
139
+ warn(`Filter placeholder "{${token}}" is not a recognised token — did you mean ` +
140
+ `"{${suggestion}}"? It is sent to the server as a literal string and will ` +
141
+ `match no records.`);
142
+ }
143
+ return value;
144
+ }
145
+ if (Array.isArray(value))
146
+ return value.map(walk);
147
+ if (typeof value === 'object') {
148
+ const out = {};
149
+ for (const k of Object.keys(value))
150
+ out[k] = walk(value[k]);
151
+ return out;
152
+ }
153
+ return value;
154
+ };
155
+ return walk(filter);
156
+ }
157
+ /**
158
+ * Resolve **every** placeholder vocabulary a filter value understands — date
159
+ * macros and context tokens — in one call.
160
+ *
161
+ * This is the function surfaces should call. Calling only one of the two
162
+ * resolvers is the defect behind framework #3574: dashboard widgets called
163
+ * `resolveDateMacros` alone, so `{today}` worked and `{current_user_id}`
164
+ * silently did not.
165
+ *
166
+ * Order is irrelevant (the vocabularies are disjoint) but fixed here so
167
+ * behaviour is identical everywhere.
168
+ */
169
+ export function resolveFilterPlaceholders(filter, scope = {}, now = new Date()) {
170
+ if (filter == null)
171
+ return filter;
172
+ return resolveContextTokens(resolveDateMacros(filter, now), scope);
173
+ }
@@ -81,8 +81,19 @@ export declare function normalizeUserAction(v: UserActionOverride | undefined |
81
81
  * {@link normalizeUserAction} so the parse lives in exactly one place.
82
82
  */
83
83
  export declare function userActionPredicates(v: UserActionOverride | undefined | null): RowCrudPredicates | undefined;
84
- /** Resolve the effective CRUD affordances for an object schema. */
85
- export declare function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances;
84
+ /**
85
+ * Resolve the effective CRUD affordances for an object schema.
86
+ *
87
+ * @param obj The object schema (managedBy bucket + userActions overrides).
88
+ * @param effectiveApiOperations OPTIONAL server-resolved effective API operation
89
+ * set for this object (from `/me/permissions` `apiOperations`, #3391). When
90
+ * provided, each affordance bit is ANDed with the corresponding API operation
91
+ * (create/import→create/import, edit→update, delete→delete, exportCsv→export),
92
+ * so the UI never shows a button the server would 405. Passing `undefined`
93
+ * (old backend / no effective set) leaves the affordances untouched —
94
+ * backward-compatible. An empty array means "expose nothing" → all bits off.
95
+ */
96
+ export declare function resolveCrudAffordances(obj: SchemaLike | null | undefined, effectiveApiOperations?: readonly string[] | null): CrudAffordances;
86
97
  /** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */
87
98
  export declare function isWriteOptedIn(v: UserActionOverride | undefined | null): boolean;
88
99
  /**
@@ -97,5 +108,12 @@ export declare function isSystemWritable(obj: SchemaLike | null | undefined): bo
97
108
  * `append-only` / `better-auth` objects resolve to `false` unless they opened
98
109
  * `userActions.edit`. (Per-record predicates gate row action buttons, not
99
110
  * object-level editability, so only the resolved boolean matters here.)
111
+ *
112
+ * @param effectiveApiOperations OPTIONAL server-resolved effective API operation
113
+ * set for this object (`/me/permissions` `apiOperations`, #3391/#3546). When
114
+ * provided, inline-edit is additionally ANDed with the server allowing
115
+ * `update`, so a record page never offers double-click/pencil editing the
116
+ * server would 405. `undefined` (old backend / unrestricted) leaves the
117
+ * bucket affordance untouched.
100
118
  */
101
- export declare function isObjectInlineEditable(obj: SchemaLike | null | undefined): boolean;
119
+ export declare function isObjectInlineEditable(obj: SchemaLike | null | undefined, effectiveApiOperations?: readonly string[] | null): boolean;
@@ -59,8 +59,32 @@ export function normalizeUserAction(v, base) {
59
59
  export function userActionPredicates(v) {
60
60
  return normalizeUserAction(v, false).predicates;
61
61
  }
62
- /** Resolve the effective CRUD affordances for an object schema. */
63
- export function resolveCrudAffordances(obj) {
62
+ /**
63
+ * The affordance-bit → server API-operation mapping used to intersect the
64
+ * UI-intent affordances with the server's effective API operation set (#3391).
65
+ * This is the objectui end of the framework's `API_METHOD_DERIVATION` contract:
66
+ * the button predicate is `affordance(op) ∧ effective.api(op)`.
67
+ */
68
+ const AFFORDANCE_TO_API_OPERATION = {
69
+ create: 'create',
70
+ import: 'import',
71
+ edit: 'update',
72
+ delete: 'delete',
73
+ exportCsv: 'export',
74
+ };
75
+ /**
76
+ * Resolve the effective CRUD affordances for an object schema.
77
+ *
78
+ * @param obj The object schema (managedBy bucket + userActions overrides).
79
+ * @param effectiveApiOperations OPTIONAL server-resolved effective API operation
80
+ * set for this object (from `/me/permissions` `apiOperations`, #3391). When
81
+ * provided, each affordance bit is ANDed with the corresponding API operation
82
+ * (create/import→create/import, edit→update, delete→delete, exportCsv→export),
83
+ * so the UI never shows a button the server would 405. Passing `undefined`
84
+ * (old backend / no effective set) leaves the affordances untouched —
85
+ * backward-compatible. An empty array means "expose nothing" → all bits off.
86
+ */
87
+ export function resolveCrudAffordances(obj, effectiveApiOperations) {
64
88
  const bucket = obj?.managedBy ?? 'platform';
65
89
  const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
66
90
  const o = obj?.userActions ?? {};
@@ -77,6 +101,16 @@ export function resolveCrudAffordances(obj) {
77
101
  out.editPredicates = edit.predicates;
78
102
  if (del.predicates)
79
103
  out.deletePredicates = del.predicates;
104
+ // [#3391] Intersect with the server's effective API operations when present.
105
+ // The frontend consumes the effective set the server hands down; it never
106
+ // reads the raw `apiMethods` nor re-derives.
107
+ if (Array.isArray(effectiveApiOperations)) {
108
+ const eff = new Set(effectiveApiOperations);
109
+ for (const [bit, op] of Object.entries(AFFORDANCE_TO_API_OPERATION)) {
110
+ if (out[bit] && !eff.has(op))
111
+ out[bit] = false;
112
+ }
113
+ }
80
114
  return out;
81
115
  }
82
116
  /** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */
@@ -100,7 +134,14 @@ export function isSystemWritable(obj) {
100
134
  * `append-only` / `better-auth` objects resolve to `false` unless they opened
101
135
  * `userActions.edit`. (Per-record predicates gate row action buttons, not
102
136
  * object-level editability, so only the resolved boolean matters here.)
137
+ *
138
+ * @param effectiveApiOperations OPTIONAL server-resolved effective API operation
139
+ * set for this object (`/me/permissions` `apiOperations`, #3391/#3546). When
140
+ * provided, inline-edit is additionally ANDed with the server allowing
141
+ * `update`, so a record page never offers double-click/pencil editing the
142
+ * server would 405. `undefined` (old backend / unrestricted) leaves the
143
+ * bucket affordance untouched.
103
144
  */
104
- export function isObjectInlineEditable(obj) {
105
- return resolveCrudAffordances(obj).edit;
145
+ export function isObjectInlineEditable(obj, effectiveApiOperations) {
146
+ return resolveCrudAffordances(obj, effectiveApiOperations).edit;
106
147
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * ObjectUI — list-view vocabulary canonicalization
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /** ListView's toolbar density vocabulary — three steps, not the spec's five. */
9
+ export type DensityMode = 'compact' | 'comfortable' | 'spacious';
10
+ /** The spec's `RowHeightSchema` vocabulary. */
11
+ export type RowHeight = 'compact' | 'short' | 'medium' | 'tall' | 'extra_tall';
12
+ /**
13
+ * `densityMode` → `rowHeight`. The two vocabularies are not the same size: the
14
+ * spec has five row heights, ListView's toolbar offers three. Widening maps
15
+ * each density onto the spec value the renderer already resolves it back to
16
+ * (see {@link ROW_HEIGHT_TO_DENSITY_MODE}), so a fold followed by a read is a
17
+ * round trip — `spacious` → `tall` → `spacious`.
18
+ */
19
+ export declare const DENSITY_MODE_TO_ROW_HEIGHT: Record<DensityMode, RowHeight>;
20
+ /**
21
+ * `rowHeight` → `densityMode`, the read direction. Narrowing five values onto
22
+ * three is lossy by construction: `short` collapses into `compact` and
23
+ * `extra_tall` into `spacious`. Exported so the renderer and the persistence
24
+ * layer agree on the collapse instead of each hard-coding its own table.
25
+ */
26
+ export declare const ROW_HEIGHT_TO_DENSITY_MODE: Record<RowHeight, DensityMode>;
27
+ /**
28
+ * Tolerant reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}. View metadata is
29
+ * user-authored, so `rowHeight` is not guaranteed to be one of the five spec
30
+ * values — an unknown one lands on `comfortable` rather than rendering an
31
+ * undefined density. Keeping the fallback here means every surface collapses
32
+ * the same way.
33
+ */
34
+ export declare function rowHeightToDensityMode(rowHeight: unknown): DensityMode;
35
+ /**
36
+ * ObjectUI's `list-view` node historically used a different vocabulary from
37
+ * `@objectstack/spec` for the same concepts (`fields` where the spec says
38
+ * `columns`, `viewType` where it says `type`, …). Issue #2231 closed the
39
+ * type-level fork; #2890 closes the vocabulary fork.
40
+ *
41
+ * Stored view metadata in user databases still carries the legacy keys, so the
42
+ * renderer cannot simply stop accepting them. Per AGENTS.md Commandment #0.1 the
43
+ * answer is NOT a per-read-site `??` fallback — those fossilize a second de-facto
44
+ * contract and drift apart (they already had: `ObjectGrid` preferred `columns`
45
+ * in one branch and `fields` in another). Instead legacy acceptance lives HERE,
46
+ * in one documented normalizer at the component boundary, mirroring
47
+ * `normalizeSchemaReferenceKeys` (object schemas) and the spec's own
48
+ * `normalizeVisibleWhen` / `normalizeFilterOperator` migration bridges.
49
+ *
50
+ * Note this cannot be a `z.preprocess` on `ListViewSchema`: nothing on the
51
+ * render path parses view metadata through zod (the zod schemas are used by the
52
+ * CLI validator, the VS Code extension and tests only), so a schema-level fold
53
+ * would never run. The guarantee comes from the call site instead — `ListView`
54
+ * normalizes before it reads anything.
55
+ *
56
+ * The fold is deliberately one-directional: the canonical key wins when both are
57
+ * present, and the legacy key is REMOVED from the result so a read-site that was
58
+ * missed fails loudly instead of quietly taking the legacy path. Like a spec
59
+ * migration bridge, this is expected to be dropped in a future major once stored
60
+ * metadata has been migrated.
61
+ *
62
+ * Non-mutating and allocation-frugal: returns the input by reference when there
63
+ * is nothing to fold, so `ListView`'s downstream `useMemo`s keep a stable
64
+ * dependency identity on the common (already-canonical) path.
65
+ *
66
+ * Currently folded:
67
+ * - `fields` → `columns` (#2890 scope A step 1)
68
+ * - `densityMode` → `rowHeight` (#2890 scope A step 2)
69
+ * - the `show*` toolbar flags → `userActions` (#2890 scope A step 3), and
70
+ * `showDescription` → `appearance.showDescription`. The canonical key wins
71
+ * per-flag, so a view may carry `userActions` for some toggles and a legacy
72
+ * flag for others. NOTE the fold does not apply any default: an absent flag
73
+ * stays absent, because the defaults are per-toggle (search/sort/filter/
74
+ * rowHeight/group default ON, hideFields/rowColor default OFF) and belong to
75
+ * the renderer, not to the vocabulary bridge.
76
+ * - `aria: { label, describedBy }` → the spec's `AriaProps`
77
+ * (`{ ariaLabel, ariaDescribedBy }`), and `sharing: { visibility, enabled }`
78
+ * → the spec's `ViewSharing` (`{ type }`) — #2890 scope A step 5. `aria.live`
79
+ * survives untouched: it has no spec counterpart.
80
+ * - `filters` → `filter` (#2890 scope A step 4). A key rename only: BOTH keys
81
+ * carry an ObjectQL FilterNode array (`[['stage','=','won']]`) everywhere in
82
+ * objectui — every consumer passes the value straight to `$filter`. The spec
83
+ * types `filter` as `ViewFilterRule[]` (`{field, operator, value}` objects),
84
+ * so objectui's field is typed from the spec but used as something else.
85
+ * That mismatch is real and out of scope here; converting formats inside a
86
+ * vocabulary fold would change what reaches the data source.
87
+ * - `viewType`: a missing kind, or the view CATEGORY `'list'` that AI-authored
88
+ * metadata stores and hosts forward verbatim, becomes the renderable `'grid'`
89
+ * — otherwise it reaches the renderer's typeless default branch and shows as
90
+ * a red "Unknown component type" box.
91
+ * - each `columns` entry's IDENTITY — `name` / `fieldName` → the spec's `field`
92
+ * (#3104). This one MIRRORS instead of deleting, for the reason given on
93
+ * {@link normalizeColumnIdentities}: `columns` entries cross the package
94
+ * boundary into host renderers, so dropping `name` from under them is a
95
+ * breaking change with no inventory. Runs AFTER the `fields` → `columns` fold
96
+ * above, so a view that spells its column list the legacy way still gets its
97
+ * entries canonicalized.
98
+ */
99
+ export declare function normalizeListViewSchema<T>(schema: T): T;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * ObjectUI — list-view vocabulary canonicalization
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ import { normalizeColumnIdentities } from './column-identity.js';
9
+ /**
10
+ * `densityMode` → `rowHeight`. The two vocabularies are not the same size: the
11
+ * spec has five row heights, ListView's toolbar offers three. Widening maps
12
+ * each density onto the spec value the renderer already resolves it back to
13
+ * (see {@link ROW_HEIGHT_TO_DENSITY_MODE}), so a fold followed by a read is a
14
+ * round trip — `spacious` → `tall` → `spacious`.
15
+ */
16
+ export const DENSITY_MODE_TO_ROW_HEIGHT = {
17
+ compact: 'compact',
18
+ comfortable: 'medium',
19
+ spacious: 'tall',
20
+ };
21
+ /**
22
+ * `rowHeight` → `densityMode`, the read direction. Narrowing five values onto
23
+ * three is lossy by construction: `short` collapses into `compact` and
24
+ * `extra_tall` into `spacious`. Exported so the renderer and the persistence
25
+ * layer agree on the collapse instead of each hard-coding its own table.
26
+ */
27
+ export const ROW_HEIGHT_TO_DENSITY_MODE = {
28
+ compact: 'compact',
29
+ short: 'compact',
30
+ medium: 'comfortable',
31
+ tall: 'spacious',
32
+ extra_tall: 'spacious',
33
+ };
34
+ /**
35
+ * Tolerant reader for {@link ROW_HEIGHT_TO_DENSITY_MODE}. View metadata is
36
+ * user-authored, so `rowHeight` is not guaranteed to be one of the five spec
37
+ * values — an unknown one lands on `comfortable` rather than rendering an
38
+ * undefined density. Keeping the fallback here means every surface collapses
39
+ * the same way.
40
+ */
41
+ export function rowHeightToDensityMode(rowHeight) {
42
+ if (typeof rowHeight === 'string' && rowHeight in ROW_HEIGHT_TO_DENSITY_MODE) {
43
+ return ROW_HEIGHT_TO_DENSITY_MODE[rowHeight];
44
+ }
45
+ return 'comfortable';
46
+ }
47
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
48
+ /**
49
+ * Legacy toolbar-visibility flags → their `userActions` key (#2890 scope A
50
+ * step 3). The spec documents `userActions` as "which interactive actions are
51
+ * available to users in the view toolbar", and already carries `rowHeight` —
52
+ * objectui's `showDensity` under its spec name. `group` / `hideFields` /
53
+ * `rowColor` are the same kind of toggle and are named after the config key
54
+ * they gate (`grouping`, `hiddenFields`, `rowColor`), pending promotion into
55
+ * `UserActionsConfigSchema` upstream.
56
+ */
57
+ const SHOW_FLAG_TO_USER_ACTION = {
58
+ showSearch: 'search',
59
+ showSort: 'sort',
60
+ showFilters: 'filter',
61
+ showDensity: 'rowHeight',
62
+ showGroup: 'group',
63
+ showHideFields: 'hideFields',
64
+ showColor: 'rowColor',
65
+ };
66
+ /**
67
+ * Legacy `sharing.visibility` → the spec's `ViewSharing.type`. The spec models
68
+ * two ownership kinds; objectui's four-value audience enum collapses onto them:
69
+ * only `private` is personal, everything wider is collaborative.
70
+ */
71
+ const VISIBILITY_TO_SHARING_TYPE = {
72
+ private: 'personal',
73
+ team: 'collaborative',
74
+ organization: 'collaborative',
75
+ public: 'collaborative',
76
+ };
77
+ /** Legacy ARIA spellings → the spec's `AriaProps` keys. */
78
+ const ARIA_KEY_ALIASES = {
79
+ label: 'ariaLabel',
80
+ describedBy: 'ariaDescribedBy',
81
+ };
82
+ /**
83
+ * ObjectUI's `list-view` node historically used a different vocabulary from
84
+ * `@objectstack/spec` for the same concepts (`fields` where the spec says
85
+ * `columns`, `viewType` where it says `type`, …). Issue #2231 closed the
86
+ * type-level fork; #2890 closes the vocabulary fork.
87
+ *
88
+ * Stored view metadata in user databases still carries the legacy keys, so the
89
+ * renderer cannot simply stop accepting them. Per AGENTS.md Commandment #0.1 the
90
+ * answer is NOT a per-read-site `??` fallback — those fossilize a second de-facto
91
+ * contract and drift apart (they already had: `ObjectGrid` preferred `columns`
92
+ * in one branch and `fields` in another). Instead legacy acceptance lives HERE,
93
+ * in one documented normalizer at the component boundary, mirroring
94
+ * `normalizeSchemaReferenceKeys` (object schemas) and the spec's own
95
+ * `normalizeVisibleWhen` / `normalizeFilterOperator` migration bridges.
96
+ *
97
+ * Note this cannot be a `z.preprocess` on `ListViewSchema`: nothing on the
98
+ * render path parses view metadata through zod (the zod schemas are used by the
99
+ * CLI validator, the VS Code extension and tests only), so a schema-level fold
100
+ * would never run. The guarantee comes from the call site instead — `ListView`
101
+ * normalizes before it reads anything.
102
+ *
103
+ * The fold is deliberately one-directional: the canonical key wins when both are
104
+ * present, and the legacy key is REMOVED from the result so a read-site that was
105
+ * missed fails loudly instead of quietly taking the legacy path. Like a spec
106
+ * migration bridge, this is expected to be dropped in a future major once stored
107
+ * metadata has been migrated.
108
+ *
109
+ * Non-mutating and allocation-frugal: returns the input by reference when there
110
+ * is nothing to fold, so `ListView`'s downstream `useMemo`s keep a stable
111
+ * dependency identity on the common (already-canonical) path.
112
+ *
113
+ * Currently folded:
114
+ * - `fields` → `columns` (#2890 scope A step 1)
115
+ * - `densityMode` → `rowHeight` (#2890 scope A step 2)
116
+ * - the `show*` toolbar flags → `userActions` (#2890 scope A step 3), and
117
+ * `showDescription` → `appearance.showDescription`. The canonical key wins
118
+ * per-flag, so a view may carry `userActions` for some toggles and a legacy
119
+ * flag for others. NOTE the fold does not apply any default: an absent flag
120
+ * stays absent, because the defaults are per-toggle (search/sort/filter/
121
+ * rowHeight/group default ON, hideFields/rowColor default OFF) and belong to
122
+ * the renderer, not to the vocabulary bridge.
123
+ * - `aria: { label, describedBy }` → the spec's `AriaProps`
124
+ * (`{ ariaLabel, ariaDescribedBy }`), and `sharing: { visibility, enabled }`
125
+ * → the spec's `ViewSharing` (`{ type }`) — #2890 scope A step 5. `aria.live`
126
+ * survives untouched: it has no spec counterpart.
127
+ * - `filters` → `filter` (#2890 scope A step 4). A key rename only: BOTH keys
128
+ * carry an ObjectQL FilterNode array (`[['stage','=','won']]`) everywhere in
129
+ * objectui — every consumer passes the value straight to `$filter`. The spec
130
+ * types `filter` as `ViewFilterRule[]` (`{field, operator, value}` objects),
131
+ * so objectui's field is typed from the spec but used as something else.
132
+ * That mismatch is real and out of scope here; converting formats inside a
133
+ * vocabulary fold would change what reaches the data source.
134
+ * - `viewType`: a missing kind, or the view CATEGORY `'list'` that AI-authored
135
+ * metadata stores and hosts forward verbatim, becomes the renderable `'grid'`
136
+ * — otherwise it reaches the renderer's typeless default branch and shows as
137
+ * a red "Unknown component type" box.
138
+ * - each `columns` entry's IDENTITY — `name` / `fieldName` → the spec's `field`
139
+ * (#3104). This one MIRRORS instead of deleting, for the reason given on
140
+ * {@link normalizeColumnIdentities}: `columns` entries cross the package
141
+ * boundary into host renderers, so dropping `name` from under them is a
142
+ * breaking change with no inventory. Runs AFTER the `fields` → `columns` fold
143
+ * above, so a view that spells its column list the legacy way still gets its
144
+ * entries canonicalized.
145
+ */
146
+ export function normalizeListViewSchema(schema) {
147
+ if (!schema || typeof schema !== 'object')
148
+ return schema;
149
+ const s = schema;
150
+ const legacyFields = s.fields;
151
+ const foldColumns = Array.isArray(legacyFields);
152
+ const legacyDensity = s.densityMode;
153
+ const foldRowHeight = typeof legacyDensity === 'string' && legacyDensity in DENSITY_MODE_TO_ROW_HEIGHT;
154
+ const legacyFilters = s.filters;
155
+ const foldFilter = Array.isArray(legacyFilters);
156
+ const legacyFlags = Object.keys(SHOW_FLAG_TO_USER_ACTION).filter((k) => typeof s[k] === 'boolean');
157
+ const foldDescription = typeof s.showDescription === 'boolean';
158
+ const aria = isRecord(s.aria) ? s.aria : undefined;
159
+ const foldAria = !!aria && Object.keys(ARIA_KEY_ALIASES).some((k) => aria[k] !== undefined);
160
+ const sharing = isRecord(s.sharing) ? s.sharing : undefined;
161
+ const foldSharing = !!sharing && (sharing.visibility !== undefined || sharing.enabled !== undefined);
162
+ const viewType = s.viewType;
163
+ const defaultViewKind = !viewType || viewType === 'list';
164
+ // The columns array the identity fold will see — mirroring the `foldColumns`
165
+ // precedence below (canonical `columns` wins; otherwise the legacy `fields`
166
+ // that is about to become it). `normalizeColumnIdentities` returns its input
167
+ // by reference when every entry is already canonical, so this comparison is
168
+ // also the "is there anything to do" test.
169
+ const columnsSource = Array.isArray(s.columns)
170
+ ? s.columns
171
+ : foldColumns
172
+ ? legacyFields
173
+ : undefined;
174
+ const foldedColumns = normalizeColumnIdentities(columnsSource);
175
+ const foldColumnIdentity = foldedColumns !== columnsSource;
176
+ if (!foldColumns && !foldRowHeight && !foldFilter && !legacyFlags.length &&
177
+ !foldDescription && !foldAria && !foldSharing && !defaultViewKind &&
178
+ !foldColumnIdentity) {
179
+ return schema;
180
+ }
181
+ const next = { ...s };
182
+ if (foldColumns) {
183
+ if (!Array.isArray(next.columns))
184
+ next.columns = legacyFields;
185
+ delete next.fields;
186
+ }
187
+ // After the `fields` → `columns` rename, so a legacy-spelled column LIST gets
188
+ // its per-entry identities canonicalized in the same pass.
189
+ if (foldColumnIdentity)
190
+ next.columns = foldedColumns;
191
+ if (foldRowHeight) {
192
+ if (typeof next.rowHeight !== 'string') {
193
+ next.rowHeight = DENSITY_MODE_TO_ROW_HEIGHT[legacyDensity];
194
+ }
195
+ delete next.densityMode;
196
+ }
197
+ if (foldFilter) {
198
+ if (!Array.isArray(next.filter))
199
+ next.filter = legacyFilters;
200
+ delete next.filters;
201
+ }
202
+ if (legacyFlags.length) {
203
+ const ua = { ...(isRecord(next.userActions) ? next.userActions : {}) };
204
+ for (const flag of legacyFlags) {
205
+ const key = SHOW_FLAG_TO_USER_ACTION[flag];
206
+ if (typeof ua[key] !== 'boolean')
207
+ ua[key] = s[flag];
208
+ delete next[flag];
209
+ }
210
+ next.userActions = ua;
211
+ }
212
+ if (foldDescription) {
213
+ const appearance = {
214
+ ...(isRecord(next.appearance) ? next.appearance : {}),
215
+ };
216
+ if (typeof appearance.showDescription !== 'boolean') {
217
+ appearance.showDescription = s.showDescription;
218
+ }
219
+ delete next.showDescription;
220
+ next.appearance = appearance;
221
+ }
222
+ if (foldAria && aria) {
223
+ const nextAria = { ...aria };
224
+ for (const [legacy, canonical] of Object.entries(ARIA_KEY_ALIASES)) {
225
+ if (nextAria[canonical] === undefined && aria[legacy] !== undefined) {
226
+ nextAria[canonical] = aria[legacy];
227
+ }
228
+ delete nextAria[legacy];
229
+ }
230
+ next.aria = nextAria;
231
+ }
232
+ if (foldSharing && sharing) {
233
+ const nextSharing = { ...sharing };
234
+ if (nextSharing.type === undefined) {
235
+ const visibility = typeof sharing.visibility === 'string' ? sharing.visibility : undefined;
236
+ // `enabled: true` with no audience is under-specified legacy input. It
237
+ // used to render the share badge titled "private", so it maps to
238
+ // `personal` — preserving what the user saw rather than adopting the
239
+ // spec's `collaborative` default and silently relabeling the badge.
240
+ const resolved = visibility
241
+ ? VISIBILITY_TO_SHARING_TYPE[visibility]
242
+ : sharing.enabled === true
243
+ ? 'personal'
244
+ : undefined;
245
+ if (resolved)
246
+ nextSharing.type = resolved;
247
+ }
248
+ delete nextSharing.visibility;
249
+ delete nextSharing.enabled;
250
+ next.sharing = nextSharing;
251
+ }
252
+ if (defaultViewKind)
253
+ next.viewType = 'grid';
254
+ return next;
255
+ }