diff --git a/next/src/custom/order.ts b/next/src/custom/order.ts new file mode 100644 index 000000000..19e4ad694 --- /dev/null +++ b/next/src/custom/order.ts @@ -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 = {} + 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 +} diff --git a/next/src/field/object.ts b/next/src/field/object.ts new file mode 100644 index 000000000..a8a1c5aa2 --- /dev/null +++ b/next/src/field/object.ts @@ -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 +} diff --git a/next/src/field/single.ts b/next/src/field/single.ts new file mode 100644 index 000000000..2611552e5 --- /dev/null +++ b/next/src/field/single.ts @@ -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 +} diff --git a/next/src/field/type.ts b/next/src/field/type.ts new file mode 100644 index 000000000..fe2bbe369 --- /dev/null +++ b/next/src/field/type.ts @@ -0,0 +1,8 @@ +/** + * WIP interface for UI field output + */ +export interface Field { + name: string + label?: string + fields?: Field[] +} diff --git a/next/src/form.ts b/next/src/form.ts index 3565ace62..b25b98206 100644 --- a/next/src/form.ts +++ b/next/src/form.ts @@ -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 @@ -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 @@ -94,7 +112,7 @@ export function createHeadlessForm(schema: JsfSchema, options: CreateHeadlessFor } return { - fields: [], + fields: buildFields({ schema }), isError, error: null, handleValidation, diff --git a/next/test/custom/order.test.ts b/next/test/custom/order.test.ts new file mode 100644 index 000000000..7b562d715 --- /dev/null +++ b/next/test/custom/order.test.ts @@ -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']) + }) +})