vaadin / hilla

Build better business applications, faster. No more juggling REST endpoints or deciphering GraphQL queries. Hilla seamlessly connects Spring Boot and React to accelerate application development.
https://hilla.dev
Apache License 2.0
900 stars 56 forks source link

React form array APIs #1196

Closed platosha closed 3 months ago

platosha commented 1 year ago

Let's discuss and figure out the API of using arrays in React forms. Consider the following use cases:

One simple way items is to add the .map method directly to the ArrayModel that maps the item models using the provided callback, similar to Array.prototype.map. However, since we share models between Lit and React binders, the model API should preferably stay free of Lit and React opinionated helpers. This would mean that, as a baseline, the mapping callback provides the only the model for the rendering:

function OrderForm() {
  const {model, field} = useForm(OrderFormModel);

  return <ul>
    {
      model.rows.map((rowModel) => (
        <li>
          <TextField label="Product" {...field(rowModel.product)}/>
        </li>
      ))
    }
  </ul>
}

This alone is clearly not sufficient for the use cases. Based on the access idea from https://github.com/vaadin/hilla/issues/1186, we could potentially add the key and the array mutation helpers to the access result:

function OrderForm() {
  const {model, field, access} = useForm(OrderFormModel);

  return <ul>
    {
      model.rows.map((rowModel) => (
        <li key={access(rowModel).key}>
          <TextField label="Product" {...field(rowModel.product)}/>
          <button onClick={access(rowModel).removeSelf}>Remove</button>
        </li>
      ))
    }
    <button onClick={access(model).appendItem}>Add</button>
  </ul>
}

Or, alternatively, we could add another hook for the array use case:

function OrderForm() {
  const {model, field, access} = useForm(OrderFormModel);
  const {append, key, remove} = useFormArray(model.rows);

  return <ul>
    {
      model.rows.map((rowModel) => (
        <li key={key(rowModel)}>
          <TextField label="Product" {...field(rowModel.product)}/>
          <button onClick={remove(rowModel)}>Remove</button>
        </li>
      ))
    }
    <button onClick={append}>Add</button>
  </ul>
}
Lodin commented 1 year ago

Considering the outcome of the #1186, I believe we could proceed with the first option (with changes):

function OrderFormRow({ model: row }) {
  const { field, removeSelf } = useFormPart(row);

  return (
    <li>
      <TextField label="Product" {...field(row)}/>
      <button onClick={removeSelf}>Remove</button>
    </li>
  );
}

function OrderForm() {
  const { model, field, appendItem } = useForm(OrderForm);

  return <ul>
    {
      model.rows.map((row) => (
        <OrderFormRow model={row} key={row.key}  />
      ))
    }
    <button onClick={appendItem}>Add</button>
  </ul>
}
platosha commented 1 year ago

How would the appendItem helper in the OrderForm know that it refers to model.rows?

platosha commented 4 months ago

After a fresh look, useFormArray / .map combination looks simpler.

platosha commented 4 months ago

useFormArray could return append, remove, and the map function that iterates through the items and provides itemModel and key.

platosha commented 4 months ago
function OrderForm() {
  const {model, field, access} = useForm(OrderFormModel);
  const {append, map, remove} = useFormArray(model.rows);

  return <ul>
    {
      map((item, index) => (
        <li key={item.key}>
          <TextField label="Product" {...field(item.model.product)}/>
          <button onClick={item.remove}>Remove</button>
        </li>
      ))
    }
    <button onClick={append}>Add</button>
  </ul>
}
platosha commented 3 months ago

We discussed the options and concluded that we'd introduce a separate useFormArrayPart hook:

  function TeamFormPlayer({ model }: { model: PlayerModel }) {
    const { field, value, invalid, ownErrors } = useFormPart(model);

    return (
      <div>
        <input data-testid={`lastName.${value!.id}`} type="text" {...field(model.lastName)} />
        <output data-testid={`validation.lastName.${value!.id}`}>
          {invalid ? ownErrors.map((e) => e.message).join(', ') : 'OK'}
        </output>
      </div>
    );
  }

  function TeamForm() {
    const { field, model } = useForm(TeamModel);
    const { items, setValue: setPlayers, value: playersValue } = useFormArrayPart(model.players);
    const name = useFormPart(model.name);

    return (
      <>
        <input data-testid="team.name" type="text" {...field(model.name)} />
        <output data-testid="validation.team.name">
          {name.invalid ? name.ownErrors.map((e) => e.message).join(', ') : 'OK'}
        </output>
        {items.map((playerModel, index) => (
          <TeamFormPlayer key={`${playersValue[index].id ?? -index}`} model={playerModel} />
        ))}
      </>
    );
  }
Philip-Nunoo commented 2 months ago

👍