@jskit-ai/users-web 0.1.59 → 0.1.61

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.
@@ -0,0 +1,503 @@
1
+ import { computed, reactive, toRef } from "vue";
2
+ import { normalizeText, normalizeUniqueTextList } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import {
4
+ defineCrudListFilters,
5
+ resolveCrudListFilterOptionLabel,
6
+ CRUD_LIST_FILTER_TYPE_FLAG,
7
+ CRUD_LIST_FILTER_TYPE_ENUM,
8
+ CRUD_LIST_FILTER_TYPE_ENUM_MANY,
9
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
10
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
11
+ CRUD_LIST_FILTER_TYPE_DATE,
12
+ CRUD_LIST_FILTER_TYPE_DATE_RANGE,
13
+ CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
14
+ CRUD_LIST_FILTER_TYPE_PRESENCE
15
+ } from "@jskit-ai/kernel/shared/support/crudListFilters";
16
+
17
+ function normalizeFunctionMap(value = {}) {
18
+ const source = value && typeof value === "object" && !Array.isArray(value)
19
+ ? value
20
+ : {};
21
+ const normalized = {};
22
+
23
+ for (const [key, entry] of Object.entries(source)) {
24
+ const normalizedKey = normalizeText(key);
25
+ if (!normalizedKey || typeof entry !== "function") {
26
+ continue;
27
+ }
28
+
29
+ normalized[normalizedKey] = entry;
30
+ }
31
+
32
+ return Object.freeze(normalized);
33
+ }
34
+
35
+ function createInitialFilterValue(filter = {}) {
36
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
37
+ return false;
38
+ }
39
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
40
+ return [];
41
+ }
42
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
43
+ return reactive({
44
+ from: "",
45
+ to: ""
46
+ });
47
+ }
48
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
49
+ return reactive({
50
+ min: "",
51
+ max: ""
52
+ });
53
+ }
54
+
55
+ return "";
56
+ }
57
+
58
+ function normalizePresetEntries(presets = []) {
59
+ const source = Array.isArray(presets) ? presets : [];
60
+ const normalized = [];
61
+ const seenKeys = new Set();
62
+
63
+ for (const rawPreset of source) {
64
+ if (!rawPreset || typeof rawPreset !== "object" || Array.isArray(rawPreset)) {
65
+ continue;
66
+ }
67
+
68
+ const key = normalizeText(rawPreset.key);
69
+ const label = normalizeText(rawPreset.label);
70
+ if (!key || !label || seenKeys.has(key)) {
71
+ continue;
72
+ }
73
+ seenKeys.add(key);
74
+
75
+ const values = rawPreset.values && typeof rawPreset.values === "object" && !Array.isArray(rawPreset.values)
76
+ ? rawPreset.values
77
+ : {};
78
+ const resolveValues = typeof rawPreset.resolveValues === "function"
79
+ ? rawPreset.resolveValues
80
+ : null;
81
+
82
+ normalized.push(Object.freeze({
83
+ key,
84
+ label,
85
+ values,
86
+ resolveValues
87
+ }));
88
+ }
89
+
90
+ return Object.freeze(normalized);
91
+ }
92
+
93
+ function resolvePresetValues(preset = {}, { values = {}, filters = {} } = {}) {
94
+ const rawValues = typeof preset.resolveValues === "function"
95
+ ? preset.resolveValues({
96
+ values,
97
+ filters,
98
+ presetKey: preset.key,
99
+ preset
100
+ })
101
+ : preset.values;
102
+
103
+ return rawValues && typeof rawValues === "object" && !Array.isArray(rawValues)
104
+ ? rawValues
105
+ : {};
106
+ }
107
+
108
+ function normalizePresetFilterValue(filter = {}, rawValue) {
109
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
110
+ return rawValue === true;
111
+ }
112
+
113
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
114
+ const allowedValues = filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY
115
+ ? new Set((filter.options || []).map((entry) => entry.value))
116
+ : null;
117
+ const normalizedList = normalizeUniqueTextList(rawValue, {
118
+ acceptSingle: true
119
+ });
120
+ if (!allowedValues) {
121
+ return normalizedList;
122
+ }
123
+
124
+ return normalizedList.filter((entry) => allowedValues.has(entry));
125
+ }
126
+
127
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
128
+ const source = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
129
+ ? rawValue
130
+ : {};
131
+ return {
132
+ from: normalizeText(source.from),
133
+ to: normalizeText(source.to)
134
+ };
135
+ }
136
+
137
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
138
+ const source = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)
139
+ ? rawValue
140
+ : {};
141
+ return {
142
+ min: normalizeText(source.min),
143
+ max: normalizeText(source.max)
144
+ };
145
+ }
146
+
147
+ const normalized = normalizeText(rawValue);
148
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM || filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
149
+ const allowedValues = new Set((filter.options || []).map((entry) => entry.value));
150
+ return allowedValues.has(normalized) ? normalized : "";
151
+ }
152
+
153
+ return normalized;
154
+ }
155
+
156
+ function normalizeCurrentManyFilterValues(value) {
157
+ const source = Array.isArray(value) ? value : [value];
158
+ return source
159
+ .map((entry) => normalizeText(entry))
160
+ .filter(Boolean);
161
+ }
162
+
163
+ function matchArrayValues(currentValue = [], expectedValue = []) {
164
+ const currentList = Array.isArray(currentValue) ? [...currentValue].sort() : [];
165
+ const expectedList = Array.isArray(expectedValue) ? [...expectedValue].sort() : [];
166
+ if (currentList.length !== expectedList.length) {
167
+ return false;
168
+ }
169
+
170
+ return currentList.every((entry, index) => entry === expectedList[index]);
171
+ }
172
+
173
+ function matchesPresetFilterValue(filter = {}, currentValue, rawExpectedValue) {
174
+ const expectedValue = normalizePresetFilterValue(filter, rawExpectedValue);
175
+
176
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
177
+ return matchArrayValues(normalizeCurrentManyFilterValues(currentValue), expectedValue);
178
+ }
179
+
180
+ const normalizedCurrentValue = normalizePresetFilterValue(filter, currentValue);
181
+
182
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
183
+ return (
184
+ normalizeText(normalizedCurrentValue?.from) === normalizeText(expectedValue?.from) &&
185
+ normalizeText(normalizedCurrentValue?.to) === normalizeText(expectedValue?.to)
186
+ );
187
+ }
188
+
189
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
190
+ return (
191
+ normalizeText(normalizedCurrentValue?.min) === normalizeText(expectedValue?.min) &&
192
+ normalizeText(normalizedCurrentValue?.max) === normalizeText(expectedValue?.max)
193
+ );
194
+ }
195
+
196
+ return normalizedCurrentValue === expectedValue;
197
+ }
198
+
199
+ function resetFilterValue(values, filter = {}) {
200
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
201
+ values[filter.key] = false;
202
+ return;
203
+ }
204
+
205
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
206
+ values[filter.key] = [];
207
+ return;
208
+ }
209
+
210
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
211
+ values[filter.key].from = "";
212
+ values[filter.key].to = "";
213
+ return;
214
+ }
215
+
216
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
217
+ values[filter.key].min = "";
218
+ values[filter.key].max = "";
219
+ return;
220
+ }
221
+
222
+ values[filter.key] = "";
223
+ }
224
+
225
+ function applyPresetFilterValue(values, filter = {}, rawValue) {
226
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE || filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
227
+ const nextValue = normalizePresetFilterValue(filter, rawValue);
228
+ Object.assign(values[filter.key], nextValue);
229
+ return;
230
+ }
231
+
232
+ values[filter.key] = normalizePresetFilterValue(filter, rawValue);
233
+ }
234
+
235
+ function createQueryParams(values, filterEntries = []) {
236
+ const queryParams = {};
237
+
238
+ for (const filter of filterEntries) {
239
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
240
+ queryParams[filter.fromKey] = toRef(values[filter.key], "from");
241
+ queryParams[filter.toKey] = toRef(values[filter.key], "to");
242
+ continue;
243
+ }
244
+
245
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
246
+ queryParams[filter.minKey] = toRef(values[filter.key], "min");
247
+ queryParams[filter.maxKey] = toRef(values[filter.key], "max");
248
+ continue;
249
+ }
250
+
251
+ queryParams[filter.queryKey] = toRef(values, filter.key);
252
+ }
253
+
254
+ return Object.freeze(queryParams);
255
+ }
256
+
257
+ function resolveAtomicValueLabel(filter = {}, value = "", labelResolvers = {}) {
258
+ const customResolver = labelResolvers[filter.key];
259
+ if (typeof customResolver === "function") {
260
+ const customLabel = normalizeText(customResolver(value, filter));
261
+ if (customLabel) {
262
+ return customLabel;
263
+ }
264
+ }
265
+
266
+ return resolveCrudListFilterOptionLabel(filter, value, {
267
+ fallback: String(value || "")
268
+ });
269
+ }
270
+
271
+ function defaultChipLabel(filter = {}, value, labelResolvers = {}) {
272
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
273
+ return filter.label;
274
+ }
275
+
276
+ if (
277
+ filter.type === CRUD_LIST_FILTER_TYPE_ENUM ||
278
+ filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE ||
279
+ filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID
280
+ ) {
281
+ return `${filter.label}: ${resolveAtomicValueLabel(filter, value, labelResolvers)}`;
282
+ }
283
+
284
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
285
+ return `${filter.label}: ${value}`;
286
+ }
287
+
288
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
289
+ if (value?.from && value?.to) {
290
+ return `${filter.label}: ${value.from} to ${value.to}`;
291
+ }
292
+ if (value?.from) {
293
+ return `${filter.label}: from ${value.from}`;
294
+ }
295
+ return `${filter.label}: to ${value?.to || ""}`;
296
+ }
297
+
298
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
299
+ if (value?.min && value?.max) {
300
+ return `${filter.label}: ${value.min} to ${value.max}`;
301
+ }
302
+ if (value?.min) {
303
+ return `${filter.label}: min ${value.min}`;
304
+ }
305
+ return `${filter.label}: max ${value?.max || ""}`;
306
+ }
307
+
308
+ return filter.label;
309
+ }
310
+
311
+ function useCrudListFilters(definitions = {}, { labelResolvers = {}, chipLabels = {}, presets = [] } = {}) {
312
+ const filters = defineCrudListFilters(definitions);
313
+ const filterEntries = Object.values(filters);
314
+ const normalizedLabelResolvers = normalizeFunctionMap(labelResolvers);
315
+ const normalizedChipLabels = normalizeFunctionMap(chipLabels);
316
+ const normalizedPresets = normalizePresetEntries(presets);
317
+ const values = reactive({});
318
+ const options = {};
319
+
320
+ for (const filter of filterEntries) {
321
+ values[filter.key] = createInitialFilterValue(filter);
322
+ if (Array.isArray(filter.options) && filter.options.length > 0) {
323
+ options[filter.key] = filter.options;
324
+ }
325
+ }
326
+
327
+ const queryParams = createQueryParams(values, filterEntries);
328
+
329
+ const activeChips = computed(() => {
330
+ const chips = [];
331
+
332
+ for (const filter of filterEntries) {
333
+ const customChipLabel = normalizedChipLabels[filter.key];
334
+ const rawValue = values[filter.key];
335
+
336
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
337
+ if (rawValue === true) {
338
+ chips.push({
339
+ id: filter.key,
340
+ filterKey: filter.key,
341
+ label: normalizeText(customChipLabel?.(rawValue, filter, values))
342
+ || normalizeText(filter.chipLabel?.(rawValue, filter, values))
343
+ || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
344
+ });
345
+ }
346
+ continue;
347
+ }
348
+
349
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
350
+ for (const value of Array.isArray(rawValue) ? rawValue : []) {
351
+ chips.push({
352
+ id: `${filter.key}:${value}`,
353
+ filterKey: filter.key,
354
+ value,
355
+ label: normalizeText(customChipLabel?.(value, filter, values))
356
+ || normalizeText(filter.chipLabel?.(value, filter, values))
357
+ || `${filter.label}: ${resolveAtomicValueLabel(filter, value, normalizedLabelResolvers)}`
358
+ });
359
+ }
360
+ continue;
361
+ }
362
+
363
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE || filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
364
+ const hasValue = Boolean(rawValue?.from || rawValue?.to || rawValue?.min || rawValue?.max);
365
+ if (!hasValue) {
366
+ continue;
367
+ }
368
+
369
+ chips.push({
370
+ id: filter.key,
371
+ filterKey: filter.key,
372
+ label: normalizeText(customChipLabel?.(rawValue, filter, values))
373
+ || normalizeText(filter.chipLabel?.(rawValue, filter, values))
374
+ || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
375
+ });
376
+ continue;
377
+ }
378
+
379
+ if (!normalizeText(rawValue)) {
380
+ continue;
381
+ }
382
+
383
+ chips.push({
384
+ id: filter.key,
385
+ filterKey: filter.key,
386
+ label: normalizeText(customChipLabel?.(rawValue, filter, values))
387
+ || normalizeText(filter.chipLabel?.(rawValue, filter, values))
388
+ || defaultChipLabel(filter, rawValue, normalizedLabelResolvers)
389
+ });
390
+ }
391
+
392
+ return chips;
393
+ });
394
+
395
+ const hasActiveFilters = computed(() => activeChips.value.length > 0);
396
+
397
+ function clearFilter(filterKey = "") {
398
+ const filter = filters[normalizeText(filterKey)];
399
+ if (!filter) {
400
+ return;
401
+ }
402
+
403
+ resetFilterValue(values, filter);
404
+ }
405
+
406
+ function clearFilters() {
407
+ for (const filter of filterEntries) {
408
+ resetFilterValue(values, filter);
409
+ }
410
+ }
411
+
412
+ function clearChip(chip = {}) {
413
+ const filter = filters[normalizeText(chip.filterKey)];
414
+ if (!filter) {
415
+ return;
416
+ }
417
+
418
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
419
+ values[filter.key] = (Array.isArray(values[filter.key]) ? values[filter.key] : [])
420
+ .filter((entry) => entry !== chip.value);
421
+ return;
422
+ }
423
+
424
+ resetFilterValue(values, filter);
425
+ }
426
+
427
+ function toggle(filterKey = "") {
428
+ const filter = filters[normalizeText(filterKey)];
429
+ if (!filter || filter.type !== CRUD_LIST_FILTER_TYPE_FLAG) {
430
+ return;
431
+ }
432
+
433
+ values[filter.key] = !values[filter.key];
434
+ }
435
+
436
+ function applyPreset(presetKey = "", { mode = "replace" } = {}) {
437
+ const preset = normalizedPresets.find((entry) => entry.key === normalizeText(presetKey));
438
+ if (!preset) {
439
+ return;
440
+ }
441
+
442
+ const presetValues = resolvePresetValues(preset, {
443
+ values,
444
+ filters
445
+ });
446
+
447
+ if (mode !== "merge") {
448
+ clearFilters();
449
+ }
450
+
451
+ for (const filter of filterEntries) {
452
+ if (!Object.hasOwn(presetValues, filter.key)) {
453
+ continue;
454
+ }
455
+
456
+ applyPresetFilterValue(values, filter, presetValues[filter.key]);
457
+ }
458
+ }
459
+
460
+ function matchesPreset(presetKey = "") {
461
+ const preset = normalizedPresets.find((entry) => entry.key === normalizeText(presetKey));
462
+ if (!preset) {
463
+ return false;
464
+ }
465
+
466
+ const presetValues = resolvePresetValues(preset, {
467
+ values,
468
+ filters
469
+ });
470
+ let matchedFilter = false;
471
+
472
+ for (const filter of filterEntries) {
473
+ if (!Object.hasOwn(presetValues, filter.key)) {
474
+ continue;
475
+ }
476
+
477
+ matchedFilter = true;
478
+ if (!matchesPresetFilterValue(filter, values[filter.key], presetValues[filter.key])) {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ return matchedFilter;
484
+ }
485
+
486
+ return Object.freeze({
487
+ filters,
488
+ values,
489
+ queryParams,
490
+ options: Object.freeze(options),
491
+ presets: normalizedPresets,
492
+ activeChips,
493
+ hasActiveFilters,
494
+ clearFilter,
495
+ clearFilters,
496
+ clearChip,
497
+ toggle,
498
+ applyPreset,
499
+ matchesPreset
500
+ });
501
+ }
502
+
503
+ export { useCrudListFilters };
@@ -17,11 +17,17 @@ test("users-web exports are explicit and aligned with production/template usage"
17
17
  "./client",
18
18
  "./client/account-settings/sections",
19
19
  "./client/composables/useAddEdit",
20
+ "./client/composables/useCommand",
21
+ "./client/composables/useEndpointResource",
20
22
  "./client/composables/useList",
23
+ "./client/composables/usePaths",
21
24
  "./client/composables/useView",
22
25
  "./client/composables/useCrudAddEdit",
26
+ "./client/composables/useCrudListFilterLookups",
27
+ "./client/composables/useCrudListFilters",
23
28
  "./client/composables/useCrudList",
24
- "./client/composables/useCrudView"
29
+ "./client/composables/useCrudView",
30
+ "./client/lib/httpClient"
25
31
  ]
26
32
  });
