@mintplayer/ng-spark 22.22.0 → 22.24.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 (68) hide show
  1. package/README.md +181 -0
  2. package/fesm2022/mintplayer-ng-spark-attribute-description.mjs +100 -47
  3. package/fesm2022/mintplayer-ng-spark-attribute-description.mjs.map +1 -1
  4. package/fesm2022/mintplayer-ng-spark-client-operations.mjs +308 -329
  5. package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
  6. package/fesm2022/mintplayer-ng-spark-column-filter.mjs +175 -135
  7. package/fesm2022/mintplayer-ng-spark-column-filter.mjs.map +1 -1
  8. package/fesm2022/mintplayer-ng-spark-grid.mjs +2259 -1117
  9. package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
  10. package/fesm2022/mintplayer-ng-spark-history.mjs +611 -0
  11. package/fesm2022/mintplayer-ng-spark-history.mjs.map +1 -0
  12. package/fesm2022/mintplayer-ng-spark-icon.mjs +64 -27
  13. package/fesm2022/mintplayer-ng-spark-icon.mjs.map +1 -1
  14. package/fesm2022/mintplayer-ng-spark-models.mjs +365 -753
  15. package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
  16. package/fesm2022/mintplayer-ng-spark-moderation.mjs +1212 -0
  17. package/fesm2022/mintplayer-ng-spark-moderation.mjs.map +1 -0
  18. package/fesm2022/mintplayer-ng-spark-panels.mjs +52 -0
  19. package/fesm2022/mintplayer-ng-spark-panels.mjs.map +1 -0
  20. package/fesm2022/mintplayer-ng-spark-pipes.mjs +1122 -765
  21. package/fesm2022/mintplayer-ng-spark-pipes.mjs.map +1 -1
  22. package/fesm2022/mintplayer-ng-spark-po-create.mjs +273 -169
  23. package/fesm2022/mintplayer-ng-spark-po-create.mjs.map +1 -1
  24. package/fesm2022/mintplayer-ng-spark-po-detail.mjs +786 -410
  25. package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
  26. package/fesm2022/mintplayer-ng-spark-po-edit.mjs +292 -284
  27. package/fesm2022/mintplayer-ng-spark-po-edit.mjs.map +1 -1
  28. package/fesm2022/mintplayer-ng-spark-po-form.mjs +2187 -1225
  29. package/fesm2022/mintplayer-ng-spark-po-form.mjs.map +1 -1
  30. package/fesm2022/mintplayer-ng-spark-query-list.mjs +516 -303
  31. package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
  32. package/fesm2022/mintplayer-ng-spark-renderers.mjs +17 -55
  33. package/fesm2022/mintplayer-ng-spark-renderers.mjs.map +1 -1
  34. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs +216 -177
  35. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs.map +1 -1
  36. package/fesm2022/mintplayer-ng-spark-routes.mjs +24 -28
  37. package/fesm2022/mintplayer-ng-spark-routes.mjs.map +1 -1
  38. package/fesm2022/mintplayer-ng-spark-services.mjs +625 -640
  39. package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
  40. package/fesm2022/mintplayer-ng-spark-shell.mjs +1013 -397
  41. package/fesm2022/mintplayer-ng-spark-shell.mjs.map +1 -1
  42. package/fesm2022/mintplayer-ng-spark-soft-delete.mjs +294 -0
  43. package/fesm2022/mintplayer-ng-spark-soft-delete.mjs.map +1 -0
  44. package/fesm2022/mintplayer-ng-spark.mjs +13 -50
  45. package/fesm2022/mintplayer-ng-spark.mjs.map +1 -1
  46. package/package.json +17 -1
  47. package/types/mintplayer-ng-spark-attribute-description.d.ts +25 -18
  48. package/types/mintplayer-ng-spark-client-operations.d.ts +133 -160
  49. package/types/mintplayer-ng-spark-column-filter.d.ts +73 -55
  50. package/types/mintplayer-ng-spark-grid.d.ts +885 -448
  51. package/types/mintplayer-ng-spark-history.d.ts +223 -0
  52. package/types/mintplayer-ng-spark-icon.d.ts +15 -13
  53. package/types/mintplayer-ng-spark-models.d.ts +624 -573
  54. package/types/mintplayer-ng-spark-moderation.d.ts +402 -0
  55. package/types/mintplayer-ng-spark-panels.d.ts +108 -0
  56. package/types/mintplayer-ng-spark-pipes.d.ts +120 -149
  57. package/types/mintplayer-ng-spark-po-create.d.ts +46 -37
  58. package/types/mintplayer-ng-spark-po-detail.d.ts +177 -117
  59. package/types/mintplayer-ng-spark-po-edit.d.ts +64 -65
  60. package/types/mintplayer-ng-spark-po-form.d.ts +507 -371
  61. package/types/mintplayer-ng-spark-query-list.d.ts +118 -82
  62. package/types/mintplayer-ng-spark-renderers.d.ts +79 -85
  63. package/types/mintplayer-ng-spark-retry-action-modal.d.ts +31 -34
  64. package/types/mintplayer-ng-spark-routes.d.ts +7 -11
  65. package/types/mintplayer-ng-spark-services.d.ts +377 -261
  66. package/types/mintplayer-ng-spark-shell.d.ts +209 -128
  67. package/types/mintplayer-ng-spark-soft-delete.d.ts +90 -0
  68. package/types/mintplayer-ng-spark.d.ts +7 -12
@@ -1,826 +1,438 @@
1
- import { signal } from '@angular/core';
2
-
3
- /** Global reactive language state — shared across library boundaries via globalThis */
4
- const currentLanguage = (globalThis.__sparkCurrentLanguage ??= signal('en'));
1
+ import { signal } from "@angular/core";
2
+ const currentLanguage = globalThis.__sparkCurrentLanguage ??= signal("en");
5
3
  function resolveTranslation(ts, lang) {
6
- if (!ts)
7
- return '';
8
- const language = lang ?? currentLanguage();
9
- return ts[language] ?? ts['en'] ?? Object.values(ts)[0] ?? '';
4
+ if (!ts) return "";
5
+ return ts[lang ?? currentLanguage()] ?? ts["en"] ?? Object.values(ts)[0] ?? "";
10
6
  }
11
-
12
- /**
13
- * Flags enum controlling on which pages an attribute should be displayed.
14
- * Values can be combined: ShowedOn.Query | ShowedOn.PersistentObject
15
- */
16
7
  var ShowedOn;
