From 42a855f95228a3e77f3d72b6b37cf2fc2e0b5474 Mon Sep 17 00:00:00 2001 From: Lazy <2818242447@qq.com> Date: Sun, 12 Jul 2026 16:57:33 +0800 Subject: [PATCH] feat: add lightweight form state helper --- docs/en/Form.md | 86 ++++++++++++++++ docs/en/Inversion.md | 1 + docs/en/SUMMARY.md | 1 + src/Form/__story__/story.tsx | 28 ++++++ src/Form/__tests__/index.test.tsx | 161 ++++++++++++++++++++++++++++++ src/Form/index.ts | 128 ++++++++++++++++++++++++ src/index.ts | 3 + 7 files changed, 408 insertions(+) create mode 100644 docs/en/Form.md create mode 100644 src/Form/__story__/story.tsx create mode 100644 src/Form/__tests__/index.test.tsx create mode 100644 src/Form/index.ts diff --git a/docs/en/Form.md b/docs/en/Form.md new file mode 100644 index 00000000..bdac0db9 --- /dev/null +++ b/docs/en/Form.md @@ -0,0 +1,86 @@ +# `
` + +Keeps form values in state and exposes helpers for binding fields, submitting, +and resetting the form. + +`Form` is intentionally a lightweight state helper. It does not include schema +validation, async submission state, or a field registry. + +## Usage + +```jsx +import {Form} from 'libreact/lib/Form'; + + console.log(values)} +> + {({field, submit, reset}) => + + + + + +
+ } + +``` + +## Props + +Signature + +```ts +interface IFormProps { + init?: {[key: string]: any}; + onChange?: (values, name, value) => void; + onSubmit?: (values, event?) => void; + onReset?: (values) => void; +} +``` + +, where + + - `init` - optional map of initial field values. + - `onChange` - optional callback fired after a field changes. + - `onSubmit` - optional callback fired by `submit()`. + - `onReset` - optional callback fired after values are reset. + +## Render props + +`
` passes the following helpers to `children` or `render`: + + - `values` - current form values. + - `get(name?)` - returns a single field value, or all values when called + without a field name. + - `set(name, value)` - sets a field value. + - `field(name)` - returns `{name, value, checked, onChange}` props for an + input-like control. + - `submit(event?)` - prevents the default event and calls `onSubmit`. + - `reset()` - restores values from `init`. + + +## `withForm()` HOC + +HOC that merges `form` prop into enhanced component's props. + +```jsx +import {withForm} from 'libreact/lib/Form'; + +const MyCompWithForm = withForm(MyComp); +``` + +You can overwrite the injected prop name: + +```js +const MyCompWithForm = withForm(MyComp, 'myForm'); +``` + +Set initial values: + +```js +const MyCompWithForm = withForm(MyComp, 'form', {email: ''}); +``` diff --git a/docs/en/Inversion.md b/docs/en/Inversion.md index a232fd8e..977a9e18 100644 --- a/docs/en/Inversion.md +++ b/docs/en/Inversion.md @@ -17,6 +17,7 @@ in JSX tree without having to write stateful React components. - [``](./State.md) — inverts `.state` and `.setState()` method. - [``](./Toggle.md), [``](./Flipflop.md), [``](./Value.md), [``](./Counter.md), [``](./List.md), and [``](./Map.md) + - [``](./Form.md) — inverts lightweight form values and field bindings. - [``](./ShouldUpdate.md) — inverts `.shouldComponentUpdate()` life-cycle method. - [`invert()`](./invert.md) and [``](./invert.md#inverted) — inverts DOM element `ref` reference. diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index a1bc7036..05d5c142 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -22,6 +22,7 @@ * [Counter](Counter.md) * [List](List.md) * [Map](Map.md) + * [Form](Form.md) * [ShouldUpdate](ShouldUpdate.md) * [Lifecycles](Lifecycles.md) * [Sensors](Sensors.md) diff --git a/src/Form/__story__/story.tsx b/src/Form/__story__/story.tsx new file mode 100644 index 00000000..0938e80f --- /dev/null +++ b/src/Form/__story__/story.tsx @@ -0,0 +1,28 @@ +import {createElement as h} from 'react'; +import {storiesOf} from '@storybook/react'; +import {Form} from '..'; +import ShowDocs from '../../ShowDocs'; + +storiesOf('Inversion/Form', module) + .add('Documentation', () => h(ShowDocs, {md: require('../../../docs/en/Form.md')})) + .add('Example', () => + console.log(values)} + > + {({field, submit, reset}) => ( + + + + + + + )} + + ); diff --git a/src/Form/__tests__/index.test.tsx b/src/Form/__tests__/index.test.tsx new file mode 100644 index 00000000..2b8fefd5 --- /dev/null +++ b/src/Form/__tests__/index.test.tsx @@ -0,0 +1,161 @@ +import {h} from '../../util'; +import {mount} from 'enzyme'; +import {Form, withForm} from '..'; + +describe('
', () => { + it('is a component', () => { + expect(Form).toBeInstanceOf(Function); + }); + + it('exposes initial values', () => { + mount( + + {({values, get}) => { + expect(values).toEqual({email: 'hello@example.com'}); + expect(get('email')).toBe('hello@example.com'); + expect(get()).toEqual({email: 'hello@example.com'}); + + return null; + }} +
+ ); + }); + + it('uses the render prop', () => { + const render = jest.fn(() => null); + + mount(h(Form, { + init: {email: 'hello@example.com'}, + render + })); + + expect(render).toHaveBeenCalledWith(expect.objectContaining({ + values: {email: 'hello@example.com'} + })); + }); + + it('sets field values', () => { + let form; + const onChange = jest.fn(); + + mount( +
+ {(props) => { + form = props; + + return null; + }} +
+ ); + + form.set('email', 'hello@example.com'); + + expect(form.get('email')).toBe('hello@example.com'); + expect(onChange).toHaveBeenCalledWith( + {email: 'hello@example.com'}, + 'email', + 'hello@example.com' + ); + }); + + it('binds text input fields', () => { + const wrapper = mount( +
+ {({field}) => } +
+ ); + + const input = wrapper.find('input'); + + expect(input.prop('name')).toBe('email'); + expect(input.prop('value')).toBe(''); + + input.simulate('change', { + target: { + value: 'hello@example.com' + } + }); + + expect(wrapper.find('input').prop('value')).toBe('hello@example.com'); + }); + + it('binds checkbox fields', () => { + const wrapper = mount( +
+ {({field}) => } +
+ ); + + wrapper.find('input').simulate('change', { + target: { + type: 'checkbox', + checked: true + } + }); + + expect(wrapper.find('input').prop('checked')).toBe(true); + expect(wrapper.find('input').prop('value')).toBeUndefined(); + }); + + it('exports the form component from the package root', () => { + const libreact = require('../../'); + + expect(libreact.Form).toBe(Form); + }); + + it('submits current values', () => { + let form; + const onSubmit = jest.fn(); + const event = { + preventDefault: jest.fn() + }; + + mount( +
+ {(props) => { + form = props; + + return null; + }} +
+ ); + + form.set('email', 'hello@example.com'); + form.submit(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith( + {email: 'hello@example.com'}, + event + ); + }); + + it('resets to initial values', () => { + let form; + const onReset = jest.fn(); + + mount( +
+ {(props) => { + form = props; + + return null; + }} +
+ ); + + form.set('email', 'changed@example.com'); + form.reset(); + + expect(form.get('email')).toBe('initial@example.com'); + expect(onReset).toHaveBeenCalledWith({email: 'initial@example.com'}); + }); + + it('injects a form object through withForm()', () => { + const View = ({form}) => {form.get('email')}; + const Enhanced = withForm(View, 'form', {email: 'hello@example.com'}); + const wrapper = mount(); + + expect(wrapper.text()).toBe('hello@example.com'); + }); +}); diff --git a/src/Form/index.ts b/src/Form/index.ts new file mode 100644 index 00000000..dff20027 --- /dev/null +++ b/src/Form/index.ts @@ -0,0 +1,128 @@ +import {Component} from 'react'; +import {noop} from '../util'; +import renderProp from '../util/renderProp'; +import {faccToHocInit} from '../Value'; + +export interface IFormValues { + [key: string]: any; +} + +export interface IFormProps { + children?: (form: IFormBag) => React.ReactElement; + render?: (form: IFormBag) => React.ReactElement; + init?: IFormValues; + onChange?: (values: IFormValues, name: string, value: any) => void; + onSubmit?: (values: IFormValues, event?: any) => void; + onReset?: (values: IFormValues) => void; +} + +export interface IFormState { + values: IFormValues; +} + +export interface IFormField { + name: string; + value?: any; + checked?: boolean; + onChange: (eventOrValue: any) => void; +} + +export interface IFormBag { + values: IFormValues; + get: (name?: string) => any; + set: (name: string, value: any) => void; + field: (name: string) => IFormField; + reset: () => void; + submit: (event?: any) => void; +} + +const getInputValue = (eventOrValue) => { + const target = eventOrValue && eventOrValue.target; + + if (!target) { + return eventOrValue; + } + + return target.type === 'checkbox' ? target.checked : target.value; +}; + +export class Form extends Component { + static defaultProps = { + init: {}, + onChange: noop, + onSubmit: noop, + onReset: noop + }; + + constructor (props, context) { + super(props, context); + + this.state = { + values: {...props.init} + }; + + this.get = this.get.bind(this); + this.set = this.set.bind(this); + this.field = this.field.bind(this); + this.reset = this.reset.bind(this); + this.submit = this.submit.bind(this); + } + + get (name?: string) { + return typeof name === 'undefined' ? this.state.values : this.state.values[name]; + } + + set (name, value) { + this.setState(({values}) => ({ + values: { + ...values, + [name]: value + } + }), () => { + this.props.onChange(this.state.values, name, value); + }); + } + + field (name: string): IFormField { + const value = this.get(name); + const isBoolean = typeof value === 'boolean'; + + return { + name, + value: isBoolean ? undefined : (typeof value === 'undefined' ? '' : value), + checked: isBoolean ? value : undefined, + onChange: (eventOrValue) => this.set(name, getInputValue(eventOrValue)) + }; + } + + reset () { + const values = {...this.props.init}; + + this.setState({values}, () => { + this.props.onReset(this.state.values); + }); + } + + submit (event?) { + if (event && event.preventDefault) { + event.preventDefault(); + } + + this.props.onSubmit(this.state.values, event); + } + + render () { + const form: IFormBag = { + values: this.state.values, + get: this.get, + set: this.set, + field: this.field, + reset: this.reset, + submit: this.submit + }; + + return renderProp(this.props, form); + } +} + +export const withForm = faccToHocInit(Form, 'form'); diff --git a/src/index.ts b/src/index.ts index 2e384542..af4b8533 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,3 +50,6 @@ export * from './Interpolation'; // Other export * from './Resolve'; + +// Inversion +export * from './Form';