diff --git a/next/src/field/object.ts b/next/src/field/object.ts index c1229c9c8..c88c8d904 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 03fcdcc8e..503890339 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) { @@ -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 7e73c9d28..c2d4fac2e 100644 --- a/next/src/field/type.ts +++ b/next/src/field/type.ts @@ -1,47 +1,157 @@ 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 + errorMessage: Record + schema: any isVisible: boolean - accept?: string - errorMessage?: Record - computedAttributes?: Record +} + +export interface FieldOption { + label: string + value?: string | number | boolean | Record + [key: string]: unknown +} + +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 +export interface FieldText extends BaseField { + type: 'text' + maxLength?: number + maskSecret?: number + pattern?: string } -/** - * 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 FieldRadio extends BaseField { + type: 'radio' + options: FieldOption[] + direction?: 'row' | 'column' + const?: string +} + +export interface FieldNumber extends BaseField { + type: 'number' + minimum?: number + maximum?: number +} + +export interface FieldMoney extends BaseField { + type: 'money' + +} + +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 interface FieldCountry extends BaseField { + type: 'country' + options?: FieldOption[] +} + +export interface Field extends BaseField { + computedAttributes?: Record + description?: string + + // 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 + 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 + fileName?: string + + // Fieldset specific properties + valueGroupingDisabled?: boolean + visualGroupingDisabled?: boolean + + fields?: Field[] + + // GroupArray specific properties + addFieldText?: string + + enum?: string[] +} diff --git a/next/src/form.ts b/next/src/form.ts index 760f6e57e..e85a49223 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 048eacc5c..3efcad31c 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,16 +139,14 @@ 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 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) } } } diff --git a/next/src/utils.ts b/next/src/utils.ts index 2d78d6e5e..53d776975 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 82ac78fc0..10e0c8bff 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 3b3f51f46..1614d6c86 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: {}, }, ], },