Skip to content

Commit 07c7ea1

Browse files
authored
DEVXP-2570: feat(next): Add custom order (#119)
* add base field * add custom order
1 parent f19f2d4 commit 07c7ea1

File tree

6 files changed

+217
-3
lines changed

6 files changed

+217
-3
lines changed

next/src/custom/order.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Field } from '../field/type'
2+
import type { JsfSchema } from '../types'
3+
4+
function sort(params: {
5+
fields: Field[]
6+
order: string[]
7+
}): Field[] {
8+
const { fields: prevFields, order } = params
9+
10+
// Map from field name to expected index
11+
const indexMap: Record<string, number | undefined> = {}
12+
order.forEach((key, index) => {
13+
indexMap[key] = index
14+
})
15+
16+
const nextFields = prevFields.sort((a, b) => {
17+
// Compare by index
18+
const indexA = indexMap[a.name] ?? Infinity
19+
const indexB = indexMap[b.name] ?? Infinity
20+
21+
// The else actually only happens when both are Infinity,
22+
// i.e., not specified in the order array
23+
if (indexA !== indexB)
24+
return indexA - indexB
25+
26+
// If not specified, maintain original relative order
27+
return prevFields.indexOf(a) - prevFields.indexOf(b)
28+
})
29+
30+
return nextFields
31+
}
32+
33+
/**
34+
* Sort fields by schema's `x-jsf-order`
35+
*/
36+
export function setCustomOrder(params: {
37+
schema: JsfSchema
38+
fields: Field[]
39+
}): Field[] {
40+
const { schema, fields } = params
41+
42+
// TypeScript does not yield if we remove this check,
43+
// but it's only because our typing is likely not right.
44+
// See internal discussion:
45+
// - https://remote-com.slack.com/archives/C02HTN0LY02/p1738745237733389?thread_ts=1738741631.346809&cid=C02HTN0LY02
46+
if (typeof schema === 'boolean')
47+
throw new Error('Schema must be an object')
48+
49+
if (schema['x-jsf-order'] !== undefined)
50+
return sort({ fields, order: schema['x-jsf-order'] })
51+
52+
return fields
53+
}

next/src/field/object.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { JSONSchema } from 'json-schema-typed'
2+
import type { Field } from './type'
3+
import { setCustomOrder } from '../custom/order'
4+
import { buildFieldSingle } from './single'
5+
6+
export function buildFieldObject(params: {
7+
schema: JSONSchema
8+
}): Field[] {
9+
const { schema } = params
10+
11+
if (typeof schema === 'boolean')
12+
throw new Error('Schema must be an object')
13+
14+
if (schema.type !== 'object')
15+
throw new Error('Schema must be of type "object"')
16+
17+
const fields: Field[] = []
18+
19+
Object
20+
.entries(schema.properties ?? {})
21+
.forEach((entry) => {
22+
const [name, schema] = entry
23+
const field = buildFieldSingle({ name, schema })
24+
if (field !== null)
25+
fields.push(field)
26+
})
27+
28+
const withOrder = setCustomOrder({ fields, schema })
29+
30+
return withOrder
31+
}

next/src/field/single.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { JsfSchema } from '../types'
2+
import type { Field } from './type'
3+
import { buildFieldObject } from './object'
4+
5+
/**
6+
* Build a single UI field from a single schema property
7+
*/
8+
export function buildFieldSingle(params: {
9+
name: string
10+
schema: JsfSchema
11+
}): Field | null {
12+
const { name, schema } = params
13+
14+
// This is different than schema.type === "boolean"
15+
if (typeof schema === 'boolean')
16+
return null
17+
18+
// Common properties for all field types
19+
const field: Field = {
20+
name,
21+
label: schema.title,
22+
}
23+
24+
// Recursive for objects
25+
if (schema.type === 'object') {
26+
field.fields = buildFieldObject({ schema })
27+
}
28+
29+
return field
30+
}

next/src/field/type.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* WIP interface for UI field output
3+
*/
4+
export interface Field {
5+
name: string
6+
label?: string
7+
fields?: Field[]
8+
}