27
33
 
@@ -29,16 +29,30 @@ test("createCrudFormModel resolves defaults and supports explicit initial values
29
29
  const model = createCrudFormModel([
30
30
  { key: "name", type: "string" },
31
31
  { key: "active", type: "boolean" },
32
+ { key: "reviewed", type: "boolean", nullable: true },
32
33
  { key: "role", type: "string", initialValue: "member" }
33
34
  ]);
34
35
 
35
36
  assert.deepEqual(model, {
36
37
  name: "",
37
38
  active: false,
39
+ reviewed: null,
38
40
  role: "member"
39
41
  });
40
42
  });
41
43
 
44
+ test("createCrudFormModel preserves explicit boolean defaults for nullable fields", () => {
45
+ const model = createCrudFormModel([
46
+ { key: "reviewed", type: "boolean", nullable: true, initialValue: false },
47
+ { key: "approved", type: "boolean", nullable: true, defaultValue: true }
48
+ ]);
49
+
50
+ assert.deepEqual(model, {
51
+ reviewed: false,
52
+ approved: true
53
+ });
54
+ });
55
+
42
56
  test("buildCrudFormPayload normalizes booleans and numbers while skipping empty numeric values", () => {
43
57
  const payload = buildCrudFormPayload(
44
58
  [
@@ -100,12 +114,14 @@ test("buildCrudFormPayload normalizes time fields to canonical HH:MM", () => {
100
114
  test("buildCrudFormPayload serializes cleared nullable typed fields as null", () => {
101
115
  const payload = buildCrudFormPayload(
102
116
  [
117
+ { key: "reviewed", type: "boolean", nullable: true },
103
118
  { key: "serviceId", type: "integer", nullable: true },
104
119
  { key: "fromDate", type: "string", format: "date", nullable: true },
105
120
  { key: "scheduledAt", type: "string", format: "date-time", nullable: true },
106
121
  { key: "fromTime", type: "string", format: "time", nullable: true }
107
122
  ],
108
123
  {
124
+ reviewed: null,
109
125
  serviceId: null,
110
126
  fromDate: "",
111
127
  scheduledAt: "",
@@ -114,6 +130,7 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
114
130
  );
115
131
 
116
132
  assert.deepEqual(payload, {
133
+ reviewed: null,
117
134
  serviceId: null,
118
135
  fromDate: null,
119
136
  scheduledAt: null,
@@ -121,6 +138,40 @@ test("buildCrudFormPayload serializes cleared nullable typed fields as null", ()
121
138
  });
122
139
  });
123
140
 
141
+ test("buildCrudFormPayload preserves nullable booleans while keeping non-nullable booleans binary", () => {
142
+ const fields = [
143
+ { key: "active", type: "boolean" },
144
+ { key: "reviewed", type: "boolean", nullable: true },
145
+ { key: "approved", type: "boolean", nullable: true }
146
+ ];
147
+
148
+ assert.deepEqual(
149
+ buildCrudFormPayload(fields, {
150
+ active: null,
151
+ reviewed: null,
152
+ approved: true
153
+ }),
154
+ {
155
+ active: false,
156
+ reviewed: null,
157
+ approved: true
158
+ }
159
+ );
160
+
161
+ assert.deepEqual(
162
+ buildCrudFormPayload(fields, {
163
+ active: 1,
164
+ reviewed: 0,
165
+ approved: false
166
+ }),
167
+ {
168
+ active: true,
169
+ reviewed: false,
170
+ approved: false
171
+ }
172
+ );
173
+ });
174
+
124
175
  test("applyCrudPayloadToForm normalizes time fields for form inputs", () => {
125
176
  const fields = [
126
177
  { key: "fromTime", type: "string", format: "time" },
@@ -146,18 +197,21 @@ test("applyCrudPayloadToForm maps payload values into reactive form model", () =
146
197
  const form = reactive({
147
198
  name: "",
148
199
  active: false,
200
+ reviewed: null,
149
201
  age: ""
150
202
  });
151
203
  applyCrudPayloadToForm(
152
204
  [
153
205
  { key: "name", type: "string" },
154
206
  { key: "active", type: "boolean" },
207
+ { key: "reviewed", type: "boolean", nullable: true },
155
208
  { key: "age", type: "integer" }
156
209
  ],
157
210
  form,
158
211
  {
159
212
  name: "Grace",
160
213
  active: 1,
214
+ reviewed: null,
161
215
  age: 33
162
216
  }
163
217
  );
@@ -165,10 +219,36 @@ test("applyCrudPayloadToForm maps payload values into reactive form model", () =
165
219
  assert.deepEqual(form, {
166
220
  name: "Grace",
167
221
  active: true,
222
+ reviewed: null,
168
223
  age: "33"
169
224
  });
170
225
  });
171
226
 
227
+ test("applyCrudPayloadToForm preserves nullable boolean payload values", () => {
228
+ const fields = [
229
+ { key: "active", type: "boolean" },
230
+ { key: "reviewed", type: "boolean", nullable: true },
231
+ { key: "approved", type: "boolean", nullable: true }
232
+ ];
233
+ const form = reactive({
234
+ active: false,
235
+ reviewed: null,
236
+ approved: null
237
+ });
238
+
239
+ applyCrudPayloadToForm(fields, form, {
240
+ active: null,
241
+ reviewed: null,
242
+ approved: true
243
+ });
244
+
245
+ assert.deepEqual(form, {
246
+ active: false,
247
+ reviewed: null,
248
+ approved: true
249
+ });
250
+ });
251
+
172
252
  test("resolveCrudRouteBoundFieldValues maps route params for route-bound form fields", () => {
173
253
  const values = resolveCrudRouteBoundFieldValues(
174
254
  [