17
- (function (ShowedOn) {
18
- ShowedOn[ShowedOn["Query"] = 1] = "Query";
19
- ShowedOn[ShowedOn["PersistentObject"] = 2] = "PersistentObject";
8
+ (function(ShowedOn) {
9
+ ShowedOn[ShowedOn["Query"] = 1] = "Query";
10
+ ShowedOn[ShowedOn["PersistentObject"] = 2] = "PersistentObject";
20
11
  })(ShowedOn || (ShowedOn = {}));
21
- /**
22
- * Helper function to check if a ShowedOn value includes a specific flag.
23
- */
24
12
  function hasShowedOnFlag(value, flag) {
25
- if (value === undefined)
26
- return true; // Default: show on all pages
27
- // Handle string values from JSON (e.g., "Query, PersistentObject")
28
- if (typeof value === 'string') {
29
- const parts = value.split(',').map(s => s.trim());
30
- const flagName = ShowedOn[flag];
31
- return parts.includes(flagName);
32
- }
33
- // Handle numeric flag values
34
- return (value & flag) === flag;
13
+ if (value === void 0) return true;
14
+ if (typeof value === "string") {
15
+ const parts = value.split(",").map((s) => s.trim());
16
+ const flagName = ShowedOn[flag];
17
+ return parts.includes(flagName);
18
+ }
19
+ return (value & flag) === flag;
35
20
  }
36
-
37
- /**
38
- * Controls how a Reference attribute is picked in the PO-edit form.
39
- * Serialized as a string by the server (mirrors the .NET EReferenceDisplayType).
40
- */
41
21
  var EReferenceDisplayType;
42
- (function (EReferenceDisplayType) {
43
- /** Renders as a `<bs-select>` listing every referenced item. */
44
- EReferenceDisplayType["Dropdown"] = "Dropdown";
45
- /** Renders a readonly textbox + "…" button that opens a searchable modal grid picker. */
46
- EReferenceDisplayType["Modal"] = "Modal";
22
+ (function(EReferenceDisplayType) {
23
+ EReferenceDisplayType["Dropdown"] = "Dropdown";
24
+ EReferenceDisplayType["Modal"] = "Modal";
47
25
  })(EReferenceDisplayType || (EReferenceDisplayType = {}));
48
-
49
- /**
50
- * Conversions between the wire format for a timestamp and what an `<input type="datetime-local">`
51
- * or `<input type="date">` will accept.
52
- *
53
- * A `DateTimeOffset` in Spark means an **instant**. The wire carries a full ISO-8601 string with an
54
- * offset (`2026-12-31T23:59:00-08:00`); an HTML date/time control speaks only bare local wall clock
55
- * (`2026-12-31T23:59`). Neither end can consume the other's format, so both directions need
56
- * converting, and the browser is deliberately the side that does it: its timezone rules are kept
57
- * fresh by the operating system, while a container's are frozen at image build time.
58
- *
59
- * Assigning a wire value straight to a `datetime-local` input does not throw and does not warn -- the
60
- * control simply renders **blank**, and saving the untouched form then writes that blank back.
61
- */
62
26
  function pad(n) {
63
- return String(n).padStart(2, '0');
27
+ return String(n).padStart(2, "0");
64
28
  }
65
- /** Formats a UTC offset in minutes as `+HH:mm` / `-HH:mm`. */
66
29
  function formatOffset(offsetMinutes) {
67
- const sign = offsetMinutes >= 0 ? '+' : '-';
68
- const abs = Math.abs(offsetMinutes);
69
- return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
70
- }
71
- /**
72
- * Parses a wire timestamp. Returns `null` for anything unusable, matching the contract of
73
- * `parsedDate` / `queryCellValue` -- a bad value renders as empty rather than as `Invalid Date`.
74
- */
30
+ const sign = offsetMinutes >= 0 ? "+" : "-";
31
+ const abs = Math.abs(offsetMinutes);
32
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
33
+ }
75
34
  function parseWireDate(value) {
76
- if (value === null || value === undefined || value === '')
77
- return null;
78
- if (value instanceof Date)
79
- return isNaN(value.getTime()) ? null : value;
80
- if (typeof value !== 'string' && typeof value !== 'number')
81
- return null;
82
- const parsed = new Date(value);
83
- return isNaN(parsed.getTime()) ? null : parsed;
84
- }
85
- /**
86
- * Wire value -> the `yyyy-MM-ddTHH:mm` a `datetime-local` input needs, in the **viewer's** zone.
87
- *
88
- * The conversion is date-sensitive, not a fixed shift: a value is rendered using the zone's offset
89
- * *on that value's own date*, so a timestamp in July and one in January convert differently in a
90
- * zone that observes DST.
91
- */
35
+ if (value === null || value === void 0 || value === "") return null;
36
+ if (value instanceof Date) return isNaN(value.getTime()) ? null : value;
37
+ if (typeof value !== "string" && typeof value !== "number") return null;
38
+ const parsed = new Date(value);
39
+ return isNaN(parsed.getTime()) ? null : parsed;
40
+ }
92
41
  function toDateTimeLocalInput(value) {
93
- const d = parseWireDate(value);
94
- if (!d)
95
- return '';
96
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
42
+ const d = parseWireDate(value);
43
+ if (!d) return "";
44
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
97
45
  }
98
- /** Wire value -> the `yyyy-MM-dd` a `date` input needs, in the viewer's zone. */
99
46
  function toDateInput(value) {
100
- const d = parseWireDate(value);
101
- if (!d)
102
- return '';
103
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
104
- }
105
- /**
106
- * `yyyy-MM-ddTHH:mm` from the control -> a complete ISO-8601 string carrying the viewer's offset
107
- * **for the entered date**.
108
- *
109
- * Building the `Date` from its parts rather than parsing the string is what makes this correct: it
110
- * lets the browser apply its own timezone rules for that date, including DST. Using the *current*
111
- * offset instead would be wrong for any value outside the present season -- entering a January date
112
- * during summer would record an offset an hour off, and the stored instant would be an hour wrong.
113
- *
114
- * Across a DST discontinuity the browser resolves the wall clock itself, taking the offset in force
115
- * before a fall-back and after a spring-forward. That is specified ECMAScript behaviour, and the
116
- * server's conversion helper deliberately reproduces the same choice so the two cannot drift.
117
- */
47
+ const d = parseWireDate(value);
48
+ if (!d) return "";
49
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
50
+ }
118
51
  function fromDateTimeLocalInput(local) {
119
- if (typeof local !== 'string' || local === '')
120
- return null;
121
- const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(local);
122
- if (!match)
123
- return null;
124
- const [, y, mo, d, h, mi, s] = match;
125
- const date = new Date(+y, +mo - 1, +d, +h, +mi, s ? +s : 0);
126
- if (isNaN(date.getTime()))
127
- return null;
128
- // getTimezoneOffset() is minutes to ADD to local to reach UTC, so it is the negation of the offset
129
- // an ISO string carries.
130
- return `${y}-${mo}-${d}T${h}:${mi}:${pad(s ? +s : 0)}${formatOffset(-date.getTimezoneOffset())}`;
131
- }
132
- /** `yyyy-MM-dd` from a `date` control -> an ISO string at local midnight with the viewer's offset. */
52
+ if (typeof local !== "string" || local === "") return null;
53
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(local);
54
+ if (!match) return null;
55
+ const [, y, mo, d, h, mi, s] = match;
56
+ const date = new Date(+y, +mo - 1, +d, +h, +mi, s ? +s : 0);
57
+ if (isNaN(date.getTime())) return null;
58
+ return `${y}-${mo}-${d}T${h}:${mi}:${pad(s ? +s : 0)}${formatOffset(-date.getTimezoneOffset())}`;
59
+ }
133
60
  function fromDateInput(local) {
134
- if (typeof local !== 'string' || local === '')
135
- return null;
136
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(local);
137
- if (!match)
138
- return null;
139
- return fromDateTimeLocalInput(`${local}T00:00`);
140
- }
141
- /**
142
- * Compares two wire timestamps **by instant**, which is what a `DateTimeOffset` means in Spark.
143
- *
144
- * Round-tripping a value through a date control legitimately rewrites its text -- a document holding
145
- * `2026-12-31T23:59:00-08:00` comes back as `2027-01-01T08:59:00+01:00` from a viewer in Brussels --
146
- * so a string comparison would mark every untouched date as edited and send a needless write on every
147
- * save. Both of those name the same moment.
148
- *
149
- * Note this is the *opposite* of the trap on the server side, where `DateTimeOffset.Equals` compares
150
- * the instant and silently hides a destroyed offset. Here comparing by instant is the correct thing:
151
- * we are asking "did the user change this value", not "is the offset intact".
152
- */
61
+ if (typeof local !== "string" || local === "") return null;
62
+ if (!/^(\d{4})-(\d{2})-(\d{2})$/.exec(local)) return null;
63
+ return fromDateTimeLocalInput(`${local}T00:00`);
64
+ }
153
65
  function wireDatesEqual(a, b) {
154
- const da = parseWireDate(a);
155
- const db = parseWireDate(b);
156
- if (da === null || db === null)
157
- return da === db;
158
- return da.getTime() === db.getTime();
66
+ const da = parseWireDate(a);
67
+ const db = parseWireDate(b);
68
+ if (da === null || db === null) return da === db;
69
+ return da.getTime() === db.getTime();
159
70
  }
160
- /** True for the attribute data types that are edited through a native date/time control. */
161
71
  function isDateDataType(dataType) {
162
- return dataType === 'date' || dataType === 'datetime';
72
+ return dataType === "date" || dataType === "datetime";
163
73
  }
164
- /** Wire -> control, picking the right shape for the data type. */
165
74
  function toDateInputValue(dataType, value) {
166
- return dataType === 'date' ? toDateInput(value) : toDateTimeLocalInput(value);
75
+ return dataType === "date" ? toDateInput(value) : toDateTimeLocalInput(value);
167
76
  }
168
- /** Control -> wire, picking the right shape for the data type. */
169
77
  function fromDateInputValue(dataType, value) {
170
- return dataType === 'date' ? fromDateInput(value) : fromDateTimeLocalInput(value);
78
+ return dataType === "date" ? fromDateInput(value) : fromDateTimeLocalInput(value);
171
79
  }
