Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Angular: Introduce argsToTemplate for property and event Bindings #24434

Merged
merged 6 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading