diff --git a/lib/form/hooks/useProtocolForm.tsx b/lib/form/hooks/useProtocolForm.tsx index 0b8b7cd32..47280b01f 100644 --- a/lib/form/hooks/useProtocolForm.tsx +++ b/lib/form/hooks/useProtocolForm.tsx @@ -2,7 +2,7 @@ import { type ComponentType, type FormField, } from '@codaco/protocol-validation'; -import { type ReactNode } from 'react'; +import { type ReactNode, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { getValidationContext, @@ -11,11 +11,11 @@ import { type Subject, } from '~/lib/interviewer/selectors/forms'; import Field from '../components/Field/Field'; -import FieldNamespace from '../components/FieldNamespace'; import { type FieldValue, type ValidationPropsCatalogue, } from '../components/Field/types'; +import FieldNamespace from '../components/FieldNamespace'; import BooleanField from '../components/fields/Boolean'; import CheckboxGroupField from '../components/fields/CheckboxGroup'; import DatePickerField from '../components/fields/DatePicker'; @@ -65,17 +65,25 @@ export default function useProtocolForm({ initialValues, subject, namespace, + currentEntityId, }: { fields: FormField[]; autoFocus?: boolean; initialValues?: Record; subject?: Subject; namespace?: string; + currentEntityId?: string; }) { - const validationContext = useSelector( + const baseValidationContext = useSelector( getValidationContext, ) as ValidationContext | null; + const validationContext = useMemo(() => { + if (!baseValidationContext) return null; + if (currentEntityId === undefined) return baseValidationContext; + return { ...baseValidationContext, currentEntityId }; + }, [baseValidationContext, currentEntityId]); + const fieldsMetadata = useSelector((state) => subject ? selectFieldMetadataWithSubject(state, subject, fields) diff --git a/lib/form/store/types.ts b/lib/form/store/types.ts index 3ba4b052a..2e8b63c6d 100644 --- a/lib/form/store/types.ts +++ b/lib/form/store/types.ts @@ -56,6 +56,7 @@ export type ValidationContext = { stageSubject: StageSubject; codebook: Codebook; network: NcNetwork; + currentEntityId?: string; }; // ═══════════════════════════════════════════════════════════════ diff --git a/lib/form/validation/functions.test.ts b/lib/form/validation/functions.test.ts index dd7e1da83..852e3a07d 100644 --- a/lib/form/validation/functions.test.ts +++ b/lib/form/validation/functions.test.ts @@ -611,6 +611,72 @@ describe('Validation Functions', () => { .safeParse('test'); }).toThrow('Attribute must be specified for unique validation'); }); + + it("should accept the currently-edited entity's own value", () => { + const mockNetwork = { + nodes: [ + { + _uid: 'node1', + type: 'person', + [entityAttributesProperty]: { name: 'John' }, + }, + { + _uid: 'node2', + type: 'person', + [entityAttributesProperty]: { name: 'Jane' }, + }, + ], + edges: [], + ego: { + _uid: 'ego', + [entityAttributesProperty]: {}, + }, + } as NcNetwork; + + const validator = validations.unique( + 'name', + createMockContext({ + network: mockNetwork, + currentEntityId: 'node1', + }), + )({}); + + const result = validator.safeParse('John'); + expect(result.success).toBe(true); + }); + + it("should still reject other entities' values when editing", () => { + const mockNetwork = { + nodes: [ + { + _uid: 'node1', + type: 'person', + [entityAttributesProperty]: { name: 'John' }, + }, + { + _uid: 'node2', + type: 'person', + [entityAttributesProperty]: { name: 'Jane' }, + }, + ], + edges: [], + ego: { + _uid: 'ego', + [entityAttributesProperty]: {}, + }, + } as NcNetwork; + + const validator = validations.unique( + 'name', + createMockContext({ + network: mockNetwork, + currentEntityId: 'node1', + }), + )({}); + + const result = validator.safeParse('Jane'); + expect(result.success).toBe(false); + }); }); describe('differentFrom', () => { diff --git a/lib/form/validation/functions.ts b/lib/form/validation/functions.ts index c4ba1463c..3a3b2fb1c 100644 --- a/lib/form/validation/functions.ts +++ b/lib/form/validation/functions.ts @@ -440,7 +440,7 @@ const unique: ValidationFunction = (attribute, context) => () => { context, 'Validation context must be provided when using unique validation', ); - const { stageSubject, network } = context; + const { stageSubject, network, currentEntityId } = context; const hint = 'Must be unique.'; @@ -455,11 +455,14 @@ const unique: ValidationFunction = (attribute, context) => () => { 'Attribute must be specified for unique validation', ); - // Collect other values of the same type. + // Collect other values of the same type, excluding the entity + // currently being edited (if any) so its own value isn't treated + // as a duplicate. const existingValues = collectNetworkValues( network, stageSubject, attribute, + currentEntityId, ); if (existingValues.some((v) => isMatchingValue(value, v))) { diff --git a/lib/form/validation/utils/collectNetworkValues.ts b/lib/form/validation/utils/collectNetworkValues.ts index 8a35a4586..47ef4acde 100644 --- a/lib/form/validation/utils/collectNetworkValues.ts +++ b/lib/form/validation/utils/collectNetworkValues.ts @@ -1,6 +1,7 @@ import { type StageSubject } from '@codaco/protocol-validation'; import { entityAttributesProperty, + entityPrimaryKeyProperty, type NcNetwork, } from '@codaco/shared-consts'; @@ -8,10 +9,12 @@ export default function collectNetworkValues( network: NcNetwork, subject: Extract, attribute: string, + excludeEntityId?: string, ) { - if (subject.entity === 'node') { - return network.nodes.map((n) => n[entityAttributesProperty][attribute]); - } + const entities = + subject.entity === 'node' ? network.nodes : network.edges; - return network.edges.map((e) => e[entityAttributesProperty][attribute]); + return entities + .filter((e) => e[entityPrimaryKeyProperty] !== excludeEntityId) + .map((e) => e[entityAttributesProperty][attribute]); } diff --git a/lib/interviewer/Interfaces/NameGenerator/components/NodeForm.tsx b/lib/interviewer/Interfaces/NameGenerator/components/NodeForm.tsx index 14d8a99a3..1db656f89 100644 --- a/lib/interviewer/Interfaces/NameGenerator/components/NodeForm.tsx +++ b/lib/interviewer/Interfaces/NameGenerator/components/NodeForm.tsx @@ -88,6 +88,7 @@ const NodeForm = (props: NodeFormProps) => { fields: form.fields, autoFocus: true, initialValues, + currentEntityId: selectedNode?.[entityPrimaryKeyProperty], }); // Handle form submission diff --git a/lib/interviewer/Interfaces/SlidesForm/SlidesForm.tsx b/lib/interviewer/Interfaces/SlidesForm/SlidesForm.tsx index 124782847..01804f846 100644 --- a/lib/interviewer/Interfaces/SlidesForm/SlidesForm.tsx +++ b/lib/interviewer/Interfaces/SlidesForm/SlidesForm.tsx @@ -101,6 +101,7 @@ const SlideContentInner = forwardRef( autoFocus: false, initialValues, subject, + currentEntityId: id, }); const handleSubmit: FormSubmitHandler = (values) => {