172
-
173
- /**
174
- * Flattens a nested `PersistentObject` into the plain `Record<string, any>` shape the
175
- * form state uses throughout ng-spark. Primitive / reference attributes contribute their
176
- * `value`; nested AsDetail attributes recurse — single becomes an inner dict, array
177
- * becomes an array of inner dicts. Returns `{}` for `null` / `undefined` input.
178
- *
179
- * This is the ONE place that reads the server's new AsDetail wire shape and collapses it
180
- * back to the flat dict the form components already handle.
181
- */
182
80
  function nestedPoToDict(po) {
183
- if (!po)
184
- return {};
185
- const dict = {};
186
- for (const attr of po.attributes ?? []) {
187
- dict[attr.name] = attributeValueForForm(attr);
188
- }
189
- // The object's own server-resolved breadcrumb, kept under a reserved key so the form can label
190
- // it without re-deriving a template it may be structurally unable to resolve. Safe to carry
191
- // through save: `dictToNestedPo` walks the entity type's attributes, never the dict's keys, so
192
- // a reserved key is never sent to the server. Attached only when it resolved, which keeps the
193
- // common case byte-for-byte identical to the plain flat dict.
194
- if (typeof po.breadcrumb === 'string' && po.breadcrumb !== '') {
195
- dict[AS_DETAIL_SELF_BREADCRUMB_KEY] = po.breadcrumb;
196
- }
197
- // The row's own key, same reserved-key mechanism and for a sharper reason: without it the key
198
- // never returns, and a save cannot tell an edited row from a deleted one plus a new one. See
199
- // AS_DETAIL_ROW_KEY.
200
- if (typeof po.id === 'string' && po.id !== '') {
201
- dict[AS_DETAIL_ROW_KEY] = po.id;
202
- }
203
- // The wire values of any date cells, kept so the save side can tell an untouched cell from an
204
- // edited one. Without it, flattening to a wall clock and converting back rewrites the stored
205
- // offset to the viewer's on every save, even for rows nobody touched.
206
- const originalDates = originalDatesOf(po);
207
- if (originalDates)
208
- dict[AS_DETAIL_ORIGINAL_DATES_KEY] = originalDates;
209
- return dict;
81
+ if (!po) return {};
82
+ const dict = {};
83
+ for (const attr of po.attributes ?? []) dict[attr.name] = attributeValueForForm(attr);
84
+ if (typeof po.breadcrumb === "string" && po.breadcrumb !== "") dict[AS_DETAIL_SELF_BREADCRUMB_KEY] = po.breadcrumb;
85
+ if (typeof po.id === "string" && po.id !== "") dict[AS_DETAIL_ROW_KEY] = po.id;
86
+ const originalDates = originalDatesOf(po);
87
+ if (originalDates) dict[AS_DETAIL_ORIGINAL_DATES_KEY] = originalDates;
88
+ return dict;
210
89
  }
211
90
  function originalDatesOf(po) {
212
- let originals;
213
- for (const attr of po.attributes ?? []) {
214
- if (isDateDataType(attr.dataType))
215
- (originals ??= {})[attr.name] = attr.value;
216
- }
217
- return originals;
91
+ let originals;
92
+ for (const attr of po.attributes ?? []) if (isDateDataType(attr.dataType)) (originals ??= {})[attr.name] = attr.value;
93
+ return originals;
218
94
  }
219
95
  function attributeValueForForm(attr) {
220
- if (attr.dataType === 'AsDetail') {
221
- if (attr.isArray)
222
- return (attr.objects ?? []).map(po => nestedPoToDict(po));
223
- return attr.object ? nestedPoToDict(attr.object) : null;
224
- }
225
- // An embedded row's date cells are edited through the same native controls as a root object's,
226
- // so they need the same wire -> wall-clock conversion. This is the FORM path only;
227
- // `nestedPoToDisplayRow` deliberately keeps the wire value, because the display path formats
228
- // through `parsedDate` rather than feeding an input.
229
- if (isDateDataType(attr.dataType))
230
- return toDateInputValue(attr.dataType, attr.value);
231
- return attr.value;
232
- }
233
- /**
234
- * Reserved key under which a flattened nested object keeps the breadcrumb the SERVER resolved for
235
- * that object itself (as opposed to {@link AS_DETAIL_BREADCRUMBS_KEY}, which keys the breadcrumbs
236
- * of its reference attributes by attribute name).
237
- *
238
- * This exists because a breadcrumb template can name a property the model does not carry. HR's
239
- * `Address` declares `[Breadcrumb, IgnoreProperty] string Crumb`, and `Address.json` renders it as
240
- * `"{Crumb}"` — the server resolves that by reflecting over the CLR property, which no client can
241
- * do, because `[IgnoreProperty]` is exactly the instruction to keep it out of the model. Flattening
242
- * used to discard the resolved string, leaving the form to substitute `{Crumb}` against a dict that
243
- * can never contain it.
244
- */
245
- const AS_DETAIL_SELF_BREADCRUMB_KEY = '__sparkBreadcrumb';
246
- /**
247
- * Reserved key under which a flattened nested object keeps its own row key — the value of whatever
248
- * property the server registered as that type's `[ValueKey]`.
249
- *
250
- * A value object's key is not a model attribute (nothing declares `Id` in its model file), so it
251
- * travels as the nested `PersistentObject`'s `id` and has to be carried across the flat-dict form
252
- * state by hand. Before this existed, flattening dropped it and `dictToNestedPo` rebuilt `id` from
253
- * `dict['Id']`, which is never present — so **every** embedded row reached the server with an empty
254
- * id, and the fresh instance the mapper builds kept the guid its own field initializer had just
255
- * minted.
256
- *
257
- * That is not cosmetic. Stored keys and incoming keys were then both plausible and never equal, so
258
- * a save could not match rows: an edit was indistinguishable from a delete plus a create. Every
259
- * per-row rule — preserving read-only fields, and the `New`/`Edit`/`Delete` rights of the row type —
260
- * rests on this key surviving the round trip.
261
- *
262
- * Safe to carry, for the same reason the breadcrumb is: `dictToNestedPo` walks the entity type's
263
- * attributes and never the dict's keys, so a reserved key is never sent back as an attribute.
264
- */
265
- const AS_DETAIL_ROW_KEY = '__sparkRowKey';
266
- /**
267
- * The breadcrumb the server resolved for a flattened object, or null when it resolved to nothing.
268
- *
269
- * `EntityMapper` never emits an empty breadcrumb: when the template renders blank it substitutes
270
- * the CLR type name, so an unset `Address` arrives as the literal string `"Address"`
271
- * (EntityMapper.cs:209-211). That is a placeholder, not data, and rendering it would be worse than
272
- * rendering nothing — it reads as a real value. `typeName` lets a caller filter it back out.
273
- */
96
+ if (attr.dataType === "AsDetail") {
97
+ if (attr.isArray) return (attr.objects ?? []).map((po) => nestedPoToDict(po));
98
+ return attr.object ? nestedPoToDict(attr.object) : null;
99
+ }
100
+ if (isDateDataType(attr.dataType)) return toDateInputValue(attr.dataType, attr.value);
101
+ return attr.value;
102
+ }
103
+ const AS_DETAIL_SELF_BREADCRUMB_KEY = "__sparkBreadcrumb";
104
+ const AS_DETAIL_ROW_KEY = "__sparkRowKey";
274
105
  function selfBreadcrumb(row, typeName) {
275
- return resolvedBreadcrumb(row?.[AS_DETAIL_SELF_BREADCRUMB_KEY], typeName);
276
- }
277
- /**
278
- * The same filter as {@link selfBreadcrumb}, for a caller holding the server's breadcrumb string
279
- * directly rather than a flattened row dict — `attr.object.breadcrumb` on a detail page, or a query
280
- * cell's `breadcrumb`.
281
- *
282
- * Split out so there is exactly one answer to "is this the type-name placeholder?". The first
283
- * implementation lived inside `selfBreadcrumb` and so covered only the two pipes that flatten a row
284
- * first; the two that read the string straight off the wire kept printing `BuildFeedback` and
285
- * `GateSettings` at users (#384). A second copy of the comparison is how that happens again.
286
- */
106
+ return resolvedBreadcrumb(row?.[AS_DETAIL_SELF_BREADCRUMB_KEY], typeName);
107
+ }
287
108
  function resolvedBreadcrumb(value, typeName) {
288
- if (typeof value !== 'string' || value.trim() === '')
289
- return null;
290
- // Callers hold the type name in two shapes — `EntityType.name` is the short model name, while an
291
- // attribute's `asDetailType` is the full CLR name — and the server's placeholder is always the
292
- // short one. Compare on the last dotted segment so either shape filters it.
293
- if (typeName) {
294
- const shortName = typeName.slice(typeName.lastIndexOf('.') + 1);
295
- if (value === shortName)
296
- return null;
297
- }
298
- return value;
299
- }
300
- /**
301
- * Reserved key under which {@link nestedPoToDisplayRow} stashes the server-resolved breadcrumb of
302
- * each reference attribute (keyed by attribute name). Lets an AsDetail reference cell render the
303
- * label the server already resolved by id — page-independent — instead of guessing from a single
304
- * reference-query options page. Prefixed to avoid colliding with a real attribute name.
305
- */
306
- const AS_DETAIL_BREADCRUMBS_KEY = '__sparkBreadcrumbs';
307
- /**
308
- * Reserved key under which a flattened row keeps the **wire** values of its date cells, keyed by
309
- * attribute name.
310
- *
311
- * A date cell is edited through a native control that speaks only a bare local wall clock, so
312
- * flattening converts the stored instant into the viewer's zone and saving converts it back. That
313
- * round trip preserves the instant but necessarily rewrites the **offset** to the viewer's. For a
314
- * cell the user actually edited that is correct — the offset is not business data. For one nobody
315
- * touched it is pure loss: merely opening a row and saving would relabel a Seattle timestamp as a
316
- * Brussels one.
317
- *
318
- * Keeping the original lets {@link dictToNestedPo} send back exactly what it was given whenever the
319
- * instant is unchanged. Safe to carry for the same reason as the other reserved keys: the rebuild
320
- * walks the entity type's attributes, never the dict's keys.
321
- */
322
- const AS_DETAIL_ORIGINAL_DATES_KEY = '__sparkOriginalDates';
323
- /**
324
- * Whether a flattened-row key is one this module reserved rather than a model attribute.
325
- *
326
- * `nestedPoToDict` and `nestedPoToDisplayRow` stash the row key and the resolved breadcrumbs
327
- * alongside the real values, which is safe on the way back — `dictToNestedPo` walks the entity
328
- * type's attributes and never the dict's keys. It is *not* safe for anything that enumerates the
329
- * dict to build display text: a caller joining `Object.values(dict)` prints the row's guid and its
330
- * own breadcrumb as though they were fields.
331
- */
109
+ if (typeof value !== "string" || value.trim() === "") return null;
110
+ if (typeName) {
111
+ if (value === typeName.slice(typeName.lastIndexOf(".") + 1)) return null;
112
+ }
113
+ return value;
114
+ }
115
+ const AS_DETAIL_BREADCRUMBS_KEY = "__sparkBreadcrumbs";
116
+ const AS_DETAIL_ORIGINAL_DATES_KEY = "__sparkOriginalDates";
332
117
  function isReservedAsDetailKey(key) {
333
- return key === AS_DETAIL_ROW_KEY
334
- || key === AS_DETAIL_SELF_BREADCRUMB_KEY
335
- || key === AS_DETAIL_BREADCRUMBS_KEY
336
- || key === AS_DETAIL_ORIGINAL_DATES_KEY;
337
- }
338
- /**
339
- * Like {@link nestedPoToDict}, but for the read-only detail display path. In addition to each
340
- * attribute's value it preserves the server-resolved per-reference `breadcrumb` under
341
- * {@link AS_DETAIL_BREADCRUMBS_KEY}, so an AsDetail reference cell can render the label by id
342
- * regardless of whether the referenced document fits on the reference query's first options page.
343
- * The form/edit path keeps using {@link nestedPoToDict}, which never carries breadcrumbs.
344
- */
118
+ return key === "__sparkRowKey" || key === "__sparkBreadcrumb" || key === "__sparkBreadcrumbs" || key === "__sparkOriginalDates";
119
+ }
345
120
  function nestedPoToDisplayRow(po) {
346
- if (!po)
347
- return {};
348
- const dict = {};
349
- let breadcrumbs;
350
- for (const attr of po.attributes ?? []) {
351
- dict[attr.name] = displayValueForAttribute(attr);
352
- if (attr.dataType === 'Reference' && !attr.isArray && typeof attr.breadcrumb === 'string' && attr.breadcrumb !== '') {
353
- (breadcrumbs ??= {})[attr.name] = attr.breadcrumb;
354
- }
355
- }
356
- // Only attach the side channel when something resolved — keeps reference-free rows (the common
357
- // case) byte-for-byte identical to the plain flat dict.
358
- if (breadcrumbs)
359
- dict[AS_DETAIL_BREADCRUMBS_KEY] = breadcrumbs;
360
- // Same self-breadcrumb the form path carries: a nested-AsDetail COLUMN in a detail table has an
361
- // inner dict as its value, which used to stringify to "[object Object]".
362
- if (typeof po.breadcrumb === 'string' && po.breadcrumb !== '') {
363
- dict[AS_DETAIL_SELF_BREADCRUMB_KEY] = po.breadcrumb;
364
- }
365
- // And the row key, for the same reason as the form path — a display row can be handed back for
366
- // saving, and a row that loses its key on that trip is indistinguishable from a new one.
367
- if (typeof po.id === 'string' && po.id !== '') {
368
- dict[AS_DETAIL_ROW_KEY] = po.id;
369
- }
370
- return dict;
121
+ if (!po) return {};
122
+ const dict = {};
123
+ let breadcrumbs;
124
+ for (const attr of po.attributes ?? []) {
125
+ dict[attr.name] = displayValueForAttribute(attr);
126
+ if (attr.dataType === "Reference" && !attr.isArray && typeof attr.breadcrumb === "string" && attr.breadcrumb !== "") (breadcrumbs ??= {})[attr.name] = attr.breadcrumb;
127
+ }
128
+ if (breadcrumbs) dict[AS_DETAIL_BREADCRUMBS_KEY] = breadcrumbs;
129
+ if (typeof po.breadcrumb === "string" && po.breadcrumb !== "") dict[AS_DETAIL_SELF_BREADCRUMB_KEY] = po.breadcrumb;
130
+ if (typeof po.id === "string" && po.id !== "") dict[AS_DETAIL_ROW_KEY] = po.id;
131
+ return dict;
371
132
  }
372
133
  function displayValueForAttribute(attr) {
373
- if (attr.dataType === 'AsDetail') {
374
- if (attr.isArray)
375
- return (attr.objects ?? []).map(po => nestedPoToDisplayRow(po));
376
- return attr.object ? nestedPoToDisplayRow(attr.object) : null;
377
- }
378
- return attr.value;
379
- }
380
- /**
381
- * Builds a nested `PersistentObject` from a flat dict against the schema in
382
- * <paramref name="entityType"/>. Used when the form is about to save — AsDetail attributes
383
- * are no longer sent as flat dicts in `attribute.value`; the server now requires
384
- * `attribute.object` / `attribute.objects` with fully scaffolded nested POs.
385
- *
386
- * `resolve` walks through AsDetail types registered elsewhere (usually the full
387
- * `getEntityTypes()` list, keyed by CLR type name). Nested AsDetail inside AsDetail is
388
- * handled recursively.
389
- */
134
+ if (attr.dataType === "AsDetail") {
135
+ if (attr.isArray) return (attr.objects ?? []).map((po) => nestedPoToDisplayRow(po));
136
+ return attr.object ? nestedPoToDisplayRow(attr.object) : null;
137
+ }
138
+ return attr.value;
139
+ }
390
140
  function dictToNestedPo(dict, entityType, resolve) {
391
- const originalDates = (dict?.[AS_DETAIL_ORIGINAL_DATES_KEY] ?? {});
392
- const attributes = (entityType.attributes ?? [])
393
- .map(attrDef => buildAttribute(attrDef, dict?.[attrDef.name], resolve, originalDates[attrDef.name]));
394
- return {
395
- // The reserved key first: it is the only one that is always present for a stored row, because
396
- // a value object's key is not a model attribute. `Id`/`id` remain as fallbacks for a root
397
- // object, whose id genuinely is part of the dict.
398
- id: dict?.[AS_DETAIL_ROW_KEY] ?? dict?.['Id'] ?? dict?.['id'] ?? '',
399
- name: entityType.name,
400
- objectTypeId: entityType.id,
401
- attributes,
402
- };
141
+ const originalDates = dict?.["__sparkOriginalDates"] ?? {};
142
+ const attributes = (entityType.attributes ?? []).map((attrDef) => buildAttribute(attrDef, dict?.[attrDef.name], resolve, originalDates[attrDef.name]));
143
+ return {
144
+ id: dict?.["__sparkRowKey"] ?? dict?.["Id"] ?? dict?.["id"] ?? "",
145
+ name: entityType.name,
146
+ objectTypeId: entityType.id,
147
+ attributes
148
+ };
403
149
  }
