I'm working on a custom Formly wrapper for my form fields and I wanted to display the wrapper in Storybook. I found an excellent example on how to show the wrapper in Storybook (given that all of a wrapper's properties are injected via the FormlyConfig rather than directly into the component) at https://stackoverflow.com/a/76075951 and I've adapted it so that I can set my config properties using Storybook args with the addition of an intermediate StorybookFormly component.
import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
import { Component, Input } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { FormlyModule, type FormlyFieldConfig } from '@ngx-formly/core';
import { FormlyBootstrapModule } from '@ngx-formly/bootstrap';
import { FormComponentsModule } from '../lib/form-components/form-components.module';
import { FieldWrapperComponent } from '../lib/form-components/field-wrapper/field-wrapper.component';
@Component({
selector: 'storybook-formly', // eslint-disable-line @angular-eslint/component-selector
template: `
<form [formGroup]="form">
<formly-form [model]="model" [fields]="fields" [options]="options" [form]="form"></formly-form>
</form>
`
})
export class StoryBookFormlyComponent {
@Input() label: string;
@Input() placeholder: string;
@Input() description: string;
@Input() required: boolean;
@Input() inputType: string;
form = new FormGroup({});
model = { example: '' };
options = {};
get fields(): FormlyFieldConfig[] {
return [
{
key: 'example',
wrappers: [FieldWrapperComponent],
type: this.inputType,
props: { label: this.label, placeholder: this.placeholder, description: this.description, required: this.required }
}
];
}
}
const meta: Meta<StoryBookFormlyComponent> = {
title: 'Atoms/Forms/Standard Inputs',
component: StoryBookFormlyComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
ReactiveFormsModule,
FormComponentsModule,
FormlyBootstrapModule,
FormlyModule.forRoot({ validationMessages: [{ name: 'required', message: 'This field is required' }] })
]
})
],
render: (args: StoryBookFormlyComponent) => ({
props: args
}),
argTypes: {}
};
export default meta;
type Story = StoryObj<StoryBookFormlyComponent>;
export const Primary: Story = {
args: {
label: 'Example Label 2',
placeholder: 'My placeholder',
description: 'Some description',
required: true,
inputType: 'input'
}
};
When I run this in Storybook it appears exactly as intended and the controls manage the properties with no problem. However, when I click in the input field, it immediately triggers the following error,
ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for 'ng-untouched': 'true'. Current value: 'false'. Expression location: StoryBookFormlyComponent component.
The validation does trigger and it shows up as a field with an error but I cannot give focus to the field. As soon as the error is triggered the field loses focus and I cannot get focus back nor can I enter any data in the field.
The error doesn't appear in the previous version from the Stack Overflow answer I used where there are no Storybook args and nothing is configurable. I understand what the error means but I cannot see what I can change or do about it. Nor can I think of any other way to make it configurable.
In case its relevant, I'm running Angular 16 and Storybook 7.1.0 and the wrapper component is shown below.
import { Component } from '@angular/core';
import { FieldWrapper } from '@ngx-formly/core';
@Component({
selector: 'component-library-field-wrapper',
template: `
<ng-template #labelTemplate>
<label *ngIf="props.label && props.hideLabel !== true" [attr.for]="id" class="form-label">
{{ props.label }}
<span *ngIf="props.required && props.hideRequiredMarker !== true" aria-hidden="true" class="required"> (Required)</span>
</label>
</ng-template>
<div class="mb-3" [class.form-floating]="props.labelPosition === 'floating'" [class.has-error]="showError">
<ng-container *ngIf="props.labelPosition !== 'floating'">
<ng-container [ngTemplateOutlet]="labelTemplate"></ng-container>
</ng-container>
<div *ngIf="props.description" class="form-text">{{ props.description }}</div>
<div *ngIf="showError" class="invalid-feedback">
<formly-validation-message [field]="field"></formly-validation-message>
</div>
<ng-template #fieldComponent></ng-template>
<ng-container *ngIf="props.labelPosition === 'floating'">
<ng-container [ngTemplateOutlet]="labelTemplate"></ng-container>
</ng-container>
</div>
`
})
export class FieldWrapperComponent extends FieldWrapper {}
I'm not sure why my idea of a full intermediate Angular component didn't work but I eventually found an answer based on https://tsvetan.dev/blog/article/render-angular-components-with-ng-content-in-storybook/. The trick is to extend the type of your existing component instead with the extra arguments you want to add and then strip them off in the render function before passing the remainder onwards for the actual component. As a bonus it means you can also use the story context to access parameters, etc for things you don't want the user to be able to control.
The final code I ended up with is below
import { moduleMetadata, type Meta, type StoryObj } from '@storybook/angular';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { FormlyModule } from '@ngx-formly/core';
import { FormlyBootstrapModule } from '@ngx-formly/bootstrap';
import { FieldWrapperComponent } from '../lib/form-components/field-wrapper/field-wrapper.component';
type StoryFieldWrapperComponent = FieldWrapperComponent & {
label: string;
placeholder: string;
description: string;
required: boolean;
};
const meta: Meta<StoryFieldWrapperComponent> = {
title: 'Atoms/Forms/Standard Inputs',
component: FieldWrapperComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [
ReactiveFormsModule,
FormlyBootstrapModule,
FormlyModule.forRoot({ validationMessages: [{ name: 'required', message: 'This field is required' }] })
]
})
],
render: (args, context) => {
const { inputType, inputProps } = context.parameters;
return {
props: {
form: new FormGroup({}),
model: {},
options: {},
fields: [
{
key: 'example',
wrappers: [FieldWrapperComponent],
type: inputType || 'input',
props: { ...args, ...inputProps }
}
]
},
template: `
<form [formGroup]="form">
<formly-form
[model]="model"
[fields]="fields"
[options]="options"
[form]="form"
></formly-form>
</form>
`
};
},
argTypes: {}
};
export default meta;
type Story = StoryObj<StoryFieldWrapperComponent>;
export const WrapperProperties: Story = {
args: {
label: 'Input Label',
placeholder: 'Enter text here...',
description: 'Click on field and then outside it to see validation message',
required: true
},
parameters: {
inputType: 'input'
}
};
export const Disabled: Story = {
args: {
label: 'Input Label',
placeholder: 'Enter text here...',
description: 'This field has been disabled',
required: true
},
parameters: {
inputType: 'input',
inputProps: {
disabled: true
}
}
};