@object-ui/core 17.2.0 → 17.3.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 +212 -0
  2. package/README.md +29 -0
  3. package/dist/actions/ActionEngine.d.ts +11 -6
  4. package/dist/actions/ActionEngine.js +26 -19
  5. package/dist/actions/ActionRunner.d.ts +16 -3
  6. package/dist/actions/ActionRunner.js +2 -1
  7. package/dist/actions/actionErrorDetail.d.ts +26 -0
  8. package/dist/actions/actionErrorDetail.js +37 -0
  9. package/dist/actions/actionKeys.d.ts +1 -1
  10. package/dist/actions/actionKeys.js +11 -0
  11. package/dist/actions/actionResponse.d.ts +42 -0
  12. package/dist/actions/actionResponse.js +104 -0
  13. package/dist/actions/index.d.ts +3 -0
  14. package/dist/actions/index.js +3 -0
  15. package/dist/actions/serverActionHandler.d.ts +143 -0
  16. package/dist/actions/serverActionHandler.js +137 -0
  17. package/dist/evaluator/ExpressionEvaluator.js +4 -2
  18. package/dist/evaluator/fieldRules.d.ts +26 -2
  19. package/dist/evaluator/fieldRules.js +63 -8
  20. package/dist/evaluator/index.d.ts +1 -0
  21. package/dist/evaluator/index.js +1 -0
  22. package/dist/evaluator/listConditional.d.ts +13 -1
  23. package/dist/evaluator/listConditional.js +0 -0
  24. package/dist/evaluator/optionRules.js +5 -1
  25. package/dist/evaluator/predicateInput.d.ts +84 -0
  26. package/dist/evaluator/predicateInput.js +72 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +12 -0
  29. package/dist/protocols/index.d.ts +0 -1
  30. package/dist/protocols/index.js +8 -1
  31. package/dist/utils/dataset-format.d.ts +6 -1
  32. package/dist/utils/dataset-pivot.d.ts +57 -0
  33. package/dist/utils/dataset-pivot.js +57 -0
  34. package/dist/utils/filter-converter.d.ts +39 -11
  35. package/dist/utils/filter-converter.js +87 -7
  36. package/dist/utils/predicate-fields.d.ts +96 -0
  37. package/dist/utils/predicate-fields.js +163 -0
  38. package/dist/utils/predicate-record.d.ts +32 -0
  39. package/dist/utils/predicate-record.js +120 -0
  40. package/dist/utils/record-title.js +0 -0
  41. package/package.json +4 -4
  42. package/dist/protocols/NotificationProtocol.d.ts +0 -71
  43. package/dist/protocols/NotificationProtocol.js +0 -89