404
150
  function buildAttribute(attrDef, raw, resolve, originalDate) {
405
- const attr = {
406
- id: attrDef.id,
407
- name: attrDef.name,
408
- label: attrDef.label,
409
- dataType: attrDef.dataType,
410
- isArray: attrDef.isArray,
411
- isRequired: attrDef.isRequired,
412
- isVisible: attrDef.isVisible,
413
- isReadOnly: attrDef.isReadOnly,
414
- order: attrDef.order,
415
- rules: attrDef.rules ?? [],
416
- isValueChanged: true,
417
- };
418
- if (attrDef.dataType === 'AsDetail') {
419
- // Server expects attr.value null for AsDetail; the nested PO carries the data.
420
- attr.value = null;
421
- attr.asDetailType = attrDef.asDetailType;
422
- const nestedType = attrDef.asDetailType ? resolve(attrDef.asDetailType) : undefined;
423
- if (!nestedType) {
424
- attr.object = null;
425
- attr.objects = attrDef.isArray ? [] : null;
426
- return attr;
427
- }
428
- if (attrDef.isArray) {
429
- const items = Array.isArray(raw) ? raw : [];
430
- attr.objects = items.map(item => dictToNestedPo(item ?? {}, nestedType, resolve));
431
- }
432
- else {
433
- attr.object = raw ? dictToNestedPo(raw, nestedType, resolve) : null;
434
- }
435
- return attr;
436
- }
437
- if (isDateDataType(attrDef.dataType)) {
438
- // The mirror of the conversion in `attributeValueForForm`: back from the control's bare wall
439
- // clock to a complete ISO-8601 instant carrying the viewer's offset for that date. When the
440
- // instant is unchanged, send back exactly what was loaded instead, so an untouched row keeps
441
- // its stored offset. See AS_DETAIL_ORIGINAL_DATES_KEY.
442
- const converted = fromDateInputValue(attrDef.dataType, raw);
443
- attr.value = wireDatesEqual(converted, originalDate) ? originalDate : converted;
444
- return attr;
445
- }
446
- attr.value = raw;
447
- return attr;
151
+ const attr = {
152
+ id: attrDef.id,
153
+ name: attrDef.name,
154
+ label: attrDef.label,
155
+ dataType: attrDef.dataType,
156
+ isArray: attrDef.isArray,
157
+ isRequired: attrDef.isRequired,
158
+ isVisible: attrDef.isVisible,
159
+ isReadOnly: attrDef.isReadOnly,
160
+ order: attrDef.order,
161
+ rules: attrDef.rules ?? [],
162
+ isValueChanged: true
163
+ };
164
+ if (attrDef.dataType === "AsDetail") {
165
+ attr.value = null;
166
+ attr.asDetailType = attrDef.asDetailType;
167
+ const nestedType = attrDef.asDetailType ? resolve(attrDef.asDetailType) : void 0;
168
+ if (!nestedType) {
169
+ attr.object = null;
170
+ attr.objects = attrDef.isArray ? [] : null;
171
+ return attr;
172
+ }
173
+ if (attrDef.isArray) attr.objects = (Array.isArray(raw) ? raw : []).map((item) => dictToNestedPo(item ?? {}, nestedType, resolve));
174
+ else attr.object = raw ? dictToNestedPo(raw, nestedType, resolve) : null;
175
+ return attr;
176
+ }
177
+ if (isDateDataType(attrDef.dataType)) {
178
+ const converted = fromDateInputValue(attrDef.dataType, raw);
179
+ attr.value = wireDatesEqual(converted, originalDate) ? originalDate : converted;
180
+ return attr;
181
+ }
182
+ attr.value = raw;
183
+ return attr;
448
184
  }
449
-
450
- /**
451
- * The value a row carries for a column, whatever shape the row is in — or `undefined` when it
452
- * carries none.
453
- *
454
- * ```ts
455
- * const isPrivate = valueFor(this.item(), 'IsPrivate')?.value === true;
456
- * ```
457
- *
458
- * Always returns the **cell** rather than the bare value, because a reference cell is worth as much
459
- * for its `objectId` as for its text, and a helper that discarded it would have to be replaced the
460
- * first time a renderer wanted to link somewhere. `?.value` is the cost, once per call site.
461
- *
462
- * ⚠️ A value only reaches a grid row if its attribute is on the **query surface**. To read a sibling
463
- * the grid does not draw, mark it `"showedOn": "Query", "isVisible": false` — shipped, not drawn. An
464
- * attribute marked `"showedOn": "PersistentObject"` is absent from a row by design, and this returns
465
- * `undefined` for it; that is the first thing to check when a sibling read comes back empty.
466
- */
467
185
  function valueFor(item, columnName) {
468
- if (!item)
469
- return undefined;
470
- if (isQueryRow(item)) {
471
- return item.values.find(v => v.key === columnName);
472
- }
473
- if (isPersistentObject(item)) {
474
- const attribute = item.attributes.find(a => a.name === columnName);
475
- if (!attribute)
476
- return undefined;
477
- return {
478
- key: attribute.name,
479
- value: attribute.value,
480
- // A single reference's value IS the target's id, which is what the row shape carries
481
- // separately. Deriving it here keeps a renderer from having to know that.
482
- objectId: attribute.dataType === 'Reference' && !attribute.isArray && attribute.value != null
483
- ? String(attribute.value)
484
- : undefined,
485
- breadcrumb: attribute.breadcrumb,
486
- breadcrumbs: attribute.breadcrumbs,
487
- };
488
- }
489
- const record = item;
490
- if (!(columnName in record))
491
- return undefined;
492
- const value = record[columnName];
493
- // The flat row's side channel is populated only for single references, so its presence for this
494
- // key is what identifies one — the record itself carries no dataType to ask.
495
- const breadcrumb = record[AS_DETAIL_BREADCRUMBS_KEY]?.[columnName];
496
- return {
497
- key: columnName,
498
- value,
499
- objectId: breadcrumb !== undefined && value != null ? String(value) : undefined,
500
- breadcrumb,
501
- };
502
- }
503
- /**
504
- * Whether this row is a query result item.
505
- *
506
- * Discriminated on the *elements*, not just on `Array.isArray(values)`: a flat AsDetail record may
507
- * legitimately have a column named `values` holding an array, and mistaking one for the other would
508
- * silently read the wrong thing.
509
- */
186
+ if (!item) return void 0;
187
+ if (isQueryRow(item)) return item.values.find((v) => v.key === columnName);
188
+ if (isPersistentObject(item)) {
189
+ const attribute = item.attributes.find((a) => a.name === columnName);
190
+ if (!attribute) return void 0;
191
+ return {
192
+ key: attribute.name,
193
+ value: attribute.value,
194
+ objectId: attribute.dataType === "Reference" && !attribute.isArray && attribute.value != null ? String(attribute.value) : void 0,
195
+ breadcrumb: attribute.breadcrumb,
196
+ breadcrumbs: attribute.breadcrumbs
197
+ };
198
+ }
199
+ const record = item;
200
+ if (!(columnName in record)) return void 0;
201
+ const value = record[columnName];
202
+ const breadcrumb = record[AS_DETAIL_BREADCRUMBS_KEY]?.[columnName];
203
+ return {
204
+ key: columnName,
205
+ value,
206
+ objectId: breadcrumb !== void 0 && value != null ? String(value) : void 0,
207
+ breadcrumb
208
+ };
209
+ }
510
210
  function isQueryRow(row) {
511
- const values = row?.values;
512
- return Array.isArray(values) && (values.length === 0 || typeof values[0]?.key === 'string');
211
+ const values = row?.values;
212
+ return Array.isArray(values) && (values.length === 0 || typeof values[0]?.key === "string");
513
213
  }
