From 880786cfb843778857a9ba4f3fc1bad6eabf84a4 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 26 Nov 2025 15:02:15 -0800 Subject: [PATCH 1/3] updated generateCollectionUISchemaInternal accepts a schema, looks up UI field definitions for each collectino updated createControl to accept achema --- src/v2/utils.ts | 217 +++++++++++++++++++++++++++++------------------- 1 file changed, 131 insertions(+), 86 deletions(-) diff --git a/src/v2/utils.ts b/src/v2/utils.ts index 11127cf..64616f4 100644 --- a/src/v2/utils.ts +++ b/src/v2/utils.ts @@ -1,15 +1,15 @@ -import { - V2Schema, - V2UIField, +import { + V2Schema, + V2UIField, V2Property, V2BaseProperty, V2Header, - JSONFormsControl, + JSONFormsControl, JSONFormsLayout, JSONFormsLabel, JSONFormsUIElement, - JSONFormsUISchema -} from '../common/types'; + JSONFormsUISchema, +} from "../common/types"; /** * Determines if a field should be rendered based on deprecation status @@ -24,19 +24,20 @@ export const isFieldVisible = (property: V2Property): boolean => { export const createControl = ( fieldName: string, property: V2Property, - uiField: V2UIField + uiField: V2UIField, + schema?: V2Schema, ): JSONFormsControl => { const control: JSONFormsControl = { - type: 'Control', + type: "Control", scope: `#/properties/${fieldName}`, label: property.title, - options: {} + options: {}, }; // Add format based on field type switch (uiField.type) { - case 'TEXT': - if (uiField.inputType === 'LONG_TEXT') { + case "TEXT": + if (uiField.inputType === "LONG_TEXT") { control.options!.multi = true; } if (uiField.placeholder) { @@ -44,55 +45,55 @@ export const createControl = ( } break; - case 'NUMERIC': - control.options!.format = 'number'; + case "NUMERIC": + control.options!.format = "number"; if (uiField.placeholder) { control.options!.placeholder = uiField.placeholder; } break; - case 'DATE_TIME': + case "DATE_TIME": // Always set format and display for DATE_TIME fields - control.options!.format = property.format || 'date-time'; - control.options!.display = property.format || 'date-time'; + control.options!.format = property.format || "date-time"; + control.options!.display = property.format || "date-time"; break; - case 'CHOICE_LIST': + case "CHOICE_LIST": // Handle multiple choice (array type) first - if (property.type === 'array') { + if (property.type === "array") { control.options!.multi = true; // For arrays, use appropriate multi-select format - if (uiField.inputType === 'DROPDOWN') { - control.options!.format = 'dropdown'; - } else if (uiField.inputType === 'LIST') { - control.options!.format = 'checkbox'; + if (uiField.inputType === "DROPDOWN") { + control.options!.format = "dropdown"; + } else if (uiField.inputType === "LIST") { + control.options!.format = "checkbox"; } } else { // Single select - if (uiField.inputType === 'DROPDOWN') { - control.options!.format = 'dropdown'; - } else if (uiField.inputType === 'LIST') { - control.options!.format = 'radio'; + if (uiField.inputType === "DROPDOWN") { + control.options!.format = "dropdown"; + } else if (uiField.inputType === "LIST") { + control.options!.format = "radio"; } } - + if (uiField.placeholder) { control.options!.placeholder = uiField.placeholder; } break; - case 'LOCATION': - control.options!.format = 'location'; - control.options!.display = 'map'; + case "LOCATION": + control.options!.format = "location"; + control.options!.display = "map"; break; - case 'COLLECTION': - control.options!.format = 'array'; - control.options!.addButtonText = uiField.buttonText || 'Add Item'; + case "COLLECTION": + control.options!.format = "array"; + control.options!.addButtonText = uiField.buttonText || "Add Item"; if (uiField.itemIdentifier) { control.options!.itemIdentifier = uiField.itemIdentifier; } - + // Add collection constraints if (property.maxItems !== undefined) { control.options!.maxItems = property.maxItems; @@ -100,16 +101,20 @@ export const createControl = ( if (property.minItems !== undefined) { control.options!.minItems = property.minItems; } - - if (property.type === 'array' && property.items?.properties) { - control.options!.detail = generateCollectionUISchemaInternal(property, uiField); + + if (property.type === "array" && property.items?.properties && schema) { + control.options!.detail = generateCollectionUISchemaInternal( + property, + uiField, + schema, + ); } break; - case 'ATTACHMENT': - control.options!.format = 'file'; + case "ATTACHMENT": + control.options!.format = "file"; if (uiField.allowableFileTypes) { - control.options!.accept = uiField.allowableFileTypes.join(','); + control.options!.accept = uiField.allowableFileTypes.join(","); } break; } @@ -127,14 +132,14 @@ export const createControl = ( */ export const createHeaderLabel = ( headerId: string, - header: V2Header + header: V2Header, ): JSONFormsLabel => { return { - type: 'Label', + type: "Label", text: header.label, options: { - size: header.size - } + size: header.size, + }, }; }; @@ -143,27 +148,29 @@ export const createHeaderLabel = ( */ export const createSectionLayout = ( sectionId: string, - section: V2Schema['ui']['sections'][string], + section: V2Schema["ui"]["sections"][string], fieldControls: JSONFormsControl[], - headers: Record + headers: Record, ): JSONFormsLayout => { const layout: JSONFormsLayout = { - type: 'VerticalLayout', // Always vertical for React Native + type: "VerticalLayout", // Always vertical for React Native label: section.label, - elements: [] + elements: [], }; // Order: leftColumn items first, then rightColumn items const orderedElements: JSONFormsUIElement[] = []; - + // Add left column elements first - section.leftColumn.forEach(item => { - if (item.type === 'field') { - const element = fieldControls.find(el => el.scope === `#/properties/${item.name}`); + section.leftColumn.forEach((item) => { + if (item.type === "field") { + const element = fieldControls.find( + (el) => el.scope === `#/properties/${item.name}`, + ); if (element) { orderedElements.push(element); } - } else if (item.type === 'header') { + } else if (item.type === "header") { const header = headers[item.name]; if (header) { const headerLabel = createHeaderLabel(item.name, header); @@ -173,13 +180,15 @@ export const createSectionLayout = ( }); // Add right column elements after left column - section.rightColumn.forEach(item => { - if (item.type === 'field') { - const element = fieldControls.find(el => el.scope === `#/properties/${item.name}`); + section.rightColumn.forEach((item) => { + if (item.type === "field") { + const element = fieldControls.find( + (el) => el.scope === `#/properties/${item.name}`, + ); if (element) { orderedElements.push(element); } - } else if (item.type === 'header') { + } else if (item.type === "header") { const header = headers[item.name]; if (header) { const headerLabel = createHeaderLabel(item.name, header); @@ -195,12 +204,18 @@ export const createSectionLayout = ( /** * Gets all fields that should be rendered (non-deprecated, visible) */ -export const getVisibleFields = (schema: V2Schema): Array<{ name: string; property: V2Property; uiField: V2UIField }> => { - const visibleFields: Array<{ name: string; property: V2Property; uiField: V2UIField }> = []; +export const getVisibleFields = ( + schema: V2Schema, +): Array<{ name: string; property: V2Property; uiField: V2UIField }> => { + const visibleFields: Array<{ + name: string; + property: V2Property; + uiField: V2UIField; + }> = []; Object.entries(schema.json.properties).forEach(([fieldName, property]) => { const uiField = schema.ui.fields[fieldName]; - + if (isFieldVisible(property) && uiField) { visibleFields.push({ name: fieldName, property, uiField }); } @@ -214,13 +229,19 @@ export const getVisibleFields = (schema: V2Schema): Array<{ name: string; proper */ export const groupFieldsBySection = ( fields: Array<{ name: string; property: V2Property; uiField: V2UIField }>, - sections: V2Schema['ui']['sections'] -): Record> => { - const grouped: Record> = {}; + sections: V2Schema["ui"]["sections"], +): Record< + string, + Array<{ name: string; property: V2Property; uiField: V2UIField }> +> => { + const grouped: Record< + string, + Array<{ name: string; property: V2Property; uiField: V2UIField }> + > = {}; - fields.forEach(field => { + fields.forEach((field) => { const sectionId = field.uiField.parent; - + // Only include fields that belong to sections (not collections) if (sections[sectionId]) { if (!grouped[sectionId]) { @@ -237,35 +258,55 @@ export const groupFieldsBySection = ( * Generate UI schema for collection items */ const generateCollectionUISchemaInternal = ( - collectionProperty: V2Property, - uiField?: V2UIField + collectionProperty: V2Property, + uiField: V2UIField, + schema: V2Schema, ): JSONFormsUISchema => { - if (collectionProperty.type !== 'array' || !collectionProperty.items?.properties) { - throw new Error('Collection property must be an array with item properties'); + if ( + collectionProperty.type !== "array" || + !collectionProperty.items?.properties + ) { + throw new Error( + "Collection property must be an array with item properties", + ); } const itemProperties = collectionProperty.items.properties; const itemControls: JSONFormsControl[] = []; - // Helper function to create control for a field - const createItemControl = (fieldName: string, property: V2BaseProperty): JSONFormsControl => { + // Helper function to create control for a collection item field + const createItemControl = ( + fieldName: string, + property: V2BaseProperty, + ): JSONFormsControl => { + const itemUiField = schema.ui.fields[fieldName]; + + if (itemUiField) { + return createControl( + fieldName, + property as V2Property, + itemUiField, + schema, + ); + } + const control: JSONFormsControl = { - type: 'Control', + type: "Control", scope: `#/properties/${fieldName}`, label: (property as any).title || fieldName, - options: {} + options: {}, }; // Set basic options based on property type switch (property.type) { - case 'string': - if (property.format === 'uri') { - control.options!.format = 'file'; - control.options!.accept = 'image/*'; + case "string": + if (property.format === "uri") { + control.options!.format = "file"; + control.options!.accept = "image/*"; } break; - case 'number': - control.options!.format = 'number'; + case "number": + control.options!.format = "number"; break; } @@ -280,18 +321,22 @@ const generateCollectionUISchemaInternal = ( if (uiField && (uiField.leftColumn || uiField.rightColumn)) { // Add left column fields first if (uiField.leftColumn) { - uiField.leftColumn.forEach(fieldName => { + uiField.leftColumn.forEach((fieldName) => { if (itemProperties[fieldName]) { - itemControls.push(createItemControl(fieldName, itemProperties[fieldName])); + itemControls.push( + createItemControl(fieldName, itemProperties[fieldName]), + ); } }); } - + // Add right column fields after (React Native single-column) if (uiField.rightColumn) { - uiField.rightColumn.forEach(fieldName => { + uiField.rightColumn.forEach((fieldName) => { if (itemProperties[fieldName]) { - itemControls.push(createItemControl(fieldName, itemProperties[fieldName])); + itemControls.push( + createItemControl(fieldName, itemProperties[fieldName]), + ); } }); } @@ -302,8 +347,8 @@ const generateCollectionUISchemaInternal = ( } const uiSchema: JSONFormsUISchema = { - type: 'VerticalLayout', - elements: itemControls + type: "VerticalLayout", + elements: itemControls, }; return uiSchema; From 6d22e33c8031dd0b3b4803badc64ec7c455f39c4 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 26 Nov 2025 15:02:48 -0800 Subject: [PATCH 2/3] pass schema to createControl --- src/v2/generateUISchema.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/v2/generateUISchema.ts b/src/v2/generateUISchema.ts index dd7bb7e..853dd1a 100644 --- a/src/v2/generateUISchema.ts +++ b/src/v2/generateUISchema.ts @@ -101,10 +101,10 @@ export const generateUISchema = (schema: V2Schema): JSONFormsUISchema => { // Only create layout if section is active and has content if (section?.isActive && (hasFields || hasHeaders)) { - const sectionControls = sectionFields.map(({ name, property, uiField }) => - createControl(name, property, uiField) + const sectionControls = sectionFields.map(({ name, property, uiField }) => + createControl(name, property, uiField, schema) ); - + const sectionLayout = createSectionLayout(sectionId, section, sectionControls, schema.ui.headers); sectionLayouts.push(sectionLayout); } From 326f55eed17f0c52be89dc51c33b3b4781e53a26 Mon Sep 17 00:00:00 2001 From: Dan O'Neill Date: Wed, 26 Nov 2025 15:04:35 -0800 Subject: [PATCH 3/3] updated tests --- test/v2.test.ts | 192 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/test/v2.test.ts b/test/v2.test.ts index 5f81794..02b9e79 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -290,11 +290,201 @@ describe('V2 generateUISchema', () => { }); expect(secondSection.elements![1]).toMatchObject({ - type: 'Control', + type: 'Control', scope: '#/properties/equipment_used', label: 'Equipment Used' }); }); + + it('should apply UI field properties to collection item fields', () => { + const result = generateUISchema(mockV2Schema); + const secondSection = result.elements![1]; + const collectionControl = secondSection.elements![1]; + + // Verify the detail schema includes UI field properties + const detail = collectionControl.options!.detail; + expect(detail).toBeDefined(); + expect(detail!.elements).toHaveLength(2); + + // item_name should get TEXT field properties + const itemNameControl = detail!.elements![0]; + expect(itemNameControl).toMatchObject({ + type: 'Control', + scope: '#/properties/item_name', + label: 'Item Name', + options: { + placeholder: 'Equipment name' + } + }); + + // item_condition should get CHOICE_LIST field properties (LIST inputType = radio) + const itemConditionControl = detail!.elements![1]; + expect(itemConditionControl).toMatchObject({ + type: 'Control', + scope: '#/properties/item_condition', + label: 'Item Condition' + }); + // Should have radio format for LIST inputType with single select + expect(itemConditionControl.options).toMatchObject({ + format: 'radio' + }); + }); + + it('should support nested collections (collection within collection)', () => { + const nestedSchema: V2Schema = { + json: { + properties: { + tasks: { + deprecated: false, + title: "Tasks", + type: "array", + items: { + properties: { + task_name: { + deprecated: false, + title: "Task Name", + type: "string" + }, + subtasks: { + deprecated: false, + title: "Subtasks", + type: "array", + items: { + properties: { + subtask_name: { + deprecated: false, + title: "Subtask Name", + type: "string" + }, + priority: { + deprecated: false, + title: "Priority", + type: "number" + } + }, + type: "object" + } + } + }, + type: "object" + } + } + }, + type: "object" + }, + ui: { + fields: { + tasks: { + buttonText: "Add Task", + itemIdentifier: "task_name", + leftColumn: ["task_name", "subtasks"], + rightColumn: [], + type: "COLLECTION", + parent: "section-1" + }, + task_name: { + inputType: "SHORT_TEXT", + placeholder: "Enter task name", + type: "TEXT", + parent: "tasks" + }, + subtasks: { + buttonText: "Add Subtask", + itemIdentifier: "subtask_name", + leftColumn: ["subtask_name", "priority"], + rightColumn: [], + type: "COLLECTION", + parent: "tasks" + }, + subtask_name: { + inputType: "SHORT_TEXT", + placeholder: "Enter subtask", + type: "TEXT", + parent: "subtasks" + }, + priority: { + placeholder: "1-5", + type: "NUMERIC", + parent: "subtasks" + } + }, + headers: {}, + order: ["section-1"], + sections: { + "section-1": { + columns: 1, + isActive: true, + label: "Task Manager", + leftColumn: [{ name: "tasks", type: "field" }], + rightColumn: [] + } + } + } + }; + + const result = generateUISchema(nestedSchema); + + // Get the top-level tasks collection + const tasksControl = result.elements![0].elements![0]; + expect(tasksControl.options).toMatchObject({ + format: 'array', + addButtonText: 'Add Task', + itemIdentifier: 'task_name' + }); + + // Verify tasks has detail schema with 2 fields + const tasksDetail = tasksControl.options!.detail; + expect(tasksDetail).toBeDefined(); + expect(tasksDetail!.elements).toHaveLength(2); + + // First field should be task_name with TEXT properties + expect(tasksDetail!.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/task_name', + label: 'Task Name', + options: { + placeholder: 'Enter task name' + } + }); + + // Second field should be the nested subtasks collection + const subtasksControl = tasksDetail!.elements![1]; + expect(subtasksControl).toMatchObject({ + type: 'Control', + scope: '#/properties/subtasks', + label: 'Subtasks' + }); + expect(subtasksControl.options).toMatchObject({ + format: 'array', + addButtonText: 'Add Subtask', + itemIdentifier: 'subtask_name' + }); + + // Verify nested collection has its own detail schema + const subtasksDetail = subtasksControl.options!.detail; + expect(subtasksDetail).toBeDefined(); + expect(subtasksDetail!.elements).toHaveLength(2); + + // Verify nested collection item fields have proper UI properties + expect(subtasksDetail!.elements![0]).toMatchObject({ + type: 'Control', + scope: '#/properties/subtask_name', + label: 'Subtask Name', + options: { + placeholder: 'Enter subtask' + } + }); + + expect(subtasksDetail!.elements![1]).toMatchObject({ + type: 'Control', + scope: '#/properties/priority', + label: 'Priority', + options: { + format: 'number', + placeholder: '1-5' + } + }); + }); });