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 2 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
12 changes: 6 additions & 6 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 Down
149 changes: 116 additions & 33 deletions next/src/field/type.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,130 @@
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!

Copy link
Collaborator

Choose a reason for hiding this comment

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

@eng-almeida when do you plan to finish this MR?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hey @sandrina-p, sorry for dropping the ball here. I'll try to wrap this up next week 🤞


/**
* 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 😶

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hi @eng-almeida , when do you plan to finish this PR? :)


interface BaseField {
type: FieldType
inputType: FieldType
name: string
label: string
required: boolean
inputType: FieldType
jsonType: JsfSchemaType
isVisible: boolean
accept?: string
errorMessage?: Record<string, string>
computedAttributes?: Record<string, unknown>
errorMessage: Record<string, string>
schema: any
isVisible: boolean
description?: string
statement?: {
title: string
inputType: 'statement'
severity: 'warning' | 'error' | 'info'
}
[key: string]: unknown
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This line hides a lot of TS errors that are hard to address without it. I'll keep investigating...

}

export interface FieldOption {
label: string
value: string
description?: string
Copy link
Collaborator

Choose a reason for hiding this comment

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

todo:

  1. The FieldOption is a mirror of oneOf, so if the oneOf has, foo inside, then the Type includes foo too.
  2. value is not necessarily a string, can be anything, number, bool, even object. It's a mirror of oneOf.const 🪩
  3. value can be optional too :p Look at this example with pattern

}

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'
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'
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[]
Copy link
Collaborator

Choose a reason for hiding this comment

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

bug: This is v0, in v1 is fields: Field[] (Once #177 is merged)) :D

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'
}

export type Field =
| FieldSelect
| FieldTextarea
| FieldDate
| FieldText
| FieldRadio
| FieldNumber
| FieldMoney
| FieldCheckbox
| FieldEmail
| FieldFile
| FieldFieldSet
| GroupArrayField
| FieldCountry
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)
}
}
}
4 changes: 2 additions & 2 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,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
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
14 changes: 14 additions & 0 deletions next/test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ describe('getField', () => {
label: 'Name',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
},
{
name: 'address',
Expand All @@ -21,6 +23,8 @@ describe('getField', () => {
label: 'Address',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
fields: [
{
name: 'street',
Expand All @@ -30,6 +34,8 @@ describe('getField', () => {
label: 'Street',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
},
{
name: 'city',
Expand All @@ -39,6 +45,8 @@ describe('getField', () => {
label: 'City',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
},
],
},
Expand Down Expand Up @@ -81,6 +89,8 @@ describe('getField', () => {
label: 'Level 1',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
fields: [
{
name: 'level2',
Expand All @@ -90,6 +100,8 @@ describe('getField', () => {
label: 'Level 2',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
fields: [
{
name: 'level3',
Expand All @@ -99,6 +111,8 @@ describe('getField', () => {
label: 'Level 3',
required: false,
isVisible: true,
errorMessage: {},
schema: {},
},
],
},
Expand Down