Skip to content

Commit

Permalink
Merge pull request #24434 from storybookjs/valentin/angular-args-to-t…
Browse files Browse the repository at this point in the history
…emplate

Angular: Introduce argsToTemplate for property and event Bindings
  • Loading branch information
yannbf authored Oct 11, 2023
2 parents 7f337b1 + a1fdb68 commit a9d1dfc
Show file tree
Hide file tree
Showing 35 changed files with 651 additions and 381 deletions.
102 changes: 102 additions & 0 deletions code/frameworks/angular/src/client/argsToTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { argsToTemplate, ArgsToTemplateOptions } from './argsToTemplate'; // adjust path

describe('argsToTemplate', () => {
it('should correctly convert args to template string and exclude undefined values', () => {
const args: Record<string, any> = {
prop1: 'value1',
prop2: undefined,
prop3: 'value3',
};
const options: ArgsToTemplateOptions<keyof typeof args> = {};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1" [prop3]="prop3"');
});

it('should include properties from include option', () => {
const args = {
prop1: 'value1',
prop2: 'value2',
prop3: 'value3',
};
const options: ArgsToTemplateOptions<keyof typeof args> = {
include: ['prop1', 'prop3'],
};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1" [prop3]="prop3"');
});

it('should include non-undefined properties from include option', () => {
const args: Record<string, any> = {
prop1: 'value1',
prop2: 'value2',
prop3: undefined,
};
const options: ArgsToTemplateOptions<keyof typeof args> = {
include: ['prop1', 'prop3'],
};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1"');
});

it('should exclude properties from exclude option', () => {
const args = {
prop1: 'value1',
prop2: 'value2',
prop3: 'value3',
};
const options: ArgsToTemplateOptions<keyof typeof args> = {
exclude: ['prop2'],
};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1" [prop3]="prop3"');
});

it('should exclude properties from exclude option and undefined properties', () => {
const args: Record<string, any> = {
prop1: 'value1',
prop2: 'value2',
prop3: undefined,
};
const options: ArgsToTemplateOptions<keyof typeof args> = {
exclude: ['prop2'],
};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1"');
});

it('should prioritize include over exclude when both options are given', () => {
const args = {
prop1: 'value1',
prop2: 'value2',
prop3: 'value3',
};
const options: ArgsToTemplateOptions<keyof typeof args> = {
include: ['prop1', 'prop2'],
exclude: ['prop2', 'prop3'],
};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1" [prop2]="prop2"');
});

it('should work when neither include nor exclude options are given', () => {
const args = {
prop1: 'value1',
prop2: 'value2',
};
const options: ArgsToTemplateOptions<keyof typeof args> = {};
const result = argsToTemplate(args, options);
expect(result).toBe('[prop1]="prop1" [prop2]="prop2"');
});

it('should bind events correctly when value is a function', () => {
const args = { event1: () => {}, event2: () => {} };
const result = argsToTemplate(args, {});
expect(result).toEqual('(event1)="event1($event)" (event2)="event2($event)"');
});

it('should mix properties and events correctly', () => {
const args = { input: 'Value1', event1: () => {} };
const result = argsToTemplate(args, {});
expect(result).toEqual('[input]="input" (event1)="event1($event)"');
});
});
74 changes: 74 additions & 0 deletions code/frameworks/angular/src/client/argsToTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Options for controlling the behavior of the argsToTemplate function.
*
* @template T The type of the keys in the target object.
*/
export interface ArgsToTemplateOptions<T> {
/**
* An array of keys to specifically include in the output.
* If provided, only the keys from this array will be included in the output,
* irrespective of the `exclude` option. Undefined values will still be excluded from the output.
*/
include?: Array<T>;
/**
* An array of keys to specifically exclude from the output.
* If provided, these keys will be omitted from the output. This option is
* ignored if the `include` option is also provided
*/
exclude?: Array<T>;
}

/**
* Converts an object of arguments to a string of property and event bindings and excludes undefined values.
* Why? Because Angular treats undefined values in property bindings as an actual value
* and does not apply the default value of the property as soon as the binding is set.
* This feels counter-intuitive and is a common source of bugs in stories.
* @example
* ```ts
* // component.ts
*ㅤ@Component({ selector: 'example' })
* export class ExampleComponent {
* ㅤ@Input() input1: string = 'Default Input1';
* ㅤ@Input() input2: string = 'Default Input2';
* ㅤ@Output() click = new EventEmitter();
* }
*
* // component.stories.ts
* import { argsToTemplate } from '@storybook/angular';
* export const Input1: Story = {
* render: (args) => ({
* props: args,
* // Problem1: <example [input1]="input1" [input2]="input2" (click)="click($event)"></example>
* // This will set input2 to undefined and the internal default value will not be used.
* // Problem2: <example [input1]="input1" (click)="click($event)"></example>
* // The default value of input2 will be used, but it is not overridable by the user via controls.
* // Solution: Now the controls will be applicable to both input1 and input2, and the default values will be used if the user does not override them.
* template: `<example ${argsToTemplate(args)}"></example>`,
* }),
* args: {
* // In this Story, we want to set the input1 property, and the internal default property of input2 should be used.
* input1: 'Input 1',
* click: { action: 'clicked' },
* },
*};
* ```
*/
export function argsToTemplate<A extends Record<string, any>>(
args: A,
options: ArgsToTemplateOptions<keyof A> = {}
) {
const includeSet = options.include ? new Set(options.include) : null;
const excludeSet = options.exclude ? new Set(options.exclude) : null;

return Object.entries(args)
.filter(([key]) => args[key] !== undefined)
.filter(([key]) => {
if (includeSet) return includeSet.has(key);
if (excludeSet) return !excludeSet.has(key);
return true;
})
.map(([key, value]) =>
typeof value === 'function' ? `(${key})="${key}($event)"` : `[${key}]="${key}"`
)
.join(' ');
}
1 change: 1 addition & 0 deletions code/frameworks/angular/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './public-types';
export type { StoryFnAngularReturnType as IStory } from './types';

