@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.
- package/CHANGELOG.md +1355 -4
- package/dist/actions/ActionRunner.d.ts +82 -14
- package/dist/actions/ActionRunner.js +164 -81
- package/dist/actions/actionKeys.d.ts +118 -0
- package/dist/actions/actionKeys.js +243 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/adapters/ApiDataSource.js +10 -1
- package/dist/adapters/ValueDataSource.d.ts +2 -2
- package/dist/evaluator/fieldRules.d.ts +0 -2
- package/dist/evaluator/fieldRules.js +3 -2
- package/dist/evaluator/optionLint.js +5 -2
- package/dist/evaluator/optionRules.d.ts +13 -6
- package/dist/evaluator/optionRules.js +0 -23
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/registry/Registry.d.ts +81 -2
- package/dist/registry/Registry.js +102 -2
- package/dist/registry/public-blocks.js +52 -1
- package/dist/theme/ThemeEngine.js +0 -12
- package/dist/utils/chart-series.d.ts +28 -0
- package/dist/utils/chart-series.js +55 -0
- package/dist/utils/column-identity.d.ts +110 -0
- package/dist/utils/column-identity.js +210 -0
- package/dist/utils/dashboard-filters.d.ts +2 -2
- package/dist/utils/expand-fields.js +8 -8
- package/dist/utils/filter-converter.d.ts +47 -0
- package/dist/utils/filter-converter.js +94 -7
- package/dist/utils/filter-tokens.d.ts +101 -0
- package/dist/utils/filter-tokens.js +173 -0
- package/dist/utils/managedBy.d.ts +21 -3
- package/dist/utils/managedBy.js +45 -4
- package/dist/utils/normalize-list-view.d.ts +99 -0
- package/dist/utils/normalize-list-view.js +255 -0
- package/dist/utils/sort-values.d.ts +52 -0
- package/dist/utils/sort-values.js +125 -0
- package/dist/validation/index.d.ts +9 -1
- package/dist/validation/index.js +9 -1
- package/dist/validation/validators/index.d.ts +7 -1
- package/dist/validation/validators/index.js +7 -1
- package/dist/validation/validators/object-validation-engine.d.ts +151 -31
- package/dist/validation/validators/object-validation-engine.js +275 -142
- 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
|
-
/**
|
|
85
|
-
|
|
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;
|
package/dist/utils/managedBy.js
CHANGED
|
@@ -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
|
-
/**
|
|
63
|
-
|
|
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
|
+
}
|