diff --git a/src/v2/mockData.ts b/src/v2/mockData.ts index 86bbd33..28e6c79 100644 --- a/src/v2/mockData.ts +++ b/src/v2/mockData.ts @@ -157,20 +157,20 @@ export const mockV2Schema = { "equipment_used": { "buttonText": "Add Equipment", "columns": 1, - "itemIdentifier": "item_name", + "itemIdentifier": "equipment_used.item_name", "itemName": "Equipment Item", - "leftColumn": ["item_name", "item_condition"], + "leftColumn": ["equipment_used.item_name", "equipment_used.item_condition"], "rightColumn": [], "parent": "section-location", "type": "COLLECTION" }, - "item_name": { + "equipment_used.item_name": { "inputType": "SHORT_TEXT", "parent": "equipment_used", "placeholder": "Equipment name", "type": "TEXT" }, - "item_condition": { + "equipment_used.item_condition": { "choices": { "eventTypeCategories": [], "existingChoiceList": ["item_condition"], diff --git a/src/v2/utils.ts b/src/v2/utils.ts index c198a9f..5283efd 100644 --- a/src/v2/utils.ts +++ b/src/v2/utils.ts @@ -29,6 +29,7 @@ export const createControl = ( uiField: V2UIField, schema?: V2Schema, conditionallyRequired?: boolean, + collectionId?: string, ): JSONFormsControl => { const control: JSONFormsControl = { type: "Control", @@ -103,7 +104,11 @@ export const createControl = ( control.options!.format = "array"; control.options!.addButtonText = uiField.buttonText || "Add Item"; if (uiField.itemIdentifier) { - control.options!.itemIdentifier = uiField.itemIdentifier; + const effectiveId = collectionId ?? fieldName; + const prefix = `${effectiveId}.`; + control.options!.itemIdentifier = uiField.itemIdentifier.startsWith(prefix) + ? uiField.itemIdentifier.slice(prefix.length) + : uiField.itemIdentifier; } // Add collection constraints @@ -119,6 +124,7 @@ export const createControl = ( property, uiField, schema, + collectionId ?? fieldName, ); } break; @@ -346,6 +352,7 @@ const generateCollectionUISchemaInternal = ( collectionProperty: V2Property, uiField: V2UIField, schema: V2Schema, + collectionId: string, ): JSONFormsUISchema => { if ( collectionProperty.type !== "array" || @@ -359,26 +366,45 @@ const generateCollectionUISchemaInternal = ( const itemProperties = collectionProperty.items.properties; const itemControls: JSONFormsControl[] = []; + const getLocalName = (columnEntry: string): string => { + const prefix = `${collectionId}.`; + return columnEntry.startsWith(prefix) + ? columnEntry.slice(prefix.length) + : columnEntry; + }; + // Helper function to create control for a collection item field const createItemControl = ( - fieldName: string, + localName: string, property: V2BaseProperty, ): JSONFormsControl => { - const itemUiField = schema.ui.fields[fieldName]; + const qualifiedId = `${collectionId}.${localName}`; + const fallbackField = schema.ui.fields[localName]; + const collectionLocalName = collectionId.split(".").pop()!; + const fallbackParent = fallbackField?.parent; + const itemUiField = + schema.ui.fields[qualifiedId] ?? + (fallbackParent === collectionId || + (fallbackParent === collectionLocalName && + !schema.ui.sections[fallbackParent]) + ? fallbackField + : undefined); if (itemUiField) { return createControl( - fieldName, + localName, property as V2Property, itemUiField, schema, + undefined, + qualifiedId, ); } const control: JSONFormsControl = { type: "Control", - scope: `#/properties/${fieldName}`, - label: (property as any).title || fieldName, + scope: `#/properties/${localName}`, + label: (property as any).title || localName, options: {}, }; @@ -406,28 +432,26 @@ const generateCollectionUISchemaInternal = ( if (uiField && (uiField.leftColumn || uiField.rightColumn)) { // Add left column fields first if (uiField.leftColumn) { - uiField.leftColumn.forEach((fieldName) => { - if (itemProperties[fieldName]) { - itemControls.push( - createItemControl(fieldName, itemProperties[fieldName]), - ); + uiField.leftColumn.forEach((columnEntry) => { + const localName = getLocalName(columnEntry); + if (itemProperties[localName]) { + itemControls.push(createItemControl(localName, itemProperties[localName])); } }); } // Add right column fields after (React Native single-column) if (uiField.rightColumn) { - uiField.rightColumn.forEach((fieldName) => { - if (itemProperties[fieldName]) { - itemControls.push( - createItemControl(fieldName, itemProperties[fieldName]), - ); + uiField.rightColumn.forEach((columnEntry) => { + const localName = getLocalName(columnEntry); + if (itemProperties[localName]) { + itemControls.push(createItemControl(localName, itemProperties[localName])); } }); } } else { - Object.entries(itemProperties).forEach(([fieldName, property]) => { - itemControls.push(createItemControl(fieldName, property)); + Object.entries(itemProperties).forEach(([localName, property]) => { + itemControls.push(createItemControl(localName, property)); }); } diff --git a/test/v2.test.ts b/test/v2.test.ts index 068011d..34ffd27 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -330,6 +330,421 @@ describe('V2 generateUISchema', () => { }); }); + it('should resolve same-named fields at different nesting levels using qualified ids', () => { + const schema: V2Schema = { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + required: ['name'], + type: 'object', + properties: { + name: { + deprecated: false, + title: 'Report Name', + type: 'string', + }, + sightings: { + deprecated: false, + title: 'Sightings', + type: 'array', + items: { + type: 'object', + properties: { + name: { + deprecated: false, + title: 'Animal Name', + type: 'string', + }, + }, + }, + }, + }, + }, + ui: { + fields: { + name: { + inputType: 'SHORT_TEXT', + parent: 'section-1', + placeholder: 'Enter report name', + type: 'TEXT', + }, + sightings: { + buttonText: 'Add Sighting', + itemIdentifier: 'name', + leftColumn: ['sightings.name'], + rightColumn: [], + type: 'COLLECTION', + parent: 'section-1', + }, + 'sightings.name': { + inputType: 'SHORT_TEXT', + parent: 'sightings', + placeholder: 'Enter animal name', + type: 'TEXT', + }, + }, + headers: {}, + order: ['section-1'], + sections: { + 'section-1': { + columns: 1, + isActive: true, + label: 'Report', + leftColumn: [ + { name: 'name', type: 'field' }, + { name: 'sightings', type: 'field' }, + ], + rightColumn: [], + }, + }, + }, + }; + + const result = generateUISchema(schema); + const section = result.elements![0]; + + // Top-level name field gets its own placeholder + expect(section.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/name', + label: 'Report Name', + options: { placeholder: 'Enter report name' }, + }); + + // Collection item name field gets its own placeholder via sightings.name qualified id + const collectionControl = section.elements![1]; + expect(collectionControl).toMatchObject({ + type: 'Control', + scope: '#/properties/sightings', + label: 'Sightings', + }); + const detail = collectionControl.options!.detail; + expect(detail.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/name', + label: 'Animal Name', + options: { placeholder: 'Enter animal name' }, + }); + }); + + it('should not apply a top-level field config to a collection child when the section id matches the collection local name', () => { + // Regression: collectionLocalName = "subtasks", section id also = "subtasks". + // Old-format schema — no qualified keys in ui.fields, so the fallback path + // is exercised. A top-level "name" field parented to section "subtasks" must + // NOT be picked up as the UI config for the "name" child inside tasks.subtasks. + const schema: V2Schema = { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + required: [], + type: 'object', + properties: { + name: { title: 'Report Name', type: 'string' }, + tasks: { + title: 'Tasks', + type: 'array', + items: { + type: 'object', + properties: { + subtasks: { + title: 'Subtasks', + type: 'array', + items: { + type: 'object', + properties: { + name: { title: 'Subtask Name', type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + ui: { + fields: { + // Root-level "name" parented to section "subtasks" — section id + // coincidentally equals the nested collection's local name. + name: { + inputType: 'SHORT_TEXT', + parent: 'subtasks', + placeholder: 'Report placeholder', + type: 'TEXT', + }, + // Old-format: no qualified keys — both collections use unqualified ids. + tasks: { + buttonText: 'Add Task', + leftColumn: ['subtasks'], // unqualified + rightColumn: [], + type: 'COLLECTION', + parent: 'subtasks', + }, + subtasks: { + buttonText: 'Add Subtask', + leftColumn: ['name'], // unqualified — triggers the fallback path + rightColumn: [], + type: 'COLLECTION', + parent: 'tasks', + }, + // Deliberately no 'tasks.subtasks.name' qualified entry + }, + headers: {}, + order: ['subtasks'], + sections: { + subtasks: { // section id = "subtasks" = nested collection local name + columns: 1, + isActive: true, + label: 'Report', + leftColumn: [ + { name: 'name', type: 'field' }, + { name: 'tasks', type: 'field' }, + ], + rightColumn: [], + }, + }, + }, + }; + + const result = generateUISchema(schema); + const section = result.elements![0]; + + // Top-level name renders with its own placeholder + expect(section.elements![0]).toMatchObject({ + scope: '#/properties/name', + options: { placeholder: 'Report placeholder' }, + }); + + // Drill into tasks → subtasks → name + const tasksControl = section.elements![1]; + const subtasksControl = tasksControl.options!.detail.elements![0]; + const subtaskNameControl = subtasksControl.options!.detail.elements![0]; + + // "name" child inside tasks.subtasks must NOT inherit the top-level + // "Report placeholder" — the section-id collision must be blocked. + expect(subtaskNameControl).toMatchObject({ + scope: '#/properties/name', + label: 'Subtask Name', + }); + expect(subtaskNameControl.options?.placeholder).toBeUndefined(); + }); + + it('should handle old-format schema where collection child shares name with parent collection', () => { + const schema: V2Schema = { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + required: [], + type: 'object', + properties: { + items_replaced: { + deprecated: false, + title: 'Items Replaced', + type: 'array', + items: { + type: 'object', + properties: { + items_replaced: { + deprecated: false, + title: 'Items', + type: 'string', + }, + equipment_quantity: { + deprecated: false, + title: 'Quantity', + type: 'number', + }, + }, + }, + }, + }, + }, + ui: { + fields: { + // Old format: collection child keys are unqualified + items_replaced: { + buttonText: 'Add', + columns: 1, + itemIdentifier: '', + itemName: '', + leftColumn: ['items_replaced', 'equipment_quantity'], + parent: 'section-1', + rightColumn: [], + type: 'COLLECTION', + }, + equipment_quantity: { + parent: 'items_replaced', + placeholder: '0', + type: 'NUMERIC', + }, + // Note: no entry for the child 'items_replaced' field — old format collision case + }, + headers: {}, + order: ['section-1'], + sections: { + 'section-1': { + columns: 1, + isActive: true, + label: 'Maintenance', + leftColumn: [{ name: 'items_replaced', type: 'field' }], + rightColumn: [], + }, + }, + }, + }; + + // Should not throw "Maximum call stack size exceeded" + const result = generateUISchema(schema); + const collectionControl = result.elements![0].elements![0]; + + expect(collectionControl).toMatchObject({ + type: 'Control', + scope: '#/properties/items_replaced', + label: 'Items Replaced', + options: { format: 'array' }, + }); + + const detail = collectionControl.options!.detail; + expect(detail).toBeDefined(); + expect(detail!.elements).toHaveLength(2); + + // Child 'items_replaced' (string): no UI config found (parent check blocks + // the COLLECTION fallback), so falls through to a generic control. + expect(detail!.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/items_replaced', + label: 'Items', + }); + // Child 'equipment_quantity' resolves via parent-validated fallback. + expect(detail!.elements![1]).toMatchObject({ + type: 'Control', + scope: '#/properties/equipment_quantity', + label: 'Quantity', + options: { placeholder: '0' }, + }); + }); + + it('should handle doubly-nested collections with shared field names (Nested.Nest_1.Nest_Text)', () => { + // Exact schema structure from the server - two Nest_Text fields at different depths + const schema: V2Schema = { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + required: [], + type: 'object', + properties: { + Nested: { + deprecated: false, + title: 'Nested', + type: 'array', + items: { + type: 'object', + properties: { + Nest_Text: { deprecated: false, title: 'Nest Text', type: 'string', default: '' }, + Nest_1: { + deprecated: false, + title: 'Nest 1', + type: 'array', + items: { + type: 'object', + properties: { + Nest_Text: { deprecated: false, title: 'Nest Text', type: 'string', default: '' }, + }, + }, + }, + }, + }, + }, + }, + }, + ui: { + fields: { + Nested: { + type: 'COLLECTION', + parent: 'section-1', + buttonText: 'Create a Nest Now', + columns: 1, + itemIdentifier: '', + leftColumn: ['Nested.Nest_Text', 'Nested.Nest_1'], + rightColumn: [], + }, + 'Nested.Nest_Text': { + type: 'TEXT', + inputType: 'SHORT_TEXT', + parent: 'Nested', + placeholder: 'Text for the Nest', + }, + 'Nested.Nest_1': { + type: 'COLLECTION', + parent: 'Nested', + buttonText: 'Next 1', + columns: 1, + itemIdentifier: '', + leftColumn: ['Nested.Nest_1.Nest_Text'], + rightColumn: [], + }, + 'Nested.Nest_1.Nest_Text': { + type: 'TEXT', + inputType: 'SHORT_TEXT', + parent: 'Nested.Nest_1', + placeholder: 'New Nest', + }, + }, + headers: {}, + order: ['section-1'], + sections: { + 'section-1': { + columns: 1, + isActive: true, + label: '', + leftColumn: [{ name: 'Nested', type: 'field' }], + rightColumn: [], + }, + }, + }, + }; + + const result = generateUISchema(schema); + const nestedControl = result.elements![0].elements![0]; + + // Top-level collection scope + expect(nestedControl).toMatchObject({ + type: 'Control', + scope: '#/properties/Nested', + label: 'Nested', + options: { format: 'array', addButtonText: 'Create a Nest Now' }, + }); + + const nestedDetail = nestedControl.options!.detail; + expect(nestedDetail.elements).toHaveLength(2); + + // Nested.Nest_Text → scope is bare local name, gets its own placeholder + expect(nestedDetail.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/Nest_Text', + label: 'Nest Text', + options: { placeholder: 'Text for the Nest' }, + }); + + // Nested.Nest_1 → inner collection + const nest1Control = nestedDetail.elements![1]; + expect(nest1Control).toMatchObject({ + type: 'Control', + scope: '#/properties/Nest_1', + label: 'Nest 1', + options: { format: 'array', addButtonText: 'Next 1' }, + }); + + // Nested.Nest_1.Nest_Text → same local name as sibling above, gets its own placeholder + const nest1Detail = nest1Control.options!.detail; + expect(nest1Detail.elements).toHaveLength(1); + expect(nest1Detail.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/Nest_Text', + label: 'Nest Text', + options: { placeholder: 'New Nest' }, + }); + }); + it('should support nested collections (collection within collection)', () => { const nestedSchema: V2Schema = { json: {