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

Implement a hook-based, all-in-one Form component #1160

Open
radekmie opened this issue Aug 27, 2022 · 1 comment
Open

Implement a hook-based, all-in-one Form component #1160

radekmie opened this issue Aug 27, 2022 · 1 comment
Labels
Area: Core Affects the uniforms package Type: Feature New features and feature requests
Milestone

Comments

@radekmie
Copy link
Contributor

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:

  1. It'd no longer be a class component but a hook-based functional one.
  2. It'd include all of the functionalities in one (with one exception) to make the API surface even smaller and easier to work with.

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 and QuickValidatedForm 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 own Form 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:
    function AutoForm(props) {
      const [model, onChange] = useAutoForm(props.model);
      return <Form {...props} model={model} onChange={onChange} />;
    }
    
    function useAutoForm(initialModel = {}) {
      const [model, setModel] = useState(initialModel);
      const onChange = useCallback(function onChange(key, value) {
        setModel(model => setWith(clone(state.model), key, value, clone));
      }, [setModel]);
      return [model, onChange];
    }
    Of course, both AutoForm and useAutoForm would be provided in the uniforms package as well. (We're unsure about the naming, though.) The onChangeModel is no longer needed, as you could simply useEffect on the model returned from useAutoForm.

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.

@radekmie radekmie added Type: Feature New features and feature requests Area: Core Affects the uniforms package labels Aug 27, 2022
@radekmie radekmie added this to the v4.0 milestone Aug 27, 2022
@radekmie radekmie self-assigned this Aug 27, 2022
@radekmie radekmie moved this to Concept in Open Source Nov 18, 2022
@radekmie radekmie moved this from Concept to To do in Open Source Dec 2, 2022
@radekmie radekmie moved this from To do to In progress in Open Source Dec 2, 2022
@radekmie
Copy link
Contributor Author

Because it turned out to be much harder and time-consuming than I thought, here's an all-in-one version of a Form component, but still as a class component. I am actively working on making it a hook-based one, though.

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)
  );
}

@radekmie radekmie modified the milestones: v4.0, v4.x Jan 20, 2023
@radekmie radekmie moved this from In progress to To do in Open Source Sep 8, 2023
@radekmie radekmie removed their assignment Sep 8, 2023
@kestarumper kestarumper moved this from To do to On Hold in Open Source Apr 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area: Core Affects the uniforms package Type: Feature New features and feature requests
Projects
Status: On Hold
Development

No branches or pull requests

1 participant