diff --git a/src/common/types.ts b/src/common/types.ts index 4198d0d..e0d9bb2 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -39,7 +39,7 @@ export interface V2BaseProperty { default?: any; minimum?: number; maximum?: number; - format?: 'date-time' | 'date' | 'time' | 'uri'; + format?: 'date-time' | 'date' | 'time' | 'uri' | 'uuid'; anyOf?: Array< | { $ref: string } | { oneOf: Array<{ const: any; title?: string }> } @@ -50,6 +50,7 @@ export interface V2BaseProperty { required?: string[]; additionalProperties?: boolean; unevaluatedItems?: boolean; + unevaluatedProperties?: boolean; maxItems?: number; minItems?: number; uniqueItems?: boolean; diff --git a/src/v2/generateUISchema.ts b/src/v2/generateUISchema.ts index c7e16d1..3083e63 100644 --- a/src/v2/generateUISchema.ts +++ b/src/v2/generateUISchema.ts @@ -60,6 +60,19 @@ const validateV2Schema = (schema: V2Schema): void => { invalidFields.push(`${fieldName}: CHOICE_LIST field requires embedded oneOf or enum arrays - $ref not supported`); } } + + if (uiField.type === 'ATTACHMENT') { + const hasItemKey = + property.items?.properties !== undefined && + Object.keys(property.items.properties).length > 0; + const hasRequired = + Array.isArray(property.items?.required) && + property.items!.required!.length > 0; + + if (property.type !== 'array' || !hasItemKey || !hasRequired) { + invalidFields.push(`${fieldName}: ATTACHMENT field requires an array type with items.properties and items.required`); + } + } }); // Validate section conditions diff --git a/src/v2/utils.ts b/src/v2/utils.ts index f63bdd4..dfddbd4 100644 --- a/src/v2/utils.ts +++ b/src/v2/utils.ts @@ -144,12 +144,31 @@ export const createControl = ( } break; - case "ATTACHMENT": + case "ATTACHMENT": { control.options!.format = "file"; if (uiField.allowableFileTypes) { control.options!.accept = uiField.allowableFileTypes.join(","); } + if (property.type === "array") { + control.options!.multi = true; + if (property.maxItems !== undefined) { + control.options!.maxItems = property.maxItems; + } + if (property.minItems !== undefined) { + control.options!.minItems = property.minItems; + } + if (property.uniqueItems) { + control.options!.uniqueItems = true; + } + const itemKey = + property.items?.required?.[0] ?? + Object.keys(property.items?.properties ?? {})[0]; + if (itemKey) { + control.options!.itemKey = itemKey; + } + } break; + } } // Add description if available diff --git a/test/v2.test.ts b/test/v2.test.ts index 498d313..eec1448 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -977,6 +977,278 @@ describe('BOOLEAN field type', () => { }); }); +// ─── ATTACHMENT field type ─────────────────────────────────────────────────── + +describe('ATTACHMENT field type', () => { + const attachmentSchema: V2Schema = { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + properties: { + photo: { + deprecated: false, + title: 'Attachment Field', + description: 'Upload files', + type: 'array', + uniqueItems: true, + minItems: 0, + maxItems: 5, + items: { + type: 'object', + properties: { + uploadId: { + format: 'uuid', + type: 'string', + }, + }, + required: ['uploadId'], + unevaluatedProperties: false, + }, + }, + }, + required: [], + type: 'object', + }, + ui: { + fields: { + photo: { + type: 'ATTACHMENT', + parent: 'section-1', + allowableFileTypes: ['image'], + }, + }, + headers: {}, + order: ['section-1'], + sections: { + 'section-1': { + columns: 1, + isActive: true, + label: 'Attachments', + leftColumn: [{ name: 'photo', type: 'field' }], + rightColumn: [], + }, + }, + }, + }; + + it('generates a file control with array semantics', () => { + const result = generateUISchema(attachmentSchema); + const control = result.elements![0].elements![0]; + expect(control).toMatchObject({ + type: 'Control', + scope: '#/properties/photo', + label: 'Attachment Field', + options: { + format: 'file', + multi: true, + maxItems: 5, + minItems: 0, + uniqueItems: true, + itemKey: 'uploadId', + accept: 'image', + description: 'Upload files', + }, + }); + }); + + it('omits maxItems/minItems when not declared', () => { + const schema: V2Schema = { + ...attachmentSchema, + json: { + ...attachmentSchema.json, + properties: { + photo: (() => { + const { minItems, maxItems, ...rest } = attachmentSchema.json.properties.photo; + return rest; + })(), + }, + }, + }; + const result = generateUISchema(schema); + const control = result.elements![0].elements![0]; + expect(control.options).toMatchObject({ + format: 'file', + multi: true, + uniqueItems: true, + itemKey: 'uploadId', + }); + expect(control.options!.maxItems).toBeUndefined(); + expect(control.options!.minItems).toBeUndefined(); + }); + + it('joins multiple allowableFileTypes into accept', () => { + const schema: V2Schema = { + ...attachmentSchema, + ui: { + ...attachmentSchema.ui, + fields: { + photo: { + ...attachmentSchema.ui.fields.photo, + allowableFileTypes: ['image', 'document'], + }, + }, + }, + }; + const result = generateUISchema(schema); + const control = result.elements![0].elements![0]; + expect(control.options!.accept).toBe('image,document'); + }); + + it('derives itemKey from items.required, not property declaration order', () => { + const schema: V2Schema = { + ...attachmentSchema, + json: { + ...attachmentSchema.json, + properties: { + photo: { + deprecated: false, + title: 'Attachment Field', + type: 'array', + items: { + type: 'object', + // caption declared first, but uploadId is the binding (required) key + properties: { + caption: { type: 'string' }, + uploadId: { format: 'uuid', type: 'string' }, + }, + required: ['uploadId'], + unevaluatedProperties: false, + }, + }, + }, + }, + }; + const result = generateUISchema(schema); + const control = result.elements![0].elements![0]; + expect(control.options!.itemKey).toBe('uploadId'); + }); + + it('supports ATTACHMENT inside a COLLECTION', () => { + const schema: V2Schema = { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + required: [], + type: 'object', + properties: { + arrests: { + deprecated: false, + title: 'Arrests', + type: 'array', + items: { + type: 'object', + properties: { + name: { title: 'Name', type: 'string' }, + arrestee_photo: { + title: 'Arrestee Photo', + type: 'array', + uniqueItems: true, + maxItems: 3, + items: { + type: 'object', + properties: { uploadId: { format: 'uuid', type: 'string' } }, + required: ['uploadId'], + unevaluatedProperties: false, + }, + }, + }, + }, + }, + }, + }, + ui: { + fields: { + arrests: { + type: 'COLLECTION', + parent: 'section-1', + buttonText: 'Add Arrest', + leftColumn: ['arrests.name', 'arrests.arrestee_photo'], + rightColumn: [], + }, + 'arrests.name': { + type: 'TEXT', + inputType: 'SHORT_TEXT', + parent: 'arrests', + }, + 'arrests.arrestee_photo': { + type: 'ATTACHMENT', + parent: 'arrests', + allowableFileTypes: ['image'], + }, + }, + headers: {}, + order: ['section-1'], + sections: { + 'section-1': { + columns: 1, + isActive: true, + label: 'Arrests', + leftColumn: [{ name: 'arrests', type: 'field' }], + rightColumn: [], + }, + }, + }, + }; + + const result = generateUISchema(schema); + const collectionControl = result.elements![0].elements![0]; + const detail = collectionControl.options!.detail; + const photoControl = detail!.elements![1]; + + expect(photoControl).toMatchObject({ + type: 'Control', + scope: '#/properties/arrestee_photo', + label: 'Arrestee Photo', + options: { + format: 'file', + multi: true, + maxItems: 3, + uniqueItems: true, + itemKey: 'uploadId', + accept: 'image', + }, + }); + }); + + it('throws when an ATTACHMENT field is not an array', () => { + const schema: V2Schema = { + ...attachmentSchema, + json: { + ...attachmentSchema.json, + properties: { + photo: { + deprecated: false, + title: 'Bad Attachment', + type: 'string', + }, + }, + }, + }; + expect(() => generateUISchema(schema)).toThrow(/ATTACHMENT field requires an array type/); + }); + + it('throws when an ATTACHMENT array lacks items.required', () => { + const schema: V2Schema = { + ...attachmentSchema, + json: { + ...attachmentSchema.json, + properties: { + photo: { + deprecated: false, + title: 'Bad Attachment', + type: 'array', + items: { + type: 'object', + properties: { uploadId: { format: 'uuid', type: 'string' } }, + }, + }, + }, + }, + }; + expect(() => generateUISchema(schema)).toThrow(/ATTACHMENT field requires an array type/); + }); +}); + // ─── enum + x-enumExtra (issue #41) ────────────────────────────────────────── const enumChoiceSchema: V2Schema = {