Skip to content
Merged
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
53 changes: 53 additions & 0 deletions next/src/custom/order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Field } from '../field/type'
import type { JsfSchema } from '../types'

function sort(params: {
fields: Field[]
order: string[]
}): Field[] {
const { fields: prevFields, order } = params

// Map from field name to expected index
const indexMap: Record<string, number | undefined> = {}
order.forEach((key, index) => {
indexMap[key] = index
})

const nextFields = prevFields.sort((a, b) => {
// Compare by index
const indexA = indexMap[a.name] ?? Infinity
const indexB = indexMap[b.name] ?? Infinity

// The else actually only happens when both are Infinity,
// i.e., not specified in the order array
if (indexA !== indexB)
return indexA - indexB

// If not specified, maintain original relative order
return prevFields.indexOf(a) - prevFields.indexOf(b)
})

return nextFields
}

/**
* Sort fields by schema's `x-jsf-order`
*/
export function setCustomOrder(params: {
schema: JsfSchema
fields: Field[]
}): Field[] {
const { schema, fields } = params

// TypeScript does not yield if we remove this check,
// but it's only because our typing is likely not right.
// See internal discussion:
// - https://remote-com.slack.com/archives/C02HTN0LY02/p1738745237733389?thread_ts=1738741631.346809&cid=C02HTN0LY02
if (typeof schema === 'boolean')
throw new Error('Schema must be an object')

if (schema['x-jsf-order'] !== undefined)
return sort({ fields, order: schema['x-jsf-order'] })

return fields
}
31 changes: 31 additions & 0 deletions next/src/field/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { JSONSchema } from 'json-schema-typed'
import type { Field } from './type'
import { setCustomOrder } from '../custom/order'
import { buildFieldSingle } from './single'

export function buildFieldObject(params: {
schema: JSONSchema
}): Field[] {
const { schema } = params

if (typeof schema === 'boolean')
throw new Error('Schema must be an object')

if (schema.type !== 'object')
throw new Error('Schema must be of type "object"')

const fields: Field[] = []

Object
.entries(schema.properties ?? {})
.forEach((entry) => {
const [name, schema] = entry
const field = buildFieldSingle({ name, schema })
if (field !== null)
fields.push(field)
})

const withOrder = setCustomOrder({ fields, schema })

return withOrder
}
30 changes: 30 additions & 0 deletions next/src/field/single.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { JsfSchema } from '../types'
import type { Field } from './type'
import { buildFieldObject } from './object'

/**
* Build a single UI field from a single schema property
*/
export function buildFieldSingle(params: {
name: string
schema: JsfSchema
}): Field | null {
const { name, schema } = params

// This is different than schema.type === "boolean"
if (typeof schema === 'boolean')
return null

// Common properties for all field types
const field: Field = {
name,
label: schema.title,
}

// Recursive for objects
if (schema.type === 'object') {
field.fields = buildFieldObject({ schema })
}

return field
}
8 changes: 8 additions & 0 deletions next/src/field/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* WIP interface for UI field output
*/
export interface Field {
name: string
label?: string
fields?: Field[]
}
24 changes: 21 additions & 3 deletions next/src/form.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Field } from './field/type'
import type { JsfSchema, SchemaValue } from './types'
import type { SchemaValidationErrorType } from './validation/schema'
import { buildFieldObject } from './field/object'
import { validateSchema } from './validation/schema'

interface FormResult {
fields: never[]
fields: Field[]
isError: boolean
error: string | null
handleValidation: (value: SchemaValue) => ValidationResult
Expand Down Expand Up @@ -83,7 +85,23 @@ interface CreateHeadlessFormOptions {
initialValues?: SchemaValue
}

export function createHeadlessForm(schema: JsfSchema, options: CreateHeadlessFormOptions = {}): FormResult {
function buildFields(params: {
schema: JsfSchema
}): Field[] {
const { schema } = params

if (typeof schema === 'boolean')
return []

const fields: Field[] = buildFieldObject({ schema })

return fields
}

export function createHeadlessForm(
schema: JsfSchema,
options: CreateHeadlessFormOptions = {},
): FormResult {
const errors = validateSchema(options.initialValues, schema)
const validationResult = validationErrorsToFormErrors(errors)
const isError = validationResult !== null
Expand All @@ -94,7 +112,7 @@ export function createHeadlessForm(schema: JsfSchema, options: CreateHeadlessFor
}

return {
fields: [],
fields: buildFields({ schema }),
isError,
error: null,
handleValidation,
Expand Down
74 changes: 74 additions & 0 deletions next/test/custom/order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { JsfSchema } from '../../src/types'
import { describe, expect, it } from '@jest/globals'
import { createHeadlessForm } from '../../src'

describe('custom order', () => {
it('should sort fields by x-jsf-order', () => {
const schema: JsfSchema = {
'type': 'object',
'properties': {
name: { type: 'string' },
age: { type: 'number' },
},
'x-jsf-order': ['age', 'name'],
}
const form = createHeadlessForm(schema)

const keys = form.fields.map(field => field.name)
expect(keys).toEqual(['age', 'name'])
})

it('should sort nested objects', () => {
const addressSchema: JsfSchema = {
'type': 'object',
'properties': {
state: { type: 'string' },
city: { type: 'string' },
street: { type: 'string' },
},
'x-jsf-order': ['street', 'city', 'state'],
}

const mainSchema: JsfSchema = {
'type': 'object',
'properties': {
address: addressSchema,
name: { type: 'string' },
},
'x-jsf-order': ['name', 'address'],
}

const form = createHeadlessForm(mainSchema)

const mainKeys = form.fields.map(field => field.name)
expect(mainKeys).toEqual(['name', 'address'])

const addressField = form.fields.find(field => field.name === 'address')
if (addressField === undefined)
throw new Error('Address field not found')

// This already throws if "fields" is undefined
const addressKeys = addressField.fields?.map(field => field.name)
expect(addressKeys).toEqual(['street', 'city', 'state'])
})

it('should respect initial, unspecified order', () => {
const schema: JsfSchema = {
'type': 'object',
'properties': {
one: { type: 'string' },
two: { type: 'string' },
three: { type: 'string' },
},
'x-jsf-order': ['three'],
}

const form = createHeadlessForm(schema)
const keys = form.fields.map(field => field.name)

// "one" and "two" are not specified,
// so they are added to the end,
// respecting their relative initial order
expect(keys).toEqual(['three', 'one', 'two'])
})
})