Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/__tests__/fields/array-field.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,123 @@ describe("array-field", () => {
schema.validate({ items: [{ type: "other", description: "Custom type" }, { type: "A" }] })
).resolves.toBeDefined();
});

describe("unique validation", () => {
it("should validate unique field values across array items", () => {
const schema = jsonToSchema({
section: {
uiType: "section",
children: {
fruits: {
referenceKey: "array-field",
fieldSchema: {
name: {
uiType: "text-field",
},
},
validation: [
{
unique: [{ field: "name", errorMessage: ERROR_MESSAGE }],
errorMessage: ERROR_MESSAGE,
},
],
},
},
},
});

// Valid - all unique values
expect(() =>
schema.validateSync({
fruits: [{ name: "Apple" }, { name: "Banana" }],
})
).not.toThrowError();

// Invalid - duplicate name
expect(
TestHelper.getError(() =>
schema.validateSync({
fruits: [{ name: "Apple" }, { name: "Apple" }],
})
).message
).toBe(ERROR_MESSAGE);
});

it("should validate multiple unique fields", () => {
const schema = jsonToSchema({
section: {
uiType: "section",
children: {
fruits: {
referenceKey: "array-field",
fieldSchema: {
name: {
uiType: "text-field",
},
colour: {
uiType: "text-field",
},
},
validation: [
{
unique: [{ field: "name" }, { field: "colour" }],
},
],
},
},
},
});

// Valid - all unique values
expect(() =>
schema.validateSync({
fruits: [
{ name: "Apple", colour: "Red" },
{ name: "Banana", colour: "Yellow" },
],
})
).not.toThrowError();

// Invalid - duplicate colour
expect(() =>
schema.validateSync({
fruits: [
{ name: "Apple", colour: "Red" },
{ name: "Cherry", colour: "Red" },
],
})
).toThrowError();
});

it("should use default error message when errorMessage is not provided", () => {
const schema = jsonToSchema({
section: {
uiType: "section",
children: {
fruits: {
referenceKey: "array-field",
fieldSchema: {
name: {
uiType: "text-field",
},
},
validation: [
{
unique: [{ field: "name" }],
},
],
},
},
},
});

expect(
TestHelper.getError(() =>
schema.validateSync({
fruits: [{ name: "Apple" }, { name: "Apple" }],
})
).message
).toBe("One or more fields are not unique");
});
});
});
66 changes: 55 additions & 11 deletions src/fields/array-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,77 @@ import { ICustomFieldSchemaBase, IValidationRule, TComponentSchema, jsonToSchema
import { ERROR_MESSAGES } from "../shared";
import { IFieldGenerator } from "./types";

export interface IArrayFieldUniqueRule {
field: string;
errorMessage?: string | undefined;
}

interface IArrayFieldValidationRule extends IValidationRule {
/** for customising error message when one section is invalid */
valid?: boolean | undefined;
/** Specify child fields that must be unique across all array items, with a custom error message per field. */
unique?: IArrayFieldUniqueRule[] | undefined;
}

export interface IArrayFieldSchema<V = undefined>
extends ICustomFieldSchemaBase<"array-field", V, IArrayFieldValidationRule> {
export interface IArrayFieldSchema<V = undefined> extends ICustomFieldSchemaBase<
"array-field",
V,
IArrayFieldValidationRule
> {
fieldSchema: Record<string, TComponentSchema>;
}

export const arrayField: IFieldGenerator<IArrayFieldSchema> = (id, { fieldSchema, validation }) => {
const isRequiredRule = validation?.find((rule) => "required" in rule);
const uniqueRule = validation?.find((rule) => "unique" in rule) as IArrayFieldValidationRule | undefined;

// Create fresh schema instance per array item to prevent mutation conflicts during parallel async validation
const itemSchema = Yup.lazy(() => jsonToSchema({ section: { uiType: "section", children: fieldSchema } }));

let yupSchema = Yup.array(itemSchema).test(
"is-empty-array",
isRequiredRule?.errorMessage || ERROR_MESSAGES.ARRAY_FIELD.REQUIRED,
(value) => {
if (!value || !isRequiredRule?.required) return true;

return value.length > 0 && value.some((item) => !isEmpty(item));
}
);

if (uniqueRule?.unique?.length) {
yupSchema = yupSchema.test(
"unique-items",
uniqueRule?.errorMessage || ERROR_MESSAGES.ARRAY_FIELD.UNIQUE,
(value) => {
if (!value) return true;

const errors: Record<string, string>[] = [];
let hasError = false;

uniqueRule.unique.forEach(({ field, errorMessage }) => {
const fieldValues = value.map((item) => item?.[field]);

fieldValues.forEach((val, idx) => {
if (!val) return;
const isDuplicate = fieldValues.findIndex((v) => v === val) !== idx;
if (isDuplicate) {
errors[idx] = {
...errors[idx],
[field]: errorMessage || ERROR_MESSAGES.ARRAY_FIELD.UNIQUE,
};
hasError = true;
}
});
});

return !hasError;
}
);
}

return {
[id]: {
yupSchema: Yup.array(itemSchema).test(
"is-empty-array",
isRequiredRule?.errorMessage || ERROR_MESSAGES.ARRAY_FIELD.REQUIRED,
(value) => {
if (!value || !isRequiredRule?.required) return true;

return value.length > 0 && value.some((item) => !isEmpty(item));
}
),
yupSchema,
validation,
},
};
Expand Down
1 change: 1 addition & 0 deletions src/shared/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,6 @@ export const ERROR_MESSAGES = {
ARRAY_FIELD: {
INVALID: "One or more of the sections is incomplete",
REQUIRED: "At least one section must be filled in",
UNIQUE: "One or more fields are not unique",
},
};