diff --git a/src/__tests__/fields/array-field.spec.ts b/src/__tests__/fields/array-field.spec.ts index 254fd7c..c49418a 100644 --- a/src/__tests__/fields/array-field.spec.ts +++ b/src/__tests__/fields/array-field.spec.ts @@ -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"); + }); + }); }); diff --git a/src/fields/array-field.ts b/src/fields/array-field.ts index 6749875..68beaa3 100644 --- a/src/fields/array-field.ts +++ b/src/fields/array-field.ts @@ -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 - extends ICustomFieldSchemaBase<"array-field", V, IArrayFieldValidationRule> { +export interface IArrayFieldSchema extends ICustomFieldSchemaBase< + "array-field", + V, + IArrayFieldValidationRule +> { fieldSchema: Record; } export const arrayField: IFieldGenerator = (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[] = []; + 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, }, }; diff --git a/src/shared/error-messages.ts b/src/shared/error-messages.ts index 35d21ae..26d2151 100644 --- a/src/shared/error-messages.ts +++ b/src/shared/error-messages.ts @@ -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", }, };