solid-primitives icon indicating copy to clipboard operation
solid-primitives copied to clipboard

createForm proposal

Open chris-czopp opened this issue 2 years ago • 4 comments

createForm() proposal.

Requirements

  • headless, allowing developer to use any UI component library for inputs
  • allowing great control with opt-out option by using helpers to make DX simpler and as close to native browser support as possible e.g. by using directives and built in features
  • opt-in advanced features :
    • validation
    • field dependencies and controlling required/visible fields based on current form values
    • supporting array values e.g. list of skills
  • controlled by a config coming from backend
  • well typed
  • SSR compatible

Proposed design

Simple

It uses directives.

const formAdapter = new MyFormAdapter(); // inherits BasicAdapter
const {
  isLoading: [isLoading, setLoading],
  isSubmitting: [isSubmitting, setSubmitting],
  directives: {
    form,
    bind
  }
} = createForm(formAdapter); // actual hook call

<form use:form>
  <fieldset disabled={isLoading() || isSubmitting()}>
    <input type="text" name={FormFieldsType.firstName} use:bind />
    <input type="text" name={FormFieldsType.surname} use:bind />
    <input type="reset" value="Reset" use:bind />
    <input type="submit" value="Submit" use:bind>
  </fieldset>
</form>

MyFormAdapter.ts :

import {
  FormDataType,
  SchemaQueryType
} from './myFormAdapter.types';
import { JsonSchemaAdapter } from './jsonSchemaAdapter';
import exampleSchema from './exampleSchema.json';

export class MyFormAdapter extends BasicAdapter {
  validateChangedValues( // opt-in
    changedValues: FormDataType
  ): FormDataType {
    console.log('validating', { changedValues, errors });
    return changedValues;
  }
  async submitForm(formData: FormDataType): Promise<void> {
    console.log('submit', formData);
  }
}

Advanced

Very explicit, giving loads of control. It uses JSON schema under the hood which is translated to signals. It's assumed the initial values come from the schema default values.

const formAdapter = new MyFormAdapter(); // inherits JsonSchemaAdapter
const {
  loadSchema,
  changeField,
  changeArrayFieldRow,
  submitForm,
  resetForm,
  addArrayFieldRow,
  removeArrayFieldRow,
} = formAdapter.getObservedActions(); // it's a way to access action triggers from a particular adapter
const onFormChanged = (changedValues: FormDataType) => {
  // just in case you want the data to be accessible out of the form
  console.log({ changedValues });
};
const [schemaQuery, setSchemaQuery] = createSignal<SchemaQueryType>({
  // ID or anything that can be used to retrieve JSON schema
  id: null,
});
const {
  isLoading: [isLoading, setLoading],
  isSubmitting: [isSubmitting, setSubmitting],
  schema: [schema, setSchema],
  initialValues: [initialValues, setInitialValues],
  visibleFields: [visibleFields, setVisibleFields],
  requiredFields: [requiredFields, setRequiredFields],
  fieldErrors: [fieldErrors, setFieldErrors],
  formError: [formError, setFormError],
  hasFormSubmissionSucceeded: [
    hasFormSubmissionSucceeded,
    setFormSubmissionSucceeded,
  ],
  changedValues: [changedValues, setChangedValues],
  fieldOptions: [fieldOptions, setFieldOptions],
} = createForm(formAdapter, onFormChanged); // actual hook call

createEffect(() => {
  loadSchema(schemaQuery()); // if the query changes, loadSchema() and re-initiate the entire form
});

<>
  <input
    type="text"
    value={changedValues().firstName}
    onInput={(e) =>
      changeField(FormFieldsType.firstName, e.currentTarget.value)
    }
  />
  {visibleFields().includes(FormFieldsType.surname) && (
    <input
      type="text"
      value={changedValues().surname}
      onInput={(e) =>
        changeField(FormFieldsType.surname, e.currentTarget.value)
      }
    />
  )}

  <button
    onClick={() => {
      submitForm(changedValues());
    }}
  >
    submit
  </button>
  <button
    onClick={() => {
      resetForm(initialValues());
    }}
  >
    reset
  </button>
  <button
    onClick={() => {
      addArrayFieldRow(changedValues().dataTypeMultiChoice);
    }}
  >
    add array item
  </button>
  <button
    onClick={() => {
      removeArrayFieldRow(
        changedValues().dataTypeMultiChoice,
        changedValues().dataTypeMultiChoice.length - 1
      );
    }}
  >
    remove last array item
  </button>
  <button
    onClick={() => {
      changeArrayFieldRow(
        changedValues().dataTypeMultiChoice,
        changedValues().dataTypeMultiChoice.length - 1,
        {
          key: 'changeKey',
          title: 'changed title',
          dataType: 'number',
          format: 'AS_PROVIDED',
          presenter: 'STANDARD',
        }
      );
    }}
  >
    change last array item
  </button>
  <pre>
    {JSON.stringify(
      {
        isLoading: isLoading(),
        isSubmitting: isSubmitting(),
        changedValues: changedValues(),
        fieldOptions: fieldOptions(),
        visibleFields: visibleFields(),
        rules: visibleFields().rules,
        requiredFields: requiredFields(),
        hasFormSubmissionSucceeded: hasFormSubmissionSucceeded(),
        formError: formError(),
        fieldErrors: fieldErrors(),
        isSurnameVisible: visibleFields().includes(FormFieldsType.surname),
      },
      undefined,
      2
    )}
  </pre>
</>  

MyFormAdapter.ts :

import {
  FormFieldsType,
  FormDataType,
  SchemaQueryType,
  SchemaType,
  SchemaError,
  FieldOptionsType,
  INITIAL_SCHEMA_DATA,
  INITIAL_FORM_DATA,
  INITIAL_FIELD_OPTIONS,
} from './myFormAdapter.types';
import { JsonSchemaAdapter } from './jsonSchemaAdapter';
import exampleSchema from './exampleSchema.json';

export class MyFormAdapter extends JsonSchemaAdapter {
  schemaQuery: SchemaQueryType = { id: null };
  schema: SchemaType = INITIAL_SCHEMA_DATA;
  formFields: FormFieldsType[] = [];
  formData: FormDataType = INITIAL_FORM_DATA;
  fieldOptions: FieldOptionsType = INITIAL_FIELD_OPTIONS;

  async loadSchema(schemaQuery: SchemaQueryType): Promise<SchemaType> {
    await new Promise((resolve) => setTimeout(() => resolve(true), 1000));

    return exampleSchema as SchemaType; // normally you'd fetch it from backend
  }
  validateChangedValues(
    changedValues: FormDataType,
    errors: SchemaError[]
  ): FormDataType {
    console.log('validating', { changedValues, errors });
    return changedValues;
  }
  postTransformChangedValues(changedValues: FormDataType): FormDataType {
    return changedValues;
  }
  async submitForm(formData: FormDataType): Promise<void> {
    console.log('submit', formData);
  }
}

Here is the playground I'll try to keep up-to-date as much as possible: https://stackblitz.com/edit/solidjs-templates-grmrsw?file=src%2FactualCode%2FmyForm.tsx

chris-czopp avatar Oct 06 '22 10:10 chris-czopp