Skip to content

chore(next): Field types #185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
8 changes: 4 additions & 4 deletions next/src/field/object.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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',
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 8 additions & 12 deletions next/src/field/schema.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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
Expand Down Expand Up @@ -120,7 +120,7 @@ function convertToOptions(nodeOptions: JsfSchema[]): Array<FieldOption> {

const result: {
label: string
value: unknown
value: string
[key: string]: unknown
} = {
label: title || '',
Expand Down Expand Up @@ -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))
Expand All @@ -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) {
Expand All @@ -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
Expand Down
174 changes: 142 additions & 32 deletions next/src/field/type.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,157 @@
import type { JsfSchemaType } from '../types'
Copy link
Collaborator

@sandrina-p sandrina-p May 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @eng-almeida 👋 I'll review it this afternoon around 16:00!


/**
* 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'
Copy link
Collaborator

@sandrina-p sandrina-p May 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: Here's my controversial opinion: JSF does not care about FieldType, it's whatever comes from JSON schema x-jsf-presentation.inputType. We do not provide any extra logic/validation just based on it, right? (right? 👀)

So answering @lukad question:

We don't actually have any sort of validation that checks that when for example 'x-js-presentation': { 'inputType': 'money'} is given there is also 'x-js-presentation': { 'currency': 'EUR'}. @sandrina-p, is that something we do in v0?

We don't and we shouldn't. That logic is a concern of Remote internals, not JSF as headless generator/validator. If we ever validate that, it's another layer on top of JSON-SCHEMA-FORM

Does it make sense to you both?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P.S. I know, this kind affects most of your MR, as the field types should be based on the json schema type, not the inputType 😶


interface BaseField {
type: FieldType
inputType: FieldType
name: string
label: string
required: boolean
inputType: FieldType
jsonType: JsfSchemaType
errorMessage: Record<string, string>
schema: any
isVisible: boolean
accept?: string
errorMessage?: Record<string, string>
computedAttributes?: Record<string, unknown>
}

export interface FieldOption {
label: string
value?: string | number | boolean | Record<string, any>
[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'
Copy link
Collaborator

@sandrina-p sandrina-p May 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: Here's another example. This is Remote internal need, not a JSF concern. A few other examples down the line (currency, fileDownload, statement, etc... All of those Types can exist, but not at JSF.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right 💯 ! Probably the right move is to extend these types on Remote SDK project which uses Remote JSON Schemas 👍

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<string, unknown>
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[]
}
10 changes: 4 additions & 6 deletions next/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
}
}
12 changes: 5 additions & 7 deletions next/src/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion next/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion next/test/custom/order.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')

Expand Down
Loading