-
-
Notifications
You must be signed in to change notification settings - Fork 244
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
Implement a hook-based, all-in-one Form
component
#1160
Labels
Milestone
Comments
radekmie
added
Type: Feature
New features and feature requests
Area: Core
Affects the uniforms package
labels
Aug 27, 2022
Because it turned out to be much harder and time-consuming than I thought, here's an all-in-one version of a import clone from 'lodash/clone';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import noop from 'lodash/noop';
import setWith from 'lodash/setWith';
import React, { Component, ComponentType, SyntheticEvent } from 'react';
import { Bridge } from './Bridge';
import { changedKeys } from './changedKeys';
import { context } from './context';
import { randomIds } from './randomIds';
import { ChangedMap, Context, ModelTransformMode, ValidateMode } from './types';
export type FormProps<Model> = {
autosave: boolean;
autosaveDelay: number;
disabled: boolean;
error: unknown;
errorsField?: ComponentType;
id?: string;
label: boolean;
model: Model;
modelTransform?: (mode: ModelTransformMode, model: Model) => Model;
noValidate: boolean;
onChange: (key: string, value: unknown) => void;
onChangeModel: (model: Model) => void;
onSubmit: (model: Model) => void | Promise<unknown>;
onValidate: (model: Model, error: unknown) => unknown;
placeholder: boolean;
readOnly: boolean;
schema: Bridge;
showInlineError: boolean;
submitField?: ComponentType;
validate: ValidateMode;
validator?: unknown;
};
export type FormState<Model> = {
changed: boolean;
changedMap: ChangedMap<Model>;
error: unknown;
model: Model;
resetCount: number;
submitted: boolean;
submitting: boolean;
validate: boolean;
validating: boolean;
validator: (model: Model) => unknown;
};
export class Form<
Model,
Props extends FormProps<Model> = FormProps<Model>,
State extends FormState<Model> = FormState<Model>,
> extends Component<Props, State> {
static defaultProps = {
autosave: false,
autosaveDelay: 0,
disabled: false,
error: null,
label: true,
model: Object.create(null),
noValidate: true,
placeholder: false,
onChange() {},
onChangeModel() {},
onSubmit() {},
onValidate(model: unknown, error: unknown) {
return error;
},
readOnly: false,
showInlineError: false,
validate: 'onChangeAfterSubmit',
};
state = {
changed: false,
changedMap: Object.create(null),
error: null,
model: this.props.model,
resetCount: 0,
submitted: false,
submitting: false,
validate: false,
validating: false,
validator: this.props.schema.getValidator(this.props.validator),
} as State;
componentDidMount() {
this.mounted = true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
componentDidUpdate(prevProps: Props, prevState: State, snapshot: never) {
const { model, schema, validate, validator } = this.props;
if (!isEqual(model, prevProps.model)) {
this.setState({ model });
}
if (schema !== prevProps.schema || validator !== prevProps.validator) {
this.setState({ validator: schema.getValidator(validator) }, () => {
if (shouldRevalidate(validate, this.state.validate)) {
this.onValidate();
}
});
} else if (
!isEqual(model, prevProps.model) &&
shouldRevalidate(validate, this.state.validate)
) {
this.onValidateModel(model);
}
}
componentWillUnmount() {
this.mounted = false;
if (this.delayId) {
clearTimeout(this.delayId);
}
// There are at least 4 places where we'd need to check, whether or not we
// actually perform `setState` after the component gets unmounted. Instead,
// we override it to hide the React warning. Also because React no longer
// will raise it in the newer versions.
// https://github.com/facebook/react/pull/22114
// https://github.com/vazco/uniforms/issues/1152
this.setState = () => {};
}
delayId: ReturnType<typeof setTimeout> | undefined = undefined;
mounted = false;
randomId = randomIds(this.props.id);
getContext(): Context<Model> {
const { onChange, onSubmit, randomId, props, state } = this;
return {
changed: state.changed,
changedMap: state.changedMap,
error: props.error ?? state.error,
// @ts-expect-error This should be limited to a few methods so the users won't do "crazy stuff", e.g., call `setState`.
formRef: this,
model: this.getModel('form'),
name: [],
onChange,
onSubmit,
randomId,
schema: props.schema,
state: {
disabled: props.disabled,
label: props.label,
placeholder: props.placeholder,
readOnly: props.readOnly,
showInlineError: props.showInlineError,
},
submitted: state.submitted,
submitting: state.submitting,
validating: state.validating,
};
}
getModel(mode?: ModelTransformMode, model = this.state.model): Model {
return mode !== undefined && this.props.modelTransform
? this.props.modelTransform(mode, model)
: model;
}
getAutoField = (): ComponentType<{ name: string }> => {
return () => null;
};
getErrorsField = (): ComponentType => {
return () => null;
};
getSubmitField = (): ComponentType => {
return () => null;
};
getNativeFormProps = () => {
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
autosave,
autosaveDelay,
disabled,
error,
errorsField: ErrorsField = this.getErrorsField(),
label,
model,
modelTransform,
onChange,
onChangeModel,
onSubmit,
onValidate,
placeholder,
readOnly,
schema,
showInlineError,
submitField: SubmitField = this.getSubmitField(),
validate,
validator,
...props
} = this.props;
/* eslint-enable @typescript-eslint/no-unused-vars */
// @ts-expect-error `props` is too generic.
props.key = `reset-${this.state.resetCount}`;
// @ts-expect-error `props` is too generic.
props.onSubmit = this.onSubmit;
if (!props.children) {
const AutoField = this.getAutoField();
// @ts-expect-error `props` is too generic.
props.children = schema
.getSubfields()
.map(key => <AutoField key={key} name={key} />)
.concat([
<ErrorsField key="$ErrorsField" />,
<SubmitField key="$SubmitField" />,
]);
}
return props;
};
onChange = (key: string, value: unknown) => {
if (shouldRevalidate(this.props.validate, this.state.validate)) {
this.onValidate(key, value);
}
// Do not set `changed` before componentDidMount
if (this.mounted) {
const keys = changedKeys(key, value, get(this.getModel(), key));
if (keys.length !== 0) {
this.setState(state =>
// If all are already marked, we can skip the update completely.
state.changed && keys.every(key => !!get(state.changedMap, key))
? null
: {
changed: true,
changedMap: keys.reduce(
(changedMap, key) => setWith(changedMap, key, {}, clone),
clone(state.changedMap),
),
},
);
}
}
if (this.props.onChange) {
this.props.onChange(key, value);
}
// Do not call `onSubmit` before componentDidMount
if (this.mounted && this.props.autosave) {
if (this.delayId) {
clearTimeout(this.delayId);
}
// Delay autosave by `autosaveDelay` milliseconds...
this.delayId = setTimeout(() => {
// ...and wait for all scheduled `setState`s to commit. This is required
// for AutoForm to validate correct model, waiting in `onChange`.
this.setState(
() => null,
() => {
this.onSubmit();
},
);
}, this.props.autosaveDelay);
}
this.setState(
// @ts-expect-error Should `Model` extend `object`?
state => ({ model: setWith(clone(state.model), key, value, clone) }),
() => {
if (this.props.onChangeModel) {
this.props.onChangeModel(this.getModel());
}
},
);
};
onReset = () => {
this.setState<never>(this.__reset);
};
onSubmit = (event?: SyntheticEvent) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
this.setState({ submitted: true, validate: true });
const result = this.onValidate().then(error => {
if (error !== null) {
return Promise.reject(error);
}
this.setState(state => (state.submitted ? null : { submitted: true }));
const result = this.props.onSubmit(this.getModel('submit'));
if (!(result instanceof Promise)) {
return Promise.resolve();
}
this.setState({ submitting: true });
result.then(
() => {
this.setState({ submitting: false });
},
error => {
this.setState({ error, submitting: false });
},
);
return result;
});
result.catch(noop);
return result;
};
onValidate = (key?: string, value?: unknown) => {
let model = this.getModel();
if (model && key) {
// @ts-expect-error Should `Model` extend `object`?
model = setWith(clone(model), key, cloneDeep(value), clone);
}
return this.onValidateModel(model);
};
onValidateModel = (originalModel: Model) => {
const model = this.getModel('validate', originalModel);
return this.__then(this.state.validator(model), (error = null) =>
this.__then(this.props.onValidate(model, error), (error = null) => {
// Do not copy the error from props to the state.
error = this.props.error === error ? null : error;
// If the whole operation was synchronous and resulted in the same
// error, we can skip the re-render.
this.setState(state =>
state.error === error && !state.validating
? null
: { error, validating: false },
);
// A predefined error takes precedence over the validation one.
return Promise.resolve(this.props.error ?? error);
}),
);
};
__reset = (state: State, props: Props) => ({
changed: false,
changedMap: Object.create(null),
error: null,
model: props.model,
resetCount: state.resetCount + 1,
submitted: false,
submitting: false,
validate: false,
validating: false,
});
// Using `then` allows using the same code for both synchronous and
// asynchronous cases. We could use `await` here, but it would make all
// calls asynchronous, unnecessary delaying synchronous validation.
__then = makeThen(() => {
this.setState({ validating: true });
});
render() {
return (
<context.Provider value={this.getContext()}>
<form {...this.getNativeFormProps()} />
</context.Provider>
);
}
}
function makeThen(callIfAsync: () => void) {
function then<T, U>(value: Promise<T>, fn: (value: T) => U): Promise<U>;
function then<T, U>(value: T, fn: (value: T) => U): U;
function then<T, U>(value: Promise<T> | T, fn: (value: T) => U) {
if (value instanceof Promise) {
callIfAsync();
return value.then(fn);
}
return fn(value);
}
return then;
}
function shouldRevalidate(inProps: ValidateMode, inState: boolean) {
return (
inProps === 'onChange' || (inProps === 'onChangeAfterSubmit' && inState)
);
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Labels
During our recent meeting regarding v4, I presented an idea of a general
Form
component, replacing all of the existing*Form
components. The main differences would be:What could it look like? Well, we'd basically merge all of the current props of all form components and make it into one. All of the instance methods could be replaced with internal callbacks and exposed with
useImperativeHandle
for programmatic access.Migration:
BaseForm
is trivial - it just works.ValidatedForm
too, as there's nothing special about it.QuickForm
andQuickValidatedForm
have to know which components to render, and instead of instance methods, we'd have to use props for that. It'll be hidden for the theme users, as the themes would expose their ownForm
components with provided fields.AutoForm
is the only special one. Not everyone uses it, and we'd like to make the automatic state management opt-in. The API would look like this:AutoForm
anduseAutoForm
would be provided in theuniforms
package as well. (We're unsure about the naming, though.) TheonChangeModel
is no longer needed, as you could simplyuseEffect
on themodel
returned fromuseAutoForm
.While I'll work on a prototype, I'd like to hear everyones' feedback. Our goal would be to release it with 4.0 or soon after and make it the only form component available in v5.0.
The text was updated successfully, but these errors were encountered: