@truedat/df 8.13.4 → 8.13.6
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/package.json +4 -4
- package/src/components/DynamicForm.js +3 -0
- package/src/components/DynamicFormWithTranslations.js +3 -0
- package/src/components/FieldGroupSegment.js +3 -0
- package/src/components/FieldGroupWithTranslations.js +13 -0
- package/src/components/SelectDynamicFormWithTranslations.js +7 -1
- package/src/components/SelectableDynamicForm.js +7 -1
- package/src/components/__tests__/__snapshots__/SelectableDynamicForm.spec.js.snap +18 -1
- package/src/components/__tests__/originalContentFlow.spec.js +131 -0
- package/src/components/widgets/DateField.js +18 -9
- package/src/components/widgets/DateTimeField.js +26 -9
- package/src/components/widgets/DynamicField.js +54 -1
- package/src/components/widgets/DynamicTableField.js +41 -7
- package/src/components/widgets/FieldByWidget.js +36 -6
- package/src/components/widgets/__tests__/dateBoundsByWidget.spec.js +133 -0
- package/src/components/widgets/__tests__/dateBoundsEndToEnd.spec.js +83 -0
- package/src/components/widgets/__tests__/datePickerBoundaries.spec.js +124 -0
- package/src/components/widgets/__tests__/dateValueValidation.spec.js +215 -0
- package/src/components/widgets/__tests__/existingDateRestrictions.spec.js +354 -0
- package/src/templates/components/templateForm/DateRestrictionsForm.js +217 -0
- package/src/templates/components/templateForm/DefaultValue.js +79 -11
- package/src/templates/components/templateForm/FieldDefinition.js +15 -1
- package/src/templates/components/templateForm/TemplateForm.js +5 -0
- package/src/templates/components/templateForm/ValuesField.js +7 -0
- package/src/templates/components/templateForm/__tests__/FieldDefinition.spec.js +48 -0
- package/src/templates/components/templateForm/__tests__/TemplateForm.spec.js +107 -0
- package/src/templates/components/templateForm/__tests__/dateRestrictionsForm.spec.js +429 -0
- package/src/templates/components/templateForm/__tests__/dateRestrictionsValidation.spec.js +166 -0
- package/src/templates/components/templateForm/__tests__/defaultValueBounds.spec.js +191 -0
- package/src/templates/components/templateForm/__tests__/defaultValueRestrictions.spec.js +175 -0
- package/src/templates/components/templateForm/contentValidation.js +184 -0
- package/src/templates/utils/__tests__/dateRestrictions.spec.js +578 -0
- package/src/templates/utils/__tests__/originalTableRows.spec.js +129 -0
- package/src/templates/utils/__tests__/validateContentDateRestrictions.spec.js +519 -0
- package/src/templates/utils/applyTemplate.js +27 -20
- package/src/templates/utils/dateRestrictions.js +497 -0
- package/src/templates/utils/dateValueUnchanged.js +12 -0
- package/src/templates/utils/originalTableRows.js +41 -0
- package/src/templates/utils/validateContent.js +75 -4
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveRestrictions,
|
|
3
|
+
compareToLimits,
|
|
4
|
+
checkValuesAgainstBounds,
|
|
5
|
+
} from "../dateRestrictions";
|
|
6
|
+
|
|
7
|
+
const NOW = "2026-01-31T14:35:47.123Z";
|
|
8
|
+
|
|
9
|
+
describe.each(["date", "datetime"])("resolveRestrictions (%s)", (type) => {
|
|
10
|
+
it.each([
|
|
11
|
+
["undefined restrictions", undefined],
|
|
12
|
+
["null restrictions", null],
|
|
13
|
+
["empty restrictions", {}],
|
|
14
|
+
])("leaves both bounds open with %s", (_name, restrictions) => {
|
|
15
|
+
expect(resolveRestrictions(restrictions, type, new Date(NOW))).toEqual({
|
|
16
|
+
min: undefined,
|
|
17
|
+
max: undefined,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("static bounds", () => {
|
|
22
|
+
it.each([
|
|
23
|
+
{
|
|
24
|
+
name: "minimum only",
|
|
25
|
+
restrictions: { min_date: { mode: "static", value: "2026-02-01" } },
|
|
26
|
+
min: "2026-02-01",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "maximum only",
|
|
30
|
+
restrictions: { max_date: { mode: "static", value: "2026-02-28" } },
|
|
31
|
+
max: "2026-02-28",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "independent minimum and maximum",
|
|
35
|
+
restrictions: {
|
|
36
|
+
min_date: { mode: "static", value: "2026-02-01" },
|
|
37
|
+
max_date: { mode: "static", value: "2026-02-28" },
|
|
38
|
+
},
|
|
39
|
+
min: "2026-02-01",
|
|
40
|
+
max: "2026-02-28",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "between with mode on the parent",
|
|
44
|
+
restrictions: {
|
|
45
|
+
between: { mode: "static", start: "2026-02-01", end: "2026-02-28" },
|
|
46
|
+
},
|
|
47
|
+
min: "2026-02-01",
|
|
48
|
+
max: "2026-02-28",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "between with an open end",
|
|
52
|
+
restrictions: { between: { mode: "static", start: "2026-02-01" } },
|
|
53
|
+
min: "2026-02-01",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "between with an open start",
|
|
57
|
+
restrictions: { between: { mode: "static", end: "2026-02-28" } },
|
|
58
|
+
max: "2026-02-28",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "legacy between keys",
|
|
62
|
+
restrictions: {
|
|
63
|
+
between_start: "2026-02-01",
|
|
64
|
+
between_end: "2026-02-28",
|
|
65
|
+
},
|
|
66
|
+
min: "2026-02-01",
|
|
67
|
+
max: "2026-02-28",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "legacy between with an open end",
|
|
71
|
+
restrictions: { between_start: "2026-02-01" },
|
|
72
|
+
min: "2026-02-01",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "legacy between with an open start",
|
|
76
|
+
restrictions: { between_end: "2026-02-28" },
|
|
77
|
+
max: "2026-02-28",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "explicit limits tighten a wider between range",
|
|
81
|
+
restrictions: {
|
|
82
|
+
min_date: { mode: "static", value: "2026-02-10" },
|
|
83
|
+
max_date: { mode: "static", value: "2026-02-20" },
|
|
84
|
+
between: { mode: "static", start: "2026-02-01", end: "2026-02-28" },
|
|
85
|
+
},
|
|
86
|
+
min: "2026-02-10",
|
|
87
|
+
max: "2026-02-20",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "between tightens wider explicit limits",
|
|
91
|
+
restrictions: {
|
|
92
|
+
min_date: { mode: "static", value: "2026-02-01" },
|
|
93
|
+
max_date: { mode: "static", value: "2026-02-28" },
|
|
94
|
+
between: { mode: "static", start: "2026-02-10", end: "2026-02-20" },
|
|
95
|
+
},
|
|
96
|
+
min: "2026-02-10",
|
|
97
|
+
max: "2026-02-20",
|
|
98
|
+
},
|
|
99
|
+
])("$name", ({ restrictions, min, max }) => {
|
|
100
|
+
expect(resolveRestrictions(restrictions, type, new Date(NOW))).toEqual({
|
|
101
|
+
min: min && (type === "datetime" ? `${min}T00:00` : min),
|
|
102
|
+
max: max && (type === "datetime" ? `${max}T23:59` : max),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.each(["2000-01-01T00:00:00Z", "2040-12-31T23:59:00Z"])(
|
|
107
|
+
"does not depend on now (%s)",
|
|
108
|
+
(now) => {
|
|
109
|
+
expect(
|
|
110
|
+
resolveRestrictions(
|
|
111
|
+
{
|
|
112
|
+
between: {
|
|
113
|
+
mode: "static",
|
|
114
|
+
start: "2026-02-01",
|
|
115
|
+
end: "2026-02-28",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
type,
|
|
119
|
+
new Date(now),
|
|
120
|
+
),
|
|
121
|
+
).toEqual({
|
|
122
|
+
min: type === "date" ? "2026-02-01" : "2026-02-01T00:00",
|
|
123
|
+
max: type === "date" ? "2026-02-28" : "2026-02-28T23:59",
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("relative bounds anchored to explicit now", () => {
|
|
130
|
+
it.each([
|
|
131
|
+
{
|
|
132
|
+
name: "positive minimum days cross a month boundary into the past",
|
|
133
|
+
now: "2026-03-01T14:35:47.123Z",
|
|
134
|
+
restrictions: {
|
|
135
|
+
min_date: { mode: "dynamic", amount: 1, unit: "days" },
|
|
136
|
+
},
|
|
137
|
+
min: "2026-02-28",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "legacy negative minimum days remain in the past",
|
|
141
|
+
now: "2026-03-01T14:35:47.123Z",
|
|
142
|
+
restrictions: {
|
|
143
|
+
min_date: { mode: "dynamic", amount: -1, unit: "days" },
|
|
144
|
+
},
|
|
145
|
+
min: "2026-02-28",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "positive days cross a year boundary",
|
|
149
|
+
now: "2025-12-31T14:35:47.123Z",
|
|
150
|
+
restrictions: {
|
|
151
|
+
max_date: { mode: "dynamic", amount: 1, unit: "days" },
|
|
152
|
+
},
|
|
153
|
+
max: "2026-01-01",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "negative maximum days point into the past",
|
|
157
|
+
restrictions: {
|
|
158
|
+
max_date: { mode: "dynamic", amount: -1, unit: "days" },
|
|
159
|
+
},
|
|
160
|
+
max: "2026-01-30",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "zero offsets keep the current day and minute",
|
|
164
|
+
restrictions: {
|
|
165
|
+
min_date: { mode: "dynamic", amount: 0, unit: "days" },
|
|
166
|
+
max_date: { mode: "dynamic", amount: "0", unit: "months" },
|
|
167
|
+
},
|
|
168
|
+
min: "2026-01-31",
|
|
169
|
+
max: "2026-01-31",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "positive integer string minimum weeks cross a year boundary",
|
|
173
|
+
now: "2026-01-03T14:35:47.123Z",
|
|
174
|
+
restrictions: {
|
|
175
|
+
min_date: { mode: "dynamic", amount: "1", unit: "weeks" },
|
|
176
|
+
},
|
|
177
|
+
min: "2025-12-27",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "legacy negative integer string weeks cross a year boundary",
|
|
181
|
+
now: "2026-01-03T14:35:47.123Z",
|
|
182
|
+
restrictions: {
|
|
183
|
+
min_date: { mode: "dynamic", amount: "-1", unit: "weeks" },
|
|
184
|
+
},
|
|
185
|
+
min: "2025-12-27",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "positive integer string weeks cross a month boundary",
|
|
189
|
+
restrictions: {
|
|
190
|
+
max_date: { mode: "dynamic", amount: "2", unit: "weeks" },
|
|
191
|
+
},
|
|
192
|
+
max: "2026-02-14",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: "positive months clamp to non-leap February",
|
|
196
|
+
restrictions: {
|
|
197
|
+
max_date: { mode: "dynamic", amount: 1, unit: "months" },
|
|
198
|
+
},
|
|
199
|
+
max: "2026-02-28",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "positive months clamp to leap February",
|
|
203
|
+
now: "2024-01-31T14:35:47.123Z",
|
|
204
|
+
restrictions: {
|
|
205
|
+
max_date: { mode: "dynamic", amount: 1, unit: "months" },
|
|
206
|
+
},
|
|
207
|
+
max: "2024-02-29",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "positive minimum months clamp to leap February",
|
|
211
|
+
now: "2024-03-31T14:35:47.123Z",
|
|
212
|
+
restrictions: {
|
|
213
|
+
min_date: { mode: "dynamic", amount: 1, unit: "months" },
|
|
214
|
+
},
|
|
215
|
+
min: "2024-02-29",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "legacy negative months clamp to leap February",
|
|
219
|
+
now: "2024-03-31T14:35:47.123Z",
|
|
220
|
+
restrictions: {
|
|
221
|
+
min_date: { mode: "dynamic", amount: -1, unit: "months" },
|
|
222
|
+
},
|
|
223
|
+
min: "2024-02-29",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "positive minimum months cross a year boundary",
|
|
227
|
+
restrictions: {
|
|
228
|
+
min_date: { mode: "dynamic", amount: 2, unit: "months" },
|
|
229
|
+
},
|
|
230
|
+
min: "2025-11-30",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "legacy negative months cross a year boundary",
|
|
234
|
+
restrictions: {
|
|
235
|
+
min_date: { mode: "dynamic", amount: -2, unit: "months" },
|
|
236
|
+
},
|
|
237
|
+
min: "2025-11-30",
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "positive months cross a year boundary",
|
|
241
|
+
now: "2025-12-31T14:35:47.123Z",
|
|
242
|
+
restrictions: {
|
|
243
|
+
max_date: { mode: "dynamic", amount: 2, unit: "months" },
|
|
244
|
+
},
|
|
245
|
+
max: "2026-02-28",
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "positive years clamp leap day",
|
|
249
|
+
now: "2024-02-29T14:35:47.123Z",
|
|
250
|
+
restrictions: {
|
|
251
|
+
max_date: { mode: "dynamic", amount: 1, unit: "years" },
|
|
252
|
+
},
|
|
253
|
+
max: "2025-02-28",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "positive minimum years clamp leap day",
|
|
257
|
+
now: "2024-02-29T14:35:47.123Z",
|
|
258
|
+
restrictions: {
|
|
259
|
+
min_date: { mode: "dynamic", amount: 1, unit: "years" },
|
|
260
|
+
},
|
|
261
|
+
min: "2023-02-28",
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "legacy negative years clamp leap day",
|
|
265
|
+
now: "2024-02-29T14:35:47.123Z",
|
|
266
|
+
restrictions: {
|
|
267
|
+
min_date: { mode: "dynamic", amount: -1, unit: "years" },
|
|
268
|
+
},
|
|
269
|
+
min: "2023-02-28",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "four years preserve leap day",
|
|
273
|
+
now: "2024-02-29T14:35:47.123Z",
|
|
274
|
+
restrictions: {
|
|
275
|
+
max_date: { mode: "dynamic", amount: 4, unit: "years" },
|
|
276
|
+
},
|
|
277
|
+
max: "2028-02-29",
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "dynamic between uses parent mode and mixed units",
|
|
281
|
+
restrictions: {
|
|
282
|
+
between: {
|
|
283
|
+
mode: "dynamic",
|
|
284
|
+
from: { amount: -1, unit: "weeks" },
|
|
285
|
+
to: { amount: 1, unit: "months" },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
min: "2026-01-24",
|
|
289
|
+
max: "2026-02-28",
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "dynamic between preserves signed offsets",
|
|
293
|
+
restrictions: {
|
|
294
|
+
between: {
|
|
295
|
+
mode: "dynamic",
|
|
296
|
+
from: { amount: 1, unit: "days" },
|
|
297
|
+
to: { amount: -1, unit: "days" },
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
min: "2026-02-01",
|
|
301
|
+
max: "2026-01-30",
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: "dynamic limits tighten dynamic between",
|
|
305
|
+
restrictions: {
|
|
306
|
+
min_date: { mode: "dynamic", amount: -1, unit: "days" },
|
|
307
|
+
max_date: { mode: "dynamic", amount: 1, unit: "days" },
|
|
308
|
+
between: {
|
|
309
|
+
mode: "dynamic",
|
|
310
|
+
from: { amount: -1, unit: "weeks" },
|
|
311
|
+
to: { amount: 1, unit: "weeks" },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
min: "2026-01-30",
|
|
315
|
+
max: "2026-02-01",
|
|
316
|
+
},
|
|
317
|
+
])("$name", ({ now = NOW, restrictions, min, max }) => {
|
|
318
|
+
expect(resolveRestrictions(restrictions, type, new Date(now))).toEqual({
|
|
319
|
+
min: min && (type === "datetime" ? `${min}T00:00` : min),
|
|
320
|
+
max: max && (type === "datetime" ? `${max}T23:59` : max),
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("invalid relative bounds", () => {
|
|
326
|
+
it.each([
|
|
327
|
+
["fractional number", 1.5],
|
|
328
|
+
["fractional string", "1.5"],
|
|
329
|
+
["non-numeric string", "invalid"],
|
|
330
|
+
["empty string", ""],
|
|
331
|
+
["null", null],
|
|
332
|
+
["missing amount", undefined],
|
|
333
|
+
["boolean", true],
|
|
334
|
+
])("leaves %s amounts open on either side", (_name, amount) => {
|
|
335
|
+
expect(
|
|
336
|
+
resolveRestrictions(
|
|
337
|
+
{
|
|
338
|
+
min_date: { mode: "dynamic", amount, unit: "days" },
|
|
339
|
+
max_date: { mode: "dynamic", amount, unit: "months" },
|
|
340
|
+
},
|
|
341
|
+
type,
|
|
342
|
+
new Date(NOW),
|
|
343
|
+
),
|
|
344
|
+
).toEqual({ min: undefined, max: undefined });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("leaves an unsupported unit open without discarding a valid sibling", () => {
|
|
348
|
+
expect(
|
|
349
|
+
resolveRestrictions(
|
|
350
|
+
{
|
|
351
|
+
between: {
|
|
352
|
+
mode: "dynamic",
|
|
353
|
+
from: { amount: -1, unit: "hours" },
|
|
354
|
+
to: { amount: 1, unit: "days" },
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
type,
|
|
358
|
+
new Date(NOW),
|
|
359
|
+
),
|
|
360
|
+
).toEqual({
|
|
361
|
+
min: undefined,
|
|
362
|
+
max: type === "date" ? "2026-02-01" : "2026-02-01T23:59",
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("default clock", () => {
|
|
368
|
+
afterEach(() => {
|
|
369
|
+
jest.useRealTimers();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("uses the frozen clock when now is omitted", () => {
|
|
373
|
+
jest.useFakeTimers();
|
|
374
|
+
jest.setSystemTime(new Date(NOW));
|
|
375
|
+
|
|
376
|
+
expect(
|
|
377
|
+
resolveRestrictions(
|
|
378
|
+
{ min_date: { mode: "dynamic", amount: 1, unit: "days" } },
|
|
379
|
+
type,
|
|
380
|
+
),
|
|
381
|
+
).toEqual({
|
|
382
|
+
min: type === "date" ? "2026-01-30" : "2026-01-30T00:00",
|
|
383
|
+
max: undefined,
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("resolveRestrictions field types and datetime precision", () => {
|
|
390
|
+
it("leaves unsupported field types open even with valid restrictions", () => {
|
|
391
|
+
expect(
|
|
392
|
+
resolveRestrictions(
|
|
393
|
+
{ min_date: { mode: "static", value: "2026-02-01" } },
|
|
394
|
+
"string",
|
|
395
|
+
new Date(NOW),
|
|
396
|
+
),
|
|
397
|
+
).toEqual({ min: undefined, max: undefined });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it.each([
|
|
401
|
+
["date", { min: "2026-02-01", max: "2026-02-02" }],
|
|
402
|
+
["datetime", { min: "2026-02-01T10:15", max: "2026-02-02T18:45" }],
|
|
403
|
+
])("formats explicit timestamps at %s precision", (type, expected) => {
|
|
404
|
+
expect(
|
|
405
|
+
resolveRestrictions(
|
|
406
|
+
{
|
|
407
|
+
min_date: { mode: "static", value: "2026-02-01T10:15:59.999Z" },
|
|
408
|
+
max_date: { mode: "static", value: "2026-02-02T18:45:30Z" },
|
|
409
|
+
},
|
|
410
|
+
type,
|
|
411
|
+
new Date(NOW),
|
|
412
|
+
),
|
|
413
|
+
).toEqual(expected);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("intersects a date-only range with minute-precision datetime limits", () => {
|
|
417
|
+
expect(
|
|
418
|
+
resolveRestrictions(
|
|
419
|
+
{
|
|
420
|
+
min_date: { mode: "static", value: "2026-02-01T10:15" },
|
|
421
|
+
max_date: { mode: "static", value: "2026-02-02T18:45" },
|
|
422
|
+
between: { mode: "static", start: "2026-02-01", end: "2026-02-02" },
|
|
423
|
+
},
|
|
424
|
+
"datetime",
|
|
425
|
+
new Date(NOW),
|
|
426
|
+
),
|
|
427
|
+
).toEqual({ min: "2026-02-01T10:15", max: "2026-02-02T18:45" });
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("datetime-local restrictions in the host timezone", () => {
|
|
432
|
+
it("preserves static local datetime components without a timezone shift", () => {
|
|
433
|
+
expect(
|
|
434
|
+
resolveRestrictions(
|
|
435
|
+
{
|
|
436
|
+
min_date: { mode: "static", value: "2026-10-05T18:30" },
|
|
437
|
+
max_date: { mode: "static", value: "2026-10-05T20:45" },
|
|
438
|
+
},
|
|
439
|
+
"datetime",
|
|
440
|
+
),
|
|
441
|
+
).toEqual({ min: "2026-10-05T18:30", max: "2026-10-05T20:45" });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it.each([
|
|
445
|
+
[1, 23, "2026-10-12T23:59"],
|
|
446
|
+
[23, 10, "2026-10-12T23:59"],
|
|
447
|
+
])(
|
|
448
|
+
"resolves a two-day relative maximum to the end of the calendar day at %s:%s",
|
|
449
|
+
(hours, minutes, expected) => {
|
|
450
|
+
const now = new Date(2026, 9, 10, hours, minutes);
|
|
451
|
+
|
|
452
|
+
expect(
|
|
453
|
+
resolveRestrictions(
|
|
454
|
+
{ max_date: { mode: "dynamic", amount: 2, unit: "days" } },
|
|
455
|
+
"datetime",
|
|
456
|
+
now,
|
|
457
|
+
),
|
|
458
|
+
).toEqual({ min: undefined, max: expected });
|
|
459
|
+
},
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
it("uses the start and end of the days in a relative range", () => {
|
|
463
|
+
expect(
|
|
464
|
+
resolveRestrictions(
|
|
465
|
+
{
|
|
466
|
+
between: {
|
|
467
|
+
mode: "dynamic",
|
|
468
|
+
from: { amount: -2, unit: "days" },
|
|
469
|
+
to: { amount: 2, unit: "days" },
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
"datetime",
|
|
473
|
+
new Date(2026, 9, 10, 23, 10),
|
|
474
|
+
),
|
|
475
|
+
).toEqual({ min: "2026-10-08T00:00", max: "2026-10-12T23:59" });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("shifts calendar days across the autumn DST transition", () => {
|
|
479
|
+
expect(
|
|
480
|
+
resolveRestrictions(
|
|
481
|
+
{ max_date: { mode: "dynamic", amount: 2, unit: "days" } },
|
|
482
|
+
"datetime",
|
|
483
|
+
new Date(2026, 9, 24, 23, 10),
|
|
484
|
+
),
|
|
485
|
+
).toEqual({ min: undefined, max: "2026-10-26T23:59" });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("shifts calendar days across the spring DST transition", () => {
|
|
489
|
+
expect(
|
|
490
|
+
resolveRestrictions(
|
|
491
|
+
{ max_date: { mode: "dynamic", amount: 1, unit: "days" } },
|
|
492
|
+
"datetime",
|
|
493
|
+
new Date(2026, 2, 28, 23, 10),
|
|
494
|
+
),
|
|
495
|
+
).toEqual({ min: undefined, max: "2026-03-29T23:59" });
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe("strict ISO date values and bounds", () => {
|
|
500
|
+
it.each([
|
|
501
|
+
["date", "2027-01-01T00:00:00Z", "2026-01-01", "2026-12-31", "too_late"],
|
|
502
|
+
["date", "2025-12-31T23:59:59+05:00", "2026-01-01", undefined, "too_early"],
|
|
503
|
+
[
|
|
504
|
+
"date",
|
|
505
|
+
"2025-12-31T23:59:59+05:00",
|
|
506
|
+
"2026-01-01",
|
|
507
|
+
"2026-01-01",
|
|
508
|
+
"too_early",
|
|
509
|
+
],
|
|
510
|
+
["date", "2026-06-15T11:30:45.123+02:00", "2026-06-15", "2026-06-15", "ok"],
|
|
511
|
+
[
|
|
512
|
+
"datetime",
|
|
513
|
+
"2027-01-01T00:00:00Z",
|
|
514
|
+
undefined,
|
|
515
|
+
"2026-12-31T23:59",
|
|
516
|
+
"too_late",
|
|
517
|
+
],
|
|
518
|
+
[
|
|
519
|
+
"datetime",
|
|
520
|
+
"2025-12-31T23:30:00+02:00",
|
|
521
|
+
"2026-01-01T00:00",
|
|
522
|
+
undefined,
|
|
523
|
+
"too_early",
|
|
524
|
+
],
|
|
525
|
+
[
|
|
526
|
+
"datetime",
|
|
527
|
+
"2026-06-15T10:00:59.999Z",
|
|
528
|
+
"2026-06-15T10:00",
|
|
529
|
+
"2026-06-15T10:00",
|
|
530
|
+
"ok",
|
|
531
|
+
],
|
|
532
|
+
])(
|
|
533
|
+
"compares %s value %s at the bounds' precision",
|
|
534
|
+
(_type, value, min, max, expected) => {
|
|
535
|
+
expect(compareToLimits(value, min, max)).toBe(expected);
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
it.each([
|
|
540
|
+
"2026-02-30",
|
|
541
|
+
"2025-02-29",
|
|
542
|
+
"2026-13-01",
|
|
543
|
+
"2026-06-15T24:00:00Z",
|
|
544
|
+
"2026-06-15T10:61:00Z",
|
|
545
|
+
"2026-06-15T10:00:00+25:00",
|
|
546
|
+
"2026-06-15T10:00:00+14:30",
|
|
547
|
+
"2026-06-15T10:00:00+01:60",
|
|
548
|
+
])("rejects impossible or malformed complete date %s", (value) => {
|
|
549
|
+
expect(compareToLimits(value, "2026-01-01", "2026-12-31")).toBe(
|
|
550
|
+
"invalid_date",
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it.each([
|
|
555
|
+
"",
|
|
556
|
+
"2026-",
|
|
557
|
+
"2026-0",
|
|
558
|
+
"2026-06-",
|
|
559
|
+
"2026-06-15T",
|
|
560
|
+
"2026-06-15T1",
|
|
561
|
+
"2026-06-15T10:00:00.",
|
|
562
|
+
])("leaves partial user input %s for the browser to complete", (value) => {
|
|
563
|
+
expect(compareToLimits(value, "2026-01-01", "2026-12-31")).toBe("ok");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("reports invalid values before checking their position against bounds", () => {
|
|
567
|
+
expect(
|
|
568
|
+
checkValuesAgainstBounds("2026-02-30", {
|
|
569
|
+
min: "2026-01-01",
|
|
570
|
+
max: "2026-12-31",
|
|
571
|
+
}),
|
|
572
|
+
).toBe("invalid_date");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("accepts an invalid date value when no bounds apply", () => {
|
|
576
|
+
expect(compareToLimits("2026-02-30", undefined, undefined)).toBe("ok");
|
|
577
|
+
});
|
|
578
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { copyRow, getOriginalRow, markNewRow } from "../originalTableRows";
|
|
2
|
+
import { applyTemplate, applyTemplateWithoutDefaults } from "../applyTemplate";
|
|
3
|
+
import { validateContent } from "../validateContent";
|
|
4
|
+
|
|
5
|
+
const cell = (value) => ({ value, origin: "user" });
|
|
6
|
+
const row = (value) => ({ date: cell(value), sibling: cell("old") });
|
|
7
|
+
const date = {
|
|
8
|
+
name: "date",
|
|
9
|
+
type: "date",
|
|
10
|
+
restrictions: { resolved: { min: "2026-01-01", max: "2026-12-31" } },
|
|
11
|
+
};
|
|
12
|
+
const table = {
|
|
13
|
+
name: "table",
|
|
14
|
+
type: "dynamic_table",
|
|
15
|
+
values: { table_columns: [date, { name: "sibling", type: "string" }] },
|
|
16
|
+
};
|
|
17
|
+
const template = { content: [{ name: "group", fields: [table] }] };
|
|
18
|
+
const content = (rows) => ({ table: cell(rows) });
|
|
19
|
+
|
|
20
|
+
describe("original table row lineage", () => {
|
|
21
|
+
it.each([null, undefined, "legacy", 0])(
|
|
22
|
+
"does not cache a non-object row (%j)",
|
|
23
|
+
(value) => {
|
|
24
|
+
expect(getOriginalRow(value, 0, [])).toBeUndefined();
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
it("tolerates a null legacy table row during edit validation", () => {
|
|
29
|
+
expect(
|
|
30
|
+
validateContent(template, content([row("2025-01-01")]))(content([null])),
|
|
31
|
+
).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps an untracked original survivor associated by identity", () => {
|
|
35
|
+
const original = [row("2025-01-01"), row("2024-01-01")];
|
|
36
|
+
expect(
|
|
37
|
+
validateContent(template, content(original))(content(original.slice(1))),
|
|
38
|
+
).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("preserves a seeded cloned survivor after the first row is removed", () => {
|
|
42
|
+
const original = [row("2025-01-01"), row("2024-01-01")];
|
|
43
|
+
const rows = original.map((value, index) => {
|
|
44
|
+
const cloned = { ...value };
|
|
45
|
+
getOriginalRow(cloned, index, original);
|
|
46
|
+
return cloned;
|
|
47
|
+
});
|
|
48
|
+
expect(
|
|
49
|
+
validateContent(template, content(original))(content(rows.slice(1))),
|
|
50
|
+
).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each([applyTemplate, applyTemplateWithoutDefaults])(
|
|
54
|
+
"retains a survivor baseline through sibling edits and complete normalization (%#)",
|
|
55
|
+
(normalize) => {
|
|
56
|
+
const original = [row("2025-01-01"), row("2024-01-01")];
|
|
57
|
+
const survivor = { ...original[1] };
|
|
58
|
+
getOriginalRow(survivor, 1, original);
|
|
59
|
+
const edited = copyRow(survivor, {
|
|
60
|
+
...survivor,
|
|
61
|
+
sibling: cell("edited"),
|
|
62
|
+
});
|
|
63
|
+
const normalized = normalize(template)(content([edited]));
|
|
64
|
+
expect(validateContent(template, content(original))(normalized)).toEqual(
|
|
65
|
+
[],
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it.each([applyTemplate, applyTemplateWithoutDefaults])(
|
|
71
|
+
"never exempts a new row at a deleted row's reused index after normalization (%#)",
|
|
72
|
+
(normalize) => {
|
|
73
|
+
const original = [row("2025-01-01")];
|
|
74
|
+
const added = markNewRow({});
|
|
75
|
+
const edited = copyRow(added, row("2025-01-01"));
|
|
76
|
+
const normalized = normalize(template)(content([edited]));
|
|
77
|
+
expect(validateContent(template, content(original))(normalized)).toEqual([
|
|
78
|
+
{
|
|
79
|
+
error: "date out of range",
|
|
80
|
+
field: "table.0.date",
|
|
81
|
+
values: date.restrictions.resolved,
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
it("scopes a shared row's baselines to each language or edit session", () => {
|
|
88
|
+
const shared = row("2025-01-01");
|
|
89
|
+
const english = [row("2025-01-01")];
|
|
90
|
+
const spanish = [row("2024-01-01")];
|
|
91
|
+
getOriginalRow(shared, 0, english);
|
|
92
|
+
getOriginalRow(shared, 0, spanish);
|
|
93
|
+
expect(getOriginalRow(shared, 4, english)).toBe(english[0]);
|
|
94
|
+
expect(getOriginalRow(shared, 4, spanish)).toBe(spanish[0]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps the new-row marker universal across baseline scopes", () => {
|
|
98
|
+
const added = markNewRow(row("2025-01-01"));
|
|
99
|
+
expect(getOriginalRow(added, 0, [row("2025-01-01")])).toBeUndefined();
|
|
100
|
+
expect(getOriginalRow(added, 0, [row("2025-01-01")])).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("retains nested row lineage through outer and inner removals and normalization", () => {
|
|
104
|
+
const nestedTable = { ...table, name: "nested" };
|
|
105
|
+
const nestedTemplate = {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
name: "group",
|
|
109
|
+
fields: [{ ...table, values: { table_columns: [nestedTable] } }],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
const original = [
|
|
114
|
+
{ nested: cell([row("2023-01-01")]) },
|
|
115
|
+
{ nested: cell([row("2025-01-01"), row("2024-01-01")]) },
|
|
116
|
+
];
|
|
117
|
+
const outer = { ...original[1] };
|
|
118
|
+
getOriginalRow(outer, 1, original);
|
|
119
|
+
const inner = { ...original[1].nested.value[1] };
|
|
120
|
+
getOriginalRow(inner, 1, original[1].nested.value);
|
|
121
|
+
const edited = copyRow(outer, {
|
|
122
|
+
nested: cell([copyRow(inner, { ...inner, sibling: cell("edited") })]),
|
|
123
|
+
});
|
|
124
|
+
const normalized = applyTemplate(nestedTemplate)(content([edited]));
|
|
125
|
+
expect(
|
|
126
|
+
validateContent(nestedTemplate, content(original))(normalized),
|
|
127
|
+
).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|