Form 组件

更新时间:2023-02-22 15:57:53标签:reactformrxjsweb前端

Form.tsx
1import {
2 useMemo, useState, ReactNode, useRef, forwardRef, ForwardRefExoticComponent,
3 PropsWithoutRef, RefAttributes, useImperativeHandle, useCallback, FormEvent, CSSProperties,
4} from 'react';
5import { Field } from './Field';
6import { FormContext, FormContextInterface, FormValuesContext, FormValuesMap, FormValues } from './Context';
7import type { Observable } from 'rxjs';
8import { concatMap, forkJoin, of, throwError } from 'rxjs';
9import { Status } from './interface';
10import { trailed, combineClass } from '@shared/utils';
11import { FormItem } from './Item';
12
13export interface FormInstance<T=any> {
14 setItem: (name: string, value: any) => void;
15 reset: (...fields: string[]) => void;
16 clearValidity: (...fields: string[]) => void;
17 validate: (...fields: string[]) => Observable<T>;
18 readonly values: FormValuesMap;
19 toJSON: FormContextInterface['toJSON'];
20}
21export interface FormProps {
22 className?: string;
23 style?: CSSProperties;
24 children?: ReactNode;
25 onChange?: (values: FormValues) => void;
26 onSubmit?: (instance: FormInstance) => void;
27}
28interface FormComponent extends ForwardRefExoticComponent<PropsWithoutRef<FormProps> & RefAttributes<FormInstance | undefined>> {
29 Field: typeof Field;
30 Item: typeof FormItem;
31}
32const Form = forwardRef<FormInstance, FormProps>((props, ref) => {
33
34 const { children, onChange, onSubmit, className, style } = props;
35
36 const [formValues, setFormValues] = useState<FormValuesMap>(new Map());
37 const formValuesRef = useRef(formValues);
38
39 const onChangeRef = useRef(onChange);
40 onChangeRef.current = onChange;
41
42 const context = useMemo<FormContextInterface>(() => {
43 let formValues: FormValuesMap = formValuesRef.current;
44 const toJSON = () => {
45 return Array.from(formValues.entries()).reduce((prev, currentValue) => {
46 const [key, val] = currentValue;
47 Object.assign(prev, { [key]: val });
48 return prev;
49 }, {} as FormValues);
50 };
51 return {
52 get values() {
53 return formValues;
54 },
55 toJSON,
56 setItem: trailed((callback) => {
57 return (name, value, options) => {
58 const emit = options?.emit ?? false;
59 const remove = options?.remove ?? false;
60 formValues = new Map(formValues.entries());
61 if (remove) {
62 formValues.delete(name);
63 } else {
64 formValues.set(name, value);
65 }
66 setFormValues(formValues);
67 if (emit) callback(() => onChangeRef.current?.(toJSON()));
68 };
69 }),
70 reductions: new Map(),
71 invalidations: new Map(),
72 validators: new Map(),
73 status: new Map(),
74 };
75 }, []);
76
77 const reset: FormInstance['reset'] = useCallback((...fields) => {
78 if (fields.length) {
79 fields.forEach(key => context.reductions.get(key)?.());
80 } else {
81 context.reductions.forEach(v => v());
82 }
83 }, [context]);
84
85 const validate: FormInstance['validate'] = useCallback((...fields) => {
86 let values = context.toJSON();
87 let obs: Observable<Status>[];
88 if (fields.length) {
89 obs = fields.map(key => context.validators.get(key)).filter(Boolean).map(v => v!());
90 values = fields.reduce((previousValue, key) => Object.assign(previousValue, { [key]: values[key] }), {} as FormValues);
91 } else {
92 obs = Array.from(context.validators.values()).map(v => v());
93 }
94 return forkJoin(obs).pipe(
95 concatMap(results => {
96 if (results.every(v => v.state === 'valid')) {
97 return of(values);
98 }
99 return throwError(() => results.filter(v => v.state === 'invalid'));
100 }),
101 );
102 }, [context]);
103
104 const setItem: FormInstance['setItem'] = useCallback((...args) => {
105 return context.setItem(...args);
106 }, [context]);
107
108 const clearValidity: FormInstance['clearValidity'] = useCallback((...args) => {
109 if (args.length) {
110 args.forEach(name => context.invalidations.get(name)?.());
111 } else {
112 context.invalidations.forEach(v => v());
113 }
114 }, [context]);
115
116 const instance: FormInstance = useMemo(() => {
117 return { setItem, reset, validate, clearValidity, values: formValues, toJSON: context.toJSON };
118 }, [formValues, reset, setItem, validate, context, clearValidity]);
119
120 const handleSubmit = useCallback((e: FormEvent) => {
121 e.preventDefault();
122 onSubmit?.(instance);
123 }, [instance, onSubmit]);
124
125 useImperativeHandle(ref, () => {
126 return instance;
127 });
128
129 return (
130 <form onSubmit={handleSubmit} className={combineClass(className)} style={style}>
131 <FormValuesContext.Provider value={formValues}>
132 <FormContext.Provider value={context}>{ children }</FormContext.Provider>
133 </FormValuesContext.Provider>
134 </form>
135 );
136}) as FormComponent;
137
138Form.displayName = 'Form';
139Form.Field = Field;
140Form.Item = FormItem;
141
142export { Form };
Field.tsx
1import {
2 cloneElement, FunctionComponent, ReactElement,
3 useCallback, useContext, useEffect, useMemo, useRef,
4} from 'react';
5import { FormContext, FormValuesContext } from './Context';
6import { Rule, Status } from './interface';
7import {
8 concat, concatMap, from, isObservable, of, take,
9 skipWhile, switchMap, takeLast, tap, throwError, skip,
10} from 'rxjs';
11import type { Observable } from 'rxjs';
12import { useStore } from '@shared/stores';
13import { useSubject } from '@shared/hooks/observable';
14
15function restore() {
16 //
17}
18export interface FieldProps {
19 name: string;
20 children: ReactElement;
21 defaultValue?: any;
22 rules?: Rule[];
23 onChange?: (e: any) => void;
24 onStatusChange?: (status: Status) => void;
25}
26interface FieldComponent extends FunctionComponent<FieldProps> {}
27const Field: FieldComponent = (props) => {
28 const { children, name, defaultValue, rules = [], onStatusChange, onChange: fieldChange } = props;
29 const context = useContext(FormContext);
30 const values = useContext(FormValuesContext);
31
32 const refs = useRef({ context, defaultValue, rules, name });
33 refs.current.context = context;
34 refs.current.rules = rules;
35
36 const [status, store] = useStore<Status>(() => ({ state: 'initial', name }));
37
38 const nullVal = useMemo(() => {
39 if (children.type === 'input') {
40 return '';
41 }
42 return undefined;
43 }, [children]);
44
45 const value = useMemo(() => {
46 return values?.get(name) ?? nullVal;
47 }, [name, nullVal, values]);
48
49 const status$ = useSubject<any>(action => action.pipe(
50 tap(() => store.set(Object.assign({}, store.state, { state: 'pending', name }))),
51 switchMap(value => {
52 if (value === restore) {
53 store.set({ state: 'initial', name });
54 return of(null).pipe(skip(1));
55 }
56 return validate(refs.current.rules, value);
57 }),
58 tap(() => store.set({ state: 'valid', name })),
59 store.capture(err => ({ state: 'invalid', name, message: err })),
60 ), [name, store]);
61
62 const onChange = useCallback((e: any) => {
63 const val = toFormValue(e);
64 status$?.next(val);
65 context?.setItem(name, val, { emit: true });
66 fieldChange?.(val);
67 }, [fieldChange, context, name, status$]);
68
69 const clearValidity = useCallback(() => {
70 status$?.next(restore);
71 }, [status$]);
72
73 const reset = useCallback(() => {
74 const { context, defaultValue, name } = refs.current;
75 const value = context?.values.get(name) ?? nullVal;
76 const next = toFormValue(defaultValue) ?? nullVal;
77 clearValidity();
78 if (value !== next) {
79 context?.setItem(name, next, { emit: true });
80 }
81 }, [clearValidity, nullVal]);
82
83 const validateField = useCallback(() => {
84 status$?.next(value); // The status will be updated to pending synchronously
85 return store.state$.pipe(
86 skipWhile(v => v.state === 'pending'),
87 take(1),
88 );
89 }, [status$, value, store]);
90
91 useEffect(() => {
92 context?.validators.set(name, validateField);
93 return () => {
94 context?.validators.delete(name);
95 };
96 }, [context, name, validateField]);
97
98 useEffect(() => {
99 context?.invalidations.set(name, clearValidity);
100 return () => {
101 context?.invalidations.delete(name);
102 };
103 }, [context, name, clearValidity]);
104
105 useEffect(() => {
106 context?.reductions.set(name, () => {
107 reset();
108 });
109 return () => {
110 context?.reductions.delete(name);
111 };
112 }, [context, name, reset]);
113
114 useEffect(() => {
115 context?.status.set(name, status);
116 return () => {
117 context?.status.delete(name);
118 };
119 }, [context, status, name]);
120
121 useEffect(() => {
122 const { context } = refs.current;
123 if (refs.current.name !== name) {
124 context?.setItem(refs.current.name, null, { emit: true, remove: true });
125 }
126 refs.current.name = name;
127 reset();
128 }, [reset, name]);
129
130 useEffect(() => {
131 onStatusChange?.(status);
132 }, [onStatusChange, status]);
133
134 return cloneElement(children, {
135 onChange,
136 value,
137 });
138};
139
140export { Field };
141
142function validate(rules: Rule[], value: any) {
143 return concat(
144 ...rules.map(v => {
145 let obs: Observable<null | string>;
146 if (typeof v === 'function') {
147 const ret = v(value);
148 if (isObservable(ret)) {
149 obs = ret;
150 } else if (ret instanceof Promise) {
151 obs = from(ret);
152 } else {
153 obs = of(ret);
154 }
155 } else {
156 obs = v.required && !value ? of(v.message ?? '') : of(null);
157 }
158 obs = obs.pipe(
159 concatMap(res => res === null ? of(null) : throwError(() => res))
160 );
161 return obs as Observable<null>;
162 }),
163 of(null),
164 ).pipe(takeLast(1));
165}
166
167function toFormValue(e: any) {
168 if (e && e.target && Object.hasOwn(e.target, 'value')) {
169 return e.target.value;
170 }
171 return e;
172}
Item.tsx
1import { cloneElement, CSSProperties, FC, ReactNode, useCallback, useState } from 'react';
2import { Field, FieldProps } from './Field';
3import { Status } from './interface';
4import styled from 'styled-components';
5import { combineClass } from '@shared/utils';
6
7const Wrapper = styled.div`
8 &:not(:last-child){
9 margin-bottom: 8px;
10 }
11 .error-tip{
12 margin: 3px 0 0 0;
13 font-size: .8em;
14 color: var(--color-error);
15 }
16`;
17
18interface FormItemProps extends FieldProps {
19 className?: string;
20 style?: CSSProperties;
21 prefix?: ReactNode;
22}
23const FormItem: FC<FormItemProps> = (props) => {
24
25 const { onStatusChange, children, className, style, prefix, ...extra } = props;
26
27 const [status, setStatus] = useState<Status>();
28
29 const handleStatusChange = useCallback((status: Status) => {
30 onStatusChange?.(status);
31 setStatus(status);
32 }, [onStatusChange]);
33
34 return (
35 <Wrapper className={className} style={style}>
36 <div className={'form-control'}>
37 { prefix }
38 <Field {...extra} onStatusChange={handleStatusChange}>
39 {
40 cloneElement(children, {
41 className: combineClass(children.props.className, {
42 'status-error': status?.state === 'invalid',
43 }),
44 })
45 }
46 </Field>
47 </div>
48 {
49 typeof status?.message === 'string' ?
50 <p className={'error-tip'}>{ status.message }</p> :
51 null
52 }
53 </Wrapper>
54 );
55};
56
57export { FormItem };
Context.ts
1import { createContext } from 'react';
2import type { Observable } from 'rxjs';
3import { Status } from './interface';
4
5export type FormValues = Record<string, any>;
6
7export type FormValuesMap = Map<string, any>;
8
9export const FormValuesContext = createContext<FormValuesMap | null>(null);
10
11export interface FormContextInterface {
12 readonly values: FormValuesMap;
13 setItem: (name: string, value: any, options?: {
14 emit?: boolean;
15 remove?: boolean;
16 }) => void;
17 toJSON: () => FormValues;
18 reductions: Map<string, () => void>;
19 invalidations: Map<string, () => void>;
20 validators: Map<string, () => Observable<Status>>;
21 status: Map<string, Status>;
22}
23
24export const FormContext = createContext<FormContextInterface | null>(null);
interface.ts
1import type { Observable } from 'rxjs';
2
3export interface FormControlled<T> {
4 value?: T;
5 onChange?(e: T): void;
6}
7
8interface RuleRequired {
9 required?: boolean;
10 message?: string;
11}
12interface RuleValidator {
13 (value: any): null | string | Promise<null | string> | Observable<null | string>;
14}
15export type Rule = RuleRequired | RuleValidator;
16
17export interface Status {
18 state: 'valid' | 'invalid' | 'pending' | 'initial';
19 name: string;
20 message?: string;
21}