Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }
Expand All @@ -50,6 +50,7 @@ export interface V2BaseProperty {
required?: string[];
additionalProperties?: boolean;
unevaluatedItems?: boolean;
unevaluatedProperties?: boolean;
maxItems?: number;
minItems?: number;
uniqueItems?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions src/v2/generateUISchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion src/v2/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
272 changes: 272 additions & 0 deletions test/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading