@object-ui/core 16.0.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 (56) hide show
  1. package/CHANGELOG.md +1589 -1
  2. package/dist/actions/ActionRunner.d.ts +88 -16
  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.d.ts +11 -1
  9. package/dist/adapters/ApiDataSource.js +21 -1
  10. package/dist/adapters/ValueDataSource.d.ts +10 -2
  11. package/dist/adapters/ValueDataSource.js +9 -0
  12. package/dist/adapters/batchTransaction.d.ts +49 -0
  13. package/dist/adapters/batchTransaction.js +118 -0
  14. package/dist/adapters/index.d.ts +1 -0
  15. package/dist/adapters/index.js +1 -0
  16. package/dist/evaluator/ExpressionEvaluator.d.ts +10 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +48 -0
  18. package/dist/evaluator/SafeExpressionParser.js +5 -2
  19. package/dist/evaluator/fieldRules.d.ts +0 -2
  20. package/dist/evaluator/fieldRules.js +3 -2
  21. package/dist/evaluator/optionLint.js +5 -2
  22. package/dist/evaluator/optionRules.d.ts +35 -6
  23. package/dist/evaluator/optionRules.js +18 -23
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.js +8 -0
  26. package/dist/registry/Registry.d.ts +81 -2
  27. package/dist/registry/Registry.js +102 -2
  28. package/dist/registry/public-blocks.js +52 -1
  29. package/dist/theme/ThemeEngine.js +0 -12
  30. package/dist/utils/chart-series.d.ts +28 -0
  31. package/dist/utils/chart-series.js +55 -0
  32. package/dist/utils/column-identity.d.ts +110 -0
  33. package/dist/utils/column-identity.js +210 -0
  34. package/dist/utils/dashboard-filters.d.ts +2 -2
  35. package/dist/utils/dataset-format.d.ts +18 -1
  36. package/dist/utils/dataset-format.js +14 -1
  37. package/dist/utils/expand-fields.js +8 -8
  38. package/dist/utils/export-filename.js +4 -1
  39. package/dist/utils/filter-converter.d.ts +47 -0
  40. package/dist/utils/filter-converter.js +94 -7
  41. package/dist/utils/filter-tokens.d.ts +101 -0
  42. package/dist/utils/filter-tokens.js +173 -0
  43. package/dist/utils/managedBy.d.ts +119 -0
  44. package/dist/utils/managedBy.js +147 -0
  45. package/dist/utils/normalize-list-view.d.ts +99 -0
  46. package/dist/utils/normalize-list-view.js +255 -0
  47. package/dist/utils/record-title.js +0 -0
  48. package/dist/utils/sort-values.d.ts +52 -0
  49. package/dist/utils/sort-values.js +125 -0
  50. package/dist/validation/index.d.ts +9 -1
  51. package/dist/validation/index.js +9 -1
  52. package/dist/validation/validators/index.d.ts +7 -1
  53. package/dist/validation/validators/index.js +7 -1
  54. package/dist/validation/validators/object-validation-engine.d.ts +151 -31
  55. package/dist/validation/validators/object-validation-engine.js +275 -142
  56. package/package.json +4 -4
@@ -11,6 +11,32 @@
11
11
  * @param operator - MongoDB-style operator (e.g., '$gte', '$in')
12
12
  * @returns ObjectStack operator or null if not recognized
13
13
  */
