@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,463 @@
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
+ * The date-only ISO spelling — `2026-08-01`, and nothing else.
10
+ *
11
+ * Spelled exactly as `dataset-format.ts`'s `ISO_DATE_ONLY_RE`, which sniffs
12
+ * the same shape one file over to decide which arm a measure takes. Two
13
+ * spellings of one convention is what this module exists to prevent, so the
14
+ * two regexes are kept identical on purpose.
15
+ */
16
+ const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
17
+ /**
18
+ * An ISO date carrying a time part — `2026-08-01T09:30:00Z`, the same with a
19
+ * space separator, with or without seconds or an offset. Matched only as far
20
+ * as `HH:mm`, so its first ten characters are the `YYYY-MM-DD` the value was
21
+ * written on.
22
+ *
23
+ * Spelled exactly as `dataset-format.ts`'s `ISO_DATETIME_RE`, which sniffs the
24
+ * same shape to route a measure to its datetime arm, for the same reason as
25
+ * {@link ISO_DATE_ONLY_RE} above: one convention, one spelling.
26
+ */
27
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
28
+ /**
29
+ * A date that exists — the pattern alone would accept `2024-02-31`.
30
+ *
31
+ * `true` only for a date-only ISO string (`YYYY-MM-DD`, the shape
32
+ * {@link ISO_DATE_ONLY_RE} names) whose year, month and day read back
33
+ * unchanged from the calendar. Anything else — another shape, a time part, an
34
+ * out-of-range month, or a day its month does not have — is `false`.
35
+ *
36
+ * ## Why this lives here (objectui#10026)
37
+ *
38
+ * It used to be module-private in `@object-ui/components`' filter builder,
39
+ * which refused `2026-02-30` at the AUTHORING boundary, while this module — a
40
+ * package below it, unable to import it — ROLLED the same value into March 2nd
41
+ * on every display face. One concept, two answers. It moved down, exactly as
42
+ * `formatDate` itself moved into this package (see the header), so the filter
43
+ * builder and {@link toDisplayDate} now ask the one function. ⛔ A move, not a
44
+ * copy: `@object-ui/components` imports it from here.
45
+ *
46
+ * ## Why the engine cannot answer this on its own
47
+ *
48
+ * ECMAScript's date-string parse accepts a DAY of `01`-`31` for every month
49
+ * and rolls the surplus forward: `Date.parse('2026-02-30')` is March 2nd, not
50
+ * `NaN`. It rejects an out-of-range MONTH (`2026-13-01`), which is why a
51
+ * bad month was always a dash on the display path and a bad day never was.
52
+ * So the day is read back instead: build the date in UTC (no zone, so no DST
53
+ * gap can move it) and require all three parts to survive.
54
+ *
55
+ * `setUTCFullYear` rather than `Date.UTC(year, …)`: the latter maps years
56
+ * 0-99 onto 1900+y, so it answered `false` for `0026-08-01` — a real day,
57
+ * and one {@link toDisplayDate} renders (it undoes the same legacy mapping for
58
+ * the same reason). The move had to fix that, or refusing through this
59
+ * function would have dashed every year below 100.
60
+ */
61
+ export function isRealCalendarDate(dateOnly) {
62
+ if (!ISO_DATE_ONLY_RE.test(dateOnly))
63
+ return false;
64
+ const [year, month, day] = dateOnly.split('-').map(Number);
65
+ const probe = new Date(0);
66
+ probe.setUTCFullYear(year, month - 1, day);
67
+ return (probe.getUTCFullYear() === year &&
68
+ probe.getUTCMonth() === month - 1 &&
69
+ probe.getUTCDate() === day);
70
+ }
71
+ /**
72
+ * The ONE `value -> Date` step behind every function below.
73
+ *
74
+ * ## The defect (objectui#10110)
75
+ *
76
+ * ECMAScript parses the two ISO shapes into two different zones: a DATE-ONLY
77
+ * form is UTC, while a date-TIME form without an offset is local. So
78
+ * `new Date('2026-08-01')` is UTC midnight, and every getter this module then
79
+ * uses — `getFullYear`, `getMonth`, `getDate`, and `toLocaleDateString`'s own
80
+ * internal ones — reads it back in the VIEWER's zone. West of UTC that lands
81
+ * on the previous calendar day: `2026-08-01` rendered `Jul 31` for a UTC-7
82
+ * viewer while the stored value, the API response and a UTC+8 viewer all said
83
+ * August 1st. The relative branch shifted with it, one day per day
84
+ * (`2026-08-31` read `4 days ago` on the 3rd instead of `3 days ago`),
85
+ * because it compares two LOCAL start-of-days.
86
+ *
87
+ * ## The repair, and why it is not an offset
88
+ *
89
+ * A date-only value names a CALENDAR DAY and carries no instant, so there is
90
+ * no conversion to perform: this rebuilds it at LOCAL midnight of the day it
91
+ * names, and every local getter downstream then reports that same day in
92
+ * every zone. ⛔ Nothing here adds or subtracts hours. An offset that
93
+ * cancels the shift would be wrong again at the next DST boundary and wrong
94
+ * in the opposite direction for a viewer EAST of UTC, where the UTC-midnight
95
+ * parse already lands on the right day — the co-located suite drives both.
96
+ * `GridField`'s sub-grid cell already parsed its own date-only values this
97
+ * way before reaching `formatDate`; this is that treatment, moved to the one
98
+ * place every caller passes through (the sub-grid reads it from here since
99
+ * objectui#10301).
100
+ *
101
+ * A value with a time part is not rebuilt, in both spellings: it HAS an
102
+ * instant, and rendering an instant in the viewer's zone is the whole point
103
+ * of a `datetime`. The regex is what separates them, so the split is the
104
+ * VALUE's shape and never the field's declared type, which this module (pure,
105
+ * no schema) cannot see.
106
+ *
107
+ * ## What it refuses (objectui#10026)
108
+ *
109
+ * The engine's own parse still decides what is a date at all (`2026-13-01`
110
+ * is an Invalid Date, and so every face below renders `—`), with ONE
111
+ * addition: a date-only value naming a day its month does not have. The
112
+ * engine accepts `2026-02-30` and rolls it into March 2nd, so before this
113
+ * card every date face showed a real day nobody wrote, with nothing to say
114
+ * so. This step hands back an Invalid Date for it instead — the same answer
115
+ * the engine gives a bad month — so every caller renders the face it already
116
+ * renders for an unparsable value: `—` from the functions below, `EmptyValue`
117
+ * from the field carriers that read validity here, the raw stored string from
118
+ * a caller whose unparsable face is the raw string. ⛔ No new marker.
119
+ *
120
+ * The maintainer's ruling on objectui#10026 (option A) placed the refusal on
121
+ * the SHARED path, in this step and not in any one consumer: refusing in a
122
+ * single face would re-create the list-cell-versus-measure split
123
+ * objectui#4576 recorded. {@link isRealCalendarDate} is the one judgement,
124
+ * shared with the filter builder's authoring boundary.
125
+ *
126
+ * A value that carries a TIME is refused the same way when the day it is
127
+ * written on does not exist (objectui#10301): `2026-02-30T10:00:00Z` parses
128
+ * too, and rendered `Mar 2, 2026, 10:00 AM` on every datetime face. Triage
129
+ * graded that an inherited branch of the same ruling. The judgement reads the
130
+ * value's leading `YYYY-MM-DD` AS WRITTEN, never the day its instant lands on
131
+ * in some zone: a parsed `Date` always names a real day, so a check made after
132
+ * any conversion could only miss the refusal, and a real day written with an
133
+ * offset (`2026-02-28T23:30:00-05:00`, March 1st in UTC) is kept. A real
134
+ * date-time is otherwise untouched — no rebuild, the engine's instant.
135
+ *
136
+ * ## Why it is exported (objectui#10183)
137
+ *
138
+ * A caller that formats with its own `Intl` options — a face none of the
139
+ * functions below produce, such as the record summary chip's
140
+ * `dateStyle: 'medium'` — or that compares a value against "today" needs the
141
+ * `Date` itself, not a string. Such callers used to parse the value on their
142
+ * own (`new Date(value)`, or `Date.parse` and then a `Date` handed to
143
+ * `formatDate`, which this step then leaves alone), so the objectui#10110
144
+ * repair never reached them and each still read a date-only value one day
145
+ * early west of UTC. They take the `Date` from here instead.
146
+ *
147
+ * ⛔ Read the result with LOCAL getters or local-zone `Intl` formatting only.
148
+ * For a date-only value it is local midnight of the named day, so its
149
+ * `toISOString()` / UTC getters name the previous day east of UTC — hand
150
+ * those the stored value, never this.
151
+ */
152
+ export function toDisplayDate(value) {
153
+ const parsed = value instanceof Date ? value : new Date(value);
154
+ // The engine rolls a date-time written on a nonexistent day forward too;
155
+ // judge the day as written and refuse it (objectui#10301).
156
+ if (typeof value === 'string' && ISO_DATETIME_RE.test(value) && !isRealCalendarDate(value.slice(0, 10))) {
157
+ return new Date(NaN);
158
+ }
159
+ if (typeof value !== 'string' || !ISO_DATE_ONLY_RE.test(value) || isNaN(parsed.getTime())) {
160
+ return parsed;
161
+ }
162
+ // The engine rolled a nonexistent day forward; refuse it (objectui#10026).
163
+ if (!isRealCalendarDate(value))
164
+ return new Date(NaN);
165
+ const [year, month, day] = value.split('-').map(Number);
166
+ const local = new Date(year, month - 1, day);
167
+ // Years 0-99 only: the multi-argument constructor maps them onto 1900+y, so
168
+ // `0026-08-01` would render as 1926 where the string parse read year 26.
169
+ // Setting the year back is co-extensive with that legacy mapping and a
170
+ // no-op on every other year.
171
+ local.setFullYear(year);
172
+ return local;
173
+ }
174
+ /**
175
+ * THE ONE READ of a stored END, where a bar or span is drawn to
176
+ * (objectui#11141): {@link toDisplayDate}'s read, and then, for a date-only
177
+ * value only, the next day's local midnight.
178
+ *
179
+ * ## The rule (objectui#11112's ruling, one home since objectui#11141)
180
+ *
181
+ * A date-only end is INCLUSIVE: `2024-01-01` to `2024-01-15` runs through
182
+ * January 15th, so the span is drawn to the start of the 16th, and a
183
+ * successor starting `2024-01-16` begins exactly where it ends. A span whose
184
+ * date-only start and end name the same day is one day long, across a DST
185
+ * change too: the step is a calendar day (`setDate`), never 24 hours, so it
186
+ * lands on the next local midnight, or on that day's first hour where its
187
+ * midnight does not exist. That is how a project plan and the schema
188
+ * catalog's gantt plans author an end.
189
+ *
190
+ * The result is the EXCLUSIVE end instant of a half-open span `[start, end)`,
191
+ * the form every gantt surface draws and schedules with. Date-only is decided
192
+ * by the value's own shape, with {@link isRealCalendarDate}, exactly as
193
+ * {@link toDisplayDate} decides it, so an end reads its day as a start does and
194
+ * then runs to that day's end. A value with a time part, a number or a `Date`
195
+ * is an instant: the span ends at it, and it is handed back as
196
+ * {@link toDisplayDate} hands it back.
197
+ *
198
+ * {@link toInclusiveEndDay} is the exact inverse: it names the day a span
199
+ * ending here runs through, so a read and a write of a date-only end agree.
200
+ *
201
+ * ⛔ One rule, one place: `plugin-timeline`'s gantt variant and
202
+ * `plugin-gantt` both read their ends here. A second copy of the step in a
203
+ * consumer is how the two gantt surfaces came to draw one plan a day apart.
204
+ * ⛔ Read the result with LOCAL getters only, as {@link toDisplayDate}'s is.
205
+ */
206
+ export function toDisplayEndDate(value) {
207
+ const end = toDisplayDate(value);
208
+ if (typeof value !== 'string' || !isRealCalendarDate(value))
209
+ return end;
210
+ end.setDate(end.getDate() + 1);
211
+ // Back to the start of that day. A day whose own midnight does not exist is
212
+ // read at its first hour, and the step keeps the hour, so without this the
213
+ // day after it would start an hour late and name itself back.
214
+ end.setHours(0, 0, 0, 0);
215
+ return end;
216
+ }
217
+ /**
218
+ * The day a span ending at `end` runs through: local midnight of the day that
219
+ * holds the last instant before `end` (objectui#11141).
220
+ *
221
+ * This is the exact inverse of {@link toDisplayEndDate} on a date-only end:
222
+ * the instant it reads `2024-01-15` as, local midnight of the 16th, names the
223
+ * 15th here, so a read, a drag and a write of a stored date-only end leave it
224
+ * the day it was, and a bar dragged to end at a day's local midnight names the
225
+ * day BEFORE it. An end inside a day (a `datetime` end, or a shift band's
226
+ * edge) names that day, the last one the span reaches into.
227
+ *
228
+ * It names a day for display and for a date-only write. ⛔ It is not a read of
229
+ * a stored value, and a `datetime` end still keeps its own instant: hand this
230
+ * only to a face that names a day, or to a field that stores one. Read the
231
+ * result with LOCAL getters only; an Invalid Date stays invalid.
232
+ */
233
+ export function toInclusiveEndDay(end) {
234
+ const day = new Date(end.getTime() - 1);
235
+ day.setHours(0, 0, 0, 0);
236
+ return day;
237
+ }
238
+ /**
239
+ * Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天",
240
+ * "3天前"), sentence-cased for locales whose `Intl` output starts lowercase.
241
+ */
242
+ function formatRelativeDays(diffDays, locale) {
243
+ try {
244
+ const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day');
245
+ return phrase.charAt(0).toUpperCase() + phrase.slice(1);
246
+ }
247
+ catch {
248
+ // Invalid locale tag — degrade to English rather than crash the cell.
249
+ if (diffDays === 0)
250
+ return 'Today';
251
+ if (diffDays === 1)
252
+ return 'Tomorrow';
253
+ if (diffDays === -1)
254
+ return 'Yesterday';
255
+ return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`;
256
+ }
257
+ }
258
+ /**
259
+ * The options bag {@link formatRelativeDate} hands to {@link formatDate} for
260
+ * its out-of-window ABSOLUTE fallback, with `style` neutralised.
261
+ *
262
+ * Two reasons, both load-bearing since objectui#7745 made `formatDate` read
263
+ * `options.style`:
264
+ *
265
+ * 1. **Behaviour preservation.** `formatRelativeDate` does not read
266
+ * `options.style`, and #7745 does not change that. Without the strip it
267
+ * would start reading it THROUGH this delegation for dates outside the
268
+ * ±7-day window only — a face change on a live path (grid cell, gantt
269
+ * tooltip) that no card authorises.
270
+ * 2. **Termination.** `formatDate` resolves `'relative'` by calling
271
+ * `formatRelativeDate`, which lands back here. With the style still in
272
+ * the bag, `formatRelativeDate(v, { style: 'relative' })` on an
273
+ * out-of-window date would recurse until the stack ran out.
274
+ *
275
+ * The bag is returned UNCHANGED when there is nothing to strip, so the common
276
+ * path allocates nothing.
277
+ */
278
+ function absoluteFallbackOptions(options) {
279
+ if (options === undefined || options.style === undefined)
280
+ return options;
281
+ return { ...options, style: undefined };
282
+ }
283
+ /**
284
+ * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
285
+ * localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
286
+ *
287
+ * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
288
+ * isn't overdue, only a past due/deadline-semantic field is. Non-due-like
289
+ * past dates render as plain "N days ago" instead. The overdue phrase has no
290
+ * `Intl` equivalent, so it resolves through `options.t` (key
291
+ * `fields.relativeDate.overdue`) with an English fallback.
292
+ */
293
+ export function formatRelativeDate(value, options) {
294
+ if (value === null || value === undefined || value === '')
295
+ return '—';
296
+ const date = toDisplayDate(value);
297
+ if (!(date instanceof Date) || isNaN(date.getTime()))
298
+ return '—';
299
+ const now = new Date();
300
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
301
+ const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
302
+ const diffMs = startOfDate.getTime() - startOfToday.getTime();
303
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
304
+ // Beyond the ±7-day window, fall back to the absolute (already localized) form.
305
+ if (diffDays < -7 || diffDays > 7)
306
+ return formatDate(date, undefined, absoluteFallbackOptions(options));
307
+ if (diffDays < -1 && options?.dueLike) {
308
+ const absDays = Math.abs(diffDays);
309
+ const key = 'fields.relativeDate.overdue';
310
+ const translated = options.t?.(key, { count: absDays });
311
+ return translated && translated !== key ? translated : `Overdue ${absDays}d`;
312
+ }
313
+ return formatRelativeDays(diffDays, options?.locale);
314
+ }
315
+ /**
316
+ * Format date value.
317
+ *
318
+ * The named face comes from EITHER spelling — the positional `style`
319
+ * parameter or `options.style` — and the precedence between them is pinned
320
+ * (objectui#7745):
321
+ *
322
+ * > **The positional argument wins. `options.style` is consulted only when the
323
+ * > positional slot is `undefined`.**
324
+ *
325
+ * Before #7745 only the positional spelling was read here, while
326
+ * {@link formatDateTime} read only the `options` one — one shared bag, two
327
+ * spellings for one concept, and on THIS function the options spelling did
328
+ * nothing at all: `formatDate(v, undefined, { style: 'short' })` silently
329
+ * rendered the default face with no diagnostic. Reading the key is the
330
+ * additive half of the maintainer's long-run ruling on objectui#7443.
331
+ *
332
+ * Why the positional wins, and not the newer key:
333
+ *
334
+ * - It is the ONLY direction that is purely additive. It fires exactly on
335
+ * the input that is a silent no-op today (positional absent, key present);
336
+ * every call that renders a face today renders the same face after.
337
+ * - The bag is SHARED across three functions, so it can legitimately carry a
338
+ * key meant for a sibling — that is this module's convention (`dueLike`
339
+ * and `t` are read by `formatRelativeDate` alone). A caller that built
340
+ * `{ style: 'compact', locale }` for `formatDateTime` and reused the bag
341
+ * for `formatDate(v, 'short', bag)` must keep its short face. A key aimed
342
+ * at a sibling function must not outrank an argument written for THIS
343
+ * call — that is objectui#7694's shape (an alias overwriting the canonical
344
+ * key), and the silent-override half of objectui#4272.
345
+ *
346
+ * `??`, not `||`, is what "absent" means here: `formatDate(v, '', bag)`
347
+ * renders the default face today and must keep doing so, so an empty string
348
+ * counts as GIVEN and does not fall through to the key.
349
+ */
350
+ export function formatDate(value, style, options) {
351
+ if (value === null || value === undefined || value === '')
352
+ return '—';
353
+ const date = toDisplayDate(value);
354
+ if (!(date instanceof Date) || isNaN(date.getTime()))
355
+ return '—';
356
+ const effectiveStyle = style ?? options?.style;
357
+ if (effectiveStyle === 'short') {
358
+ // Compact format for mobile: "Jan 15, '24" / "1月 15, '24".
359
+ // Only the MONTH token is localized: the surrounding compact shape (day,
360
+ // apostrophe + 2-digit year) is a deliberate fixed layout for narrow
361
+ // cards, not a locale-derived one. The tag comes from `options.locale`
362
+ // like the default branch below — hardcoding `'en-US'` here made this the
363
+ // one branch that ignored a locale its caller had threaded (objectui#4272).
364
+ const month = date.toLocaleDateString(options?.locale, { month: 'short' });
365
+ const day = date.getDate();
366
+ const year = String(date.getFullYear()).slice(-2);
367
+ return `${month} ${day}, '${year}`;
368
+ }
369
+ if (effectiveStyle === 'relative') {
370
+ return formatRelativeDate(date, options);
371
+ }
372
+ // Default format: locale-aware human-readable. Drop the year when it
373
+ // matches the current year — Salesforce / HubSpot / Linear all do this
374
+ // because the year is rarely useful for in-progress records and the
375
+ // verbose "2026年7月21日" form crowds cards and table cells. Past- /
376
+ // future-year dates keep the year so users can disambiguate.
377
+ const isCurrentYear = date.getFullYear() === new Date().getFullYear();
378
+ return date.toLocaleDateString(options?.locale, {
379
+ year: isCurrentYear ? undefined : 'numeric',
380
+ month: 'short',
381
+ day: 'numeric',
382
+ });
383
+ }
384
+ /**
385
+ * The `'compact'` datetime face as the two halves a grid cell paints
386
+ * separately — `7/4/2024` and `7:00 am` for `2024-07-04T07:00:00Z` in `en-US`.
387
+ *
388
+ * `formatDateTime(value, { style: 'compact', ...options })` is exactly
389
+ * `date + ' ' + time` of what this returns, so a caller that wants the face
390
+ * as ONE string and a caller that wants to style the halves differently
391
+ * cannot drift apart. That drift is what objectui#7443 recorded:
392
+ * `DateTimeCellRenderer` inlined these two option bags and never called this
393
+ * module, so `datetime` had two display conventions while `date` had one —
394
+ * the same shape as objectui#4576, which this repo has already paid for once.
395
+ *
396
+ * `null` for a value this module renders as `'—'`; the cell renders its own
397
+ * empty state for those, so it never sees the dash.
398
+ */
399
+ export function formatDateTimeCompactParts(value, options) {
400
+ if (value === null || value === undefined || value === '')
401
+ return null;
402
+ const date = toDisplayDate(value);
403
+ if (!(date instanceof Date) || isNaN(date.getTime()))
404
+ return null;
405
+ return {
406
+ date: date.toLocaleDateString(options?.locale, {
407
+ month: 'numeric',
408
+ day: 'numeric',
409
+ year: 'numeric',
410
+ }),
411
+ // `hour12` stays declared: this is the compact Airtable-style cell, and
412
+ // the 12-hour face is its design, not a locale artefact. Locales that
413
+ // write no am/pm marker simply ignore it.
414
+ time: date.toLocaleTimeString(options?.locale, {
415
+ hour: 'numeric',
416
+ minute: '2-digit',
417
+ hour12: true,
418
+ }).toLowerCase(),
419
+ };
420
+ }
421
+ /**
422
+ * Format datetime value.
423
+ *
424
+ * `options.style` selects a named face:
425
+ *
426
+ * - `'compact'` — the dense grid face, `7/4/2024 7:00 am` in `en-US`. It is
427
+ * what every `datetime` CELL renders, and what `DateTimeCellRenderer`
428
+ * used to build from its own inlined `Intl` bags (objectui#7443).
429
+ * - anything else, including absent — the verbose default,
430
+ * `Jul 4, 2024, 07:00 AM` in `en-US`. Unchanged, and still what a
431
+ * non-cell caller (dataset measure, gantt tooltip, data-table) gets.
432
+ *
433
+ * The signature is `(value, options?)`, unchanged: `style` is a key of
434
+ * `options`, not a positional parameter, so every existing call —
435
+ * `formatDateTime(v, { locale })` included — keeps meaning exactly what it
436
+ * meant (see the note on `DateDisplayOptions.style` for why the positional
437
+ * shape `formatDate` uses was refused here).
438
+ *
439
+ * `options` is optional, so a caller that passes nothing keeps the exact
440
+ * runtime-default behavior it had. Before objectui#4272 the parameter did not
441
+ * exist at all, which meant no caller could localize this function however
442
+ * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the
443
+ * MACHINE's locale, which is neither of the repo's two locale channels.
444
+ * Callers should pass the tag from `useDisplayLocale()`.
445
+ */
446
+ export function formatDateTime(value, options) {
447
+ if (value === null || value === undefined || value === '')
448
+ return '—';
449
+ const date = toDisplayDate(value);
450
+ if (!(date instanceof Date) || isNaN(date.getTime()))
451
+ return '—';
452
+ if (options?.style === 'compact') {
453
+ const parts = formatDateTimeCompactParts(date, options);
454
+ return parts ? `${parts.date} ${parts.time}` : '—';
455
+ }
456
+ return date.toLocaleDateString(options?.locale, {
457
+ year: 'numeric',
458
+ month: 'short',
459
+ day: 'numeric',
460
+ hour: '2-digit',
461
+ minute: '2-digit',
462
+ });
463
+ }
@@ -182,7 +182,14 @@ export function resolveDateMacros(filter, now = new Date()) {
182
182
  }