@@ -0,0 +1,163 @@
1
+ /**
2
+ * ObjectUI — predicate field harvesting
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
+ * Which record fields a view's PREDICATES read — the half of a view's field
10
+ * appetite that is not its columns.
11
+ *
12
+ * A list view projects what it DISPLAYS: `$select` is built from the column
13
+ * list (plus `id` and the expanded relation roots). That is correct for
14
+ * rendering and wrong for gating, because a `visible` / `disabled` /
15
+ * conditional-formatting predicate reads fields nobody put on screen — gating a
16
+ * row action on `record.owner` while showing Title / Status is the ordinary
17
+ * case, not an exotic one.
18
+ *
19
+ * The field then simply is not in the payload, and CEL does not treat an absent
20
+ * key as null: `record.owner == os.user.id` FAULTS with `No such key: owner`.
21
+ * Fault handling differs per surface and both answers are wrong — fail-CLOSED
22
+ * on the row kebab and the selection bar (the button silently disappears for
23
+ * everyone), fail-OPEN on the lenient paths (it appears for everyone). Nothing
24
+ * in either outcome points at a projection.
25
+ *
26
+ * Harvesting the names a predicate mentions and adding them to `$select` is
27
+ * what closes that. Note what it deliberately does NOT add: `$expand`. A
28
+ * predicate wants the stored foreign key, which is exactly what an unexpanded
29
+ * relation column already is — so a predicate-only relation field costs one
30
+ * more column and no extra round trip. (A relation that IS displayed still gets
31
+ * expanded for its label, and `toPredicateRecord` collapses it back for the
32
+ * predicate.)
33
+ */
34
+ /**
35
+ * The columns the framework provisions on EVERY business object
36
+ * (`applySystemFields`) but does not publish in the object's `fields` metadata.
37
+ *
38
+ * They matter here because the most ordinary ownership predicate there is —
39
+ * `record.owner_id == os.user.id` — names one of them. Checking a harvested
40
+ * name against the declared fields alone would drop it, leaving the predicate
41
+ * with the same missing key the harvest exists to prevent.
42
+ *
43
+ * Deliberately the columns OBSERVED on a live payload rather than
44
+ * `SYSTEM_MANAGED_FIELD_NAMES` (`@object-ui/types`), which is the broader
45
+ * DISPLAY-oriented set and carries legacy aliases (`_id`, `createdAt`,
46
+ * `modified`, `space`) that are not columns anywhere. An alias in `$select` is
47
+ * exactly the unknown key the guard exists to keep out.
48
+ */
49
+ export const PLATFORM_RECORD_COLUMNS = new Set([
50
+ 'id',
51
+ 'created_at', 'created_by',
52
+ 'updated_at', 'updated_by',
53
+ 'owner_id', 'organization_id', 'owning_business_unit_id',
54
+ ]);
55
+ /**
56
+ * Whether a harvested name is safe to put in a `$select` projection: either the
57
+ * object declares it, or it is one of the platform columns every object has.
58
+ *
59
+ * The check is not pedantry — an unknown key is not ignored by every backend
60
+ * (the cloud multi-tenant runtime answers with an EMPTY result set), so without
61
+ * it one typo'd predicate could blank a whole list.
62
+ */
63
+ export function isProjectableField(name, declaredFields) {
64
+ if (PLATFORM_RECORD_COLUMNS.has(name))
65
+ return true;
66
+ return !!declaredFields && typeof declaredFields === 'object' && name in declaredFields;
67
+ }
68
+ /**
69
+ * `record.x` / `data.x` — the two record-scoped spellings every surface binds.
70
+ * A bare `x` is deliberately NOT harvested: at predicate scope a bare
71
+ * identifier is just as likely to be `features`, `os`, `user` or a CEL builtin,
72
+ * and there is no way to tell without resolving. Authors are already directed
73
+ * to the prefixed form on every surface (a bare field reference throws on the
74
+ * record-header path), so this covers the documented spelling.
75
+ */
76
+ const RECORD_REF = /\b(?:record|data)\.([A-Za-z_][A-Za-z0-9_]*)/g;
77
+ /** Pull the CEL source out of any of the shapes a predicate is authored in. */
78
+ function predicateSource(pred) {
79
+ if (typeof pred === 'string')
80
+ return pred.trim() || null;
81
+ if (pred && typeof pred === 'object') {
82
+ const src = pred.source;
83
+ if (typeof src === 'string')
84
+ return src.trim() || null;
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Collect the record field names referenced by a set of predicates.
90
+ *
91
+ * Accepts predicates in every authored shape (bare CEL string, the
92
+ * `{ dialect, source }` envelope, `null`/`undefined`/boolean — the last three
93
+ * contribute nothing). Returns names in first-seen order, deduplicated.
94
+ *
95
+ * The result is a set of CANDIDATES, not verified fields: a typo'd predicate
96
+ * yields a name this object does not have. Callers must intersect it with the
97
+ * object's declared fields before putting it in a query — an unknown key in
98
+ * `$select` is not ignored by every backend (the cloud runtime answers with an
99
+ * empty result set), which would turn a typo into a blank list.
100
+ */
101
+ export function collectPredicateFieldRefs(predicates) {
102
+ const out = [];
103
+ const seen = new Set();
104
+ for (const pred of predicates) {
105
+ const source = predicateSource(pred);
106
+ if (!source)
107
+ continue;
108
+ RECORD_REF.lastIndex = 0;
109
+ for (let m = RECORD_REF.exec(source); m; m = RECORD_REF.exec(source)) {
110
+ const name = m[1];
111
+ if (seen.has(name))
112
+ continue;
113
+ seen.add(name);
114
+ out.push(name);
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Every predicate a list-ish view carries, flattened for
121
+ * {@link collectPredicateFieldRefs}.
122
+ *
123
+ * One reader so the projection cannot fall behind the surfaces: each entry here
124
+ * is a place a predicate is actually evaluated against a row — conditional
125
+ * formatting, the row kebab (custom defs AND the built-in Edit/Delete
126
+ * overrides), the selection bar, and the object's declared actions.
127
+ *
128
+ * `objectActions` is deliberately the object's WHOLE action set rather than
129
+ * only the ones this view promotes. Narrowing it would mean re-running the
130
+ * name resolution `resolveLegacyRowActions` / `resolveBulkActions` already do,
131
+ * at a point in the fetch where the view's `rowActions` / `bulkActions` lists
132
+ * have not been folded yet — and the two directions of error are not
133
+ * symmetric: an extra column costs bytes, a missing one silently breaks the
134
+ * predicate that needed it.
135
+ */
136
+ export function listViewPredicates(view) {
137
+ const preds = [];
138
+ for (const rule of view.conditionalFormatting ?? []) {
139
+ if (!rule || typeof rule !== 'object')
140
+ continue;
141
+ const r = rule;
142
+ preds.push(r.condition, r.expression);
143
+ // The native `{ field, operator, value }` shape names its field directly;
144
+ // spell it as a `record.` reference so the one harvester handles it.
145
+ if (typeof r.field === 'string' && r.field)
146
+ preds.push(`record.${r.field}`);
147
+ }
148
+ for (const list of [view.rowActionDefs, view.bulkActionDefs, view.objectActions]) {
149
+ for (const def of list ?? []) {
150
+ if (!def || typeof def !== 'object')
151
+ continue;
152
+ const d = def;
153
+ preds.push(d.visible, d.disabled);
154
+ }
155
+ }
156
+ for (const override of Object.values(view.userActions ?? {})) {
157
+ if (!override || typeof override !== 'object')
158
+ continue;
159
+ const o = override;
160
+ preds.push(o.visibleWhen, o.disabledWhen);
161
+ }
162
+ return preds;
163
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ObjectUI — predicate record normalization
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
+ /** The two field-container shapes the metadata API serves. */
9
+ export type FieldContainerLike = Record<string, {
10
+ type?: unknown;
11
+ } | unknown> | readonly {
12
+ name?: unknown;
13
+ type?: unknown;
14
+ }[] | null | undefined;
15
+ /**
16
+ * Collapse `record`'s expanded relation values back to the ids they stand for.
17
+ *
18
+ * Returns the input BY REFERENCE when there is nothing to collapse — the common
19
+ * case (no schema, no relations, nothing expanded) allocates nothing and keeps
20
+ * the identity downstream `useMemo` deps rely on.
21
+ *
22
+ * `multiple: true` relations store an ARRAY of ids and expand element-wise, so
23
+ * an array is mapped element-wise too; a member that is not an expanded record
24
+ * (already an id) is left alone.
25
+ *
26
+ * @param record A fetched row / detail record, as the payload delivered it.
27
+ * @param fields The object's field definitions (`objectSchema.fields`), in
28
+ * either served shape. Without them nothing is collapsed —
29
+ * a caller that cannot name the relations must not guess.
30
+ */
31
+ export declare function toPredicateRecord<T extends Record<string, unknown>>(record: T, fields: FieldContainerLike): T;
32
+ export declare function toPredicateRecord<T extends Record<string, unknown>>(record: T | null | undefined, fields: FieldContainerLike): T | null | undefined;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ObjectUI — predicate record normalization
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
+ * Bind a fetched record into an expression scope the way the SERVER binds it —
10
+ * a relation field is its stored foreign key, never the record it points at.
11
+ *
12
+ * ## Why a normalization step exists at all
13
+ *
14
+ * A `lookup` / `master_detail` / `user` / `tree` field stores an id. That is
15
+ * what the server's CEL engine sees in a validation rule, a sharing predicate
16
+ * or a flow condition, and it is what an author writes:
17
+ *
18
+ * ```
19
+ * visible: 'record.owner == os.user.id'
20
+ * ```
21
+ *
22
+ * The client does not necessarily see that. `$expand` (the `populate=` wire
23
+ * param) **replaces the id in place** with the whole related record, so the same
24
+ * field arrives as `'P1'` or as `{ id: 'P1', name: 'Website' }` depending on
25
+ * whether the surface happened to expand it — and views expand exactly the
26
+ * relations they show as COLUMNS (`buildExpandFields`), which is a display
27
+ * decision no predicate author participates in. Measured against a live
28
+ * runtime, one predicate over one field therefore has four different fates:
29
+ *
30
+ * | value in the payload | `record.owner == "P1"` | `record.owner.id == "P1"` |
31
+ * |---------------------------------|------------------------|---------------------------|
32
+ * | `'P1'` (not expanded) | **true** | fault |
33
+ * | `{ id: 'P1', … }` (expanded) | **false** | true |
34
+ * | `null` (empty lookup) | false | fault |
35
+ * | absent (outside `$select`) | fault | fault |
36
+ *
37
+ * No spelling is right everywhere, and neither failure announces itself: a
38
+ * fault is fail-CLOSED on the row kebab / selection bar (button silently gone)
39
+ * and fail-OPEN on the lenient paths (button shown to everyone). That is the
40
+ * whole of "lookup fields cannot be used in expressions" — the author is not
41
+ * writing the predicate wrong, the operand means two things.
42
+ *
43
+ * Collapsing the expanded form back to its id is what makes the first column
44
+ * of that table the only one: `record.owner` is the id on every surface and on
45
+ * the server, so a predicate authored once reaches one verdict everywhere.
46
+ *
47
+ * ## Why it is schema-driven and not a shape sniff
48
+ *
49
+ * "An object carrying an `id` key" would also catch a genuine `json` field
50
+ * whose payload happens to have one, and silently change what
51
+ * `record.config.id` means. The object's own field types are the authority:
52
+ * only a field DECLARED relational (see `EXPANDABLE_FIELD_TYPES` — the same set
53
+ * that decides what gets expanded in the first place) is collapsed, so the two
54
+ * halves cannot disagree about which fields are relations.
55
+ */
56
+ import { EXPANDABLE_FIELD_TYPES } from './expand-fields.js';
57
+ /** Names of the fields this object declares as relations (`lookup` / …). */
58
+ function relationFieldNames(fields) {
59
+ if (!fields || typeof fields !== 'object')
60
+ return null;
61
+ const out = new Set();
62
+ if (Array.isArray(fields)) {
63
+ for (const def of fields) {
64
+ const name = def?.name;
65
+ const type = def?.type;
66
+ if (typeof name === 'string' && EXPANDABLE_FIELD_TYPES.has(type))
67
+ out.add(name);
68
+ }
69
+ }
70
+ else {
71
+ for (const [name, def] of Object.entries(fields)) {
72
+ const type = def?.type;
73
+ if (EXPANDABLE_FIELD_TYPES.has(type))
74
+ out.add(name);
75
+ }
76
+ }
77
+ return out.size > 0 ? out : null;
78
+ }
79
+ /** The id an expanded related record is stored under, or `undefined`. */
80
+ function expandedId(value) {
81
+ if (!value || typeof value !== 'object' || Array.isArray(value))
82
+ return undefined;
83
+ const rec = value;
84
+ const id = rec.id ?? rec._id;
85
+ return typeof id === 'string' || typeof id === 'number' ? String(id) : undefined;
86
+ }
87
+ // Two overloads rather than one `: T`: a null/undefined record is passed
88
+ // STRAIGHT THROUGH (a caller with no record loaded yet must be able to tell
89
+ // that from an empty one — `page:header` binds the result into an eval scope
90
+ // where `{}` and `undefined` are different answers). Declaring the return as
91
+ // plain `T` would have been a lie the `as T` cast hid.
92
+ export function toPredicateRecord(record, fields) {
93
+ if (!record || typeof record !== 'object')
94
+ return record;
95
+ const relations = relationFieldNames(fields);
96
+ if (!relations)
97
+ return record;
98
+ let out = null;
99
+ const write = (key, value) => {
100
+ if (!out)
101
+ out = { ...record };
102
+ out[key] = value;
103
+ };
104
+ for (const name of relations) {
105
+ if (!(name in record))
106
+ continue;
107
+ const value = record[name];
108
+ if (Array.isArray(value)) {
109
+ // Only rebuild the array when at least one member was expanded.
110
+ if (!value.some(v => expandedId(v) !== undefined))
111
+ continue;
112
+ write(name, value.map(v => expandedId(v) ?? v));
113
+ continue;
114
+ }
115
+ const id = expandedId(value);
116
+ if (id !== undefined)
117
+ write(name, id);
118
+ }
119
+ return (out ?? record);
120
+ }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/core",
3
- "version": "17.2.0",
3
+ "version": "17.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,11 +25,11 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@objectstack/formula": "^17.0.0-rc.1",
29
- "@objectstack/spec": "^17.0.0-rc.1",
28
+ "@objectstack/formula": "^17.0.0-rc.2",
29
+ "@objectstack/spec": "^17.0.0-rc.2",
30
30
  "lodash": "^4.18.1",
31
31
  "zod": "^4.4.3",
32
- "@object-ui/types": "17.2.0"
32
+ "@object-ui/types": "17.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^6.0.3",
@@ -1,71 +0,0 @@
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
- * @object-ui/core - Notification Protocol Bridge
10
- *
11
- * Converts spec-aligned Notification schemas into toast-compatible
12
- * objects that UI layers can render. Maps severity to variant,
13
- * position names, and resolves default notification configs.
14
- *
15
- * @module protocols/NotificationProtocol
16
- * @packageDocumentation
17
- */
18
- import type { Notification as SpecNotification, NotificationConfig, NotificationPosition } from '@object-ui/types';
19
- /** Fully resolved notification configuration. */
20
- export interface ResolvedNotificationConfig {
21
- defaultPosition: NotificationPosition;
22
- defaultDuration: number;
23
- maxVisible: number;
24
- stackDirection: 'up' | 'down';
25
- pauseOnHover: boolean;
26
- }
27
- /** Toast-compatible representation of a spec Notification. */
28
- export interface ToastNotification {
29
- title?: string;
30
- description: string;
31
- variant: string;
32
- position: string;
33
- duration: number;
34
- dismissible: boolean;
35
- actions: ToastAction[];
36
- }
37
- /** Toast-compatible action button. */
38
- export interface ToastAction {
39
- label: string;
40
- action: string;
41
- variant: 'primary' | 'secondary' | 'link';
42
- }
43
- /**
44
- * Map a spec notification severity to a toast variant string.
45
- *
46
- * @param severity - Spec severity (info, success, warning, error)
47
- * @returns Toast variant (default, success, warning, destructive)
48
- */
49
- export declare function mapSeverityToVariant(severity: string): string;
50
- /**
51
- * Map a spec notification position (underscore-separated) to a
52
- * toast position string (hyphen-separated).
53
- *
54
- * @param position - Spec position (e.g. "top_right")
55
- * @returns Toast position (e.g. "top-right")
56
- */
57
- export declare function mapPosition(position: string): string;
58
- /**
59
- * Resolve a notification configuration by applying spec defaults.
60
- *
61
- * @param config - NotificationConfig from the spec
62
- * @returns Fully resolved notification configuration
63
- */
64
- export declare function resolveNotificationConfig(config: NotificationConfig): ResolvedNotificationConfig;
65
- /**
66
- * Convert a spec Notification to a toast-compatible object.
67
- *
68
- * @param notification - Spec Notification
69
- * @returns Toast-compatible notification with title, description, variant, etc.
70
- */
71
- export declare function specNotificationToToast(notification: SpecNotification): ToastNotification;
@@ -1,89 +0,0 @@
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
- // Severity → Variant Mapping
10
- // ============================================================================
11
- const SEVERITY_TO_VARIANT = {
12
- info: 'default',
13
- success: 'success',
14
- warning: 'warning',
15
- error: 'destructive',
16
- };
17
- /**
18
- * Map a spec notification severity to a toast variant string.
19
- *
20
- * @param severity - Spec severity (info, success, warning, error)
21
- * @returns Toast variant (default, success, warning, destructive)
22
- */
23
- export function mapSeverityToVariant(severity) {
24
- return SEVERITY_TO_VARIANT[severity] ?? 'default';
25
- }
26
- // ============================================================================
27
- // Position Mapping
28
- // ============================================================================
29
- const POSITION_MAP = {
30
- top_left: 'top-left',
31
- top_center: 'top-center',
32
- top_right: 'top-right',
33
- bottom_left: 'bottom-left',
34
- bottom_center: 'bottom-center',
35
- bottom_right: 'bottom-right',
36
- };
37
- /**
38
- * Map a spec notification position (underscore-separated) to a
39
- * toast position string (hyphen-separated).
40
- *
41
- * @param position - Spec position (e.g. "top_right")
42
- * @returns Toast position (e.g. "top-right")
43
- */
44
- export function mapPosition(position) {
45
- return POSITION_MAP[position] ?? 'top-right';
46
- }
47
- // ============================================================================
48
- // Notification Config Resolution
49
- // ============================================================================
50
- /**
51
- * Resolve a notification configuration by applying spec defaults.
52
- *
53
- * @param config - NotificationConfig from the spec
54
- * @returns Fully resolved notification configuration
55
- */
56
- export function resolveNotificationConfig(config) {
57
- return {
58
- defaultPosition: config.defaultPosition ?? 'top_right',
59
- defaultDuration: config.defaultDuration ?? 5000,
60
- maxVisible: config.maxVisible ?? 5,
61
- stackDirection: config.stackDirection ?? 'down',
62
- pauseOnHover: config.pauseOnHover ?? true,
63
- };
64
- }
65
- // ============================================================================
66
- // Spec Notification → Toast
67
- // ============================================================================
68
- /**
69
- * Convert a spec Notification to a toast-compatible object.
70
- *
71
- * @param notification - Spec Notification
72
- * @returns Toast-compatible notification with title, description, variant, etc.
73
- */
74
- export function specNotificationToToast(notification) {
75
- const actions = (notification.actions ?? []).map((a) => ({
76
- label: a.label,
77
- action: a.action,
78
- variant: a.variant ?? 'primary',
79
- }));
80
- return {
81
- title: notification.title,
82
- description: notification.message ?? '',
83
- variant: mapSeverityToVariant(notification.severity ?? 'info'),
84
- position: mapPosition(notification.position ?? 'top_right'),
85
- duration: notification.duration ?? 5000,
86
- dismissible: notification.dismissible ?? true,
87
- actions,
88
- };
89
- }