0.5.0 • Published 23 days ago

@smallpearl/crispy-mat-form v0.5.0

Weekly downloads
-
License
MIT
Repository
github
Last release
23 days ago

CrispyMatForm

Crispy forms is a library to dynamically layout the controls of an Angular reactive form based on simple layout definition. The name is derived from the Django Crispy Forms library which provides a similar feature, but for the Django backend.

The library depends on Angular Material and all the fields used in the form are to be compatible wth the <mat-form-field> interface. That is the field controls will be wrapped in a <mat-form-field> tag and therefore should conform to the MatFormFieldControl<> interface.

Dependencies

  • Angular ^15.2.0
  • Angular Material ^15.2.0

How to use

There are two ways.

  • Raw approach: By defining the FormGroup and the layout for the individual fields in the FormGroup.
  • Using helper methods: By using helper methods in the library to combine the above two into a single step.

Each of these methods are explained below.

Raw approach

  1. First define your reactive form group. For eg.:

    const form = fb.group({
      firstName: ['', Validators.required],
      lastName: ['', Validators.required],
      units: [0],
      category: ['CL', Validators.required],
      password: ['', Validators.required],
      confirmPassword: [''],
      publishOn: ['',],
      publishedOnRange: fb.group({
        published_on__gte: new FormControl<Date | null>(null),
        published_on__lte: new FormControl<Date | null>(null)
      }),
      telephone: [null, Validators.required],
    });
  2. Then define the form layout, specifying the field type and layout rules for each field in the form above in a CrispyForm object.

    ```
    const crispy: CrispyForm = {
      form: form,
      fields: [
        {
          label: 'First name',
          type: 'text',
          formControlName: 'firstName',
          cssClass: 'pe-2 w-50'
        },
        {
          label: 'Last name',
          type: 'text',
          formControlName: 'lastName',
          cssClass: 'w-50'
        },
        {
          label: 'Units',
          type: 'number',
          formControlName: 'units',
          cssClass: 'pe-2 w-50'
        },
        {
          label: 'Category',
          type: 'select',
          formControlName: 'category',
          cssClass: 'w-50',
          selectOptions: [
            { label: 'All', value: '' },
            { label: 'Open', value: 'OP' },
            { label: 'Closed', value: 'CL' },
          ]
        },
        {
          label: 'Password',
          hint: 'Minium 8 characters, at least one non-alphabet.',
          type: 'password',
          formControlName: 'password',
          cssClass: 'pe-2 w-50'
        },
        {
          label: 'Confirm password',
          hint: 'Renter the same password.',
          type: 'password',
          formControlName: 'confirmPassword',
          cssClass: 'w-50'
        },
        {
          label: 'Published On',
          type: 'daterange',
          formControlName: 'publishedOnRange',
          cssClass: 'pe-2 w-50',
          dateRangeOptions: {
            beginRangeLabel: 'From',
            endRangeLabel: 'To',
            beginRangeFormControlName: 'published_on__gte',
            endRangeFormControlName: 'published_on__lte'
          }
        },
        {
          label: 'Unit',
          type: 'custom',
          formControlName: 'unit',
          cssClass: 'pe-2 w-50',
          customControlOptions: {
            component: CustomControlComponent
          }
        },
        {
          label: 'Telephone',
          type: 'custom',
          formControlName: 'mobile',
          cssClass: 'w-50',
          customControlOptions: {
            component: QQWebTelInputComponent
          }
        }
      ]
    }
    ```
    
    As you can see, for each field in the form, we define its layout rules in the `fields` member of `crispy`, which is of type `CrispyForm`. Each field in the `fields` array is declared as of type `CrispyField`.
  3. With the CrispyForm defined, you can use it like below in the template:

    <form [formGroup]="crispy.form" (ngSubmit)="onSubmit()">
      <app-crispy-form [crispy]="crispy"></app-crispy-form>
      <div>
        <button mat-raised-button color="primary" type="reset">Reset</button>&nbsp;
        <button mat-raised-button color="primary" type="submit">Submit</button>
      </div>
    </form>

    And in the corresponding TypeScript source, handle the form like you would do with a regular Angular reactive form:

    {
      ...
      onSubmit() {
        console.log(`onSubmit - form.value: ${JSON.stringify(this.crispy.form.value)}`);
      }
      ...
    }

    Keeping the <form> tag outside of the <app-crispy-form> tag allows you to add any additional fields that either does not conform to the MatFormFieldControl<> interface or for some reason has compatibility issues with the crispy-forms library. Moreover, you can add additional buttons other than the standard Reset and Submit, if necessary.

CrispyField

