solidjs-community / solid-primitives

A library of high-quality primitives that extend SolidJS reactivity.
https://primitives.solidjs.community
MIT License
1.27k stars 126 forks source link

createForm proposal #224

Open chris-czopp opened 2 years ago

chris-czopp commented 2 years ago

createForm() proposal.

Requirements

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

atk commented 2 years ago

Very nice use of directives there, loving it. I don't get where this is composable in the meaning where you only have to load what you actually use. It looks more fully integrated to me (not that this is necessarily bad, just reading through your claims and trying to make sense of them). Great job and I really think this will make a nice addition.

chris-czopp commented 2 years ago

Thanks. Directives is actually @davedbase idea and it seems to fit very well here. Re composable, I refer to the way of using the actual inputs. In the past, I worked with JSON Forms (great for prototyping), and the lib uses JSON for laying out inputs: https://jsonforms.io/docs/uischema/layouts. It was a real pain to make any serious customisations. I want to relay on composition and use JSX as DSL for constructing UI. Forms are tricky because it's hard to find the balance between how chunky the component should be. Too small isn't that helpful for a developer, too chunky and it isn't easily customisable. Anyway, I agree the claim "composable" is probably too generic.

atk commented 2 years ago

Thanks for the clarification. I'd really appreciate a PR. Though on the topic of composability, have a look at the fetch component. Maybe a future version could similarly encapsulate the different separate abilities (bound inputs, validation, etc) in separate exports and have a main entry point to bind everything together. That is, if those abilities make sense in separation.

chris-czopp commented 2 years ago

just looked at fetch's with* modifiers, really awesome concept!