export { moduleMetadata, componentWrapperDecorator, applicationConfig } from './decorators';
export { argsToTemplate } from './argsToTemplate';

// optimization: stop HMR propagation in webpack
if (typeof module !== 'undefined') module?.hot?.decline();
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { Args } from '@storybook/angular';
import { Meta, StoryObj, argsToTemplate } from '@storybook/angular';
import { DocButtonComponent } from './doc-button.component';

export default {
const meta: Meta<DocButtonComponent<any>> = {
component: DocButtonComponent,
};

export const Basic = (args: Args) => ({
props: args,
});
Basic.args = { label: 'Args test', isDisabled: false };
Basic.ArgTypes = {
theDefaultValue: {
table: {
defaultValue: { summary: 'Basic default value' },
export default meta;

type Story = StoryObj<DocButtonComponent<any>>;

export const Basic: Story = {
args: { label: 'Args test', isDisabled: false },
argTypes: {
theDefaultValue: {
table: {
defaultValue: { summary: 'Basic default value' },
},
},
},
};

export const WithTemplate = (args: Args) => ({
props: args,
template: '<my-button [label]="label" [appearance]="appearance"></my-button>',
});
WithTemplate.args = { label: 'Template test', appearance: 'primary' };
export const WithTemplate: Story = {
args: { label: 'Template test', appearance: 'primary' },
render: (args) => ({
props: args,
template: `<my-button ${argsToTemplate(args)}></my-button>`,
}),
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { Meta, StoryObj } from '@storybook/angular';
import { DocDirective } from './doc-directive.directive';

export default {
const meta: Meta<DocDirective> = {
component: DocDirective,
};

const modules = {
declarations: [DocDirective],
};
export default meta;

type Story = StoryObj<DocDirective>;

export const Basic = () => ({
moduleMetadata: modules,
template: '<div docDirective [hasGrayBackground]="true"><h1>DocDirective</h1></div>',
});
export const Basic: Story = {
render: () => ({
moduleMetadata: {
declarations: [DocDirective],
},
template: '<div docDirective [hasGrayBackground]="true"><h1>DocDirective</h1></div>',
}),
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { Meta, StoryObj } from '@storybook/angular';
import { DocInjectableService } from './doc-injectable.service';

export default {
const meta: Meta<DocInjectableService> = {
component: DocInjectableService,
};

const modules = {
provider: [DocInjectableService],
};
export default meta;

type Story = StoryObj<DocInjectableService>;

export const Basic = () => ({
moduleMetadata: modules,
template: '<div><h1>DocInjectable</h1></div>',
});
export const Basic: Story = {
render: () => ({
moduleMetadata: {
providers: [DocInjectableService],
},
template: '<div><h1>DocInjectable</h1></div>',
}),
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { Meta, StoryObj } from '@storybook/angular';
import { DocPipe } from './doc-pipe.pipe';

export default {
const meta: Meta<DocPipe> = {
component: DocPipe,
};

const modules = {
declarations: [DocPipe],
};
export default meta;

type Story = StoryObj<DocPipe>;

export const Basic = () => ({
moduleMetadata: modules,
template: `<div><h1>{{ 'DocPipe' | docPipe }}</h1></div>`,
});
export const Basic: Story = {
render: () => ({
moduleMetadata: {
declarations: [DocPipe],
},
template: `<div><h1>{{ 'DocPipe' | docPipe }}</h1></div>`,
}),
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { FormsModule } from '@angular/forms';
import { Meta, StoryFn, moduleMetadata } from '@storybook/angular';
import { Meta, StoryFn, StoryObj, moduleMetadata } from '@storybook/angular';
import { CustomCvaComponent } from './custom-cva.component';

export default {
const meta: Meta<CustomCvaComponent> = {
// title: 'Basics / Angular forms / ControlValueAccessor',
component: CustomCvaComponent,
decorators: [
Expand All @@ -17,11 +17,16 @@ export default {
],
} as Meta;

export const SimpleInput: StoryFn = () => ({
props: {
ngModel: 'Type anything',
ngModelChange: () => {},
},
});
export default meta;

SimpleInput.storyName = 'Simple input';
type Story = StoryObj<CustomCvaComponent>;

export const SimpleInput: Story = {
name: 'Simple input',
render: () => ({
props: {
ngModel: 'Type anything',
ngModelChange: () => {},
},
}),
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Meta, StoryObj } from '@storybook/angular';
import { AttributeSelectorComponent } from './attribute-selector.component';

export default {
const meta: Meta<AttributeSelectorComponent> = {
// title: 'Basics / Component / With Complex Selectors',
component: AttributeSelectorComponent,
};

export const AttributeSelectors = {};
export default meta;

type Story = StoryObj<AttributeSelectorComponent>;

export const AttributeSelectors: Story = {};
Loading

0 comments on commit a9d1dfc

Please sign in to comment.