@object-ui/core 17.6.0 → 17.7.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 (134) hide show
  1. package/CHANGELOG.md +6610 -0
  2. package/README.md +101 -14
  3. package/dist/actions/ActionEngine.d.ts +4 -2
  4. package/dist/actions/ActionEngine.js +13 -8
  5. package/dist/actions/ActionRunner.d.ts +288 -27
  6. package/dist/actions/ActionRunner.js +443 -63
  7. package/dist/actions/TransactionManager.d.ts +7 -2
  8. package/dist/actions/actionKeys.d.ts +9 -7
  9. package/dist/actions/actionKeys.js +57 -14
  10. package/dist/actions/actionResponse.js +2 -2
  11. package/dist/actions/index.d.ts +1 -0
  12. package/dist/actions/index.js +1 -0
  13. package/dist/actions/recordDelete.d.ts +118 -0
  14. package/dist/actions/recordDelete.js +112 -0
  15. package/dist/adapters/ValueDataSource.d.ts +6 -1
  16. package/dist/adapters/ValueDataSource.js +1090 -98
  17. package/dist/adapters/batchTransaction.d.ts +1 -1
  18. package/dist/adapters/batchTransaction.js +1 -1
  19. package/dist/builder/schema-builder.d.ts +38 -81
  20. package/dist/builder/schema-builder.js +33 -152
  21. package/dist/data-scope/ViewDataProvider.d.ts +1 -1
  22. package/dist/data-scope/ViewDataProvider.js +21 -1
  23. package/dist/data-scope/element-data-source.d.ts +261 -7
  24. package/dist/data-scope/element-data-source.js +342 -52
  25. package/dist/data-scope/index.d.ts +9 -4
  26. package/dist/data-scope/index.js +9 -4
  27. package/dist/evaluator/ExpressionEvaluator.d.ts +86 -0
  28. package/dist/evaluator/ExpressionEvaluator.js +184 -12
  29. package/dist/evaluator/FormulaFunctions.d.ts +0 -10
  30. package/dist/evaluator/FormulaFunctions.js +166 -27
  31. package/dist/evaluator/declaredPredicate.d.ts +93 -9
  32. package/dist/evaluator/declaredPredicate.js +117 -16
  33. package/dist/evaluator/fieldRules.d.ts +88 -4
  34. package/dist/evaluator/fieldRules.js +203 -31
  35. package/dist/evaluator/index.d.ts +1 -0
  36. package/dist/evaluator/index.js +1 -0
  37. package/dist/evaluator/listConditional.d.ts +103 -34
  38. package/dist/evaluator/listConditional.js +127 -50
  39. package/dist/evaluator/optionRules.d.ts +77 -6
  40. package/dist/evaluator/optionRules.js +87 -10
  41. package/dist/evaluator/predicateInput.d.ts +15 -4
  42. package/dist/evaluator/predicateInput.js +35 -2
  43. package/dist/evaluator/rowPredicateCanon.d.ts +7 -18
  44. package/dist/evaluator/rowPredicateCanon.js +65 -115
  45. package/dist/evaluator/subjectPermissions.d.ts +76 -0
  46. package/dist/evaluator/subjectPermissions.js +44 -0
  47. package/dist/evaluator/unevaluablePredicate.d.ts +57 -0
  48. package/dist/evaluator/unevaluablePredicate.js +70 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.js +80 -2
  51. package/dist/protocols/index.d.ts +0 -1
  52. package/dist/protocols/index.js +9 -1
  53. package/dist/registry/Registry.d.ts +358 -31
  54. package/dist/registry/Registry.js +263 -11
  55. package/dist/registry/WidgetRegistry.js +7 -3
  56. package/dist/registry/html-tier-intrinsics.d.ts +90 -0
  57. package/dist/registry/html-tier-intrinsics.js +110 -0
  58. package/dist/registry/public-blocks.d.ts +8 -0
  59. package/dist/registry/public-blocks.js +57 -0
  60. package/dist/theme/ThemeEngine.js +1 -1
  61. package/dist/utils/chart-category-key.d.ts +82 -0
  62. package/dist/utils/chart-category-key.js +84 -0
  63. package/dist/utils/chart-measure-key.d.ts +64 -0
  64. package/dist/utils/chart-measure-key.js +65 -0
  65. package/dist/utils/chart-presentation.d.ts +95 -112
  66. package/dist/utils/chart-presentation.js +169 -104
  67. package/dist/utils/chart-series.d.ts +131 -5
  68. package/dist/utils/chart-series.js +98 -16
  69. package/dist/utils/column-sortability.d.ts +157 -0
  70. package/dist/utils/column-sortability.js +217 -0
  71. package/dist/utils/dashboard-filters.d.ts +38 -6
  72. package/dist/utils/dashboard-filters.js +107 -61
  73. package/dist/utils/dataset-format.d.ts +211 -30
  74. package/dist/utils/dataset-format.js +389 -46
  75. package/dist/utils/date-display.d.ts +354 -0
  76. package/dist/utils/date-display.js +463 -0
  77. package/dist/utils/date-macros.js +7 -0
  78. package/dist/utils/drill-down.d.ts +78 -0
  79. package/dist/utils/drill-down.js +93 -0
  80. package/dist/utils/emptiness.d.ts +96 -0
  81. package/dist/utils/emptiness.js +101 -0
  82. package/dist/utils/expand-fields.d.ts +76 -6
  83. package/dist/utils/expand-fields.js +76 -6
  84. package/dist/utils/extract-records.d.ts +65 -5
  85. package/dist/utils/extract-records.js +65 -8
  86. package/dist/utils/filter-converter.d.ts +152 -4
  87. package/dist/utils/filter-converter.js +1092 -22
  88. package/dist/utils/filter-tokens.d.ts +49 -5
  89. package/dist/utils/filter-tokens.js +105 -33
  90. package/dist/utils/grouping-fields.d.ts +61 -0
  91. package/dist/utils/grouping-fields.js +84 -0
  92. package/dist/utils/managedBy.d.ts +3 -3
  93. package/dist/utils/managedBy.js +5 -5
  94. package/dist/utils/native-date-value.d.ts +48 -0
  95. package/dist/utils/native-date-value.js +167 -0
  96. package/dist/utils/non-grid-row-ceiling.d.ts +143 -0
  97. package/dist/utils/non-grid-row-ceiling.js +141 -0
  98. package/dist/utils/normalize-list-view.d.ts +121 -1
  99. package/dist/utils/normalize-list-view.js +186 -6
  100. package/dist/utils/number-display.d.ts +63 -14
  101. package/dist/utils/number-display.js +34 -12
  102. package/dist/utils/object-aggregate-query.d.ts +113 -0
  103. package/dist/utils/object-aggregate-query.js +50 -0
  104. package/dist/utils/parent-scope.d.ts +123 -0
  105. package/dist/utils/parent-scope.js +140 -0
  106. package/dist/utils/predicate-fields.d.ts +45 -12
  107. package/dist/utils/predicate-fields.js +137 -12
  108. package/dist/utils/record-color.d.ts +75 -0
  109. package/dist/utils/record-color.js +65 -0
  110. package/dist/utils/record-source.d.ts +298 -0
  111. package/dist/utils/record-source.js +331 -0
  112. package/dist/utils/record-title.d.ts +148 -8
  113. package/dist/utils/record-title.js +220 -26
  114. package/dist/utils/reference-keys.d.ts +21 -22
  115. package/dist/utils/reference-keys.js +532 -29
  116. package/dist/utils/sort-query.d.ts +115 -15
  117. package/dist/utils/sort-query.js +240 -13
  118. package/dist/utils/text-comparand.d.ts +92 -0
  119. package/dist/utils/text-comparand.js +99 -0
  120. package/dist/utils/without-denied-fields.d.ts +49 -0
  121. package/dist/utils/without-denied-fields.js +52 -0
  122. package/dist/validation/index.d.ts +8 -4
  123. package/dist/validation/index.js +8 -4
  124. package/dist/validation/schema-validator.d.ts +1 -1
  125. package/dist/validation/schema-validator.js +200 -82
  126. package/dist/validation/validators/object-validation-engine.d.ts +1 -1
  127. package/dist/validation/validators/object-validation-engine.js +14 -3
  128. package/package.json +8 -9
  129. package/dist/data-scope/DataScopeManager.d.ts +0 -127
  130. package/dist/data-scope/DataScopeManager.js +0 -229
  131. package/dist/protocols/ResponsiveProtocol.d.ts +0 -73
  132. package/dist/protocols/ResponsiveProtocol.js +0 -158
  133. package/dist/validation/validation-engine.d.ts +0 -88
  134. package/dist/validation/validation-engine.js +0 -434
@@ -0,0 +1,123 @@
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
+ * The parent-relationship condition a detail-page related list is scoped by —
9
+ * compiled ONCE, here, for every surface that asks the question.
10
+ *
11
+ * ## Why this is a seam and not a helper
12
+ *
13
+ * A related list asks its backend "which children belong to this parent?" from
14
+ * two places: the ROW query (`RelatedList`) and the tab-badge count probe
15
+ * (`RelatedCountStore`). Those two used to compile the condition separately,
16
+ * and objectui#8882 is what that costs: objectui#7299 taught the ROW side to
17
+ * compile by the relationship field's ARITY, the BADGE side kept sending bare
18
+ * equality, and on a multi-value relationship the page then rendered rows above
19
+ * a tab with no count at all — two implementations of one question, drifted.
20
+ *
21
+ * Patching the second copy to match the first would leave two copies. So the
22
+ * condition has one implementation, and both callers import it.
23
+ *
24
+ * ## The rule itself is not invented here
25
+ *
26
+ * The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`. That
27
+ * matters more here than anywhere: this function chooses `$contains` vs `=` and
28
+ * every surface that asks the question now reads that one choice, so two
29
+ * readers of one question disagreeing — the entire defect class — cannot
30
+ * recur INSIDE this repository. The spec's rule is BROADER than an eyeballed
31
+ * `multiple === true` in both directions — `multiselect` / `checkboxes` /
32
+ * `tags` persist an array with no flag at all, and `multiple: true` is INERT on
33
+ * a type outside the spec's multi-capable set (`master_detail`, say) — so a
34
+ * local approximation is wrong in both directions, not merely incomplete.
35
+ *
36
+ * ⛔ Do not add a local arity rule at any call site, however small, and ⛔ do
37
+ * not widen this function to accept an arity the caller computed: the parameter
38
+ * it takes is METADATA, and the verdict is drawn from it here.
39
+ *
40
+ * ## ⚠️ The STORAGE side does not read this predicate — the two rules DIVERGE
41
+ *
42
+ * This header used to say `isMultiValueField` is "the same predicate the driver
43
+ * that executes the query decides on". It is not, and the difference is
44
+ * observable (objectui#8937). Measured on objectstack `origin/main`,
45
+ * `driver-sql` gates the equality family on its own STORAGE question,
46
+ * `isJsonField`: a column is JSON when the field's type is in that driver's
47
+ * `JSON_COLUMN_TYPES` — the spec's `STRUCTURED_JSON_TYPES` and
48
+ * `MULTI_OPTION_TYPES`, plus the driver-internal `object` / `array` aliases —
49
+ * OR when `multiple` is merely TRUTHY, on ANY type; a single-value media type
50
+ * answers from the ADR-0104 dual-encoding window instead. The spec's predicate
51
+ * is `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) &&
52
+ * multiple === true)`.
53
+ *
54
+ * ⇒ They diverge for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying
55
+ * `multiple: true` (`master_detail` / `tree` / `text`): the spec says
56
+ * single-valued, so this seam compiles `=`, while the driver stores a JSON
57
+ * column and refuses `=` with the same `400 INVALID_FILTER` objectui#7299 was
58
+ * filed for. That is a KNOWN divergence and ⛔ not a regression — both
59
+ * surfaces sent `=` for that shape before objectui#7299 and objectui#8882 too.
60
+ *
61
+ * ⛔ Do NOT close it by widening the predicate here: which of the two rules is
62
+ * right is an upstream question, filed as objectstack#17469 (enforce-or-align),
63
+ * and this seam is deliberately not blocked on it. Widening on this side would
64
+ * move the disagreement across the wire rather than end it, and would make the
65
+ * ONE compiler this module exists to be disagree with the spec it delegates to.
66
+ * The divergence is re-derived from the installed spec on every run by
67
+ * `relatedListParentScopeResidue-8937.test.ts`, so this section reddens when the
68
+ * spec side moves.
69
+ */
70
+ import { type ValueShapeFieldDef } from '@objectstack/spec/data';
71
+ import type { FieldContainerLike } from './predicate-record.js';
72
+ /**
73
+ * Look one field def up in either container shape the metadata API serves.
74
+ *
75
+ * The pair is the one {@link FieldContainerLike} names: the Record keyed by
76
+ * field name, and the array of defs carrying their own `name`. A reader that
77
+ * knows only one of them silently answers "no such field" for the other — and
78
+ * "no such field" here means "single-valued", which is this card's own bug
79
+ * spelled as a default.
80
+ */
81
+ export declare function parentRelationshipFieldDef(fields: FieldContainerLike, fieldName: string | undefined): ValueShapeFieldDef | undefined;
82
+ /**
83
+ * Does this relationship field store MANY parent ids rather than one?
84
+ *
85
+ * The seam's verdict, exposed on its own because a caller sometimes needs the
86
+ * ANSWER without the condition — the raw-URL related-list path, whose
87
+ * `filter[<field>]=<value>` grammar has no membership operator, has to know
88
+ * the arity in order to REFUSE. Deriving it a second time at that call site is
89
+ * how the two compilers this module exists to merge came about, so it is
90
+ * derived once here and read from both shapes below.
91
+ */
92
+ export declare function isMultiValueRelationship(fields: FieldContainerLike, fieldName: string | undefined): boolean;
93
+ /**
94
+ * The parent-relationship condition, compiled to match the field's ARITY.
95
+ *
96
+ * - single-valued → `{ [relationshipField]: parentId }` — equality, byte for
97
+ * byte what both surfaces have always sent;
98
+ * - `multiple: true` (per the spec predicate) →
99
+ * `{ [relationshipField]: { $contains: parentId } }` — MEMBERSHIP, because
100
+ * the stored value is an ARRAY of ids and equality asks whether that whole
101
+ * array IS one id. `$contains` is the spelling the drivers execute for it,
102
+ * the one `driver-sql` names in the `400 INVALID_FILTER` it answers the
103
+ * equality form with.
104
+ *
105
+ * The author never writes either: they named a relationship, and its storage
106
+ * form is the renderer's business.
107
+ *
108
+ * The return value is the plain MongoDB-style object both call sites have
109
+ * always put on the wire, NOT a lowered ObjectQL AST. That is deliberate: with
110
+ * nothing else declared the query must stay byte-identical to what it was, and
111
+ * a freshly lowered AST would mean the same thing, be invisible on screen, and
112
+ * break every caller pinning the wire. Composition with a list's own declared
113
+ * scope stays the caller's step, through `mergeFilterNodes`.
114
+ *
115
+ * @param relationshipField The child field pointing back at the parent.
116
+ * @param parentId The parent record's primary key value.
117
+ * @param fields The CHILD object's field defs (`objectSchema.fields`),
118
+ * in either served shape. Without them the condition
119
+ * compiles to equality — the historical wire, and the
120
+ * only answer available to a caller that cannot see
121
+ * the metadata. A caller that CAN see it must pass it.
122
+ */
123
+ export declare function composeParentScopeFilter(relationshipField: string, parentId: string | number, fields?: FieldContainerLike): Record<string, unknown>;
@@ -0,0 +1,140 @@
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
+ * The parent-relationship condition a detail-page related list is scoped by —
9
+ * compiled ONCE, here, for every surface that asks the question.
10
+ *
11
+ * ## Why this is a seam and not a helper
12
+ *
13
+ * A related list asks its backend "which children belong to this parent?" from
14
+ * two places: the ROW query (`RelatedList`) and the tab-badge count probe
15
+ * (`RelatedCountStore`). Those two used to compile the condition separately,
16
+ * and objectui#8882 is what that costs: objectui#7299 taught the ROW side to
17
+ * compile by the relationship field's ARITY, the BADGE side kept sending bare
18
+ * equality, and on a multi-value relationship the page then rendered rows above
19
+ * a tab with no count at all — two implementations of one question, drifted.
20
+ *
21
+ * Patching the second copy to match the first would leave two copies. So the
22
+ * condition has one implementation, and both callers import it.
23
+ *
24
+ * ## The rule itself is not invented here
25
+ *
26
+ * The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`. That
27
+ * matters more here than anywhere: this function chooses `$contains` vs `=` and
28
+ * every surface that asks the question now reads that one choice, so two
29
+ * readers of one question disagreeing — the entire defect class — cannot
30
+ * recur INSIDE this repository. The spec's rule is BROADER than an eyeballed
31
+ * `multiple === true` in both directions — `multiselect` / `checkboxes` /
32
+ * `tags` persist an array with no flag at all, and `multiple: true` is INERT on
33
+ * a type outside the spec's multi-capable set (`master_detail`, say) — so a
34
+ * local approximation is wrong in both directions, not merely incomplete.
35
+ *
36
+ * ⛔ Do not add a local arity rule at any call site, however small, and ⛔ do
37
+ * not widen this function to accept an arity the caller computed: the parameter
38
+ * it takes is METADATA, and the verdict is drawn from it here.
39
+ *
40
+ * ## ⚠️ The STORAGE side does not read this predicate — the two rules DIVERGE
41
+ *
42
+ * This header used to say `isMultiValueField` is "the same predicate the driver
43
+ * that executes the query decides on". It is not, and the difference is
44
+ * observable (objectui#8937). Measured on objectstack `origin/main`,
45
+ * `driver-sql` gates the equality family on its own STORAGE question,
46
+ * `isJsonField`: a column is JSON when the field's type is in that driver's
47
+ * `JSON_COLUMN_TYPES` — the spec's `STRUCTURED_JSON_TYPES` and
48
+ * `MULTI_OPTION_TYPES`, plus the driver-internal `object` / `array` aliases —
49
+ * OR when `multiple` is merely TRUTHY, on ANY type; a single-value media type
50
+ * answers from the ADR-0104 dual-encoding window instead. The spec's predicate
51
+ * is `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) &&
52
+ * multiple === true)`.
53
+ *
54
+ * ⇒ They diverge for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying
55
+ * `multiple: true` (`master_detail` / `tree` / `text`): the spec says
56
+ * single-valued, so this seam compiles `=`, while the driver stores a JSON
57
+ * column and refuses `=` with the same `400 INVALID_FILTER` objectui#7299 was
58
+ * filed for. That is a KNOWN divergence and ⛔ not a regression — both
59
+ * surfaces sent `=` for that shape before objectui#7299 and objectui#8882 too.
60
+ *
61
+ * ⛔ Do NOT close it by widening the predicate here: which of the two rules is
62
+ * right is an upstream question, filed as objectstack#17469 (enforce-or-align),
63
+ * and this seam is deliberately not blocked on it. Widening on this side would
64
+ * move the disagreement across the wire rather than end it, and would make the
65
+ * ONE compiler this module exists to be disagree with the spec it delegates to.
66
+ * The divergence is re-derived from the installed spec on every run by
67
+ * `relatedListParentScopeResidue-8937.test.ts`, so this section reddens when the
68
+ * spec side moves.
69
+ */
70
+ import { isMultiValueField } from '@objectstack/spec/data';
71
+ /**
72
+ * Look one field def up in either container shape the metadata API serves.
73
+ *
74
+ * The pair is the one {@link FieldContainerLike} names: the Record keyed by
75
+ * field name, and the array of defs carrying their own `name`. A reader that
76
+ * knows only one of them silently answers "no such field" for the other — and
77
+ * "no such field" here means "single-valued", which is this card's own bug
78
+ * spelled as a default.
79
+ */
80
+ export function parentRelationshipFieldDef(fields, fieldName) {
81
+ if (!fieldName || !fields || typeof fields !== 'object')
82
+ return undefined;
83
+ const def = Array.isArray(fields)
84
+ ? fields.find((f) => f?.name === fieldName)
85
+ : fields[fieldName];
86
+ if (!def || typeof def !== 'object')
87
+ return undefined;
88
+ // `type` is the one member the spec's predicate reads besides `multiple`; a
89
+ // def without it answers `false` through both of the predicate's set lookups,
90
+ // which is the right answer for a field whose type nobody declared.
91
+ return def;
92
+ }
93
+ /**
94
+ * Does this relationship field store MANY parent ids rather than one?
95
+ *
96
+ * The seam's verdict, exposed on its own because a caller sometimes needs the
97
+ * ANSWER without the condition — the raw-URL related-list path, whose
98
+ * `filter[<field>]=<value>` grammar has no membership operator, has to know
99
+ * the arity in order to REFUSE. Deriving it a second time at that call site is
100
+ * how the two compilers this module exists to merge came about, so it is
101
+ * derived once here and read from both shapes below.
102
+ */
103
+ export function isMultiValueRelationship(fields, fieldName) {
104
+ const def = parentRelationshipFieldDef(fields, fieldName);
105
+ return def !== undefined && isMultiValueField(def);
106
+ }
107
+ /**
108
+ * The parent-relationship condition, compiled to match the field's ARITY.
109
+ *
110
+ * - single-valued → `{ [relationshipField]: parentId }` — equality, byte for
111
+ * byte what both surfaces have always sent;
112
+ * - `multiple: true` (per the spec predicate) →
113
+ * `{ [relationshipField]: { $contains: parentId } }` — MEMBERSHIP, because
114
+ * the stored value is an ARRAY of ids and equality asks whether that whole
115
+ * array IS one id. `$contains` is the spelling the drivers execute for it,
116
+ * the one `driver-sql` names in the `400 INVALID_FILTER` it answers the
117
+ * equality form with.
118
+ *
119
+ * The author never writes either: they named a relationship, and its storage
120
+ * form is the renderer's business.
121
+ *
122
+ * The return value is the plain MongoDB-style object both call sites have
123
+ * always put on the wire, NOT a lowered ObjectQL AST. That is deliberate: with
124
+ * nothing else declared the query must stay byte-identical to what it was, and
125
+ * a freshly lowered AST would mean the same thing, be invisible on screen, and
126
+ * break every caller pinning the wire. Composition with a list's own declared
127
+ * scope stays the caller's step, through `mergeFilterNodes`.
128
+ *
129
+ * @param relationshipField The child field pointing back at the parent.
130
+ * @param parentId The parent record's primary key value.
131
+ * @param fields The CHILD object's field defs (`objectSchema.fields`),
132
+ * in either served shape. Without them the condition
133
+ * compiles to equality — the historical wire, and the
134
+ * only answer available to a caller that cannot see
135
+ * the metadata. A caller that CAN see it must pass it.
136
+ */
137
+ export function composeParentScopeFilter(relationshipField, parentId, fields) {
138
+ const multi = isMultiValueRelationship(fields, relationshipField);
139
+ return { [relationshipField]: multi ? { $contains: parentId } : parentId };
140
+ }
@@ -32,7 +32,10 @@
32
32
  * predicate.)