export interface CrispyField {
  // Label use for the field. Wil be placed in a <mat-label> tag.
  label: string;
  // The field type. This controls the type of UI widget used.
  type: CrispyFieldType,
  // Related reactive form control's name.
  formControlName: string;
  // An optional hint for the field. If specified, will be placed in a <mat-hint> tag.
  hint?: string;
  // Additional cssClasses that will be added to the <mat-form-field> wrapper.
  cssClass?: string;
  /* Only for 'select' field type. Specifies the individual options. */
  selectOptions?: Array<{
    label: string;
    value: string | number;
  }>;
  /* Only for 'daterange' field type. The member should be self-explantory. */
  dateRangeOptions?: {
    beginRangeLabel?: string; // defaults to 'Start'
    beginRangeFormControlName: string;
    endRangeLabel?: string;   // defaults to 'End'
    endRangeFormControlName: string;
  };
  /* Only for 'custom' field type. */
  customControlOptions?: {
    component: any  // The custom component class object that will be dynamically created.
  }
}

Essentially for each of the form's field, you declare its label, field type and the form control's name. Field type controls the UI widget that will be used to render the form field.

The table below lists the field types that are currently recognized along with their corresponding HTML widget type.

Field TypeHTML widget
numberHTML input with type="number"
textHTML input with type="text"
emailHTML input with type="email"
passwordHTML input with type="password"
searchHTML input with type="search"
checkboxMatCheckbox
dateMatDatePicker
daterangeMatDateRangeInput
selectMatSelect
customCustom widget, as specified in customControlOptions.component
template<ng-template> fragment which will be loaded and placed as the field's HTML

Note that for the custom field type, the widget component should implement the MatFormFieldControl<> interface as the control will be wrapped in a <mat-form-field> tag. In the example above, both CustomControlComponent and QQWebTelInputComponent are two custom control components that comfrom to the MatFormFieldControl<> interface. Refer to Creating a custom field control doc for details.

Also remember to import the custom control component in the parent component's module(or component, if you're using standalone components) for its dynamic instantiation from within crispy-forms to work.

For template field type, the ng-template should be defined as:

<ng-template crispyFieldName="reported_by">
  <!-- your custom field's HTML -->
</ng-template>

crispyFieldName directive's value should match the field's name used in FormGroup.controls and CrispyForm.fields.

Using helper methods

The task of defining a FormGroup and then specifying its layout via CrispyForm.fields can soon become quite repetitive and tedious. Besides, this process spreads the logic of the form across two different declarations, even though they are closely related to each other. So to streamline the process, the library component comes with a few helper methods that can be used to define the form & its layout in one declaration.

This is facilitated by the CrispyFormField interface, which is defined as:

export interface CrispyFormField {
  name: string;
  type: CrispyFieldType;
  initial: any;
  validators?: ValidatorFn | ValidatorFn[];
  label?: string;
  hint?: string;
  cssClass?: string;
  children?: CrispyFormField[];
  options?: Partial<CrispyFieldProps>;
}

Just like each field in a reactive form is defined via a FormControl instance, in crispy forms, each field is defined by a CrispyFormField object definition. You define an array of CrispyFormField objects and then pass it as the first argument to getCrispyFormHelper function, also provided by the library. getCrispyFormHelper will create the FormGroup and also its layout definition, returning a Crispy object. This object can then passed to crispy-mat-form component.

Alternatively, crispy-mat-form also takes the array of CrispyFormField as a parameter in which case the component would internall call getCrispyFormHelper to convert that into a Crispy object.

The following example illustrates the latter approach:

...
import {
  CrispyMatFormComponent,
  crispyTextField,
} from '@smallpearl/crispy-mat-form';

@Component({
imports: [
  CommonModule,
  ReactiveFormsModule,
  MatButtonModule,
  CrispyMatFormModule,
],
template: `
  <form [formGroup]="crispyComponent.form" (ngSubmit)="onSubmit()">
    <crispy-mat-form [cffs]="cffs"> </crispy-mat-form>
    <div>
      <button mat-raised-button color="primary" type="button" (click)="onReset()">
        Reset
      </button>&nbsp;
      <button mat-raised-button color="primary" type="submit" [disabled]="crispyComponent.form.invalid">
        Submit
      </button>
    </div>
  </form>
`,
})
export class AppComponent implements OnInit {
  cffs!: CrispyFormField[];
  @ViewChild(CrispyMatFormComponent, { static: true }) crispyComponent!: CrispyMatFormComponent;

