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: Add random attribute to bootstrapped selector #24972

Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { bootstrapApplication } from '@angular/platform-browser';

import { BehaviorSubject, Subject } from 'rxjs';
import { stringify } from 'telejson';
import { ICollection, Parameters, StoryFnAngularReturnType } from '../types';

import { ICollection, StoryFnAngularReturnType } from '../types';
import { getApplication } from './StorybookModule';
import { storyPropsProvider } from './StorybookProvider';
import { componentNgModules } from './StorybookWrapperComponent';
Expand All @@ -16,6 +17,14 @@ type StoryRenderInfo = {

const applicationRefs = new Map<HTMLElement, ApplicationRef>();

/**
* Attribute name for the story UID that may be written to the targetDOMNode.
*
* If a target DOM node has a story UID attribute, it will be used as part of
* the selector for the Angular component.
*/
export const STORY_UID_ATTRIBUTE = 'data-sb-story-uid';

export abstract class AbstractRenderer {
/**
* Wait and destroy the platform
Expand Down Expand Up @@ -122,10 +131,17 @@ export abstract class AbstractRenderer {

const analyzedMetadata = new PropertyExtractor(storyFnAngular.moduleMetadata, component);

const storyUid = targetDOMNode.getAttribute(STORY_UID_ATTRIBUTE);
const componentSelector = storyUid !== null ? `${targetSelector}[${storyUid}]` : targetSelector;
if (storyUid !== null) {
const element = targetDOMNode.querySelector(targetSelector);
element.toggleAttribute(storyUid, true);
}

const application = getApplication({
storyFnAngular,
component,
targetSelector,
targetSelector: componentSelector,
analyzedMetadata,
});

Expand Down Expand Up @@ -161,8 +177,10 @@ export abstract class AbstractRenderer {
return storyIdIsInvalidHtmlTagName ? `sb-${id.replace(invalidHtmlTag, '')}-component` : id;
}

/**
* Adds DOM element that angular will use as bootstrap component.
*/
protected initAngularRootElement(targetDOMNode: HTMLElement, targetSelector: string) {
// Adds DOM element that angular will use as bootstrap component
// eslint-disable-next-line no-param-reassign
targetDOMNode.innerHTML = '';
targetDOMNode.appendChild(document.createElement(targetSelector));
Expand Down
13 changes: 12 additions & 1 deletion code/frameworks/angular/src/client/angular-beta/DocsRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { addons } from '@storybook/preview-api';
import { DOCS_RENDERED, STORY_CHANGED } from '@storybook/core-events';
import { AbstractRenderer } from './AbstractRenderer';

import { getNextStoryUID } from './utils/StoryUID';
import { AbstractRenderer, STORY_UID_ATTRIBUTE } from './AbstractRenderer';
import { StoryFnAngularReturnType, Parameters } from '../types';

export class DocsRenderer extends AbstractRenderer {
Expand Down Expand Up @@ -45,4 +47,13 @@ export class DocsRenderer extends AbstractRenderer {
async afterFullRender(): Promise<void> {
await AbstractRenderer.resetCompiledComponents();
}

protected override initAngularRootElement(
targetDOMNode: HTMLElement,
targetSelector: string
): void {
super.initAngularRootElement(targetDOMNode, targetSelector);

targetDOMNode.setAttribute(STORY_UID_ATTRIBUTE, getNextStoryUID(targetDOMNode.id));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ describe('RendererFactory', () => {
let rendererFactory: RendererFactory;
let rootTargetDOMNode: HTMLElement;
let rootDocstargetDOMNode: HTMLElement;
let storyInDocstargetDOMNode: HTMLElement;

beforeEach(async () => {
rendererFactory = new RendererFactory();
document.body.innerHTML =
'<div id="storybook-root"></div><div id="root-docs"><div id="story-in-docs"></div></div>';
'<div id="storybook-root"></div><div id="root-docs"><div id="story-in-docs"></div></div>' +
'<div id="storybook-docs"></div>';
rootTargetDOMNode = global.document.getElementById('storybook-root');
rootDocstargetDOMNode = global.document.getElementById('root-docs');
(platformBrowserDynamic as any).mockImplementation(platformBrowserDynamicTesting);
Expand Down Expand Up @@ -180,5 +182,47 @@ describe('RendererFactory', () => {
const render = await rendererFactory.getRendererInstance(rootDocstargetDOMNode);
expect(render).toBeInstanceOf(DocsRenderer);
});

describe('when multiple story for the same component', () => {
it('should render both stories', async () => {
@Component({ selector: 'foo', template: '🦊' })
class FooComponent {}

const render = await rendererFactory.getRendererInstance(
global.document.getElementById('storybook-docs')
);

const targetDOMNode1 = global.document.createElement('div');
targetDOMNode1.id = 'story-1';
global.document.getElementById('storybook-docs').appendChild(targetDOMNode1);
await render?.render({
storyFnAngular: {
props: {},
},
forced: false,
component: FooComponent,
targetDOMNode: targetDOMNode1,
});

const targetDOMNode2 = global.document.createElement('div');
targetDOMNode2.id = 'story-1';
global.document.getElementById('storybook-docs').appendChild(targetDOMNode2);
await render?.render({
storyFnAngular: {
props: {},
},
forced: false,
component: FooComponent,
targetDOMNode: targetDOMNode2,
});

expect(global.document.querySelectorAll('#story-1 > story-1')[0].innerHTML).toBe(
'<foo>🦊</foo><!--container-->'
);
expect(global.document.querySelectorAll('#story-1 > story-1')[1].innerHTML).toBe(
'<foo>🦊</foo><!--container-->'
);
});
});
});
});
43 changes: 43 additions & 0 deletions code/frameworks/angular/src/client/angular-beta/utils/StoryUID.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Count of stories for each storyId.
*/
const storyCounts = new Map<string, number>();

/**
* Increments the count for a storyId and returns the next UID.
*
* When a story is bootstrapped, the storyId is used as the element tag. That
* becomes an issue when a story is rendered multiple times in the same docs
* page. This function returns a UID that is appended to the storyId to make
* it unique.
*
* @param storyId id of a story
* @returns uid of a story
*/
export const getNextStoryUID = (storyId: string): string => {
if (!storyCounts.has(storyId)) {
storyCounts.set(storyId, -1);
}

const count = storyCounts.get(storyId) + 1;
storyCounts.set(storyId, count);
return `${storyId}-${count}`;
};

/**
* Clears the storyId counts.
*
* Can be useful for testing, where you need predictable increments, without
* reloading the global state.
*
* If onlyStoryId is provided, only that storyId is cleared.
*
* @param onlyStoryId id of a story
*/
export const clearStoryUIDs = (onlyStoryId?: string): void => {
if (onlyStoryId !== undefined && onlyStoryId !== null) {
storyCounts.delete(onlyStoryId);
} else {
storyCounts.clear();
}
};