Skip to content

[Feature] add mappedForm() for enhancing signal forms #646

@ptandler

Description

@ptandler

When using Angular 21's signals forms, it is sometimes required to use an adjusted model for the form, e.g. when a domain model prop can be initially undefined and needs to be mapped to the default value to show in the form (or to null).

The Angular team currently do not want to add specific support for this case, see angular/angular#65194.

Nevertheless, I believe that this is a common case and developers would benefit of something like a mappedForm like below and I think it would nicely fit to ngxtensions 😊

/**
 * Create a signal form that uses a different model for data and form:
 * The signal that is passed gets set from the input model and written back via effect
 * This can also be used to provide default values for the form model.
 *
 * NOTE
 * - changes to the form are only written back to the model signal when the form is valid (including async validators)
 * - changes to the model signal are reflected in the form immediately
 *
 * IMPORTANT
 * - modelToForm and formToModel must be stable functions to avoid infinite loops
 *
 * @example
 * ```typescript
 * interface Model { someNumber?: number; option: null|string }
 * interface Form { someNumber: number; option: string }
 * export class SomeComponent implements FormValueControl<Model> {
 *   public readonly value = model<Model>();
 *   protected readonly valueForm = mappedForm<Model, Form>(this.value, {
 *     modelToForm: (modelValue) => ({
 *       someNumber: modelValue.someNumber ?? 42,
 *       option: modelValue.option ?? 'none',
 *     }),
 *     formToModel: (formValue) => ({
 *       someNumber: formValue.someNumber,
 *       option: formValue.option === 'none' ? null : formValue.option,
 *     }),
 *   })
 * }
 * ```
 */
export function mappedForm<Model, Form>(
  signal: WritableSignal<Model>,
  {
    modelToForm,
    formToModel,
    equal,
    schema,
    ...formOptions
  }: {
    modelToForm: (
      value: NoInfer<Model>,
      previous?: { source: NoInfer<Model>; value: NoInfer<Form> },
    ) => Form;
    formToModel: (formValue: NoInfer<Form>) => Model;
    equal?: <T extends Form | Model>(a: NoInfer<T>, b: NoInfer<T>) => boolean;
    schema?: SchemaOrSchemaFn<Form>;
    injector?: Injector;
    name?: string;
  },
): FieldTree<Form> {
  equal ??= isEqual;
  const editSignal = linkedSignal<Model, Form>({
    source: signal,
    computation: (
      model: Model,
      previous?: { source: NoInfer<Model>; value: Form },
    ) => modelToForm(model, previous),
    equal,
  });
  const f = schema
    ? form<Form>(editSignal, schema, formOptions)
    : form<Form>(editSignal, formOptions);
  // Sync form changes back from the valueEdit signal to the main model value signal - if the form is valid
  effect(
    () => {
      // IMPORTANT: we test for `valid()` here to ensure that async validators have completed
      //            `invalid()` is still false while async validation is running
      if (!f().valid()) return;
      const valueFromForm = formToModel(editSignal());
      if (!equal(signal(), valueFromForm)) {
        signal.set(valueFromForm);
      }
    },
    { injector: formOptions.injector },
  );
  return f;
}

I would be willing to provide a PR if this is something you want to consider including.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions