Skip to content
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

feat: support escaped paths in field name #551

Closed
Closed
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
41 changes: 41 additions & 0 deletions packages/form-core/src/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,47 @@ describe('field api', () => {
expect(field.getValue()).toBe('other')
})

it('should set a nested value correctly', () => {
const form = new FormApi({
defaultValues: {
name: {
nested: 'test',
},
},
})

const field = new FieldApi({
form,
name: 'name.nested',
})

field.setValue('other')

expect(field.getValue()).toBe('other')
expect(form.state.values.name.nested).toBe('other')
})

it('should set a value with dot correctly', () => {
const form = new FormApi({
defaultValues: {
'name.withdot': 'test',
},
})

const field = new FieldApi({
form,
name: '["name.withdot"]',
})

field.setValue('other')

expect(field.getValue()).toBe('other')
expect(form.state.values['name.withdot']).toBe('other')
expect(form.state.values).toMatchObject({
'name.withdot': 'other',
})
})

it('should push an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand Down
36 changes: 36 additions & 0 deletions packages/form-core/src/tests/FieldApi.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ it('should type value properly', () => {
assertType<'test'>(field.getValue())
})

it('should type nested value properly', () => {
const form = new FormApi({
defaultValues: {
name: {
nested: 'test',
},
},
} as const)

const field = new FieldApi({
form,
name: 'name.nested',
} as const)

assertType<'test'>(field.state.value)
assertType<'name.nested'>(field.options.name)
assertType<'test'>(field.getValue())
})

it('should type properties with dot properly', () => {
const form = new FormApi({
defaultValues: {
'name.withdot': 'test',
},
} as const)

const field = new FieldApi({
form,
name: '["name.withdot"]',
})

assertType<'test'>(field.state.value)
assertType<'["name.withdot"]' | "['name.withdot']">(field.options.name)
assertType<'test'>(field.getValue())
})

it('should type onChange properly', () => {
const form = new FormApi({
defaultValues: {
Expand Down
8 changes: 8 additions & 0 deletions packages/form-core/src/tests/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ describe('getBy', () => {
mother: {
name: 'Lisa',
},
'key.with.dot': 'value',
'key.with.dot.2': {
'key.with.dot.3': 'value2',
},
}

it('should get subfields by path', () => {
expect(getBy(structure, 'name')).toBe(structure.name)
expect(getBy(structure, 'mother.name')).toBe(structure.mother.name)
expect(getBy(structure, "['key.with.dot']")).toBe(structure['key.with.dot'])
expect(getBy(structure, "['key.with.dot.2']['key.with.dot.3']")).toBe(
structure['key.with.dot.2']['key.with.dot.3'],
)
})

it('should get array subfields by path', () => {
Expand Down
89 changes: 61 additions & 28 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,35 +114,56 @@ export function deleteBy(obj: any, _path: any) {
return doDelete(obj)
}

const reFindNumbers0 = /^(\d*)$/gm
const reFindNumbers1 = /\.(\d*)\./gm
const reFindNumbers2 = /^(\d*)\./gm
const reFindNumbers3 = /\.(\d*$)/gm
const reFindMultiplePeriods = /\.{2,}/gm

const intPrefix = '__int__'
const intReplace = `${intPrefix}$1`
// This utilty is from stringToPath in lodash with minor modifications to extract numbers.
// https://github.com/lodash/lodash/blob/aa18212085c52fc106d075319637b8729e0f179f/src/.internal/stringToPath.ts
const charCodeOfDot = '.'.charCodeAt(0)
const reEscapeChar = /\\(\\)?/g
const rePropName = RegExp(
// Match anything that isn't a dot or bracket.
'[^.[\\]]+' +
'|' +
// Or match property names within brackets.
'\\[(?:' +
// Match a non-string expression.
'([^"\'][^[]*)' +
'|' +
// Or match strings (supports escaping characters).
'(["\'])((?:(?!\\2)[^\\\\]|\\\\.)*?)\\2' +
')\\]' +
'|' +
// Or match "" as the space between consecutive dots or empty brackets.
'(?=(?:\\.|\\[\\])(?:\\.|\\[\\]|$))',
'g',
)
const reFindNumber = /^\d+$/

function makePathArray(str: string) {
if (typeof str !== 'string') {
throw new Error('Path must be a string.')
}

return str
.replace('[', '.')
.replace(']', '')
.replace(reFindNumbers0, intReplace)
.replace(reFindNumbers1, `.${intReplace}.`)
.replace(reFindNumbers2, `${intReplace}.`)
.replace(reFindNumbers3, `.${intReplace}`)
.replace(reFindMultiplePeriods, '.')
.split('.')
.map((d) => {
if (d.indexOf(intPrefix) === 0) {
return parseInt(d.substring(intPrefix.length), 10)
const result: (string | number)[] = []
if (str.charCodeAt(0) === charCodeOfDot) {
result.push('')
}
str.replace(
rePropName,
(match: string, expression: string, quote: string, subString: string) => {
let key: string | number = match
if (quote) {
key = subString.replace(reEscapeChar, '$1')
} else if (expression) {
key = expression.trim()
}
// key difference from lodash string to path algoritm
if (reFindNumber.test(key)) {
key = parseInt(key, 10)
}
return d
})
result.push(key)
return ''
},
)
return result
}

export function isNonEmptyArray(obj: any) {
Expand Down Expand Up @@ -309,7 +330,9 @@ export type DeepKeys<T, TDepth extends any[] = []> = TDepth['length'] extends 5
: T extends Date
? never
: T extends object
? (keyof T & string) | DeepKeysPrefix<T, keyof T, TDepth>
?
| EscapedKey<keyof T & string>
| DeepKeysPrefix<T, keyof T, TDepth>
: never

type DeepKeysPrefix<
Expand All @@ -320,11 +343,21 @@ type DeepKeysPrefix<
? `${TPrefix}.${DeepKeys<T[TPrefix], [...TDepth, any]> & string}`
: never

export type DeepValue<T, TProp> = T extends Record<string | number, any>
? TProp extends `${infer TBranch}.${infer TDeepProp}`
? DeepValue<T[TBranch], TDeepProp>
: T[TProp & string]
: never
export type DeepValue<T, TProp> = TProp extends ''
? T
: T extends Record<string | number, any>
? TProp extends `["${infer TBranch}"].${infer TDeepProp}`
? DeepValue<T[TBranch], TDeepProp>
: TProp extends `["${infer TBranch}"]${infer TDeepProp}`
? DeepValue<T[TBranch], TDeepProp>
: TProp extends `${infer TBranch}.${infer TDeepProp}`
? DeepValue<T[TBranch], TDeepProp>
: T[TProp & string]
: never

type EscapedKey<T extends string> = T extends `${string}.${string}`
? `["${T}"]` | `['${T}']`
: T

type Narrowable = string | number | bigint | boolean

Expand Down
2 changes: 1 addition & 1 deletion packages/react-form/src/useField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function useField<
fieldApi.store,
opts.mode === 'array'
? (state) => {
return [state.meta, Object.keys(state.value).length]
return [state.meta, Object.keys(state.value as any[]).length]
}
: undefined,
)
Expand Down