514
- /** Whether this row is a full persistent object. Discriminated on elements, as above. */
515
214
  function isPersistentObject(row) {
516
- const attributes = row?.attributes;
517
- return Array.isArray(attributes) && (attributes.length === 0 || typeof attributes[0]?.name === 'string');
215
+ const attributes = row?.attributes;
216
+ return Array.isArray(attributes) && (attributes.length === 0 || typeof attributes[0]?.name === "string");
518
217
  }
519
-
520
218
  var ELookupDisplayType;
521
- (function (ELookupDisplayType) {
522
- ELookupDisplayType[ELookupDisplayType["Dropdown"] = 0] = "Dropdown";
523
- ELookupDisplayType[ELookupDisplayType["Modal"] = 1] = "Modal";
219
+ (function(ELookupDisplayType) {
220
+ ELookupDisplayType[ELookupDisplayType["Dropdown"] = 0] = "Dropdown";
221
+ ELookupDisplayType[ELookupDisplayType["Modal"] = 1] = "Modal";
524
222
  })(ELookupDisplayType || (ELookupDisplayType = {}));
525
-
526
- /**
527
- * The custom actions a query should offer, from the entity type's full set.
528
- *
529
- * `showedOn` must include the query side. The accepted values are `"detail"`, `"query"`
530
- * and `"both"` — as the server model and the custom-actions guide have always
531
- * documented. Both grids previously tested for `"list"`, a value nothing emits, so an
532
- * action authored per the documentation rendered nowhere at all.
533
- *
534
- * ⚠️ This narrows what is DISPLAYED. It is NOT an authorization boundary: the grant
535
- * is, and it is enforced independently in `ExecuteCustomAction` regardless of which
536
- * query the caller clicked from — a caller can always POST directly.
537
- */
223
+ function isShowedOnQuery(showedOn) {
224
+ const value = (showedOn ?? "").trim().toLowerCase();
225
+ return value === "query" || value === "both";
226
+ }
227
+ function isShowedOnDetail(showedOn) {
228
+ const value = (showedOn ?? "").trim().toLowerCase();
229
+ return value === "detail" || value === "both";
230
+ }
538
231
  function filterQueryActions(actions) {
539
- return actions.filter(a => a.showedOn === 'query' || a.showedOn === 'both');
232
+ return actions.filter((a) => !a.isDefault && isShowedOnQuery(a.showedOn));
233
+ }
234
+ function filterDetailActions(actions) {
235
+ return actions.filter((a) => !a.isDefault && isShowedOnDetail(a.showedOn));
236
+ }
237
+ function defaultQueryActions(actions) {
238
+ return actions.filter((a) => !!a.isDefault && isShowedOnQuery(a.showedOn));
540
239
  }
541
-
542
- /**
543
- * Parses a custom action's `selectionRule` — a cardinality expression over the number
544
- * of selected rows — into a predicate.
545
- *
546
- * A port of the server's `SelectionRuleParser`, and the two MUST agree: they are tested
547
- * against one shared fixture (`selection-rule.fixture.json`) for exactly this reason.
548
- * Vidyano, where this grammar comes from, has the same algorithm in C# and JavaScript and
549
- * the two have already drifted — one throws on a non-numeric operand where the other
550
- * silently permits everything.
551
- *
552
- * Grammar: `X` is the count placeholder, whitespace is insignificant, terms split on `X`
553
- * are AND-combined (`1<X<5` is a range), operators are `<= >= < > != =` matched in that
554
- * order so `>=` is never read as `>`, and a number-first term is mirrored (`0<X` is `>0`).
555
- *
556
- * Client-side this only drives whether a button is disabled. The server enforces the same
557
- * rule independently — and neither is an authorization boundary: the action's grant is.
558
- */
559
240
  function parseSelectionRule(rule) {
560
- if (!rule || !rule.trim())
561
- return () => true;
562
- try {
563
- return compile(rule);
564
- }
565
- catch {
566
- // Unlike the server, which refuses to start on a malformed rule, the client cannot
567
- // usefully fail: the rule arrived over the wire from a server that already validated
568
- // it. Disabling the button is the safe direction — it never permits an action the
569
- // server would refuse.
570
- return () => false;
571
- }
572
- }
573
- const OPERATORS = ['<=', '>=', '<', '>', '!=', '='];
241
+ if (!rule || !rule.trim()) return () => true;
242
+ try {
243
+ return compile(rule);
244
+ } catch {
245
+ return () => false;
246
+ }
247
+ }
248
+ const OPERATORS = [
249
+ "<=",
250
+ ">=",
251
+ "<",
252
+ ">",
253
+ "!=",
254
+ "="
255
+ ];
574
256
  function compile(rule) {
575
- const normalized = rule.replace(/ /g, '').toUpperCase();
576
- const terms = normalized.split('X').filter(t => t.length > 0);
577
- if (terms.length === 0)
578
- throw new Error(`Selection rule '${rule}' has no condition.`);
579
- const numberFirst = !normalized.startsWith('X') && normalized.includes('X');
580
- const predicates = terms.map((term, i) => compileTerm(term, rule, numberFirst && i === 0));
581
- return count => predicates.every(p => p(count));
257
+ const normalized = rule.replace(/ /g, "").toUpperCase();
258
+ const terms = normalized.split("X").filter((t) => t.length > 0);
259
+ if (terms.length === 0) throw new Error(`Selection rule '${rule}' has no condition.`);
260
+ const numberFirst = !normalized.startsWith("X") && normalized.includes("X");
261
+ const predicates = terms.map((term, i) => compileTerm(term, rule, numberFirst && i === 0));
262
+ return (count) => predicates.every((p) => p(count));
582
263
  }
583
264
  function compileTerm(term, rule, mirrored) {
584
- const op = OPERATORS.find(o => (mirrored ? term.endsWith(o) : term.startsWith(o)));
585
- if (!op)
586
- throw new Error(`Selection rule '${rule}' has no recognised operator in '${term}'.`);
587
- const numberPart = mirrored ? term.slice(0, term.length - op.length) : term.slice(op.length);
588
- // Number(' ') is 0 and Number('1.5') is 1.5 — neither is a valid operand here.
589
- if (!/^-?\d+$/.test(numberPart)) {
590
- throw new Error(`Selection rule '${rule}' has a non-numeric operand in '${term}'.`);
591
- }
592
- const value = Number(numberPart);
593
- const effective = mirrored ? mirror(op) : op;
594
- switch (effective) {
595
- case '<=': return count => count <= value;
596
- case '>=': return count => count >= value;
597
- case '<': return count => count < value;
598
- case '>': return count => count > value;
599
- case '!=': return count => count !== value;
600
- case '=': return count => count === value;
601
- default: throw new Error(`Selection rule '${rule}' has an unsupported operator '${effective}'.`);
602
- }
265
+ const op = OPERATORS.find((o) => mirrored ? term.endsWith(o) : term.startsWith(o));
266
+ if (!op) throw new Error(`Selection rule '${rule}' has no recognised operator in '${term}'.`);
267
+ const numberPart = mirrored ? term.slice(0, term.length - op.length) : term.slice(op.length);
268
+ if (!/^-?\d+$/.test(numberPart)) throw new Error(`Selection rule '${rule}' has a non-numeric operand in '${term}'.`);
269
+ const value = Number(numberPart);
270
+ const effective = mirrored ? mirror(op) : op;
271
+ switch (effective) {
272
+ case "<=": return (count) => count <= value;
273
+ case ">=": return (count) => count >= value;
274
+ case "<": return (count) => count < value;
275
+ case ">": return (count) => count > value;
276
+ case "!=": return (count) => count !== value;
277
+ case "=": return (count) => count === value;
278
+ default: throw new Error(`Selection rule '${rule}' has an unsupported operator '${effective}'.`);
279
+ }
603
280
  }
604
281
  function mirror(op) {
605
- switch (op) {
606
- case '<': return '>';
607
- case '>': return '<';
608
- case '<=': return '>=';
609
- case '>=': return '<=';
610
- default: return op;
611
- }
612
- }
613
-
614
- /**
615
- * The selection mode a grid needs in order to satisfy the actions offered on it.
616
- *
617
- * Derived rather than configured, so a grid gains a checkbox column exactly when an
618
- * action needs one and is otherwise pixel-identical to a grid with no selection at all.
619
- * Vidyano's query grid does the same thing — it renders the checkbox column only if some
620
- * action is selection-gated.
621
- *
622
- * `'single'` when every gated action is satisfied by one row and refused by two; anything
623
- * else that cares about the count gets `'multiple'`.
624
- */
625
- function selectionModeFor(actions) {
626
- // An action with no rule is not selection-gated: it acts on the query, not on rows.
627
- const gated = actions.filter(a => !!a.selectionRule?.trim());
628
- if (gated.length === 0)
629
- return 'none';
630
- const everyRuleWantsExactlyOne = gated.every(a => {
631
- const rule = parseSelectionRule(a.selectionRule);
632
- return rule(1) && !rule(2);
633
- });
634
- return everyRuleWantsExactlyOne ? 'single' : 'multiple';
282
+ switch (op) {
283
+ case "<": return ">";
284
+ case ">": return "<";
285
+ case "<=": return ">=";
286
+ case ">=": return "<=";
287
+ default: return op;
288
+ }
289
+ }
290
+ function selectionModeFor(actions, declared) {
291
+ if (declared && declared !== "auto") return declared;
292
+ const gated = actions.filter((a) => !!a.selectionRule?.trim());
293
+ if (gated.length === 0) return "none";
294
+ return gated.every((a) => {
295
+ const rule = parseSelectionRule(a.selectionRule);
296
+ return rule(1) && !rule(2);
297
+ }) ? "single" : "multiple";
298
+ }
299
+ function normalizeSubQuery(entry) {
300
+ return typeof entry === "string" ? { query: entry } : entry;
301
+ }
302
+ function subQueriesOf(type) {
303
+ return (type?.queries ?? []).map(normalizeSubQuery).filter((entry) => !!entry.query);
635
304
  }
636
-
637
- /** Applies an overlay to one attribute definition, returning a new object when anything changed. */
638
305
  function applyOverlay(attr, overlay) {
639
- if (!overlay)
640
- return attr;
641
- return {
642
- ...attr,
643
- isRequired: overlay.isRequired ?? attr.isRequired,
644
- isReadOnly: overlay.isReadOnly ?? attr.isReadOnly,
645
- isVisible: overlay.isVisible ?? attr.isVisible,
646
- rules: overlay.rules ?? attr.rules,
647
- query: overlay.query ?? attr.query,
648
- };
649
- }
650
- /**
651
- * Reads a refresh response into an overlay.
652
- *
653
- * Everything here is presentation the server owns outright, so it is taken verbatim — there is no
654
- * merging to do on this half, only on values.
655
- */
306
+ if (!overlay) return attr;
307
+ return {
308
+ ...attr,
309
+ isRequired: overlay.isRequired ?? attr.isRequired,
310
+ isReadOnly: overlay.isReadOnly ?? attr.isReadOnly,
311
+ isVisible: overlay.isVisible ?? attr.isVisible,
312
+ rules: overlay.rules ?? attr.rules,
313
+ query: overlay.query ?? attr.query
314
+ };
315
+ }
656
316
  function overlayFromResponse(response) {
657
- const overlay = {};
658
- for (const attr of response.attributes ?? []) {
659
- overlay[attr.name] = {
660
- isRequired: attr.isRequired,
661
- isReadOnly: attr.isReadOnly,
662
- isVisible: attr.isVisible,
663
- rules: attr.rules ?? [],
664
- query: attr.query,
665
- options: attr.options ?? undefined,
666
- };
667
- }
668
- return overlay;
669
- }
670
- /**
671
- * Merges a refresh response's values into the live form.
672
- *
673
- * The rule, and the reason for it: a refresh is not instant, and the user keeps typing during it —
674
- * the form is deliberately never frozen. So for each attribute we ask whether the *server* changed
675
- * it, by comparing the response against the values that were **sent**, not against what is on
676
- * screen now.
677
- *
678
- * - server value equals what we sent → the hook did not touch it, so whatever is in the form now
679
- * wins, including anything typed while the request was in flight;
680
- * - server value differs → the hook deliberately changed it, and it wins over a concurrent edit.
681
- *
682
- * Comparing against the displayed value instead is the classic "refresh eats my typing" bug;
683
- * refusing to overwrite anything the user touched is the equally wrong opposite, where a dependent
684
- * field the hook computed never appears.
685
- *
686
- * @param sent values as they were POSTed, captured before the request left
687
- * @param current values as they are now, which may have moved on
688
- * @param response the reshaped object
689
- */
317
+ const overlay = {};
318
+ for (const attr of response.attributes ?? []) overlay[attr.name] = {
319
+ isRequired: attr.isRequired,
320
+ isReadOnly: attr.isReadOnly,
321
+ isVisible: attr.isVisible,
322
+ rules: attr.rules ?? [],
323
+ query: attr.query,
324
+ options: attr.options ?? void 0
325
+ };
326
+ return overlay;
327
+ }
690
328
  function mergeRefreshValues(sent, current, response) {
691
- const merged = { ...current };
692
- for (const attr of response.attributes ?? []) {
693
- const serverValue = attr.value ?? null;
694
- const sentValue = sent[attr.name] ?? null;
695
- if (!valuesEqual(serverValue, sentValue)) {
696
- merged[attr.name] = attr.value;
697
- }
698
- }
699
- return merged;
700
- }
701
- /**
702
- * Structural for arrays, `===` otherwise.
703
- *
704
- * Multi-reference attributes hold `string[]`, and a fresh array of the same ids is a different
705
- * object — so reference equality would report every one of them as "changed by the server" on
706
- * every refresh, and clobber in-flight edits to them.
707
- */
329
+ const merged = { ...current };
330
+ for (const attr of response.attributes ?? []) if (!valuesEqual(attr.value ?? null, sent[attr.name] ?? null)) merged[attr.name] = attr.value;
331
+ return merged;
332
+ }
708
333
  function valuesEqual(a, b) {
709
- if (a === b)
710
- return true;
711
- if (Array.isArray(a) && Array.isArray(b)) {
712
- return a.length === b.length && a.every((item, i) => valuesEqual(item, b[i]));
713
- }
714
- // Values arrive off the wire as JSON scalars; anything else compares by JSON shape rather than
715
- // by identity, which is what a caller means by "did this change".
716
- if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
717
- return JSON.stringify(a) === JSON.stringify(b);
718
- }
719
- return false;
334
+ if (a === b) return true;
335
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => valuesEqual(item, b[i]));
336
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
337
+ return false;
720
338
  }
