Refactor ExternalX to reuse DocsContext

Simplifies things down a lot!
This commit is contained in:
Tom Coleman 2022-07-05 16:30:48 +10:00
parent 697de50991
commit b2e34b88f1
9 changed files with 162 additions and 257 deletions

View File

@ -1,67 +0,0 @@
import React from 'react';
import { ThemeProvider, themes, ensure } from '@storybook/theming';
import { DocsContextProps } from '@storybook/preview-web';
import { ModuleExport, ModuleExports, Story } from '@storybook/store';
import { AnyFramework, StoryId, StoryName } from '@storybook/csf';
import { DocsContext } from './DocsContext';
import { ExternalPreview } from './ExternalPreview';
let preview: ExternalPreview<AnyFramework>;
export const ExternalDocsContainer: React.FC<{ projectAnnotations: any }> = ({
projectAnnotations,
children,
}) => {
if (!preview) preview = new ExternalPreview(projectAnnotations);
let pageMeta: ModuleExport;
const setMeta = (m: ModuleExport) => {
pageMeta = m;
};
const docsContext: DocsContextProps = {
id: 'external-docs',
title: 'External',
name: 'Docs',
storyIdByModuleExport: (storyExport: ModuleExport, metaExport: ModuleExports) => {
return preview.storyIdByModuleExport(storyExport, metaExport || pageMeta);
},
storyIdByName: (name: StoryName) => {
// TODO
throw new Error('not implemented');
},
storyById: (id: StoryId) => {
return preview.storyById(id);
},
getStoryContext: () => {
throw new Error('not implemented');
},
componentStories: () => {
// TODO: could implement in a very similar way to in DocsRender. (TODO: How to share code?)
throw new Error('not implemented');
},
loadStory: async (id: StoryId) => {
return preview.storyById(id);
},
renderStoryToElement: (story: Story<AnyFramework>, element: HTMLElement) => {
return preview.renderStoryToElement(story, element);
},
setMeta,
};
return (
<DocsContext.Provider value={docsContext}>
<ThemeProvider theme={ensure(themes.light)}>{children}</ThemeProvider>
</DocsContext.Provider>
);
};

View File

@ -1,80 +0,0 @@
import { StoryId } from '@storybook/csf';
import { ExternalPreview } from './ExternalPreview';
const projectAnnotations = { render: jest.fn(), renderToDOM: jest.fn() };
const csfFileWithTitle = {
default: { title: 'Component' },
one: { args: { a: 'foo' } },
two: { args: { b: 'bar' } },
};
const csfFileWithoutTitle = {
default: {},
one: { args: { a: 'foo' } },
};
describe('ExternalPreview', () => {
describe('storyIdByModuleExport and storyById', () => {
it('handles csf files with titles', async () => {
const preview = new ExternalPreview(projectAnnotations);
const storyId = preview.storyIdByModuleExport(
csfFileWithTitle.one,
csfFileWithTitle
) as StoryId;
const story = preview.storyById(storyId);
expect(story).toMatchObject({
title: 'Component',
initialArgs: { a: 'foo' },
});
});
it('returns consistent story ids and objects', () => {
const preview = new ExternalPreview(projectAnnotations);
const storyId = preview.storyIdByModuleExport(
csfFileWithTitle.one,
csfFileWithTitle
) as StoryId;
const story = preview.storyById(storyId);
expect(preview.storyIdByModuleExport(csfFileWithTitle.one, csfFileWithTitle)).toEqual(
storyId
);
expect(preview.storyById(storyId)).toBe(story);
});
it('handles more than one export', async () => {
const preview = new ExternalPreview(projectAnnotations);
preview.storyById(
preview.storyIdByModuleExport(csfFileWithTitle.one, csfFileWithTitle) as StoryId
);
const story = preview.storyById(
preview.storyIdByModuleExport(csfFileWithTitle.two, csfFileWithTitle) as StoryId
);
expect(story).toMatchObject({
title: 'Component',
initialArgs: { b: 'bar' },
});
});
it('handles csf files without titles', async () => {
const preview = new ExternalPreview(projectAnnotations);
const storyId = preview.storyIdByModuleExport(
csfFileWithoutTitle.one,
csfFileWithoutTitle
) as StoryId;
const story = preview.storyById(storyId);
expect(story).toMatchObject({
title: expect.any(String),
initialArgs: { a: 'foo' },
});
});
});
});

View File

