Skip to content

Commit

Permalink
Update Formulier, useFormField implementations
Browse files Browse the repository at this point in the history
  • Loading branch information
vjee committed Feb 8, 2024
1 parent 3f118cc commit 38eeb88
Show file tree
Hide file tree
Showing 18 changed files with 246 additions and 186 deletions.
8 changes: 8 additions & 0 deletions .changeset/two-crews-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@formulier/docs": minor
"@formulier/examples-react": minor
"@formulier/core": minor
"@formulier/react": minor
---

Update Formulier, useFormField implementations
3 changes: 2 additions & 1 deletion docs/docs/react-components/guide-getting-started-example.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ function InputField({name, label, type = 'text'}) {
{label}
</label>
<input
{...field}
className="input"
type={type}
{...field}
name={name}
value={field.value || ''}
onChange={event => field.onChange(event.target.value)}
/>
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/react-components/guide-validation-min-length.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ function InputField({name, label, type = 'text', minLength}) {
{label}
</label>
<input
{...field}
className="input"
type={type}
{...field}
name={name}
value={field.value || ''}
onChange={event => field.onChange(event.target.value)}
/>
Expand Down
3 changes: 2 additions & 1 deletion docs/docs/react-components/guide-validation-required.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ function InputField({name, label, type = 'text', minLength, required}) {
{label}
</label>
<input
{...field}
className="input"
type={type}
{...field}
name={name}
value={field.value || ''}
onChange={event => field.onChange(event.target.value)}
/>
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/react/api/use-form-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ function TextInput({name, label}) {

return (
<>
<label htmlFor={name}>{label}</label>
<input id={name} type="text" {...fieldProps} />
<label htmlFor={field.id}>{label}</label>
<input name={name} type="text" {...field} />
{meta.error && <span>{meta.error}</span>}
</>
)
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/react/guide/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This makes it easy to validate when the form has conditional fields or requires
Let's validate that the `firstName` and `lastName` are at least 2 characters long.\
We start with the form we made on the [Getting Started](./getting-started) page.

<<< @/react-components/guide-validation-min-length.jsx{20,21,30,32-43,45,59}
<<< @/react-components/guide-validation-min-length.jsx{20,21,30,32-43,45,60}

## Example: Required

Expand Down
27 changes: 18 additions & 9 deletions examples/react/src/fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ export function TextField({name, label}: {name: string; label: string}) {
const {error} = meta

return (
<Field name={name} label={label} error={error}>
<input type="text" id={id} value={value || ''} onChange={event => onChange(event.target.value)} onBlur={onBlur} />
<Field id={id} label={label} error={error}>
<input
type="text"
id={id}
name={name}
value={value || ''}
onChange={event => onChange(event.target.value)}
onBlur={onBlur}
/>
</Field>
)
}
Expand All @@ -23,11 +30,12 @@ export function IntegerField({name, label}: {name: string; label: string}) {
const {error} = meta

return (
<Field name={name} label={label} error={error}>
<Field id={id} label={label} error={error}>
<input
type="number"
step="1"
id={id}
name={name}
value={value || ''}
onChange={event => onChange(parseInt(event.target.value, 10))}
onBlur={onBlur}
Expand All @@ -44,8 +52,8 @@ export function SelectField({name, label, children}: {name: string; label: strin
const {error} = meta

return (
<Field name={name} label={label} error={error}>
<select id={id} value={value || ''} onChange={event => onChange(event.target.value)} onBlur={onBlur}>
<Field id={id} label={label} error={error}>
<select id={id} name={name} value={value || ''} onChange={event => onChange(event.target.value)} onBlur={onBlur}>
{children}
</select>
</Field>
Expand All @@ -60,10 +68,11 @@ export function CheckboxField({name, label}: {name: string; label: string}) {
const {error} = meta

return (
<Field name={name} label={label} error={error} displayHorizontal>
<Field id={id} label={label} error={error} displayHorizontal>
<input
type="checkbox"
id={id}
name={name}
checked={value || false}
onChange={event => onChange(event.target.checked)}
onBlur={onBlur}
Expand All @@ -73,21 +82,21 @@ export function CheckboxField({name, label}: {name: string; label: string}) {
}

export function Field({
name,
id,
label,
error,
children,
displayHorizontal = false,
}: {
name: string
id: string
label: string
error: string | null
children: React.ReactNode
displayHorizontal?: boolean
}) {
return (
<div className={`field ${displayHorizontal ? 'horizontal' : ''}`}>
<label className="field-label" htmlFor={name}>
<label className="field-label" htmlFor={id}>
{label}
</label>
<div className="field-input">{children}</div>
Expand Down
185 changes: 102 additions & 83 deletions packages/core/src/form.ts
Original file line number Diff line number Diff line change
@@ -1,126 +1,145 @@
import {FieldValidator, FormListener, FormulierState, Nullable, Primitives, Values} from './types'
import {FieldValidator, FormulierState, Nullable, Primitives, Values} from './types'
import {getPath, isEqual, removeKey, setKey, setPath} from './state-utils'

export interface FormulierOptions<V extends Values, P extends Primitives> {
initialValues: Nullable<V, P>
}

export class Formulier<V extends Values = Values, P extends Primitives = Primitives> {
notifyEnabled: boolean
listeners: Set<FormListener<V, P>>
state: FormulierState<V, P>
store: Store<FormulierState<V, P>>
instances: Record<string, Set<string>> = {}

constructor({initialValues}: FormulierOptions<V, P>) {
this.notifyEnabled = true
this.listeners = new Set()
this.state = {
this.store = new Store({
values: initialValues,
validators: {},
errors: {},
touched: {},
submitCount: 0,
} as FormulierState<V, P>
})
}

this.getState = this.getState.bind(this)
this.subscribe = this.subscribe.bind(this)
setFieldErrors = (fieldErrors: FormulierState<V, P>['errors']): void => {
this.store.batch(() => {
this.store.setState(state => ({...state, errors: {}}))
for (const [name, error] of Object.entries(fieldErrors)) {
this.store.setState(state => ({...state, errors: setKey(state.errors, name, error)}))
}
})
}

getState(): FormulierState<V, P> {
return this.state
validateFields = (): boolean => {
const {validators, values} = this.store.getState()
const fieldErrors = Object.fromEntries(
Object.entries(validators).map(([name, validate]) => {
const value = getPath(values, name, null)
const error = validate?.(value) || null
return [name, error]
}),
)
this.setFieldErrors(fieldErrors)
const noErrors = Object.values(this.store.getState().errors).every(value => value == null)
return noErrors
}

subscribe(listener: FormListener<V, P>): () => void {
this.listeners.add(listener)
validateField = (name: string): boolean => {
const {validators, values} = this.store.getState()
const validate = validators[name]
const value = getPath(values, name, null)
const error = validate?.(value) || null
this.store.setState(state => ({...state, errors: setKey(state.errors, name, error)}))
return !!validate && !error
}

registerField = (name: string, validate: FieldValidator | undefined): (() => void) => {
this.store.setState(state => ({
...state,
values: setPath(state.values, name, getPath(state.values, name, null)),
validators: setKey(state.validators, name, validate || null),
errors: state.errors[name] === undefined ? setKey(state.errors, name, null) : state.errors,
touched: state.touched[name] === undefined ? setKey(state.touched, name, false) : state.touched,
}))
return () => {
this.listeners.delete(listener)
this.store.setState(state => ({
...state,
values: setPath(state.values, name, undefined),
validators: removeKey(state.validators, name),
errors: removeKey(state.errors, name),
touched: removeKey(state.touched, name),
}))
}
}

notify(): void {
if (this.notifyEnabled === false) return
const state = this.getState()
this.listeners.forEach(listener => listener(state))
addInstance = (name: string, instanceId: string): (() => void) => {
this.instances[name] ||= new Set()
this.instances[name].add(instanceId)
return () => {
this.instances[name]?.delete(instanceId)
}
}

validateFields(): boolean {
this.state = {...this.state, errors: {}}
Object.entries(this.state.validators).forEach(([name, validate]) => {
const value = getPath(this.state.values, name, null)
const error = validate?.(value) || null
this.state = {...this.state, errors: setKey(this.state.errors, name, error)}
})
this.notify()
const noErrors = !Object.values(this.state.errors).some(value => value !== null)
return noErrors
hasInstance = (name: string): boolean => {
return !!this.instances[name]?.size
}

validateField(name: string): boolean {
const validate = this.state.validators[name]
const value = getPath(this.state.values, name, null)
const error = validate?.(value) || null
this.state = {...this.state, errors: setKey(this.state.errors, name, error)}
this.notify()
return !!validate && !error
setFieldValue = (name: string, value: unknown): void => {
const {values} = this.store.getState()
if (isEqual(getPath(values, name), value)) return
this.store.setState(state => ({...state, values: setPath(state.values, name, value)}))
}

registerField(name: string, validate: FieldValidator | undefined): void {
const value = getPath(this.state.values, name, null)
const errors = this.state.errors[name]
const touched = this.state.touched[name]
this.state = {
...this.state,
values: setPath(this.state.values, name, value),
validators: setKey(this.state.validators, name, validate || null),
}
if (errors === undefined) {
this.state = {...this.state, errors: setKey(this.state.errors, name, null)}
}
if (touched === undefined) {
this.state = {...this.state, touched: setKey(this.state.touched, name, false)}
}
this.notify()
touchField = (name: string, value = true): void => {
const {touched} = this.store.getState()
if (touched[name] === value) return
this.store.setState(state => ({...state, touched: setKey(state.touched, name, value)}))
}

unregisterField(name: string): void {
this.state = {
...this.state,
values: setPath(this.state.values, name, undefined),
validators: removeKey(this.state.validators, name),
errors: removeKey(this.state.errors, name),
touched: removeKey(this.state.touched, name),
}
this.notify()
incrementSubmitCount = (): void => {
this.store.setState(state => ({...state, submitCount: state.submitCount + 1}))
}
}

setFieldValue(name: string, value: unknown): void {
if (isEqual(getPath(this.state.values, name), value)) return
this.state = {
...this.state,
values: setPath(this.state.values, name, value),
}
this.notify()
class Store<S> {
private batching = false
private flushing = 0
private listeners = new Set<(state: S) => void>()
private state: S

constructor(state: S) {
this.state = state
}

touchField(name: string, touched = true): void {
if (this.state.touched[name] === touched) return
this.state = {
...this.state,
touched: setKey(this.state.touched, name, touched),
}
this.notify()
getState = (): S => {
return this.state
}

incrementSubmitCount(): void {
this.state = {
...this.state,
submitCount: this.state.submitCount + 1,
setState = (updater: (previous: S) => S): void => {
this.state = updater(this.state)
this.flush()
}

subscribe = (listener: (state: S) => void): (() => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
this.notify()
}

withoutNotify(callback: CallableFunction): void {
this.notifyEnabled = false
batch = (callback: () => void): void => {
if (this.batching === true) return void callback()
this.batching = true
callback()
this.notifyEnabled = true
this.batching = false
this.flush()
}

private flush = (): void => {
if (this.batching === true) return
const state = this.getState()
const flushId = ++this.flushing
for (const listener of this.listeners) {
if (this.flushing !== flushId) continue
listener(state)
}
}
}
4 changes: 0 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ export interface FormulierState<V extends Values, P extends Primitives = Primiti
submitCount: number
}

export interface FormListener<V extends Values = Values, P extends Primitives = Primitives> {
(state: FormulierState<V, P>): void
}

export type GetFieldType<T, P extends string> = string extends P
? any
: P extends `${infer Left}.${infer Right}`
Expand Down
Loading

0 comments on commit 38eeb88

Please sign in to comment.