next/src/form.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import type { Field } from './field/type'
12
import type { JsfSchema, SchemaValue } from './types'
23
import type { SchemaValidationErrorType } from './validation/schema'
4+
import { buildFieldObject } from './field/object'
35
import { validateSchema } from './validation/schema'
46

57
interface FormResult {
6-
fields: never[]
8+
fields: Field[]
79
isError: boolean
810
error: string | null
911
handleValidation: (value: SchemaValue) => ValidationResult
@@ -83,7 +85,23 @@ interface CreateHeadlessFormOptions {
8385
initialValues?: SchemaValue
8486
}
8587

86-
export function createHeadlessForm(schema: JsfSchema, options: CreateHeadlessFormOptions = {}): FormResult {
88+
function buildFields(params: {
89+
schema: JsfSchema
90+
}): Field[] {
91+
const { schema } = params
92+
93+
if (typeof schema === 'boolean')
94+
return []
95+
96+
const fields: Field[] = buildFieldObject({ schema })
97+
98+
return fields
99+
}
100+
101+
export function createHeadlessForm(
102+
schema: JsfSchema,
103+
options: CreateHeadlessFormOptions = {},
104+
): FormResult {
87105
const errors = validateSchema(options.initialValues, schema)
88106
const validationResult = validationErrorsToFormErrors(errors)
89107
const isError = validationResult !== null
@@ -94,7 +112,7 @@ export function createHeadlessForm(schema: JsfSchema, options: CreateHeadlessFor
94112
}
95113

96114
return {
97-
fields: [],
115+
fields: buildFields({ schema }),
98116
isError,
99117
error: null,
100118
handleValidation,

next/test/custom/order.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { JsfSchema } from '../../src/types'
2+
import { describe, expect, it } from '@jest/globals'
3+
import { createHeadlessForm } from '../../src'
4+
5+
describe('custom order', () => {
6+
it('should sort fields by x-jsf-order', () => {
7+
const schema: JsfSchema = {
8+
'type': 'object',
9+
'properties': {
10+
name: { type: 'string' },
11+
age: { type: 'number' },
12+
},
13+
'x-jsf-order': ['age', 'name'],
14+
}
15+
const form = createHeadlessForm(schema)
16+
17+
const keys = form.fields.map(field => field.name)
18+
expect(keys).toEqual(['age', 'name'])
19+
})
20+
21+
it('should sort nested objects', () => {
22+
const addressSchema: JsfSchema = {
23+
'type': 'object',
24+
'properties': {
25+
state: { type: 'string' },
26+
city: { type: 'string' },
27+
street: { type: 'string' },
28+
},
29+
'x-jsf-order': ['street', 'city', 'state'],
30+
}
31+
32+
const mainSchema: JsfSchema = {
33+
'type': 'object',
34+
'properties': {
35+
address: addressSchema,
36+
name: { type: 'string' },
37+
},
38+
'x-jsf-order': ['name', 'address'],
39+
}
40+
41+
const form = createHeadlessForm(mainSchema)
42+
43+
const mainKeys = form.fields.map(field => field.name)
44+
expect(mainKeys).toEqual(['name', 'address'])
45+
46+
const addressField = form.fields.find(field => field.name === 'address')
47+
if (addressField === undefined)
48+
throw new Error('Address field not found')
49+
50+
// This already throws if "fields" is undefined
51+
const addressKeys = addressField.fields?.map(field => field.name)
52+
expect(addressKeys).toEqual(['street', 'city', 'state'])
53+
})
54+
55+
it('should respect initial, unspecified order', () => {
56+
const schema: JsfSchema = {
57+
'type': 'object',
58+
'properties': {
59+
one: { type: 'string' },
60+
two: { type: 'string' },
61+
three: { type: 'string' },
62+
},
63+
'x-jsf-order': ['three'],
64+
}
65+
66+
const form = createHeadlessForm(schema)
67+
const keys = form.fields.map(field => field.name)
68+
69+
// "one" and "two" are not specified,
70+
// so they are added to the end,
71+
// respecting their relative initial order
72+
expect(keys).toEqual(['three', 'one', 'two'])
73+
})
74+
})

0 commit comments

Comments
 (0)