From ac2b75998991023217ab4313a4e3e20461bae326 Mon Sep 17 00:00:00 2001 From: Joao Almeida Date: Thu, 8 May 2025 17:35:39 +0100 Subject: [PATCH 1/5] create TS type for each field --- next/src/field/type.ts | 142 +++++++++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 33 deletions(-) diff --git a/next/src/field/type.ts b/next/src/field/type.ts index 7e73c9d2..e037fdd4 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -1,47 +1,123 @@ import type { JsfSchemaType } from '../types' -/** - * WIP type for UI field output that allows for all `x-jsf-presentation` properties to be splatted - * TODO/QUESTION: what are the required fields for a field? what are the things we want to deprecate, if any? - */ -export interface Field { - name: string - label?: string - description?: string - fields?: Field[] - // @deprecated in favor of inputType, +export type FieldType = 'text' | 'number' | 'select' | 'file' | 'radio' | 'group-array' | 'email' | 'date' | 'checkbox' | 'fieldset' | 'money' | 'country' | 'textarea' + +interface BaseField { type: FieldType - inputType: FieldType + name: string + label: string required: boolean + inputType: FieldType jsonType: JsfSchemaType - isVisible: boolean - accept?: string - errorMessage?: Record computedAttributes?: Record + errorMessage: Record + schema: any + isVisible: boolean + description?: string + statement?: { + title: string + inputType: 'statement' + severity: 'warning' | 'error' | 'info' + } + [key: string]: unknown +} + +export interface FieldOption { + label: string + value: string + description?: string +} + +export interface FieldSelect extends BaseField { + type: 'select' + options: FieldOption[] +} + +export interface FieldTextarea extends BaseField { + type: 'textarea' + maxLength: number + minLength?: number +} + +export interface FieldDate extends BaseField { + type: 'date' + format: string minDate?: string maxDate?: string maxLength?: number - maxFileSize?: number - format?: string - anyOf?: unknown[] - options?: unknown[] - const?: unknown - checkboxValue?: unknown - - // Allow additional properties from x-jsf-presentation (e.g. meta from oneOf/anyOf) - [key: string]: unknown } -/** - * Field option - * @description - * Represents a key/value pair that is used to populate the options for a field. - * Will be created from the oneOf/anyOf elements in a schema. - */ -export interface FieldOption { +export interface FieldText extends BaseField { + type: 'text' + maxLength: number + maskSecret?: number +} + +export interface FieldRadio extends BaseField { + type: 'radio' + options: FieldOption[] + direction?: 'row' | 'column' + const?: string +} + +export interface FieldNumber extends BaseField { + type: 'number' +} + +export interface FieldMoney extends BaseField { + type: 'money' + currency: string +} + +export interface FieldCheckbox extends BaseField { + type: 'checkbox' + options?: FieldOption[] + multiple?: boolean + direction?: 'row' | 'column' + checkboxValue?: string | boolean + const?: string +} + +export interface FieldEmail extends BaseField { + type: 'email' + maxLength: number + format: 'email' +} + +export interface FieldFile extends BaseField { + type: 'file' + accept: string + multiple?: boolean + fileDownload: string + fileName: string +} +export interface FieldFieldSet extends BaseField { + type: 'fieldset' + valueGroupingDisabled?: boolean + visualGroupingDisabled?: boolean + variant?: 'card' | 'focused' | 'default' + fields: Field[] +} + +export interface GroupArrayField extends BaseField { + type: 'group-array' + name: string label: string - value: unknown - [key: string]: unknown + description: string + fields: () => Field[] + addFieldText: string } -export type FieldType = 'text' | 'number' | 'select' | 'file' | 'radio' | 'group-array' | 'email' | 'date' | 'checkbox' | 'fieldset' | 'money' | 'country' | 'textarea' +export type Field = + | FieldSelect + | FieldTextarea + | FieldDate + | FieldText + | FieldRadio + | FieldNumber + | FieldMoney + | FieldCheckbox + | FieldEmail + | FieldFile + | FieldFieldSet + | GroupArrayField From 84457da6ad9f0e1a0dc20003d005655263a4393f Mon Sep 17 00:00:00 2001 From: Joao Almeida Date: Fri, 9 May 2025 14:53:11 +0100 Subject: [PATCH 2/5] fix TS error --- next/src/field/object.ts | 8 ++++---- next/src/field/schema.ts | 12 ++++++------ next/src/field/type.ts | 13 ++++++++++--- next/src/form.ts | 10 ++++------ next/src/mutations.ts | 4 ++-- next/src/utils.ts | 2 +- next/test/custom/order.test.ts | 3 ++- next/test/utils.test.ts | 14 ++++++++++++++ 8 files changed, 43 insertions(+), 23 deletions(-) diff --git a/next/src/field/object.ts b/next/src/field/object.ts index c1229c9c..c88c8d90 100644 --- a/next/src/field/object.ts +++ b/next/src/field/object.ts @@ -1,5 +1,5 @@ import type { JsfObjectSchema } from '../types' -import type { Field } from './type' +import type { Field, FieldFile } from './type' import { setCustomOrder } from '../custom/order' import { buildFieldSchema } from './schema' @@ -23,7 +23,7 @@ export function buildFieldObject(schema: JsfObjectSchema, name: string, required const orderedFields = setCustomOrder({ fields, schema }) - const field: Field = { + const field = { ...schema['x-jsf-presentation'], type: schema['x-jsf-presentation']?.inputType || 'fieldset', inputType: schema['x-jsf-presentation']?.inputType || 'fieldset', @@ -32,7 +32,7 @@ export function buildFieldObject(schema: JsfObjectSchema, name: string, required required, fields: orderedFields, isVisible: true, - } + } as Field if (schema.title !== undefined) { field.label = schema.title @@ -43,7 +43,7 @@ export function buildFieldObject(schema: JsfObjectSchema, name: string, required } if (schema['x-jsf-presentation']?.accept) { - field.accept = schema['x-jsf-presentation']?.accept + (field as FieldFile).accept = schema['x-jsf-presentation']?.accept } return field diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 03fcdcc8..0ac97653 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -1,5 +1,5 @@ import type { JsfObjectSchema, JsfSchema, JsfSchemaType, NonBooleanJsfSchema } from '../types' -import type { Field, FieldOption, FieldType } from './type' +import type { Field, FieldCheckbox, FieldOption, FieldType } from './type' import { buildFieldObject } from './object' /** @@ -8,7 +8,7 @@ import { buildFieldObject } from './object' * @param field - The field to add the attributes to * @param schema - The schema of the field */ -function addCheckboxAttributes(inputType: string, field: Field, schema: NonBooleanJsfSchema) { +function addCheckboxAttributes(inputType: string, field: FieldCheckbox, schema: NonBooleanJsfSchema) { // The checkboxValue attribute indicates which is the valid value a checkbox can have (for example "acknowledge", or `true`) // So, we set it to what's specified in the schema (if any) field.checkboxValue = schema.const @@ -120,7 +120,7 @@ function convertToOptions(nodeOptions: JsfSchema[]): Array { const result: { label: string - value: unknown + value: string [key: string]: unknown } = { label: title || '', @@ -211,7 +211,7 @@ export function buildFieldSchema( const inputType = getInputType(schema, strictInputType) // Build field with all schema properties by default, excluding ones that need special handling - const field: Field = { + const field = { // Spread all schema properties except excluded ones ...Object.entries(schema) .filter(([key]) => !excludedSchemaProps.includes(key)) @@ -225,10 +225,10 @@ export function buildFieldSchema( required, isVisible: true, ...(errorMessage && { errorMessage }), - } + } as Field if (inputType === 'checkbox') { - addCheckboxAttributes(inputType, field, schema) + addCheckboxAttributes(inputType, field as FieldCheckbox, schema) } if (schema.title) { diff --git a/next/src/field/type.ts b/next/src/field/type.ts index e037fdd4..e877df6a 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -35,7 +35,7 @@ export interface FieldSelect extends BaseField { export interface FieldTextarea extends BaseField { type: 'textarea' - maxLength: number + maxLength?: number minLength?: number } @@ -49,7 +49,7 @@ export interface FieldDate extends BaseField { export interface FieldText extends BaseField { type: 'text' - maxLength: number + maxLength?: number maskSecret?: number } @@ -62,6 +62,8 @@ export interface FieldRadio extends BaseField { export interface FieldNumber extends BaseField { type: 'number' + minimum?: number + maximum?: number } export interface FieldMoney extends BaseField { @@ -80,7 +82,7 @@ export interface FieldCheckbox extends BaseField { export interface FieldEmail extends BaseField { type: 'email' - maxLength: number + maxLength?: number format: 'email' } @@ -108,6 +110,10 @@ export interface GroupArrayField extends BaseField { addFieldText: string } +export interface FieldCountry extends BaseField { + type: 'country' +} + export type Field = | FieldSelect | FieldTextarea @@ -121,3 +127,4 @@ export type Field = | FieldFile | FieldFieldSet | GroupArrayField + | FieldCountry diff --git a/next/src/form.ts b/next/src/form.ts index 760f6e57..e85a4922 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -212,7 +212,7 @@ export interface CreateHeadlessFormOptions { function buildFields(params: { schema: JsfObjectSchema, strictInputType?: boolean }): Field[] { const { schema, strictInputType } = params const fields = buildFieldObject(schema, 'root', true, strictInputType).fields || [] - return fields + return fields as Field[] } export function createHeadlessForm( @@ -262,14 +262,12 @@ function buildFieldsInPlace(fields: Field[], schema: JsfObjectSchema): void { const newFields = buildFieldObject(schema, 'root', true).fields || [] // Push all new fields into existing array - fields.push(...newFields) + fields.push(...(newFields as Field[])) // Recursively update any nested fields for (const field of fields) { - // eslint-disable-next-line ts/ban-ts-comment - // @ts-expect-error - if (field.fields && schema.properties?.[field.name]?.type === 'object') { - buildFieldsInPlace(field.fields, schema.properties[field.name] as JsfObjectSchema) + if (field.fields && schema.properties?.[field.name] && typeof schema.properties[field.name] === 'object' && (schema.properties[field.name] as JsfObjectSchema).type === 'object') { + buildFieldsInPlace(field.fields as Field[], schema.properties[field.name] as JsfObjectSchema) } } } diff --git a/next/src/mutations.ts b/next/src/mutations.ts index 048eacc5..bcbbf173 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -31,7 +31,7 @@ export function mutateFields( const field = fields.find(field => field.name === fieldName) if (field?.fields) { - applySchemaRules(field.fields, values[fieldName], fieldSchema as JsfObjectSchema, options) + applySchemaRules(field.fields as Field[], values[fieldName], fieldSchema as JsfObjectSchema, options) } } } @@ -139,7 +139,7 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, } // If the field has inner fields, we need to process them else if (field?.fields) { - processBranch(field.fields, values, fieldSchema) + processBranch(field.fields as Field[], values, fieldSchema) } // If the field has properties being declared on this branch, we need to update the field // with the new properties diff --git a/next/src/utils.ts b/next/src/utils.ts index 2d78d6e5..53d77697 100644 --- a/next/src/utils.ts +++ b/next/src/utils.ts @@ -39,7 +39,7 @@ export function getField(fields: Field[], name: string, ...subNames: string[]) { if (!field?.fields) { return undefined } - return getField(field.fields, subNames[0], ...subNames.slice(1)) + return getField(field.fields as Field[], subNames[0], ...subNames.slice(1)) } return field } diff --git a/next/test/custom/order.test.ts b/next/test/custom/order.test.ts index 82ac78fc..10e0c8bf 100644 --- a/next/test/custom/order.test.ts +++ b/next/test/custom/order.test.ts @@ -1,3 +1,4 @@ +import type { FieldFieldSet } from '../../src/field/type' import type { JsfObjectSchema } from '../../src/types' import { describe, expect, it } from '@jest/globals' import { createHeadlessForm } from '../../src' @@ -43,7 +44,7 @@ describe('custom order', () => { const mainKeys = form.fields.map(field => field.name) expect(mainKeys).toEqual(['name', 'address']) - const addressField = form.fields.find(field => field.name === 'address') + const addressField = form.fields.find(field => field.name === 'address') as FieldFieldSet if (addressField === undefined) throw new Error('Address field not found') diff --git a/next/test/utils.test.ts b/next/test/utils.test.ts index 3b3f51f4..1614d6c8 100644 --- a/next/test/utils.test.ts +++ b/next/test/utils.test.ts @@ -12,6 +12,8 @@ describe('getField', () => { label: 'Name', required: false, isVisible: true, + errorMessage: {}, + schema: {}, }, { name: 'address', @@ -21,6 +23,8 @@ describe('getField', () => { label: 'Address', required: false, isVisible: true, + errorMessage: {}, + schema: {}, fields: [ { name: 'street', @@ -30,6 +34,8 @@ describe('getField', () => { label: 'Street', required: false, isVisible: true, + errorMessage: {}, + schema: {}, }, { name: 'city', @@ -39,6 +45,8 @@ describe('getField', () => { label: 'City', required: false, isVisible: true, + errorMessage: {}, + schema: {}, }, ], }, @@ -81,6 +89,8 @@ describe('getField', () => { label: 'Level 1', required: false, isVisible: true, + errorMessage: {}, + schema: {}, fields: [ { name: 'level2', @@ -90,6 +100,8 @@ describe('getField', () => { label: 'Level 2', required: false, isVisible: true, + errorMessage: {}, + schema: {}, fields: [ { name: 'level3', @@ -99,6 +111,8 @@ describe('getField', () => { label: 'Level 3', required: false, isVisible: true, + errorMessage: {}, + schema: {}, }, ], }, From 2a8f2f5a8552a16a2cdba462676d6ff8e5544227 Mon Sep 17 00:00:00 2001 From: Joao Almeida Date: Fri, 9 May 2025 15:58:32 +0100 Subject: [PATCH 3/5] improve Field type --- next/src/field/schema.ts | 8 ++--- next/src/field/type.ts | 78 ++++++++++++++++++++++++++++------------ next/src/mutations.ts | 8 ++--- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/next/src/field/schema.ts b/next/src/field/schema.ts index 0ac97653..50389033 100644 --- a/next/src/field/schema.ts +++ b/next/src/field/schema.ts @@ -237,12 +237,8 @@ export function buildFieldSchema( // Spread presentation properties to the root level if (Object.keys(presentation).length > 0) { - Object.entries(presentation).forEach(([key, value]) => { - // inputType is already handled above - if (key !== 'inputType') { - field[key] = value - } - }) + const { inputType: _, ...presentationProps } = presentation + Object.assign(field, presentationProps) } // Handle options diff --git a/next/src/field/type.ts b/next/src/field/type.ts index e877df6a..136af6f7 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -9,17 +9,9 @@ interface BaseField { required: boolean inputType: FieldType jsonType: JsfSchemaType - computedAttributes?: Record errorMessage: Record schema: any isVisible: boolean - description?: string - statement?: { - title: string - inputType: 'statement' - severity: 'warning' | 'error' | 'info' - } - [key: string]: unknown } export interface FieldOption { @@ -51,6 +43,7 @@ export interface FieldText extends BaseField { type: 'text' maxLength?: number maskSecret?: number + pattern?: string } export interface FieldRadio extends BaseField { @@ -112,19 +105,60 @@ export interface GroupArrayField extends BaseField { export interface FieldCountry extends BaseField { type: 'country' + options?: FieldOption[] } -export type Field = - | FieldSelect - | FieldTextarea - | FieldDate - | FieldText - | FieldRadio - | FieldNumber - | FieldMoney - | FieldCheckbox - | FieldEmail - | FieldFile - | FieldFieldSet - | GroupArrayField - | FieldCountry +export interface Field extends BaseField { + computedAttributes?: Record + description?: string + statement?: { + title: string + inputType: 'statement' + severity: 'warning' | 'error' | 'info' + } + + // Select specific properties + options?: FieldOption[] + + // Text specific properties + maxLength?: number + maskSecret?: number + minLength?: number + pattern?: string + + // Date specific properties + format?: string + minDate?: string + maxDate?: string + + // Radio specific properties + direction?: 'row' | 'column' + const?: string + + // Number specific properties + minimum?: number + maximum?: number + + // Money specific properties + currency?: string + + // Checkbox specific properties + multiple?: boolean + checkboxValue?: string | boolean + + // File specific properties + accept?: string + fileDownload?: string + fileName?: string + + // Fieldset specific properties + valueGroupingDisabled?: boolean + visualGroupingDisabled?: boolean + variant?: 'card' | 'focused' | 'default' + fields?: Field[] + + // GroupArray specific properties + addFieldText?: string + + enum?: string[] +} diff --git a/next/src/mutations.ts b/next/src/mutations.ts index bcbbf173..3efcad31 100644 --- a/next/src/mutations.ts +++ b/next/src/mutations.ts @@ -144,11 +144,9 @@ function processBranch(fields: Field[], values: SchemaValue, branch: JsfSchema, // If the field has properties being declared on this branch, we need to update the field // with the new properties const newField = buildFieldSchema(fieldSchema as JsfObjectSchema, fieldName, true) - for (const key in newField) { - // We don't want to override the type property - if (!['type'].includes(key)) { - field[key] = newField[key] - } + if (newField) { + const { type: _, ...newProps } = newField + Object.assign(field, newProps) } } } From bf44b4dff883a9a5e0c14ef21eaa7d4bb6b447a4 Mon Sep 17 00:00:00 2001 From: Joao Almeida Date: Fri, 9 May 2025 17:19:03 +0100 Subject: [PATCH 4/5] remove Remote properties from Types --- next/src/field/type.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/next/src/field/type.ts b/next/src/field/type.ts index 136af6f7..327b825c 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -16,8 +16,8 @@ interface BaseField { export interface FieldOption { label: string - value: string - description?: string + value?: string | number | boolean | Record + [key: string]: unknown } export interface FieldSelect extends BaseField { @@ -61,7 +61,7 @@ export interface FieldNumber extends BaseField { export interface FieldMoney extends BaseField { type: 'money' - currency: string + } export interface FieldCheckbox extends BaseField { @@ -111,11 +111,6 @@ export interface FieldCountry extends BaseField { export interface Field extends BaseField { computedAttributes?: Record description?: string - statement?: { - title: string - inputType: 'statement' - severity: 'warning' | 'error' | 'info' - } // Select specific properties options?: FieldOption[] @@ -132,7 +127,6 @@ export interface Field extends BaseField { maxDate?: string // Radio specific properties - direction?: 'row' | 'column' const?: string // Number specific properties @@ -148,13 +142,12 @@ export interface Field extends BaseField { // File specific properties accept?: string - fileDownload?: string fileName?: string // Fieldset specific properties valueGroupingDisabled?: boolean visualGroupingDisabled?: boolean - variant?: 'card' | 'focused' | 'default' + fields?: Field[] // GroupArray specific properties From 1f1fdf803c075ff1dc91da815309ed19ace004e1 Mon Sep 17 00:00:00 2001 From: Joao Almeida Date: Fri, 9 May 2025 17:20:04 +0100 Subject: [PATCH 5/5] update fields type --- next/src/field/type.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next/src/field/type.ts b/next/src/field/type.ts index 327b825c..c2d4fac2 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -99,7 +99,7 @@ export interface GroupArrayField extends BaseField { name: string label: string description: string - fields: () => Field[] + fields: Field[] addFieldText: string }