183
183
  if (Array.isArray(value))
184
184
  return value.map(walk);
185
+ // Only a PLAIN object (prototype `Object.prototype` or `null`) is walked;
186
+ // any other object — a `Date` comparand above all — is returned as the
187
+ // same instance rather than rebuilt from its own keys into `{}`
188
+ // (objectui#10506).
185
189
  if (typeof value === 'object') {
190
+ const proto = Object.getPrototypeOf(value);
191
+ if (proto !== Object.prototype && proto !== null)
192
+ return value;
186
193
  const out = {};
187
194
  for (const k of Object.keys(value))
188
195
  out[k] = walk(value[k]);
@@ -89,3 +89,81 @@ export declare function computeDrillFilter(config: DrillDownConfig | undefined,
89
89
  export declare function resolveDrillTitle(config: DrillDownConfig | undefined, event: DrillEvent, fallback?: string): string;
90
90
  /** Whether drill-down is enabled on a config (treats `{}` as enabled too). */
91
91
  export declare function isDrillEnabled(config: DrillDownConfig | undefined): boolean;
92
+ /**
93
+ * Compose a widget's OWN filter with the filter a drill click derived, into the
94
+ * one filter the drilled list is scoped by.
95
+ *
96
+ * ## The rule: `widget.filter ∧ drill.filter`
97
+ *
98
+ * The two are independent filter SOURCES and a drill must satisfy BOTH. The
99
+ * widget's filter is what scopes the chart; the click context only says WHICH
100
+ * bucket of that scope the user asked to see. So a drill may narrow the widget's
101
+ * scope and may never widen it — which makes the composition a conjunction, not
102
+ * a merge and emphatically not a spread.
103
+ *
104
+ * ⛔ The rule is NOT invented here. It is the contract {@link mergeFilterNodes}
105
+ * already states — "combine filter sources under a single `and`, each as its OWN
106
+ * child" — the sink every other multi-source filter in this repo goes through
107
+ * (`ObjectView`, `RelatedList`, `LineItemsPanel`, `RecordPickerDialog`,
108
+ * `ElementDataSourceGate`, `buildEffectiveFilter`). This function only applies
109
+ * it at the drill seam and names it, so the answer is in one place rather than
110
+ * re-derived per widget.
111
+ *
112
+ * ## Why the two arms needed a sink at all (objectui#8944)
113
+ *
114
+ * `ObjectChartSchema.filter` admits BOTH a spec `FilterArray`
115
+ * (`[['stage','=','won']]`) and the ObjectQL `$filter` object
116
+ * (`{ close_date: { $gte } }`), because both are read — both are forwarded
117
+ * verbatim to `ds.aggregate` / `ds.find`. The drill seam used to compose them by
118
+ * SPREADING the widget's filter into an object literal, which is correct for the
119
+ * object arm and silent nonsense for the array arm: spreading `[['stage','=',
120
+ * 'won']]` yields the index key `{ '0': ['stage','=','won'] }`, so the widget's
121
+ * own conditions were replaced by a key the query layer ignores and the drilled
122
+ * list showed rows the chart itself was scoped to exclude. `toFilterNode` (via
123
+ * `mergeFilterNodes`) already lowers all three shapes in circulation, so routing
124
+ * the pair through it is what makes the array arm survive.
125
+ *
126
+ * ## Why the result is lowered back to the object dialect
127
+ *
128
+ * {@link mergeFilterNodes} answers in the ObjectQL AST
129
+ * (`['and', <widget>, <drill>]`). Both drill sinks take the `FilterCondition`
130
+ * OBJECT dialect instead — the drawer hands the value to `object-data-table`'s
131
+ * `filter` (which becomes `$filter`), and `DrillNavigationContext.openRecordList`
132
+ * declares `Record<string, unknown>` and serializes it to `filter[...]` URL
133
+ * params. `parseFilterAST` is the spec's single lowering sink between the two
134
+ * dialects, so it is what converts, rather than a second local translation.
135
+ *
136
+ * ⭐ A lone surviving source lowers back to exactly the flat object the spread
137
+ * produced (`{ stage: 'won' }`), so a chart with no filter of its own drills
138
+ * identically to before; only a genuinely composed pair gains the `$and`.
139
+ *
140
+ * Returns `undefined` when neither source carries anything, so callers can omit
141
+ * the key rather than send an empty filter.
142
+ *
143
+ * ⚠️ Refusals are the sink's, not this seam's: `mergeFilterNodes` rejects a
144
+ * comparand the wire would also reject (a bare array on `=`, a `RegExp`) with
145
+ * the `INVALID_FILTER` / 400 envelope. Such a filter already fails the widget's
146
+ * OWN query for the same reason, so the drill and the chart now agree instead of
147
+ * the drill quietly sending something the chart could not.
148
+ *
149
+ * ## One refusal type out of this seam (objectui#10789)
150
+ *
151
+ * The seam has TWO refusing steps, and they used to throw two different
152
+ * things. `mergeFilterNodes` throws a {@link FilterOperatorError}. The spec's
153
+ * `parseFilterAST`, which judges comparand shapes the converter passes
154
+ * through, throws a plain `Error` that carries the same envelope as own
155
+ * properties: `code: 'INVALID_FILTER'` and `status: 400`. An array-dialect
156
+ * widget filter holds no rule objects, so the converter never judges it, and
157
+ * `[['stage', 'in', 'won']]` (a scalar on a list operator) is refused only
158
+ * there. So is the object spelling `{ stage: { $in: 'won' } }`.
159
+ *
160
+ * Every caller catches the refusal as a `FilterOperatorError`: the chart,
161
+ * pivot and dataset drills report it and open nothing (never a filter-less
162
+ * drill). So the spec's refusal is re-raised here AS one, with its message
163
+ * verbatim and the same `INVALID_FILTER` / 400 envelope, rather than taught to
164
+ * each caller as a second shape. Only an error whose `code` is
165
+ * `INVALID_FILTER` is re-raised; anything else from `parseFilterAST` is a
166
+ * defect rather than a statement about the author's filter, and passes
167
+ * through untouched.
168
+ */
169
+ export declare function composeDrillFilter(widgetFilter: unknown, drillFilter: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
@@ -21,6 +21,8 @@
21
21
  * The helpers are intentionally protocol-agnostic — any caller can
22
22
  * synthesize an `event` object and rely on the same defaults / templating.
23
23
  */
24
+ import { parseFilterAST } from '@objectstack/spec/data';
25
+ import { FilterOperatorError, mergeFilterNodes } from './filter-converter.js';
24
26
  const TEMPLATE = /\$\{event\.([a-zA-Z0-9_]+)\}/g;
25
27
  /**
26
28
  * Substitute `${event.x}` placeholders in a string with values from the
@@ -137,3 +139,94 @@ export function isDrillEnabled(config) {
137
139
  return false;
138
140
  return config.enabled !== false;
139
141
  }
142
+ /**
143
+ * Compose a widget's OWN filter with the filter a drill click derived, into the
144
+ * one filter the drilled list is scoped by.
145
+ *
146
+ * ## The rule: `widget.filter ∧ drill.filter`
147
+ *
148
+ * The two are independent filter SOURCES and a drill must satisfy BOTH. The
149
+ * widget's filter is what scopes the chart; the click context only says WHICH
150
+ * bucket of that scope the user asked to see. So a drill may narrow the widget's
151
+ * scope and may never widen it — which makes the composition a conjunction, not
152
+ * a merge and emphatically not a spread.
153
+ *
154
+ * ⛔ The rule is NOT invented here. It is the contract {@link mergeFilterNodes}
155
+ * already states — "combine filter sources under a single `and`, each as its OWN
156
+ * child" — the sink every other multi-source filter in this repo goes through
157
+ * (`ObjectView`, `RelatedList`, `LineItemsPanel`, `RecordPickerDialog`,
158
+ * `ElementDataSourceGate`, `buildEffectiveFilter`). This function only applies
159
+ * it at the drill seam and names it, so the answer is in one place rather than
160
+ * re-derived per widget.
161
+ *
162
+ * ## Why the two arms needed a sink at all (objectui#8944)
163
+ *
164
+ * `ObjectChartSchema.filter` admits BOTH a spec `FilterArray`
165
+ * (`[['stage','=','won']]`) and the ObjectQL `$filter` object
166
+ * (`{ close_date: { $gte } }`), because both are read — both are forwarded
167
+ * verbatim to `ds.aggregate` / `ds.find`. The drill seam used to compose them by
168
+ * SPREADING the widget's filter into an object literal, which is correct for the
169
+ * object arm and silent nonsense for the array arm: spreading `[['stage','=',
170
+ * 'won']]` yields the index key `{ '0': ['stage','=','won'] }`, so the widget's
171
+ * own conditions were replaced by a key the query layer ignores and the drilled
172
+ * list showed rows the chart itself was scoped to exclude. `toFilterNode` (via
173
+ * `mergeFilterNodes`) already lowers all three shapes in circulation, so routing
174
+ * the pair through it is what makes the array arm survive.
175
+ *
176
+ * ## Why the result is lowered back to the object dialect
177
+ *
178
+ * {@link mergeFilterNodes} answers in the ObjectQL AST
179
+ * (`['and', <widget>, <drill>]`). Both drill sinks take the `FilterCondition`
180
+ * OBJECT dialect instead — the drawer hands the value to `object-data-table`'s
181
+ * `filter` (which becomes `$filter`), and `DrillNavigationContext.openRecordList`
182
+ * declares `Record<string, unknown>` and serializes it to `filter[...]` URL
183
+ * params. `parseFilterAST` is the spec's single lowering sink between the two
184
+ * dialects, so it is what converts, rather than a second local translation.
185
+ *
186
+ * ⭐ A lone surviving source lowers back to exactly the flat object the spread
187
+ * produced (`{ stage: 'won' }`), so a chart with no filter of its own drills
188
+ * identically to before; only a genuinely composed pair gains the `$and`.
189
+ *
190
+ * Returns `undefined` when neither source carries anything, so callers can omit
191
+ * the key rather than send an empty filter.
192
+ *
193
+ * ⚠️ Refusals are the sink's, not this seam's: `mergeFilterNodes` rejects a
194
+ * comparand the wire would also reject (a bare array on `=`, a `RegExp`) with
195
+ * the `INVALID_FILTER` / 400 envelope. Such a filter already fails the widget's
196
+ * OWN query for the same reason, so the drill and the chart now agree instead of
197
+ * the drill quietly sending something the chart could not.
198
+ *
199
+ * ## One refusal type out of this seam (objectui#10789)
200
+ *
201
+ * The seam has TWO refusing steps, and they used to throw two different
202
+ * things. `mergeFilterNodes` throws a {@link FilterOperatorError}. The spec's
203
+ * `parseFilterAST`, which judges comparand shapes the converter passes
204
+ * through, throws a plain `Error` that carries the same envelope as own
205
+ * properties: `code: 'INVALID_FILTER'` and `status: 400`. An array-dialect
206
+ * widget filter holds no rule objects, so the converter never judges it, and
207
+ * `[['stage', 'in', 'won']]` (a scalar on a list operator) is refused only
208
+ * there. So is the object spelling `{ stage: { $in: 'won' } }`.
209
+ *
210
+ * Every caller catches the refusal as a `FilterOperatorError`: the chart,
211
+ * pivot and dataset drills report it and open nothing (never a filter-less
212
+ * drill). So the spec's refusal is re-raised here AS one, with its message
213
+ * verbatim and the same `INVALID_FILTER` / 400 envelope, rather than taught to
214
+ * each caller as a second shape. Only an error whose `code` is
215
+ * `INVALID_FILTER` is re-raised; anything else from `parseFilterAST` is a
216
+ * defect rather than a statement about the author's filter, and passes
217
+ * through untouched.
218
+ */
219
+ export function composeDrillFilter(widgetFilter, drillFilter) {
220
+ const merged = mergeFilterNodes(widgetFilter, drillFilter);
221
+ try {
222
+ // `FilterCondition` is the spec's object-dialect filter; the drill sinks
223
+ // type the same value as `Record<string, unknown>`, and this is the one
224
+ // seam where the two names meet.
225
+ return parseFilterAST(merged);
226
+ }
227
+ catch (error) {
228
+ if (error?.code !== 'INVALID_FILTER')
229
+ throw error;
230
+ throw new FilterOperatorError(error.message);
231
+ }
232
+ }