Skip to content

Commit 2071ecb

Browse files
authored
fix(GAT-9237): bug in the onboarding form for tools (#1579)
1 parent 04eace6 commit 2071ecb

3 files changed

Lines changed: 192 additions & 48 deletions

File tree

src/app/[locale]/account/team/[teamId]/(withoutLeftNav)/datasets/components/CreateDataset/CreateDataset.tsx

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@ import {
2121
NewDataset,
2222
StructuralMetadata,
2323
} from "@/interfaces/Dataset";
24-
import {
25-
FormHydrationSchema,
26-
FormHydrationValidation,
27-
} from "@/interfaces/FormHydration";
24+
import { FormHydrationSchema } from "@/interfaces/FormHydration";
2825
import { LegendItem } from "@/interfaces/FormLegend";
2926
import { Team } from "@/interfaces/Team";
3027
import { Defs } from "@/interfaces/V4Schema";
@@ -78,7 +75,7 @@ import {
7875
isLastSection,
7976
mapFormFieldsForSubmission,
8077
renderFormHydrationField,
81-
formatValidationItems,
78+
generateValidationRules,
8279
} from "@/utils/formHydration";
8380
import { capitalise, decodeHtmlEntity, splitCamelcase } from "@/utils/general";
8481
import IntroScreen from "../IntroScreen";
@@ -272,40 +269,6 @@ const CreateDataset = ({
272269
setExistingFormData(mappedFormData);
273270
}, [dataset, isLoading]);
274271

275-
const generateValidationRules = useMemo(
276-
() => (validationFields: FormHydrationValidation[]) => {
277-
const transformedObject: Record<
278-
string,
279-
Omit<FormHydrationValidation, "title">
280-
> = {};
281-
282-
validationFields.forEach(field => {
283-
const { title, items, required, of, ...rest } = field;
284-
285-
if (items && Array.isArray(items)) {
286-
// When field has an items array, convert to formatted object (used for field arrays)
287-
transformedObject[title] = {
288-
...rest,
289-
required,
290-
items: formatValidationItems(items),
291-
};
292-
} else if (of && required) {
293-
// Ensure required array of enums requires at least 1 value
294-
transformedObject[title] = {
295-
...rest,
296-
required,
297-
min: 1,
298-
};
299-
} else {
300-
transformedObject[title] = { ...rest, required };
301-
}
302-
});
303-
304-
return transformedObject;
305-
},
306-
[]
307-
);
308-
309272
const [selectedFormSection, setSelectedFormSection] = useState<string>("");
310273
const [isSaving, setIsSaving] = useState<boolean>(false);
311274
const [guidanceText, setGuidanceText] = useState<string>();
@@ -333,7 +296,7 @@ const CreateDataset = ({
333296
? generateValidationRules(currentFormJSON.validation)
334297
: {},
335298
},
336-
[currentFormJSON.validation, generateValidationRules, isDraft]
299+
[currentFormJSON.validation, isDraft]
337300
);
338301

339302
const yupSchema = buildYup(generatedYupValidation);

src/utils/formHydration.test.tsx

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { formGetFieldsCompletedCount } from "./formHydration";
1+
import { buildYup } from "schema-to-yup";
2+
import { FormHydrationValidation } from "@/interfaces/FormHydration";
3+
import {
4+
formGenerateLegendItems,
5+
formGetFieldsCompletedCount,
6+
generateValidationRules,
7+
} from "./formHydration";
8+
9+
const buildYupSchema = (validationFields: FormHydrationValidation[]) =>
10+
buildYup({
11+
title: "Metadata form",
12+
type: "object",
13+
properties: generateValidationRules(validationFields),
14+
});
215

316
const schemaFields = [
417
{
@@ -84,3 +97,118 @@ describe("formGetFieldsCompletedCount", () => {
8497
);
8598
});
8699
});
100+
101+
describe("generateValidationRules", () => {
102+
// Shape confirmed against the real HDRUK 4.0.0 form_hydration response for
103+
// "Tools" (an optional list of URL strings) - see GAT-9237.
104+
const toolsValidation: FormHydrationValidation[] = [
105+
{
106+
title: "Tools",
107+
type: "array",
108+
required: false,
109+
of: {
110+
title: "Tools",
111+
type: "string",
112+
required: false,
113+
format: "url",
114+
},
115+
},
116+
];
117+
118+
it("rejects a non-URL value in an optional url-format array field", () => {
119+
const schema = buildYupSchema(toolsValidation);
120+
121+
expect(() =>
122+
schema.validateSync({ Tools: ["das"] })
123+
).toThrow(/must be a valid URL/);
124+
});
125+
126+
it("accepts a valid URL in an optional url-format array field", () => {
127+
const schema = buildYupSchema(toolsValidation);
128+
129+
expect(() =>
130+
schema.validateSync({ Tools: ["https://example.com"] })
131+
).not.toThrow();
132+
});
133+
134+
it("accepts an empty array for an optional url-format array field", () => {
135+
const schema = buildYupSchema(toolsValidation);
136+
137+
expect(() => schema.validateSync({ Tools: [] })).not.toThrow();
138+
});
139+
140+
it("still enforces at least one value for a required array field", () => {
141+
const requiredEnumValidation: FormHydrationValidation[] = [
142+
{
143+
title: "Category",
144+
type: "array",
145+
required: true,
146+
of: {
147+
title: "Category",
148+
type: "string",
149+
required: true,
150+
enum: ["A", "B"],
151+
},
152+
},
153+
];
154+
const schema = buildYupSchema(requiredEnumValidation);
155+
156+
expect(() => schema.validateSync({ Category: [] })).toThrow();
157+
expect(() =>
158+
schema.validateSync({ Category: ["A"] })
159+
).not.toThrow();
160+
});
161+
});
162+
163+
describe("formGenerateLegendItems", () => {
164+
const section = "enrichmentAndLinkage";
165+
const sectionFields = [
166+
{
167+
title: "Tools",
168+
is_array_form: false,
169+
description: "",
170+
location: "enrichmentAndLinkage.tools",
171+
guidance: "",
172+
field: {
173+
component: "Autocomplete",
174+
name: "Tools",
175+
required: false,
176+
hidden: false,
177+
},
178+
},
179+
];
180+
181+
// activeSectionName === section short-circuits formGetSectionStatus to
182+
// ACTIVE, so this test only exercises the clearErrors behaviour we care about.
183+
const runLegend = async (dirtyFields: Record<string, boolean>) => {
184+
const clearErrors = jest.fn();
185+
const trigger = jest.fn(() => Promise.resolve(false));
186+
const getValues = jest.fn();
187+
188+
await formGenerateLegendItems(
189+
[section],
190+
section,
191+
true,
192+
sectionFields,
193+
clearErrors,
194+
getValues,
195+
trigger,
196+
false,
197+
dirtyFields
198+
);
199+
200+
return clearErrors;
201+
};
202+
203+
it("does not clear the error for a field the user has already touched", async () => {
204+
const clearErrors = await runLegend({ Tools: true });
205+
206+
expect(clearErrors).not.toHaveBeenCalled();
207+
});
208+
209+
it("clears the probe-validation error for a field the user hasn't touched", async () => {
210+
const clearErrors = await runLegend({});
211+
212+
expect(clearErrors).toHaveBeenCalledWith(["Tools"]);
213+
});
214+
});

