@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.
- package/README.md +181 -0
- package/fesm2022/mintplayer-ng-spark-attribute-description.mjs +100 -47
- package/fesm2022/mintplayer-ng-spark-attribute-description.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-client-operations.mjs +308 -329
- package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-column-filter.mjs +175 -135
- package/fesm2022/mintplayer-ng-spark-column-filter.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-grid.mjs +2259 -1117
- package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-history.mjs +611 -0
- package/fesm2022/mintplayer-ng-spark-history.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-icon.mjs +64 -27
- package/fesm2022/mintplayer-ng-spark-icon.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-models.mjs +365 -753
- package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-moderation.mjs +1212 -0
- package/fesm2022/mintplayer-ng-spark-moderation.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-panels.mjs +52 -0
- package/fesm2022/mintplayer-ng-spark-panels.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark-pipes.mjs +1122 -765
- package/fesm2022/mintplayer-ng-spark-pipes.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-po-create.mjs +273 -169
- package/fesm2022/mintplayer-ng-spark-po-create.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-po-detail.mjs +786 -410
- package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-po-edit.mjs +292 -284
- package/fesm2022/mintplayer-ng-spark-po-edit.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-po-form.mjs +2187 -1225
- package/fesm2022/mintplayer-ng-spark-po-form.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-query-list.mjs +516 -303
- package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-renderers.mjs +17 -55
- package/fesm2022/mintplayer-ng-spark-renderers.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs +216 -177
- package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-routes.mjs +24 -28
- package/fesm2022/mintplayer-ng-spark-routes.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-services.mjs +625 -640
- package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-shell.mjs +1013 -397
- package/fesm2022/mintplayer-ng-spark-shell.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-soft-delete.mjs +294 -0
- package/fesm2022/mintplayer-ng-spark-soft-delete.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-spark.mjs +13 -50
- package/fesm2022/mintplayer-ng-spark.mjs.map +1 -1
- package/package.json +17 -1
- package/types/mintplayer-ng-spark-attribute-description.d.ts +25 -18
- package/types/mintplayer-ng-spark-client-operations.d.ts +133 -160
- package/types/mintplayer-ng-spark-column-filter.d.ts +73 -55
- package/types/mintplayer-ng-spark-grid.d.ts +885 -448
- package/types/mintplayer-ng-spark-history.d.ts +223 -0
- package/types/mintplayer-ng-spark-icon.d.ts +15 -13
- package/types/mintplayer-ng-spark-models.d.ts +624 -573
- package/types/mintplayer-ng-spark-moderation.d.ts +402 -0
- package/types/mintplayer-ng-spark-panels.d.ts +108 -0
- package/types/mintplayer-ng-spark-pipes.d.ts +120 -149
- package/types/mintplayer-ng-spark-po-create.d.ts +46 -37
- package/types/mintplayer-ng-spark-po-detail.d.ts +177 -117
- package/types/mintplayer-ng-spark-po-edit.d.ts +64 -65
- package/types/mintplayer-ng-spark-po-form.d.ts +507 -371
- package/types/mintplayer-ng-spark-query-list.d.ts +118 -82
- package/types/mintplayer-ng-spark-renderers.d.ts +79 -85
- package/types/mintplayer-ng-spark-retry-action-modal.d.ts +31 -34
- package/types/mintplayer-ng-spark-routes.d.ts +7 -11
- package/types/mintplayer-ng-spark-services.d.ts +377 -261
- package/types/mintplayer-ng-spark-shell.d.ts +209 -128
- package/types/mintplayer-ng-spark-soft-delete.d.ts +90 -0
- package/types/mintplayer-ng-spark.d.ts +7 -12
|
@@ -1,826 +1,438 @@
|
|
|
1
|
-
import { signal } from
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
517
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
219
|
+
(function(ELookupDisplayType) {
|
|
220
|
+
ELookupDisplayType[ELookupDisplayType["Dropdown"] = 0] = "Dropdown";
|
|
221
|
+
ELookupDisplayType[ELookupDisplayType["Modal"] = 1] = "Modal";
|
|
524
222
|
})(ELookupDisplayType || (ELookupDisplayType = {}));
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
function
|
|
626
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|