angularformsstorybookangular-formly

How to write a storybook story for a custom formly field?


I am writing an Angular 15 Application making use of the formly package to generate forms for me. Formly provides most fields to render inside of its forms out of the box, but not a file-field. So I wrote one like this:

import { Component } from '@angular/core';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core';

@Component({
  selector: 'app-formly-file-field',
  templateUrl: './formly-file-field.component.html',
  styleUrls: ['./formly-file-field.component.scss'] // <-- That file is simply empty
})
export class FormlyFileFieldComponent extends FieldType<FieldTypeConfig>{
  //Extends needs to be this elaborate as otherwise the Angular compiler does not know
  //that FieldType.formControl contains all fields required to satisfy the interface FormControl
  //https://github.com/ngx-formly/ngx-formly/issues/2842#issuecomment-1016476706
}
<div class="mb-3"> 
  <input type="file" [formControl]="formControl" [formlyAttributes]="field">
</div>
# file-value-accessor.ts
import { Directive } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Directive({
  // tslint:disable-next-line
  selector: 'input[type=file]',
  host: {
    '(change)': 'onChange($event.target.files)',
    '(blur)': 'onTouched()',
  },
  providers: [
    { provide: NG_VALUE_ACCESSOR, useExisting: FileValueAccessor, multi: true },
  ],
})
// https://github.com/angular/angular/issues/7341
export class FileValueAccessor implements ControlValueAccessor {
  value: any;
  onChange = (_: any) => { };
  onTouched = () => { };

  writeValue(value: any) { }
  registerOnChange(fn: any) { this.onChange = fn; }
  registerOnTouched(fn: any) { this.onTouched = fn; }
}

I want to have a storybook story to see how it looks like when rendered. This is where I'm struggling.

You would typically never render this component on its own. Rather, you would write it inside of a form tag like this:

# app.component.ts
import { Component } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormlyFormOptions, FormlyFieldConfig } from '@ngx-formly/core';

@Component({
  selector: 'formly-app-example',
  templateUrl: './app.component.html',
})
export class AppComponent {
  form = new FormGroup({});
  model = {};
  options: FormlyFormOptions = {};
  fields: FormlyFieldConfig[] = [
    {
      key: 'file',
      type: 'file',
    },
  ];
}
# app.component.html
<form [formGroup]="form">
  <formly-form [model]="model" [fields]="fields" [options]="options" [form]="form"></formly-form>
</form>

I've read in multiple places that it is feasible, but none of them show an example. How do I do this?


Solution

  • The solution lies with Storybook's CompositeComponent feature and I'll share it as it took me quite a while to figure out.

    With componentWrapperDecorator write the HTML that you would normally write to spawn a form. To get the values you need there, add the necessary fields to your args object like so:

    ...
      args: {
        form: new FormGroup({}),
        model: {},
        options: {},
        fields: [
          {
            key: 'file',
            type: 'file',
          },
        ],
      },
    ...
    

    The rest works as normal:

    import { FormGroup, ReactiveFormsModule } from '@angular/forms';
    import { FormlyBootstrapModule } from '@ngx-formly/bootstrap';
    import { FormlyModule } from '@ngx-formly/core';
    import { Meta, StoryFn, componentWrapperDecorator, moduleMetadata } from '@storybook/angular';
    import { AtomsModule } from 'src/app/atoms/atoms.module';
    import { FormlyFileFieldComponent } from './formly-file-field.component';
    
    
    export default {
      title: 'FormlyFileFieldComponent',
      component: FormlyFileFieldComponent,
      args: {
        form: new FormGroup({}),
        model: {},
        options: {},
        fields: [
          {
            key: 'file',
            type: 'file',
          },
        ],
      },
      decorators: [
        moduleMetadata({
          declarations: [
            FormlyFileFieldComponent
          ],
          imports: [
            ReactiveFormsModule,
            FormlyBootstrapModule,
            FormlyModule.forRoot({
              types: [{ name: 'file', component: FormlyFileFieldComponent, wrappers: ['form-field'] }],
            })
          ],
        }),
        componentWrapperDecorator(() => `
          <form [formGroup]="form">
            <formly-form 
              [model]="model" 
              [fields]="fields" 
              [options]="options" 
              [form]="form"
            ></formly-form>
          </form>
        `)
      ],
    } as Meta<FormlyFileFieldComponent>;
    
    const Template: StoryFn<FormlyFileFieldComponent> = (args: FormlyFileFieldComponent) => ({ 
      props: {
        ...args,
      },
    });
    
    export const Default = Template.bind({});
    Default.args = {}