src/utils/formHydration.tsx

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,18 +180,25 @@ const formGetSectionStatus = (
180180
return LegendStatus.VALID;
181181
};
182182

183-
const formValidateSection = async (
183+
const getSectionFieldNames = (
184184
schemaFields: FormHydration[],
185-
section: string,
186-
trigger: UseFormTrigger<FieldValues>
187-
) => {
185+
section: string
186+
): string | string[] => {
188187
const allSectionFields = formGetAllSectionFields(schemaFields, section);
189188
const isArrayForm = allSectionFields[0]?.is_array_form;
190-
const fields = isArrayForm
189+
return isArrayForm
191190
? allSectionFields[0].title
192191
: allSectionFields
193192
.filter(schemaField => !schemaField?.field?.hidden)
194193
.map(field => field.title);
194+
};
195+
196+
const formValidateSection = async (
197+
schemaFields: FormHydration[],
198+
section: string,
199+
trigger: UseFormTrigger<FieldValues>
200+
) => {
201+
const fields = getSectionFieldNames(schemaFields, section);
195202

196203
return await trigger(fields, { shouldFocus: false });
197204
};
@@ -227,9 +234,22 @@ const formGenerateLegendItems = async (
227234
? await formValidateSection(schemaFields, section, trigger)
228235
: false;
229236

230-
// Reset form error state
237+
// Reset probe-validation errors, but keep errors for fields the
238+
// user has actually touched (dirty) so they don't vanish on nav
231239
if (!submissionRequested) {
232-
clearErrors();
240+
const sectionFieldNames = getSectionFieldNames(
241+
schemaFields,
242+
section
243+
);
244+
const namesToClear = (
245+
Array.isArray(sectionFieldNames)
246+
? sectionFieldNames
247+
: [sectionFieldNames]
248+
).filter(name => !dirtyFields[name]);
249+
250+
if (namesToClear.length) {
251+
clearErrors(namesToClear);
252+
}
233253
}
234254

235255
// Get status of section
@@ -340,6 +360,38 @@ const formatValidationItems = (items: Partial<FormHydrationValidation>[]) => ({
340360
),
341361
});
342362

363+
const generateValidationRules = (validationFields: FormHydrationValidation[]) => {
364+
const transformedObject: Record<
365+
string,
366+
Omit<FormHydrationValidation, "title">
367+
> = {};
368+
369+
validationFields.forEach(field => {
370+
const { title, items, required, of, ...rest } = field;
371+
372+
if (items && Array.isArray(items)) {
373+
// When field has an items array, convert to formatted object (used for field arrays)
374+
transformedObject[title] = {
375+
...rest,
376+
required,
377+
items: formatValidationItems(items),
378+
};
379+
} else if (of && required) {
380+
// Ensure required array of enums requires at least 1 value
381+
transformedObject[title] = {
382+
...rest,
383+
required,
384+
of,
385+
min: 1,
386+
};
387+
} else {
388+
transformedObject[title] = { ...rest, required, of };
389+
}
390+
});
391+
392+
return transformedObject;
393+
};
394+
343395
const convertRevisionsToArray = (data: {
344396
revisions: Revision | Revision[] | null;
345397
}) => {
@@ -583,6 +635,7 @@ export {
583635
hasVisibleFieldsForLocation,
584636
renderFormHydrationField,
585637
formatValidationItems,
638+
generateValidationRules,
586639
formGetFieldsCompletedCount,
587640
mapFormFieldsForSubmission,
588641
mapExistingDatasetToFormFields,

0 commit comments

Comments
 (0)