14
+ /**
15
+ * A filter operator this layer will not translate.
16
+ *
17
+ * Carries the data API's own code and status for the same refusal so a failed
18
+ * list renders "the filter is malformed" rather than "check your connection" —
19
+ * `classifyLoadError` reads these, and a bare `Error` classifies as a network
20
+ * fault, which is the one thing this is definitely not.
21
+ */
22
+ export class FilterOperatorError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ Object.defineProperty(this, "code", {
26
+ enumerable: true,
27
+ configurable: true,
28
+ writable: true,
29
+ value: 'INVALID_FILTER'
30
+ });
31
+ Object.defineProperty(this, "httpStatus", {
32
+ enumerable: true,
33
+ configurable: true,
34
+ writable: true,
35
+ value: 400
36
+ });
37
+ this.name = 'FilterOperatorError';
38
+ }
39
+ }
14
40
  export function convertOperatorToAST(operator) {
15
41
  // Spec reference: framework/packages/spec/src/data/filter.zod.ts
16
42
  // Canonical MongoDB-style keys are camelCase ($startsWith, $endsWith, $notContains).
@@ -69,14 +95,22 @@ export function convertFiltersToAST(filter) {
69
95
  if (typeof value === 'object' && !Array.isArray(value)) {
70
96
  // Handle operator-based filters
71
97
  for (const [operator, operatorValue] of Object.entries(value)) {
72
- // Special handling for $regex - warn users about limited support
98
+ // `$regex` is refused, not downgraded. It used to become `contains`
99
+ // behind a `console.warn` — but substring matching is a DIFFERENT
100
+ // QUESTION, not a weaker version of the same one: `$regex: 'a.c'`
101
+ // matches "abc", `contains 'a.c'` does not, and neither result looks
102
+ // wrong on screen. A warning is not an error channel; nobody reads the
103
+ // console of a deployed app.
104
+ //
105
+ // The spec has no `$regex` (`FILTER_OPERATORS`, data/filter.zod.ts), so
106
+ // there is nothing to translate it INTO. Same treatment the unknown
107
+ // operator below already gets, and for the same stated reason.
73
108
  if (operator === '$regex') {
74
- console.warn(`[ObjectUI] Warning: $regex operator is not fully supported. ` +
75
- `Converting to 'contains' which only supports substring matching, not regex patterns. ` +
109
+ throw new FilterOperatorError(`[ObjectUI] The '$regex' filter operator is not supported. It used to be ` +
110
+ `converted to 'contains', which matches a literal substring rather than a ` +
111
+ `pattern — a different result, not a degraded one. ` +
76
112
  `Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` +
77
- `Consider using $contains or $startsWith instead.`);
78
- conditions.push([field, 'contains', operatorValue]);
79
- continue;
113
+ `Use $contains, $startsWith or $endsWith.`);
80
114
  }
81
115
  // $null / $exists translate based on their boolean value (per spec semantics).
82
116
  // $null: true → IS NULL | $null: false → IS NOT NULL
@@ -95,7 +129,7 @@ export function convertFiltersToAST(filter) {
95
129
  }
96
130
  else {
97
131
  // Unknown operator - throw error to avoid silent failure
98
- throw new Error(`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` +
132
+ throw new FilterOperatorError(`[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` +
99
133
  `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, ` +
100
134
  `$contains, $notContains, $startsWith, $endsWith, $null, $exists. ` +
101
135
  `If you need exact object matching, use the value directly without an operator.`);
@@ -118,3 +152,56 @@ export function convertFiltersToAST(filter) {
118
152
  // Multiple conditions: combine with 'and'
119
153
  return ['and', ...conditions];
120
154
  }
155
+ /**
156
+ * Normalize ONE filter source into a single filter node.
157
+ *
158
+ * A "source" is whatever a view hands a renderer, and there are three shapes in
159
+ * circulation, all legitimate:
160
+ *
161
+ * - `[{ field, operator, value }, ...]` a spec `ViewFilterRule[]`
162
+ * - `[['stage', '=', 'won'], ...]` an AST node / legacy flat array
163
+ * - `{ status: 'active' }` a MongoDB-style object
164
+ *
165
+ * The third is the one that kept getting lost. Renderers tested `source.length
166
+ * > 0` before using it, which is `undefined > 0` for an object — so a
167
+ * `table.defaultFilters` (declared `Record<string, any>`) was DROPPED and the
168
+ * view returned every record. Silently: no error, just a wider answer.
169
+ *
170
+ * Returns `undefined` for an absent or empty source, so callers can skip
171
+ * `$filter` rather than sending an empty array.
172
+ */
173
+ export function toFilterNode(source) {
174
+ if (source === null || source === undefined)
175
+ return undefined;
176
+ if (Array.isArray(source))
177
+ return source.length > 0 ? source : undefined;
178
+ if (typeof source !== 'object')
179
+ return undefined;
180
+ const obj = source;
181
+ if (Object.keys(obj).length === 0)
182
+ return undefined;
183
+ // MongoDB-style → AST, so it can sit beside the other shapes under one `and`.
184
+ return convertFiltersToAST(obj);
185
+ }
186
+ /**
187
+ * Combine filter sources under a single `and`, each as its OWN child.
188
+ *
189
+ * Wrapping rather than spreading, on purpose. `['and', ...rules]` looks
190
+ * equivalent and is not: spreading a `ViewFilterRule[]` puts bare rule OBJECTS
191
+ * where the AST expects nodes, and the server neither understands nor rejects
192
+ * that cleanly — `isFilterAST` says no (a 400 since objectstack#4121), while
193
+ * `parseFilterAST` reads the rule as a Mongo condition and filters on columns
194
+ * literally named `field` / `operator` / `value`. Spreading is only correct
195
+ * when the source happens to be an array of nodes, which is why it survived.
196
+ *
197
+ * Sources that normalize to nothing are skipped; one surviving source is
198
+ * returned as-is rather than wrapped in a pointless `and`.
199
+ */
200
+ export function mergeFilterNodes(...sources) {
201
+ const nodes = sources.map(toFilterNode).filter((n) => n !== undefined);
202
+ if (nodes.length === 0)
203
+ return undefined;
204
+ if (nodes.length === 1)
205
+ return nodes[0];
206
+ return ['and', ...nodes];
207
+ }
@@ -0,0 +1,101 @@
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
+ /**
9
+ * Session-scoped filter placeholders — the sibling vocabulary to
10
+ * `{date-macros}`.
11
+ *
12
+ * Filter values travel as JSON, so a user-scoped slice cannot call
13
+ * `currentUser().id` inline; it writes a placeholder that this module expands
14
+ * immediately before the filter reaches the data source:
15
+ *
16
+ * { owner_id: '{current_user_id}' }
17
+ *
18
+ * ## Why this module exists (framework #3574)
19
+ *
20
+ * There used to be no shared resolver. Three ad-hoc implementations had grown
21
+ * up independently — one in `ObjectView` for list views, one in
22
+ * `ObjectDataPage` for URL filter triples, one in `NavigationRenderer` for
23
+ * hrefs — and each handled only the shape its own surface happened to use.
24
+ * Dashboard widgets never got one at all, so `{current_user_id}` in a widget
25
+ * filter reached SQL as a literal, matched no row, and the widget rendered
26
+ * `0` with no error in the console or the server log.
27
+ *
28
+ * The failure mode is the point: a metric reading `0` is indistinguishable
29
+ * from a metric that is legitimately zero, so the bug survived review and
30
+ * shipped. Any *new* surface that resolves placeholders itself will
31
+ * eventually reproduce it, which is why resolution lives here, once, and
32
+ * every surface calls {@link resolveFilterPlaceholders}.
33
+ *
34
+ * ## Presentation scope, NOT a security boundary
35
+ *
36
+ * These tokens scope what a surface *shows*. They do not decide what a caller
37
+ * is *allowed* to read — that is RLS, enforced server-side against a
38
+ * different vocabulary rooted at `current_user` (`owner_id = current_user.id`).
39
+ * Dropping a `{current_user_id}` filter widens a view; it must never widen
40
+ * access. Never use a context token as an access control.
41
+ *
42
+ * ## Contract source
43
+ *
44
+ * The canonical vocabulary is published as part of the platform contract at
45
+ * `@objectstack/spec` → `CONTEXT_TOKENS` / `classifyFilterToken`. It is
46
+ * mirrored here — exactly as `date-macros.ts` mirrors `DATE_MACRO_TOKENS` —
47
+ * because the installed `@objectstack/spec` predates that export. Keep the two
48
+ * in sync; the duplication is temporary until the next coordinated release.
49
+ */
50
+ /** The complete set of session-scoped filter tokens. */
51
+ export declare const CONTEXT_TOKENS: readonly ["current_user_id", "current_org_id"];
52
+ export type ContextTokenName = (typeof CONTEXT_TOKENS)[number];
53
+ /** Session values a filter placeholder can resolve against. */
54
+ export interface FilterTokenScope {
55
+ /** The signed-in user's id. */
56
+ currentUserId?: string | null;
57
+ /** The active organization id. */
58
+ currentOrgId?: string | null;
59
+ /**
60
+ * Called when a placeholder cannot be resolved — either a recognised token
61
+ * with no value in scope (signed out), or a near-miss spelling that resolves
62
+ * in no vocabulary. Defaults to a `console.warn`; pass `null` to silence.
63
+ */
64
+ onUnresolved?: ((message: string) => void) | null;
65
+ }
66
+ /**
67
+ * Expand `{current_user_id}` / `{current_org_id}` inside a filter.
68
+ *
69
+ * Walks arrays and plain objects recursively, which is what makes one
70
+ * resolver cover both platform filter shapes: the MongoDB-style object a
71
+ * dashboard widget carries (`{ owner_id: '{current_user_id}' }`) and the
72
+ * condition/triple arrays a list view carries (`[{ field, operator, value }]`
73
+ * / `[['owner','=','…']]`). The predecessor helper in `ObjectView` bailed on
74
+ * non-arrays (`if (!Array.isArray(filter)) return filter`), which is why
75
+ * lifting it into the dashboard would have been a silent no-op.
76
+ *
77
+ * Only whole-string placeholders are substituted — an id is an opaque value,
78
+ * so embedding it in a larger string (`'user-{current_user_id}'`) is never
79
+ * what an author means, and substituting there would silently produce a value
80
+ * that matches nothing. Unknown tokens are passed through untouched so that
81
+ * date macros and nav-only `AppContextSelector` ids survive this pass.
82
+ *
83
+ * An unresolvable *known* token is deliberately left as-is rather than
84
+ * dropped: leaving it yields an empty result, whereas dropping the clause
85
+ * would widen the result set — and a filter that silently widens is far worse
86
+ * than one that silently narrows.
87
+ */
88
+ export declare function resolveContextTokens<T = any>(filter: T, scope?: FilterTokenScope): T;
89
+ /**
90
+ * Resolve **every** placeholder vocabulary a filter value understands — date
91
+ * macros and context tokens — in one call.
92
+ *
93
+ * This is the function surfaces should call. Calling only one of the two
94
+ * resolvers is the defect behind framework #3574: dashboard widgets called
95
+ * `resolveDateMacros` alone, so `{today}` worked and `{current_user_id}`
96
+ * silently did not.
97
+ *
98
+ * Order is irrelevant (the vocabularies are disjoint) but fixed here so
99
+ * behaviour is identical everywhere.
100
+ */
101
+ export declare function resolveFilterPlaceholders<T = any>(filter: T, scope?: FilterTokenScope, now?: Date): T;
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * managedBy — the single source of truth for interpreting an object's
3
+ * lifecycle bucket (`ObjectSchema.managedBy`) into CRUD affordances.
4
+ *
5
+ * UI-side mirror of the framework's `resolveCrudAffordances()`
6
+ * (`@objectstack/spec/data/object.zod.ts`, ADR-0103). Lives in `@object-ui/core`
7
+ * — React-free and reachable by every UI package (app-shell, plugin-detail,
8
+ * plugin-form, plugin-grid) — so the bucket logic is defined ONCE instead of
9
+ * hand-mirrored in each. Components ask "should I show New / Import / Edit /
10
+ * Delete / Export?" and get an answer that tracks the object's lifecycle without
11
+ * special-casing `sys_*` names.
12
+ *
13
+ * The bucket UNION type lives in `@object-ui/types` (`ManagedByBucket`) so the
14
+ * schema field can reference it; this module re-exports it for convenience.
15
+ * Only the affordance BOOLEANS live here — all translated copy (badge variants,
16
+ * empty-state messages) stays in `@object-ui/app-shell`.
17
+ */
18
+ export type { ManagedByBucket } from '@object-ui/types';
19
+ export interface CrudAffordances {
20
+ /** Generic "New" button for single-record creation. */
21
+ create: boolean;
22
+ /** CSV bulk-import wizard. */
23
+ import: boolean;
24
+ /** Inline + form editing of existing rows. */
25
+ edit: boolean;
26
+ /** Row-level + bulk delete. */
27
+ delete: boolean;
28
+ /** CSV / clipboard export. */
29
+ exportCsv: boolean;
30
+ /**
31
+ * Per-record CEL predicates for the built-in row Edit/Delete actions,
32
+ * present only when `userActions.edit` / `delete` used the object form
33
+ * (objectui#2614). Carried through as authored for row renderers to evaluate;
34
+ * they never affect the object-level booleans above.
35
+ */
36
+ editPredicates?: RowCrudPredicates;
37
+ deletePredicates?: RowCrudPredicates;
38
+ }
39
+ /** Per-record predicates from the #2614 object form of a userActions flag. */
40
+ export interface RowCrudPredicates {
41
+ visibleWhen?: unknown;
42
+ disabledWhen?: unknown;
43
+ }
44
+ /** `edit`/`delete` accept a bare boolean or the #2614 object form. */
45
+ export type UserActionOverride = boolean | {
46
+ enabled?: boolean;
47
+ visibleWhen?: unknown;
48
+ disabledWhen?: unknown;
49
+ };
50
+ export interface UserActionsOverride {
51
+ create?: boolean;
52
+ import?: boolean;
53
+ edit?: UserActionOverride;
54
+ delete?: UserActionOverride;
55
+ exportCsv?: boolean;
56
+ }
57
+ export interface SchemaLike {
58
+ managedBy?: string | null;
59
+ userActions?: UserActionsOverride | null;
60
+ }
61
+ /**
62
+ * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
63
+ * the given bucket default, surfacing any per-record predicates alongside.
64
+ *
65
+ * THE single parser for the `userActions.{edit,delete}` override shape — every
66
+ * UI package (grid row affordances, related-list row predicates, this module's
67
+ * own `resolveCrudAffordances`) routes through here instead of re-implementing
68
+ * the boolean/object-form parse locally. `base` is the bucket default an
69
+ * omitted `enabled` falls back to (callers that only gate on an explicit
70
+ * opt-out pass `true`; predicate extraction is independent of `base`).
71
+ */
72
+ export declare function normalizeUserAction(v: UserActionOverride | undefined | null, base: boolean): {
73
+ enabled: boolean;
74
+ predicates?: RowCrudPredicates;
75
+ };
76
+ /**
77
+ * Extract the per-record CEL predicates (#2614 object form) from a
78
+ * `userActions.edit` / `delete` flag, or `undefined` for a bare boolean /
79
+ * predicate-less flag. Independent of the object-level enabled verdict — row
80
+ * renderers use this to gate a built-in action per record. Thin wrapper over
81
+ * {@link normalizeUserAction} so the parse lives in exactly one place.
82
+ */
83
+ export declare function userActionPredicates(v: UserActionOverride | undefined | null): RowCrudPredicates | undefined;
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;
97
+ /** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */
98
+ export declare function isWriteOptedIn(v: UserActionOverride | undefined | null): boolean;
99
+ /**
100
+ * ADR-0103 — a `system`-bucket object that opened ANY write via `userActions`
101
+ * is admin/user-writable DATA, not an engine-owned monitoring surface. Used to
102
+ * pick the writable-system badge/empty-state copy.
103
+ */
104
+ export declare function isSystemWritable(obj: SchemaLike | null | undefined): boolean;
105
+ /**
106
+ * Whether an object's rows may be edited in place (detail inline-edit / form
107
+ * fields) — the resolved `edit` affordance. Engine-owned `system` /
108
+ * `append-only` / `better-auth` objects resolve to `false` unless they opened
109
+ * `userActions.edit`. (Per-record predicates gate row action buttons, not
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.
118
+ */
119
+ export declare function isObjectInlineEditable(obj: SchemaLike | null | undefined, effectiveApiOperations?: readonly string[] | null): boolean;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * managedBy — the single source of truth for interpreting an object's
3
+ * lifecycle bucket (`ObjectSchema.managedBy`) into CRUD affordances.
4
+ *
5
+ * UI-side mirror of the framework's `resolveCrudAffordances()`
6
+ * (`@objectstack/spec/data/object.zod.ts`, ADR-0103). Lives in `@object-ui/core`
7
+ * — React-free and reachable by every UI package (app-shell, plugin-detail,
8
+ * plugin-form, plugin-grid) — so the bucket logic is defined ONCE instead of
9
+ * hand-mirrored in each. Components ask "should I show New / Import / Edit /
10
+ * Delete / Export?" and get an answer that tracks the object's lifecycle without
11
+ * special-casing `sys_*` names.
12
+ *
13
+ * The bucket UNION type lives in `@object-ui/types` (`ManagedByBucket`) so the
14
+ * schema field can reference it; this module re-exports it for convenience.
15
+ * Only the affordance BOOLEANS live here — all translated copy (badge variants,
16
+ * empty-state messages) stays in `@object-ui/app-shell`.
17
+ */
18
+ const DEFAULTS = {
19
+ platform: { create: true, import: true, edit: true, delete: true, exportCsv: true },
20
+ config: { create: true, import: false, edit: true, delete: true, exportCsv: true },
21
+ system: { create: false, import: false, edit: false, delete: false, exportCsv: true },
22
+ 'engine-owned': { create: false, import: false, edit: false, delete: false, exportCsv: true },
23
+ 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true },
24
+ 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true },
25
+ };
26
+ /**
27
+ * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
28
+ * the given bucket default, surfacing any per-record predicates alongside.
29
+ *
30
+ * THE single parser for the `userActions.{edit,delete}` override shape — every
31
+ * UI package (grid row affordances, related-list row predicates, this module's
32
+ * own `resolveCrudAffordances`) routes through here instead of re-implementing
33
+ * the boolean/object-form parse locally. `base` is the bucket default an
34
+ * omitted `enabled` falls back to (callers that only gate on an explicit
35
+ * opt-out pass `true`; predicate extraction is independent of `base`).
36
+ */
37
+ export function normalizeUserAction(v, base) {
38
+ if (v == null)
39
+ return { enabled: base };
40
+ if (typeof v === 'boolean')
41
+ return { enabled: v };
42
+ const enabled = v.enabled ?? base;
43
+ if (v.visibleWhen == null && v.disabledWhen == null)
44
+ return { enabled };
45
+ const predicates = {};
46
+ if (v.visibleWhen != null)
47
+ predicates.visibleWhen = v.visibleWhen;
48
+ if (v.disabledWhen != null)
49
+ predicates.disabledWhen = v.disabledWhen;
50
+ return { enabled, predicates };
51
+ }
52
+ /**
53
+ * Extract the per-record CEL predicates (#2614 object form) from a
54
+ * `userActions.edit` / `delete` flag, or `undefined` for a bare boolean /
55
+ * predicate-less flag. Independent of the object-level enabled verdict — row
56
+ * renderers use this to gate a built-in action per record. Thin wrapper over
57
+ * {@link normalizeUserAction} so the parse lives in exactly one place.
58
+ */
59
+ export function userActionPredicates(v) {
60
+ return normalizeUserAction(v, false).predicates;
61
+ }
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) {
88
+ const bucket = obj?.managedBy ?? 'platform';
89
+ const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
90
+ const o = obj?.userActions ?? {};
91
+ const edit = normalizeUserAction(o.edit, base.edit);
92
+ const del = normalizeUserAction(o.delete, base.delete);
93
+ const out = {
94
+ create: o.create ?? base.create,
95
+ import: o.import ?? base.import,
96
+ edit: edit.enabled,
97
+ delete: del.enabled,
98
+ exportCsv: o.exportCsv ?? base.exportCsv,
99
+ };
100
+ if (edit.predicates)
101
+ out.editPredicates = edit.predicates;
102
+ if (del.predicates)
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
+ }
114
+ return out;
115
+ }
116
+ /** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */
117
+ export function isWriteOptedIn(v) {
118
+ return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
119
+ }
120
+ /**
121
+ * ADR-0103 — a `system`-bucket object that opened ANY write via `userActions`
122
+ * is admin/user-writable DATA, not an engine-owned monitoring surface. Used to
123
+ * pick the writable-system badge/empty-state copy.
124
+ */
125
+ export function isSystemWritable(obj) {
126
+ if (obj?.managedBy !== 'system')
127
+ return false;
128
+ const o = obj?.userActions ?? {};
129
+ return o.create === true || isWriteOptedIn(o.edit) || isWriteOptedIn(o.delete);
130
+ }
131
+ /**
132
+ * Whether an object's rows may be edited in place (detail inline-edit / form
133
+ * fields) — the resolved `edit` affordance. Engine-owned `system` /
134
+ * `append-only` / `better-auth` objects resolve to `false` unless they opened
135
+ * `userActions.edit`. (Per-record predicates gate row action buttons, not
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.
144
+ */
145
+ export function isObjectInlineEditable(obj, effectiveApiOperations) {
146
+ return resolveCrudAffordances(obj, effectiveApiOperations).edit;
147
+ }