Skip to content

Commit 667d3b3

Browse files
committed
Add field transformation coverage
1 parent 558a439 commit 667d3b3

3 files changed

Lines changed: 243 additions & 51 deletions

File tree

src/admin.tsx

Lines changed: 116 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,104 @@ const helpTextStyle = {
162162

163163
const fullWidthButtonClassName = "h-9 min-h-9 w-full justify-center";
164164

165-
function asRecord(value: unknown): JsonRecord {
165+
export function normalizeObjectValue(value: unknown): JsonRecord {
166166
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
167167
}
168168

169-
function asList(value: unknown): JsonRecord[] {
170-
return Array.isArray(value) ? value.map((item) => asRecord(item)) : [];
169+
export function updateObjectValue(value: unknown, key: string, nextValue: unknown): JsonRecord {
170+
return { ...normalizeObjectValue(value), [key]: nextValue };
171171
}
172172

173-
function choices(value?: FieldsChoice[] | string[]): FieldsChoice[] {
173+
export function normalizeStructureValue(value: unknown): JsonRecord[] {
174+
return Array.isArray(value) ? value.map((item) => normalizeObjectValue(item)) : [];
175+
}
176+
177+
export function addStructureItem(value: unknown): JsonRecord[] {
178+
return [...normalizeStructureValue(value), {}];
179+
}
180+
181+
export function updateStructureItem(
182+
value: unknown,
183+
index: number,
184+
nextItem: unknown,
185+
): JsonRecord[] {
186+
const items = normalizeStructureValue(value);
187+
if (index < 0 || index >= items.length) {
188+
return items;
189+
}
190+
191+
const nextItems = [...items];
192+
nextItems[index] = normalizeObjectValue(nextItem);
193+
return nextItems;
194+
}
195+
196+
export function removeStructureItem(value: unknown, index: number): JsonRecord[] {
197+
return normalizeStructureValue(value).filter((_item, itemIndex) => itemIndex !== index);
198+
}
199+
200+
export function moveStructureItem(
201+
value: unknown,
202+
fromIndex: number,
203+
toIndex: number,
204+
): JsonRecord[] {
205+
const items = normalizeStructureValue(value);
206+
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) {
207+
return items;
208+
}
209+
210+
const nextItems = [...items];
211+
const [moved] = nextItems.splice(fromIndex, 1);
212+
if (!moved) {
213+
return items;
214+
}
215+
216+
nextItems.splice(toIndex, 0, moved);
217+
return nextItems;
218+
}
219+
220+
export function normalizeLinkValue(value: unknown): LinkValue {
221+
return normalizeObjectValue(value) as LinkValue;
222+
}
223+
224+
export function updateLinkValue(value: unknown, nextValue: Partial<LinkValue>): LinkValue {
225+
return { ...normalizeLinkValue(value), ...nextValue };
226+
}
227+
228+
export function normalizeChoices(value?: FieldsChoice[] | string[]): FieldsChoice[] {
174229
return (value ?? []).map((choice) =>
175230
typeof choice === "string" ? { value: choice, label: choice } : choice,
176231
);
177232
}
178233

234+
export function normalizeChoiceSelection(value: unknown, multiple: boolean): string[] {
235+
if (multiple) {
236+
return Array.isArray(value)
237+
? [...new Set(value.filter((item): item is string => typeof item === "string"))]
238+
: [];
239+
}
240+
241+
return typeof value === "string" ? [value] : [];
242+
}
243+
244+
export function updateChoiceSelection(
245+
value: unknown,
246+
choiceValue: string,
247+
checked: boolean,
248+
multiple: boolean,
249+
): string | string[] {
250+
if (!multiple) {
251+
return checked ? choiceValue : (normalizeChoiceSelection(value, false)[0] ?? "");
252+
}
253+
254+
const nextSelected = new Set(normalizeChoiceSelection(value, true));
255+
if (checked) {
256+
nextSelected.add(choiceValue);
257+
} else {
258+
nextSelected.delete(choiceValue);
259+
}
260+
return [...nextSelected];
261+
}
262+
179263
function choiceColumnCount(columns: number | undefined, total: number) {
180264
if (typeof columns === "number" && Number.isFinite(columns)) {
181265
return Math.max(1, Math.min(Math.floor(columns), Math.max(total, 1)));
@@ -276,7 +360,7 @@ function renderSubField(
276360
onChange(readInputValue(event, type)),
277361
};
278362

279-
const selectChoices = choices(field.options);
363+
const selectChoices = normalizeChoices(field.options);
280364

281365
return (
282366
<div key={field.key} style={fieldStyle}>
@@ -339,7 +423,7 @@ function renderObjectFields(
339423
renderSubField(
340424
field,
341425
value[field.key],
342-
(nextValue) => onChange({ ...value, [field.key]: nextValue }),
426+
(nextValue) => onChange(updateObjectValue(value, field.key, nextValue)),
343427
idPrefix,
344428
),
345429
);
@@ -359,7 +443,7 @@ export function ObjectField({
359443
id = "fields-object",
360444
options,
361445
}: FieldWidgetProps<ObjectOptions>) {
362-
const data = asRecord(value);
446+
const data = normalizeObjectValue(value);
363447
const fields = options?.fields ?? [];
364448

365449
if (!fields.length) {
@@ -380,7 +464,7 @@ export function StructureField({
380464
id = "fields-structure",
381465
options,
382466
}: FieldWidgetProps<StructureOptions>) {
383-
const items = asList(value);
467+
const items = normalizeStructureValue(value);
384468
const fields = options?.fields ?? [];
385469
const itemLabel = options?.itemLabel ?? "Item";
386470
const sortable = options?.sortable !== false;
@@ -393,14 +477,6 @@ export function StructureField({
393477
onChange(nextItems);
394478
}
395479

396-
function moveItem(fromIndex: number, toIndex: number) {
397-
const nextItems = [...items];
398-
const [moved] = nextItems.splice(fromIndex, 1);
399-
if (!moved) return;
400-
nextItems.splice(toIndex, 0, moved);
401-
updateItems(nextItems);
402-
}
403-
404480
return (
405481
<div id={id} tabIndex={-1} style={wrapperStyle}>
406482
{items.map((item, index) => (
@@ -410,9 +486,7 @@ export function StructureField({
410486
fields,
411487
item,
412488
(nextItem) => {
413-
const nextItems = [...items];
414-
nextItems[index] = nextItem;
415-
updateItems(nextItems);
489+
updateItems(updateStructureItem(items, index, nextItem));
416490
},
417491
`${id}-${index}`,
418492
)}
@@ -423,7 +497,7 @@ export function StructureField({
423497
variant="secondary-destructive"
424498
icon={TrashIcon}
425499
disabled={typeof options?.min === "number" && items.length <= options.min}
426-
onClick={() => updateItems(items.filter((_item, itemIndex) => itemIndex !== index))}
500+
onClick={() => updateItems(removeStructureItem(items, index))}
427501
>
428502
Remove
429503
</Button>
@@ -434,7 +508,7 @@ export function StructureField({
434508
size="sm"
435509
icon={ArrowUpIcon}
436510
disabled={index === 0}
437-
onClick={() => moveItem(index, index - 1)}
511+
onClick={() => updateItems(moveStructureItem(items, index, index - 1))}
438512
>
439513
Up
440514
</Button>
@@ -443,7 +517,7 @@ export function StructureField({
443517
size="sm"
444518
icon={ArrowDownIcon}
445519
disabled={index === items.length - 1}
446-
onClick={() => moveItem(index, index + 1)}
520+
onClick={() => updateItems(moveStructureItem(items, index, index + 1))}
447521
>
448522
Down
449523
</Button>
@@ -458,7 +532,7 @@ export function StructureField({
458532
className={fullWidthButtonClassName}
459533
icon={PlusIcon}
460534
disabled={typeof options?.max === "number" && items.length >= options.max}
461-
onClick={() => updateItems([...items, {}])}
535+
onClick={() => updateItems(addStructureItem(items))}
462536
>
463537
Add {itemLabel}
464538
</Button>
@@ -478,10 +552,10 @@ export function LinkField({
478552
onChange,
479553
id = "fields-link",
480554
}: FieldWidgetProps<Record<string, unknown>>) {
481-
const data = asRecord(value) as LinkValue;
555+
const data = normalizeLinkValue(value);
482556

483557
function update(nextValue: Partial<LinkValue>) {
484-
onChange({ ...data, ...nextValue });
558+
onChange(updateLinkValue(data, nextValue));
485559
}
486560

487561
return (
@@ -545,17 +619,11 @@ export function ChoicesField({
545619
id = "fields-choices",
546620
options,
547621
}: FieldWidgetProps<ChoicesOptions>) {
548-
const choicesList = choices(options?.choices ?? options?.options);
622+
const choicesList = normalizeChoices(options?.choices ?? options?.options);
549623
const legend = label ?? "Choices";
550624
const multiple = Boolean(options?.multiple);
551625
const horizontal = options?.orientation === "horizontal";
552-
const selected = multiple
553-
? new Set(
554-
Array.isArray(value)
555-
? value.filter((item): item is string => typeof item === "string")
556-
: [],
557-
)
558-
: new Set(typeof value === "string" ? [value] : []);
626+
const selected = new Set(normalizeChoiceSelection(value, multiple));
559627

560628
if (!choicesList.length) {
561629
return <p>Widget misconfigured: choices requires options.choices.</p>;
@@ -589,18 +657,19 @@ export function ChoicesField({
589657
style={choiceControlStyle}
590658
onChange={(event: ChangeEvent<HTMLInputElement>) => {
591659
if (multiple) {
592-
const nextSelected = new Set(selected);
593-
if (event.currentTarget.checked) {
594-
nextSelected.add(choice.value);
595-
} else {
596-
nextSelected.delete(choice.value);
597-
}
598-
onChange([...nextSelected]);
660+
onChange(
661+
updateChoiceSelection(
662+
value,
663+
choice.value,
664+
event.currentTarget.checked,
665+
true,
666+
),
667+
);
599668
return;
600669
}
601670

602671
if (event.currentTarget.checked) {
603-
onChange(choice.value);
672+
onChange(updateChoiceSelection(value, choice.value, true, false));
604673
}
605674
}}
606675
/>
@@ -629,13 +698,9 @@ export function ChoicesField({
629698
value={choice.value}
630699
style={choiceControlStyle}
631700
onChange={(event: ChangeEvent<HTMLInputElement>) => {
632-
const nextSelected = new Set(selected);
633-
if (event.currentTarget.checked) {
634-
nextSelected.add(choice.value);
635-
} else {
636-
nextSelected.delete(choice.value);
637-
}
638-
onChange([...nextSelected]);
701+
onChange(
702+
updateChoiceSelection(value, choice.value, event.currentTarget.checked, true),
703+
);
639704
}}
640705
/>
641706
{choice.icon ? renderChoiceCardLabel(choice) : (choice.label ?? choice.value)}
@@ -653,7 +718,9 @@ export function ChoicesField({
653718
legend={legend}
654719
appearance="card"
655720
value={typeof value === "string" ? value : ""}
656-
onValueChange={(nextValue) => onChange(nextValue)}
721+
onValueChange={(nextValue) =>
722+
onChange(updateChoiceSelection(value, String(nextValue), true, false))
723+
}
657724
description={options?.helpText}
658725
>
659726
{choicesList.map((choice) => {

tests/numeric-input.test.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@ test("numeric input emits undefined for invalid values instead of NaN", () => {
1616
test("number input accepts integer, decimal, and boundary finite values", () => {
1717
assert.equal(parseNumericInput("42", "number"), 42);
1818
assert.equal(parseNumericInput("3.14", "number"), 3.14);
19-
assert.equal(parseNumericInput(String(Number.MAX_SAFE_INTEGER), "number"), Number.MAX_SAFE_INTEGER);
20-
assert.equal(parseNumericInput(String(Number.MIN_SAFE_INTEGER), "number"), Number.MIN_SAFE_INTEGER);
19+
assert.equal(
20+
parseNumericInput(String(Number.MAX_SAFE_INTEGER), "number"),
21+
Number.MAX_SAFE_INTEGER,
22+
);
23+
assert.equal(
24+
parseNumericInput(String(Number.MIN_SAFE_INTEGER), "number"),
25+
Number.MIN_SAFE_INTEGER,
26+
);
2127
});
2228

2329
test("integer input accepts integers and rejects decimals", () => {

0 commit comments

Comments
 (0)