721
-
722
339
  const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/i;
723
340
  const URL = /^https?:\/\/[^\s]+$/i;
724
341
  function isEmpty(value) {
725
- if (value === null || value === undefined)
726
- return true;
727
- if (typeof value === 'string')
728
- return value.trim() === '';
729
- if (Array.isArray(value))
730
- return value.length === 0;
731
- return false;
732
- }
733
- /**
734
- * Evaluates an attribute's rules against a value, mirroring the server's `ValidationService`.
735
- *
736
- * ⚠️ **Parity with the server is the point, and disagreement is worse than silence.** A client that
737
- * rejects something the server would accept blocks legitimate work with no recourse; one that
738
- * accepts something the server rejects merely defers the error to the round-trip, which is where it
739
- * used to live anyway. So the rule set here is deliberately limited to the types the server
740
- * implements, and an unrecognised rule type is ignored rather than guessed at.
741
- *
742
- * This exists because a refresh hook that imposes a rule needs it to bite before Save — previously
743
- * `rules` was carried on the wire and never evaluated in the browser at all.
744
- */
342
+ if (value === null || value === void 0) return true;
343
+ if (typeof value === "string") return value.trim() === "";
344
+ if (Array.isArray(value)) return value.length === 0;
345
+ return false;
346
+ }
745
347
  function evaluateRules(attr, value) {
746
- const failures = [];
747
- const label = attr.label?.['en'] ?? attr.name;
748
- if (attr.isRequired && isEmpty(value)) {
749
- return [{ attributeName: attr.name, ruleType: 'required', message: `${label} is required` }];
750
- }
751
- // A rule other than "required" says nothing about an absent value — that is what required is for.
752
- if (isEmpty(value))
753
- return failures;
754
- const text = value?.toString() ?? '';
755
- for (const rule of attr.rules ?? []) {
756
- const failure = evaluateRule(attr.name, label, rule, value, text);
757
- if (failure)
758
- failures.push(failure);
759
- }
760
- return failures;
348
+ const failures = [];
349
+ const label = attr.label?.["en"] ?? attr.name;
350
+ if (attr.isRequired && isEmpty(value)) return [{
351
+ attributeName: attr.name,
352
+ ruleType: "required",
353
+ message: `${label} is required`
354
+ }];
355
+ if (isEmpty(value)) return failures;
356
+ const text = value?.toString() ?? "";
357
+ for (const rule of attr.rules ?? []) {
358
+ const failure = evaluateRule(attr.name, label, rule, value, text);
359
+ if (failure) failures.push(failure);
360
+ }
361
+ return failures;
761
362
  }