33
33
  *
34
34
  * Since objectstack#8018 the same reader also carries the non-predicate row keys
35
- * a view's actions read — today just `recordIdField`. The name kept the
35
+ * a view's actions read: `recordIdField`, since objectui#10277 the field a
36
+ * `defaultFromRow` param seeds from and the `{field}` tokens of an action's
37
+ * `target`, and since objectui#10404 the fields an `undoable` action writes,
38
+ * whose prior values the Undo capture reads off the row. The name kept the
36
39
  * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
40
  * it against the declared fields, add it to `$select`); see
38
41
  * {@link listViewPredicates} for what is in the set and why.
@@ -85,24 +88,54 @@ export declare function collectPredicateFieldRefs(predicates: readonly unknown[]
85
88
  * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
86
89
  * and the object's declared actions.
87
90
  *
88
- * Most entries are predicates. Two are not, and both are spelled as a synthetic
91
+ * Most entries are predicates. Five are not, and all are spelled as a synthetic
89
92
  * `record.<name>` so the one harvester handles them: conditional formatting's
90
- * native `{ field, operator, value }` shape, and an action's `recordIdField`
91
- * (objectstack#8018). The `recordIdField` case is the same *class* of bug the
92
- * predicate harvest exists to close, one surface over — the action runtime reads
93
+ * native `{ field, operator, value }` shape, an action's `recordIdField`
94
+ * (objectstack#8018), the field each `defaultFromRow` param seeds from, each
95
+ * `{field}` token of the action's `target` (both objectui#10277), and the fields
96
+ * an `undoable` action writes (objectui#10404). The
97
+ * `recordIdField` case is the same *class* of bug the predicate harvest exists
98
+ * to close, one surface over — the action runtime reads
93
99
  * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
94
100
  * listView columns arrived absent and the injection was silently skipped, which
95
101
  * turns a record-scoped mutation into one that names no record while still
96
102
  * reporting success. The default (`id`) is already projected unconditionally, so
97
103
  * only an explicit declaration adds anything here.
98
104
  *
99
- * A `recordIdField` that is not a bare identifier is dropped here, and one naming
100
- * a field the object does not declare is dropped by the caller's
101
- * `isProjectableField` guard. Both drops are the safe direction: such a name is
102
- * not a column anywhere, and an unknown key in `$select` is not ignored by every
103
- * backend. The identifier gate is not decoration — without it the harvester's
104
- * regex would read a PREFIX of a malformed name (`record.not a field` → `not`)
105
- * and contribute a plausible wrong field instead of nothing.
105
+ * The last two are the same class again, and they were silent in the same way.
106
+ * `resolveActionParam` seeds a `defaultFromRow` param only when the row HAS the
107
+ * key (an own-property check), so an absent key opened the param dialog with the
108
+ * field blank; and the console's `api` handler fills a `{field}` token from the
109
+ * row, so an absent key put an empty path segment into the URL. The key a param
110
+ * seeds from is `field ?? name` — the runtime's precedence, not the param's
111
+ * payload name — and the token grammar is the handler's own. Neither is
112
+ * narrowed by the action's `type`, for the asymmetry stated below: an extra
113
+ * column costs bytes.
114
+ *
115
+ * The written fields are the same class once more, and the worst of them,
116
+ * because what went wrong was a write. The Undo capture of an `undoable` update
117
+ * reads each written field's prior value off the row; on a projected row a
118
+ * written field no column shows was absent, the capture recorded `null`, and
119
+ * Undo then wrote `null` over the stored value it existed to restore. The
120
+ * written keys are `patch`, each param's collected key and `bodyExtra` (see
121
+ * `undoableWrittenKeys`). This one IS narrowed, to `undoable`, and not for
122
+ * cost: the capture is the only reader of a written field's prior value, and it
123
+ * runs only under `undoable`, so without it the row reads none of these keys.
124
+ *
125
+ * A `recordIdField`, param key or written key that is not a bare identifier is
126
+ * dropped here
127
+ * (a `target` token is one by construction of its grammar), and a name the
128
+ * object does not declare is dropped by the caller's `isProjectableField`
129
+ * guard. Both drops are the safe direction: such a name is not a column
130
+ * anywhere, and an unknown key in `$select` is not ignored by every backend.
131
+ * The identifier gate is not decoration — without it the harvester's regex would
132
+ * read a PREFIX of a malformed name (`record.not a field` → `not`) and
133
+ * contribute a plausible wrong field instead of nothing.
134
+ *
135
+ * ⛔ Harvesting is not a read grant. Every name here is a CANDIDATE: each
136
+ * consumer still passes it through its own field-level read filter before it
137
+ * reaches `$select`, so a field the principal may not read is not requested
138
+ * because an action names it.
106
139
  *
107
140
  * `objectActions` is deliberately the object's WHOLE action set rather than
108
141
  * only the ones this view promotes. Narrowing it would mean re-running the
@@ -32,7 +32,10 @@
32
32
  * predicate.)
33
33
  *
34
34
  * Since objectstack#8018 the same reader also carries the non-predicate row keys
35
- * a view's actions read — today just `recordIdField`. The name kept the
35
+ * a view's actions read: `recordIdField`, since objectui#10277 the field a
36
+ * `defaultFromRow` param seeds from and the `{field}` tokens of an action's
37
+ * `target`, and since objectui#10404 the fields an `undoable` action writes,
38
+ * whose prior values the Undo capture reads off the row. The name kept the
36
39
  * "predicate" spelling because the mechanism is identical (harvest a name, gate
37
40
  * it against the declared fields, add it to `$select`); see
38
41
  * {@link listViewPredicates} for what is in the set and why.
@@ -88,6 +91,73 @@ const RECORD_REF = /\b(?:record|data)\.([A-Za-z_][A-Za-z0-9_]*)/g;
88
91
  * declaration contributes nothing instead of contributing a plausible wrong name.
89
92
  */
90
93
  const BARE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
94
+ /**
95
+ * A `{field}` token in an action's `target` — the grammar the console's `api`
96
+ * handler (`useConsoleActionRuntime` in `@object-ui/app-shell`) substitutes from
97
+ * the row record, spelled exactly as that handler spells it, so the harvest
98
+ * reads the same tokens it fills. The capture is a bare identifier by
99
+ * construction: a dotted path (`{owner.name}`), an expression (`{a + b}`) and
100
+ * the runner's own `${param.X}` / `${ctx.X}` scopes all fall outside it and
101
+ * match nothing here — this grammar does not read them off the row, so they owe
102
+ * the projection nothing.
103
+ */
104
+ const TARGET_ROW_TOKEN = /\{([a-z_][a-z0-9_]*)\}/gi;
105
+ /**
106
+ * The row key a `defaultFromRow` param seeds its value from — `field` wins,
107
+ * because row data is keyed by object field; `name` is the fallback. The same
108
+ * precedence `resolveActionParam` (`@object-ui/app-shell`) reads, which is the
109
+ * point: the projection must carry the key the runtime looks up, not the
110
+ * param's payload name. `remove_team_member` seeds `teamId` from `team_id`.
111
+ */
112
+ function defaultFromRowKey(param) {
113
+ if (!param || typeof param !== 'object')
114
+ return undefined;
115
+ const p = param;
116
+ if (!p.defaultFromRow)
117
+ return undefined;
118
+ return p.field ?? p.name;
119
+ }
120
+ /**
121
+ * The keys an `undoable` action WRITES, which are the keys its Undo capture
122
+ * reads off the row (objectui#10404). Returned unfiltered; the caller applies
123
+ * the identifier gate.
124
+ *
125
+ * Each source is the one a writer actually merges into the written set:
126
+ *
127
+ * - `patch`: the static field values of `operation: 'update'`, merged under the
128
+ * collected params (`ActionRunner`'s `executeUpdateOperation`);
129
+ * - each param's COLLECTED key, `name ?? field`: the key the param dialog
130
+ * stores its answer under (`paramName` in `app-shell`'s
131
+ * `resolveActionParams`). This is the opposite precedence to
132
+ * {@link defaultFromRowKey} on purpose: that one asks which row key a value
133
+ * is READ from, this one asks which key a value is WRITTEN under, and the
134
+ * capture looks the written key up on the row;
135
+ * - `bodyExtra`: the static body the console's generic `api` handler merges
136
+ * over the params before it writes (`useConsoleActionRuntime`).
137
+ *
138
+ * `recordId` is left out: both writers read it as the record's address and
139
+ * strip it from the written set, so no capture ever looks it up.
140
+ *
141
+ * A key the harvest cannot make the row carry (undeclared, or a field the
142
+ * principal may not read) is not rescued here. The capture refuses to invent a
143
+ * prior value for it instead, and offers no Undo.
144
+ */
145
+ function undoableWrittenKeys(def) {
146
+ const keys = [];
147
+ for (const bag of [def.patch, def.bodyExtra]) {
148
+ if (bag && typeof bag === 'object' && !Array.isArray(bag))
149
+ keys.push(...Object.keys(bag));
150
+ }
151
+ if (Array.isArray(def.params)) {
152
+ for (const param of def.params) {
153
+ if (!param || typeof param !== 'object')
154
+ continue;
155
+ const p = param;
156
+ keys.push(p.name ?? p.field);
157
+ }
158
+ }
159
+ return keys.filter((key) => key !== 'recordId');
160
+ }
91
161
  /** Pull the CEL source out of any of the shapes a predicate is authored in. */
92
162
  function predicateSource(pred) {
93
163
  if (typeof pred === 'string')
@@ -139,24 +209,54 @@ export function collectPredicateFieldRefs(predicates) {
139
209
  * kebab (custom defs AND the built-in Edit/Delete overrides), the selection bar,
140
210
  * and the object's declared actions.
141
211
  *
142
- * Most entries are predicates. Two are not, and both are spelled as a synthetic
212
+ * Most entries are predicates. Five are not, and all are spelled as a synthetic
143
213
  * `record.<name>` so the one harvester handles them: conditional formatting's
144
- * native `{ field, operator, value }` shape, and an action's `recordIdField`
145
- * (objectstack#8018). The `recordIdField` case is the same *class* of bug the
146
- * predicate harvest exists to close, one surface over — the action runtime reads
214
+ * native `{ field, operator, value }` shape, an action's `recordIdField`
215
+ * (objectstack#8018), the field each `defaultFromRow` param seeds from, each
216
+ * `{field}` token of the action's `target` (both objectui#10277), and the fields
217
+ * an `undoable` action writes (objectui#10404). The
218
+ * `recordIdField` case is the same *class* of bug the predicate harvest exists
219
+ * to close, one surface over — the action runtime reads
147
220
  * `rowRecord[action.recordIdField]` to seed `recordIdParam`, so a key outside the
148
221
  * listView columns arrived absent and the injection was silently skipped, which
149
222
  * turns a record-scoped mutation into one that names no record while still
150
223
  * reporting success. The default (`id`) is already projected unconditionally, so
151
224
  * only an explicit declaration adds anything here.
152
225
  *
153
- * A `recordIdField` that is not a bare identifier is dropped here, and one naming
154
- * a field the object does not declare is dropped by the caller's
155
- * `isProjectableField` guard. Both drops are the safe direction: such a name is
156
- * not a column anywhere, and an unknown key in `$select` is not ignored by every
157
- * backend. The identifier gate is not decoration — without it the harvester's
158
- * regex would read a PREFIX of a malformed name (`record.not a field` → `not`)
159
- * and contribute a plausible wrong field instead of nothing.
226
+ * The last two are the same class again, and they were silent in the same way.
227
+ * `resolveActionParam` seeds a `defaultFromRow` param only when the row HAS the
228
+ * key (an own-property check), so an absent key opened the param dialog with the
229
+ * field blank; and the console's `api` handler fills a `{field}` token from the
230
+ * row, so an absent key put an empty path segment into the URL. The key a param
231
+ * seeds from is `field ?? name` — the runtime's precedence, not the param's
232
+ * payload name — and the token grammar is the handler's own. Neither is
233
+ * narrowed by the action's `type`, for the asymmetry stated below: an extra
234
+ * column costs bytes.
235
+ *
236
+ * The written fields are the same class once more, and the worst of them,
237
+ * because what went wrong was a write. The Undo capture of an `undoable` update
238
+ * reads each written field's prior value off the row; on a projected row a
239
+ * written field no column shows was absent, the capture recorded `null`, and
240
+ * Undo then wrote `null` over the stored value it existed to restore. The
241
+ * written keys are `patch`, each param's collected key and `bodyExtra` (see
242
+ * `undoableWrittenKeys`). This one IS narrowed, to `undoable`, and not for
243
+ * cost: the capture is the only reader of a written field's prior value, and it
244
+ * runs only under `undoable`, so without it the row reads none of these keys.
245
+ *
246
+ * A `recordIdField`, param key or written key that is not a bare identifier is
247
+ * dropped here
248
+ * (a `target` token is one by construction of its grammar), and a name the
249
+ * object does not declare is dropped by the caller's `isProjectableField`
250
+ * guard. Both drops are the safe direction: such a name is not a column
251
+ * anywhere, and an unknown key in `$select` is not ignored by every backend.
252
+ * The identifier gate is not decoration — without it the harvester's regex would
253
+ * read a PREFIX of a malformed name (`record.not a field` → `not`) and
254
+ * contribute a plausible wrong field instead of nothing.
255
+ *
256
+ * ⛔ Harvesting is not a read grant. Every name here is a CANDIDATE: each
257
+ * consumer still passes it through its own field-level read filter before it
258
+ * reaches `$select`, so a field the principal may not read is not requested
259
+ * because an action names it.
160
260
  *
161
261
  * `objectActions` is deliberately the object's WHOLE action set rather than
162
262
  * only the ones this view promotes. Narrowing it would mean re-running the
@@ -191,6 +291,31 @@ export function listViewPredicates(view) {
191
291
  if (typeof d.recordIdField === 'string' && BARE_IDENTIFIER.test(d.recordIdField)) {
192
292
  preds.push(`record.${d.recordIdField}`);
193
293
  }
294
+ // A `defaultFromRow` param's seed field and the `{field}` tokens of the
295
+ // action's `target` (objectui#10277) — two more row keys the runtime
296
+ // READS, by the same rule and the same identifier gate.
297
+ if (Array.isArray(d.params)) {
298
+ for (const param of d.params) {
299
+ const key = defaultFromRowKey(param);
300
+ if (typeof key === 'string' && BARE_IDENTIFIER.test(key))
301
+ preds.push(`record.${key}`);
302
+ }
303
+ }
304
+ if (typeof d.target === 'string') {
305
+ TARGET_ROW_TOKEN.lastIndex = 0;
306
+ for (let m = TARGET_ROW_TOKEN.exec(d.target); m; m = TARGET_ROW_TOKEN.exec(d.target)) {
307
+ preds.push(`record.${m[1]}`);
308
+ }
309
+ }
310
+ // The fields an `undoable` action writes (objectui#10404): its Undo
311
+ // capture reads each one's prior value off the row. Same rule, same
312
+ // identifier gate.
313
+ if (d.undoable) {
314
+ for (const key of undoableWrittenKeys(d)) {
315
+ if (typeof key === 'string' && BARE_IDENTIFIER.test(key))
316
+ preds.push(`record.${key}`);
317
+ }
318
+ }
194
319
  }
195
320
  }
196
321
  for (const override of Object.values(view.userActions ?? {})) {
@@ -0,0 +1,75 @@
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
+ * Resolve the colour a record's value stands for, off the field's own metadata.
10
+ *
11
+ * ## Why this is shared (objectui#7243)
12
+ *
13
+ * `colorField` is one authored key — `gantt.colorField`, `calendar.colorField`,
14
+ * `timeline.colorField` — and it used to mean three different things:
15
+ *
16
+ * - `plugin-timeline` resolved the field's option `color` (correct);
17
+ * - `plugin-calendar` passed the raw value on to a deterministic hash, so the
18
+ * colour the author declared on the option was never consulted;
19
+ * - `plugin-gantt` passed the raw value straight into `backgroundColor`, so
20
+ * `colorField: 'status'` emitted `backgroundColor: "open"` — not a colour.
21
+ * The browser dropped the declaration and every bar rendered identically,
22
+ * which made DECLARING the documented key strictly worse than omitting it.
23
+ *
24
+ * The timeline's resolver is lifted here verbatim so all three answer the same
25
+ * question the same way. What lives here is the part that is genuinely common —
26
+ * the two rungs that depend only on the field metadata and the stored value:
27
+ *
28
+ * 1. the field's own option `color` for that value;
29
+ * 2. the value itself when it already IS a CSS colour literal.
30
+ *
31
+ * The LAST rung deliberately stays with each caller, because each one has a
32
+ * different right answer for "a value that is neither": the gantt derives a
33
+ * semantic token hex (a bar must be painted), the calendar hashes onto its
34
+ * theme-aware 8-stop class palette (a soft tint, not a solid fill), and the
35
+ * timeline draws its default marker variant. Pulling those together would be a
36
+ * repaint of existing views, not a bug fix.
37
+ *
38
+ * ## Keyed by option `value` only
39
+ *
40
+ * Deliberately NOT `buildOptionColorMap` (`./chart-series.ts`), which keys by
41
+ * value AND display label: that one reads dataset ROWS, where the server may
42
+ * have resolved a select dimension to its label. These callers read RECORDS,
43
+ * where `record[colorField]` is the stored value. Keying by label as well would
44
+ * colour a record whose stored value happens to collide with another option's
45
+ * label — a tolerance nothing here needs, and one the timeline never had.
46
+ */
47
+ /** One entry of a select-like field's `options`, as far as colour is concerned. */
48
+ export interface FieldColorOption {
49
+ value?: unknown;
50
+ color?: unknown;
51
+ }
52
+ /** The slice of a field definition this resolver reads. */
53
+ export interface FieldColorDefinition {
54
+ options?: unknown;
55
+ }
56
+ /** Does this string already name a colour the DOM can paint? */
57
+ export declare function isColorLiteral(value: string): boolean;
58
+ /**
59
+ * Build a `value -> option colour` lookup from a field definition's `options`.
60
+ * Returns an empty map for a field with no options, so callers need no guard.
61
+ */
62
+ export declare function buildFieldColorMap(fieldDef: FieldColorDefinition | undefined | null): Record<string, string>;
63
+ /**
64
+ * Build the per-field colour resolver: rung 1 (option colour) then rung 2 (the
65
+ * value is already a colour literal), else `undefined` so the caller runs its
66
+ * own last rung.
67
+ *
68
+ * A factory rather than a plain function because callers resolve one field
69
+ * across every row of a view — the option map is built once, not per record.
70
+ *
71
+ * @param fieldDef `objectSchema.fields[colorField]`, or `undefined` when the
72
+ * view has no schema in hand (an inline-data or `api`-provider view). With no
73
+ * options, rung 1 is a no-op and rung 2 still answers for literal colours.
74
+ */
75
+ export declare function createFieldColorResolver(fieldDef: FieldColorDefinition | undefined | null): (value: unknown) => string | undefined;