@ -1,93 +0,0 @@
import { Preview } from '@storybook/preview-web';
import { Path, ModuleExports, StoryIndex, ModuleExport } from '@storybook/store';
import { toId, AnyFramework, ComponentTitle, StoryId, ProjectAnnotations } from '@storybook/csf';
type StoryExport = ModuleExport;
type MetaExport = ModuleExports;
type ExportName = string;
class ConstantMap<TKey, TValue extends string> {
entries = new Map<TKey, TValue>();
// eslint-disable-next-line no-useless-constructor
constructor(private prefix: string) {}
get(key: TKey) {
if (!this.entries.has(key)) {
this.entries.set(key, `${this.prefix}${this.entries.size}` as TValue);
}
return this.entries.get(key);
}
}
export class ExternalPreview<TFramework extends AnyFramework> extends Preview<TFramework> {
private initialized = false;
private importPaths = new ConstantMap<MetaExport, Path>('./importPath/');
private titles = new ConstantMap<MetaExport, ComponentTitle>('title-');
public storyIds = new Map<StoryExport, StoryId>();
private storyIndex: StoryIndex = { v: 4, entries: {} };
private moduleExportsByImportPath: Record<Path, ModuleExports> = {};
constructor(public projectAnnotations: ProjectAnnotations) {
super();
}
storyIdByModuleExport(storyExport: StoryExport, meta: MetaExport) {
if (!this.storyIds.has(storyExport)) this.addStoryFromExports(storyExport, meta);
return this.storyIds.get(storyExport);
}
addStoryFromExports(storyExport: StoryExport, meta: MetaExport) {
const importPath = this.importPaths.get(meta);
this.moduleExportsByImportPath[importPath] = meta;
const title = meta.default.title || this.titles.get(meta);
const exportEntry = Object.entries(meta).find(
([_, moduleExport]) => moduleExport === storyExport
);
if (!exportEntry)
throw new Error(`Didn't find \`of\` used in Story block in the provided CSF exports`);
const storyId = toId(title, exportEntry[0]);
this.storyIds.set(storyExport, storyId);
this.storyIndex.entries[storyId] = {
id: storyId,
importPath,
title,
name: 'Name',
type: 'story',
};
if (!this.initialized) {
this.initialized = true;
return this.initialize({
getStoryIndex: () => this.storyIndex,
importFn: (path: Path) => {
return Promise.resolve(this.moduleExportsByImportPath[path]);
},
getProjectAnnotations: () => this.projectAnnotations,
});
}
// else
return this.onStoriesChanged({ storyIndex: this.storyIndex });
}
storyById(storyId: StoryId) {
const entry = this.storyIndex.entries[storyId];
if (!entry) throw new Error(`Unknown storyId ${storyId}`);
const { importPath, title } = entry;
const moduleExports = this.moduleExportsByImportPath[importPath];
const csfFile = this.storyStore.processCSFFileWithCache<TFramework>(
moduleExports,
importPath,
title
);
return this.storyStore.storyFromCSFFile({ storyId, csfFile });
}
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { ThemeProvider, themes, ensure } from '@storybook/theming';
import { AnyFramework } from '@storybook/csf';
import { DocsContext } from '../DocsContext';
import { ExternalPreview } from './ExternalPreview';
let preview: ExternalPreview<AnyFramework>;
export const ExternalDocsContainer: React.FC<{ projectAnnotations: any }> = ({
projectAnnotations,
children,
}) => {
if (!preview) preview = new ExternalPreview(projectAnnotations);
return (
<DocsContext.Provider value={preview.docsContext()}>
<ThemeProvider theme={ensure(themes.light)}>{children}</ThemeProvider>
</DocsContext.Provider>
);
};

View File

@ -0,0 +1,31 @@
import { StoryId, AnyFramework, ComponentTitle, StoryName } from '@storybook/csf';
import { DocsContext, DocsContextProps } from '@storybook/preview-web';
import { CSFFile, ModuleExport, ModuleExports, StoryStore } from '@storybook/store';
export class ExternalDocsContext<TFramework extends AnyFramework> extends DocsContext<TFramework> {
constructor(
public readonly id: StoryId,
public readonly title: ComponentTitle,
public readonly name: StoryName,
protected store: StoryStore<TFramework>,
public renderStoryToElement: DocsContextProps['renderStoryToElement'],
private processMetaExports: (metaExports: ModuleExports) => CSFFile<TFramework>
) {
super(id, title, name, store, renderStoryToElement, [], true);
}
setMeta = (metaExports: ModuleExports) => {
const csfFile = this.processMetaExports(metaExports);
this.referenceCSFFile(csfFile, true);
};
storyIdByModuleExport(storyExport: ModuleExport, metaExports?: ModuleExports) {
if (metaExports) {
const csfFile = this.processMetaExports(metaExports);
this.referenceCSFFile(csfFile, false);
}
// This will end up looking up the story id in the CSF file referenced above or via setMeta()
return super.storyIdByModuleExport(storyExport);
}
}

View File

@ -0,0 +1,84 @@
import { Preview } from '@storybook/preview-web';
import { Path, ModuleExports, StoryIndex, composeConfigs } from '@storybook/store';
import { toId, AnyFramework, ComponentTitle, ProjectAnnotations } from '@storybook/csf';
import { ExternalDocsContext } from './ExternalDocsContext';
type MetaExports = ModuleExports;
class ConstantMap<TKey, TValue extends string> {
entries = new Map<TKey, TValue>();
// eslint-disable-next-line no-useless-constructor
constructor(private prefix: string) {}
get(key: TKey) {
if (!this.entries.has(key)) {
this.entries.set(key, `${this.prefix}${this.entries.size}` as TValue);
}
return this.entries.get(key);
}
}
export class ExternalPreview<TFramework extends AnyFramework> extends Preview<TFramework> {
private importPaths = new ConstantMap<MetaExports, Path>('./importPath/');
private titles = new ConstantMap<MetaExports, ComponentTitle>('title-');
private storyIndex: StoryIndex = { v: 4, entries: {} };
private moduleExportsByImportPath: Record<Path, ModuleExports> = {};
constructor(public projectAnnotations: ProjectAnnotations) {
super();
this.initialize({
getStoryIndex: () => this.storyIndex,
importFn: (path: Path) => {
return Promise.resolve(this.moduleExportsByImportPath[path]);
},
getProjectAnnotations: () =>
composeConfigs([
{ parameters: { docs: { inlineStories: true } } },
this.projectAnnotations,
]),
});
}
processMetaExports = (metaExports: MetaExports) => {
const importPath = this.importPaths.get(metaExports);
this.moduleExportsByImportPath[importPath] = metaExports;
const title = metaExports.default.title || this.titles.get(metaExports);
const csfFile = this.storyStore.processCSFFileWithCache<TFramework>(
metaExports,
importPath,
title
);
Object.values(csfFile.stories).forEach(({ id, name }) => {
this.storyIndex.entries[id] = {
id,
importPath,
title,
name,
type: 'story',
};
});
this.onStoriesChanged({ storyIndex: this.storyIndex });
return csfFile;
};
docsContext = () => {
return new ExternalDocsContext(
'storybook--docs',
'Storybook',
'Docs',
this.storyStore,
this.renderStoryToElement.bind(this),
this.processMetaExports.bind(this)
);
};
}

View File

@ -9,8 +9,8 @@ export * from './DocsRenderer';
export * from './DocsPage';
export * from './DocsContainer';
export * from './DocsStory';
export * from './ExternalDocsContainer';
export * from './ExternalPreview';
export * from './external/ExternalDocsContainer';
export * from './external/ExternalPreview';
export * from './Heading';
export * from './Meta';
export * from './Preview';

View File

@ -5,8 +5,7 @@ import {
StoryId,
StoryName,
} from '@storybook/csf';
import { CSFFile, ModuleExport, Story, StoryStore } from '@storybook/store';
import { PreviewWeb } from '../PreviewWeb';
import { CSFFile, ModuleExport, ModuleExports, Story, StoryStore } from '@storybook/store';
import { DocsContextProps } from './DocsContextProps';
@ -24,8 +23,8 @@ export class DocsContext<TFramework extends AnyFramework> implements DocsContext
public readonly title: ComponentTitle,
public readonly name: StoryName,
protected store: StoryStore<TFramework>,
public renderStoryToElement: DocsContextProps['renderStoryToElement'],
/** The CSF files known (via the index) to be refererenced by this docs file */
public renderStoryToElement: PreviewWeb<TFramework>['renderStoryToElement'],
csfFiles: CSFFile<TFramework>[],
componentStoriesFromAllCsfFiles = true
) {
@ -35,27 +34,35 @@ export class DocsContext<TFramework extends AnyFramework> implements DocsContext
this.componentStoriesValue = [];
csfFiles.forEach((csfFile, index) => {
Object.values(csfFile.stories).forEach((annotation) => {
this.storyIdToCSFFile.set(annotation.id, csfFile);
this.exportToStoryId.set(annotation.moduleExport, annotation.id);
this.nameToStoryId.set(annotation.name, annotation.id);
if (componentStoriesFromAllCsfFiles || index === 0)
this.componentStoriesValue.push(this.storyById(annotation.id));
});
this.referenceCSFFile(csfFile, componentStoriesFromAllCsfFiles || index === 0);
});
}
setMeta() {
// Do nothing
// This docs entry references this CSF file and can syncronously load the stories, as well
// as reference them by module export. If the CSF is part of the "component" stories, they
// can also be referenced by name and are in the componentStories list.
referenceCSFFile(csfFile: CSFFile<TFramework>, addToComponentStories: boolean) {
Object.values(csfFile.stories).forEach((annotation) => {
this.storyIdToCSFFile.set(annotation.id, csfFile);
this.exportToStoryId.set(annotation.moduleExport, annotation.id);
if (addToComponentStories) {
this.nameToStoryId.set(annotation.name, annotation.id);
this.componentStoriesValue.push(this.storyById(annotation.id));
}
});
}
storyIdByModuleExport = (storyExport: ModuleExport) => {
setMeta(metaExports: ModuleExports) {
// Do nothing (this is really only used by external docs)
}
storyIdByModuleExport(storyExport: ModuleExport, metaExports?: ModuleExports) {
const storyId = this.exportToStoryId.get(storyExport);
if (storyId) return storyId;
throw new Error(`No story found with that export: ${storyExport}`);
};
}
storyIdByName = (storyName: StoryName) => {
const storyId = this.nameToStoryId.get(storyName);

View File

@ -6,5 +6,6 @@ export { PreviewWeb } from './PreviewWeb';
export { simulatePageLoad, simulateDOMContentLoaded } from './simulate-pageload';
export { DocsContext } from './docs-context/DocsContext';
export type { DocsContextProps } from './docs-context/DocsContextProps';
export type { DocsRenderFunction } from './docs-context/DocsRenderFunction';