762
363
  function evaluateRule(name, label, rule, value, text) {
763
- const message = rule.message?.['en'];
764
- switch (rule.type?.toLowerCase()) {
765
- case 'maxlength': {
766
- const max = toInt(rule.value);
767
- if (max === null || text.length <= max)
768
- return null;
769
- return { attributeName: name, ruleType: 'maxLength', message: message ?? `${label} must be at most ${max} characters` };
770
- }
771
- case 'minlength': {
772
- const min = toInt(rule.value);
773
- if (min === null || text.length >= min)
774
- return null;
775
- return { attributeName: name, ruleType: 'minLength', message: message ?? `${label} must be at least ${min} characters` };
776
- }
777
- case 'range': {
778
- const numeric = toNumber(value);
779
- if (numeric === null)
780
- return null;
781
- if (rule.min !== undefined && rule.min !== null && numeric < rule.min) {
782
- return { attributeName: name, ruleType: 'range', message: message ?? `${label} must be at least ${rule.min}` };
783
- }
784
- if (rule.max !== undefined && rule.max !== null && numeric > rule.max) {
785
- return { attributeName: name, ruleType: 'range', message: message ?? `${label} must be at most ${rule.max}` };
786
- }
787
- return null;
788
- }
789
- case 'regex': {
790
- const pattern = rule.value?.toString();
791
- if (!pattern)
792
- return null;
793
- let re;
794
- try {
795
- re = new RegExp(pattern);
796
- }
797
- catch {
798
- // An unparseable pattern is a server-side authoring bug. Staying silent leaves the server to
799
- // report it, which is strictly better than blocking the user over a rule nobody can evaluate.
800
- return null;
801
- }
802
- return re.test(text) ? null : { attributeName: name, ruleType: 'regex', message: message ?? `${label} is not in the expected format` };
803
- }
804
- case 'email':
805
- return EMAIL.test(text) ? null : { attributeName: name, ruleType: 'email', message: message ?? `${label} must be a valid email address` };
806
- case 'url':
807
- return URL.test(text) ? null : { attributeName: name, ruleType: 'url', message: message ?? `${label} must be a valid URL` };
808
- default:
809
- return null;
810
- }
364
+ const message = rule.message?.["en"];
365
+ switch (rule.type?.toLowerCase()) {
366
+ case "maxlength": {
367
+ const max = toInt(rule.value);
368
+ if (max === null || text.length <= max) return null;
369
+ return {
370
+ attributeName: name,
371
+ ruleType: "maxLength",
372
+ message: message ?? `${label} must be at most ${max} characters`
373
+ };
374
+ }
375
+ case "minlength": {
376
+ const min = toInt(rule.value);
377
+ if (min === null || text.length >= min) return null;
378
+ return {
379
+ attributeName: name,
380
+ ruleType: "minLength",
381
+ message: message ?? `${label} must be at least ${min} characters`
382
+ };
383
+ }
384
+ case "range": {
385
+ const numeric = toNumber(value);
386
+ if (numeric === null) return null;
387
+ if (rule.min !== void 0 && rule.min !== null && numeric < rule.min) return {
388
+ attributeName: name,
389
+ ruleType: "range",
390
+ message: message ?? `${label} must be at least ${rule.min}`
391
+ };
392
+ if (rule.max !== void 0 && rule.max !== null && numeric > rule.max) return {
393
+ attributeName: name,
394
+ ruleType: "range",
395
+ message: message ?? `${label} must be at most ${rule.max}`
396
+ };
397
+ return null;
398
+ }
399
+ case "regex": {
400
+ const pattern = rule.value?.toString();
401
+ if (!pattern) return null;
402
+ let re;
403
+ try {
404
+ re = new RegExp(pattern);
405
+ } catch {
406
+ return null;
407
+ }
408
+ return re.test(text) ? null : {
409
+ attributeName: name,
410
+ ruleType: "regex",
411
+ message: message ?? `${label} is not in the expected format`
412
+ };
413
+ }
414
+ case "email": return EMAIL.test(text) ? null : {
415
+ attributeName: name,
416
+ ruleType: "email",
417
+ message: message ?? `${label} must be a valid email address`
418
+ };
419
+ case "url": return URL.test(text) ? null : {
420
+ attributeName: name,
421
+ ruleType: "url",
422
+ message: message ?? `${label} must be a valid URL`
423
+ };
424
+ default: return null;
425
+ }
811
426
  }
812
427
  function toInt(value) {
813
- const n = Number(value);
814
- return Number.isFinite(n) ? Math.trunc(n) : null;
428
+ if (typeof value === "number") return Number.isInteger(value) ? value : null;
429
+ if (typeof value === "string" && /^\s*[+-]?\d+\s*$/.test(value)) return parseInt(value, 10);
430
+ return null;
815
431
  }
816
432
  function toNumber(value) {
817
- const n = Number(value);
818
- return Number.isFinite(n) ? n : null;
433
+ const n = Number(value);
434
+ return Number.isFinite(n) ? n : null;
819
435
  }
436
+ export { AS_DETAIL_BREADCRUMBS_KEY, AS_DETAIL_ORIGINAL_DATES_KEY, AS_DETAIL_ROW_KEY, AS_DETAIL_SELF_BREADCRUMB_KEY, ELookupDisplayType, EReferenceDisplayType, ShowedOn, applyOverlay, currentLanguage, defaultQueryActions, dictToNestedPo, evaluateRules, filterDetailActions, filterQueryActions, fromDateInput, fromDateInputValue, fromDateTimeLocalInput, hasShowedOnFlag, isDateDataType, isPersistentObject, isQueryRow, isReservedAsDetailKey, isShowedOnDetail, isShowedOnQuery, mergeRefreshValues, nestedPoToDict, nestedPoToDisplayRow, normalizeSubQuery, overlayFromResponse, parseSelectionRule, parseWireDate, resolveTranslation, resolvedBreadcrumb, selectionModeFor, selfBreadcrumb, subQueriesOf, toDateInput, toDateInputValue, toDateTimeLocalInput, valueFor, wireDatesEqual };
820
437
 
821
- /**
822
- * Generated bundle index. Do not edit.
823
- */
824
-
825
- export { AS_DETAIL_BREADCRUMBS_KEY, AS_DETAIL_ORIGINAL_DATES_KEY, AS_DETAIL_ROW_KEY, AS_DETAIL_SELF_BREADCRUMB_KEY, ELookupDisplayType, EReferenceDisplayType, ShowedOn, applyOverlay, currentLanguage, dictToNestedPo, evaluateRules, filterQueryActions, fromDateInput, fromDateInputValue, fromDateTimeLocalInput, hasShowedOnFlag, isDateDataType, isPersistentObject, isQueryRow, isReservedAsDetailKey, mergeRefreshValues, nestedPoToDict, nestedPoToDisplayRow, overlayFromResponse, parseSelectionRule, parseWireDate, resolveTranslation, resolvedBreadcrumb, selectionModeFor, selfBreadcrumb, toDateInput, toDateInputValue, toDateTimeLocalInput, valueFor, wireDatesEqual };
826
- //# sourceMappingURL=mintplayer-ng-spark-models.mjs.map
438
+ //# sourceMappingURL=mintplayer-ng-spark-models.mjs.map