  ngOnInit() {
    this.cffs = [
      crispyTextField('name', '', Validators.required, 'pe-2 w-50'),
      crispyEmailField('email', '', Validators.required, 'w-50'),
    ];
  }
  onReset() {
    this.crispyComponent.form.reset();
  }
  onSubmit() {
    console.log(
      `onSubmit - form.value: ${JSON.stringify(
        this.crispyComponent.form.value
      )}`
    );
  }
}

Field functions

To ease the process further, instead of defining an array of CrispyFormField objects, the library comes with a series of helper functions that would return a CrispyFormField object from its arguments. Using these functions is easier as each function comes with own brief parameter documentation which will be available during its definition via the editor's context-sensitive help feature. Also using functions allows the library to ensure that the CrispyFormField definition for the field type is accurate and is complete (as required CrispyFormField object fields are make mandatory in the function's parameter declaration.

This table lists the functions and their corresponding field type:

FunctionField Type
crispyTextFieldHTML input with type="text"
crispyNumberFieldHTML input with type="number"
crispySearchFieldHTML input with type="search"
crispyEmailFieldHTML input with type="email"
crispyPasswordFieldHTML input with type="password"
crispyCheckboxFieldMatCheckboxField
crispySelectFieldMatSelect
crispyDateFieldMatDateField
crispyDateRangeFieldMatDateRangeField
crispyCustomFieldField with a custom reactive forms control (ControlValueAccesor)
crispyTemplateFieldField whose HTML will be rendered with contents of <ng-template>

Error-tailor support

Crispy forms includes support for reflecting field errors via the @ngneat/error-tailor. However, due to vagaries of material's error reporting mechanism, the client code needs to initialize Error Tailor with the appropriate configuration for this to work. Specifically, in the client relevant module class declaration, initialize Error Tailor like this:

import { errorTailorImports, provideErrorTailorConfig } from '@ngneat/error-tailor';
import { MatErrorTailorControlErrorComponent } from '@smallpearl/crispy-mat-form';

@NgModule({
  ...
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    FormsModule,
    ...
    errorTailorImports,
    CrispyMatFormModule,
  ],
  providers: [
    provideErrorTailorConfig({
      blurPredicate(element) {
        return element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'MAT-SELECT';
      },
      controlErrorComponent: MatErrorTailorControlErrorComponent,
      errors: {
          useValue: {
            required: 'This field is required',
            pattern: "Doesn't match the required pattern",
            minlength: ({ requiredLength, actualLength }) => 
                        `Expect ${requiredLength} but got ${actualLength}`,
            invalidAddress: error => `Address isn't valid`
          }
        }
      }),
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Note the MatErrorTailorControlErrorComponent which is used to substitute the Error Tailor original DefaultControlErrorComponent for rendering field errors. Also note how blurPredicate is overridden to support MAT-SELECT.

Once this is setup, Crispy Material Forms should render any field validation errors when the field receives the 'blur' event or when the enclosing form is submitted.

Using in pure Angular Material forms

If you've have a pure Angular form (not crispy form), you can add Error Tailor support using the same MatErrorTailorControlErrorComponent like this:

<form [formGroup]="form" (ngSubmit)="onSubmit()" errorTailor>
  <mat-form-field>
    <mat-label>Name</mat-label>
    <input
      type="text"
      matInput
      [formControlName]="name"
      [controlErrorAnchor]="errorAnchor"
    />
    <mat-error><ng-template controlErrorAnchor #errorAnchor="controlErrorAnchor"></ng-template></mat-error>
  </mat-form-field>
</form>

We're using Error Tailor's controlErrorAnchor directive to customize its behavior to render errors as <mat-error> elements.

Sample code

The demo project in the workspace shows a rather comprehensive example of how the component can be used to efficiently design forms and then process their value in your code. Particularly, you may want to pay attention to the form validators that abstracts all of the business logic of the form's data.

TODO

  • Unit tests
  • Improved documentation. Perhaps a reference section.

Version History

  • 0.1.0 - Initial release
  • 0.2.0 -
  • 0.3.0 - Added crispyCheckboxField.
  • 0.3.1 - doc update.
  • 0.3.2 - doc update.
  • 0.3.3 - doc update.
  • 0.3.4 - Set context for template field type.
  • 0.4.0 - Support for error messages via @ngneat/error-tailor.
  • 0.4.1 - Fix errors in publishing.
  • 0.4.2 - Fix 'string' value returned for 'number' field type.
0.5.0

23 days ago

0.4.1

8 months ago

0.4.0

8 months ago

0.3.4

10 months ago

0.4.2

8 months ago

0.3.0

11 months ago

0.3.2

11 months ago

0.3.1

11 months ago

0.3.3

11 months ago

0.2.0

11 months ago

0.1.0

11 months ago