Skip to content

Commit

Permalink
Merge pull request #24972 from Marklb/marklb/fix-angular-name-collisions
Browse files Browse the repository at this point in the history
Angular: Add random attribute to bootstrapped selector
  • Loading branch information
valentinpalkovic authored Dec 12, 2023
2 parents 00124ed + 9b09b1c commit 8a9dce4
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 5 deletions.
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();
}
};

0 comments on commit 8a9dce4

Please sign in to comment.