REFACTOR preview

This commit is contained in:
Norbert de Langen 2020-02-11 01:13:30 +01:00
parent 5bfcbf2a0a
commit 867a662879
No known key found for this signature in database
GPG Key ID: 976651DA156C2825
11 changed files with 381 additions and 305 deletions

View File

@ -16,9 +16,10 @@ import Panel from '../panel/panel';
import { Preview } from '../preview/preview';
import { panels } from '../panel/panel.stories';
import { previewProps } from '../preview/preview.stories';
import { previewProps } from '../preview/preview.mockdata';
import { mockDataset } from '../sidebar/treeview/treeview.mockdata';
import { store } from './persist';
const realNavProps = {

View File

@ -7,7 +7,6 @@ export interface PreviewProps {
storyId: string;
viewMode: ViewMode;
docsOnly: boolean;
isLoading: boolean;
options: {
isFullscreen: boolean;
isToolshown: boolean;

View File

@ -19,3 +19,10 @@ export const UnstyledLink = styled(Link)({
textDecoration: 'inherit',
display: 'inline-block',
});
export const DesktopOnly = styled.span({
// Hides full screen icon at mobile breakpoint defined in app.js
'@media (max-width: 599px)': {
display: 'none',
},
});

View File

@ -1,141 +0,0 @@
import window from 'global';
import React, { Fragment } from 'react';
import memoize from 'memoizerific';
import copy from 'copy-to-clipboard';
import { State, API } from '@storybook/api';
import { types, Addon } from '@storybook/addons';
import { Icons, IconButton, TabButton, TabBar, Separator } from '@storybook/components';
import { ZoomConsumer, Zoom } from './zoom';
import { getElementList, DesktopOnly, stringifyQueryParams } from './preview';
import * as S from './components';
export const getTools = memoize(10)(
(
getElements: API['getElements'],
queryParams: State['customQueryParams'],
panels: Partial<Addon>[],
api: API,
options,
storyId: string,
viewMode: State['viewMode'],
docsOnly: boolean,
location: State['location'],
path: string,
baseUrl: string
) => {
const tools = getElementList(getElements, types.TOOL, [
panels.filter(p => !p.hidden).length > 1
? ({
render: () => (
<Fragment>
<TabBar key="tabs">
{panels
.filter(p => !p.hidden)
.map((t, index) => {
const to = t.route({ storyId, viewMode, path, location });
const isActive = path === to;
return (
<S.UnstyledLink key={t.id || `l${index}`} to={to}>
<TabButton disabled={t.disabled} active={isActive}>
{t.title}
</TabButton>
</S.UnstyledLink>
);
})}
</TabBar>
<Separator />
</Fragment>
),
} as Partial<Addon>)
: null,
{
match: p => p.viewMode === 'story',
render: () => (
<Fragment>
<ZoomConsumer>
{({ set, value }) => (
<Zoom key="zoom" set={(v: number) => set(value * v)} reset={() => set(1)} />
)}
</ZoomConsumer>
<Separator />
</Fragment>
),
},
]);
const extraTools = getElementList(getElements, types.TOOLEXTRA, [
{
match: p => p.viewMode === 'story',
render: () => (
<DesktopOnly>
<IconButton
key="full"
onClick={api.toggleFullscreen as any}
title={options.isFullscreen ? 'Exit full screen' : 'Go full screen'}
>
<Icons icon={options.isFullscreen ? 'close' : 'expand'} />
</IconButton>
</DesktopOnly>
),
},
{
match: p => p.viewMode === 'story',
render: () => (
<IconButton
key="opener"
href={`${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`}
target="_blank"
title="Open canvas in new tab"
>
<Icons icon="share" />
</IconButton>
),
},
{
match: p => p.viewMode === 'story',
render: () => (
<IconButton
key="copy"
onClick={() =>
copy(
`${window.location.origin}${
window.location.pathname
}${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`
)
}
title="Copy canvas link"
>
<Icons icon="copy" />
</IconButton>
),
},
]);
// if its a docsOnly page, even the 'story' view mode is considered 'docs'
const filter = (item: Partial<Addon>) =>
item &&
(!item.match ||
item.match({
storyId,
viewMode: docsOnly && viewMode === 'story' ? 'docs' : viewMode,
location,
path,
}));
const displayItems = (list: Partial<Addon>[]) =>
list.reduce(
(acc, item, index) =>
item ? (
// @ts-ignore
<Fragment key={item.id || item.key || `f-${index}`}>
{acc}
{item.render({}) || item}
</Fragment>
) : (
acc
),
null
);
const left = displayItems(tools.filter(filter));
const right = displayItems(extraTools.filter(filter));
return { left, right };
}
);

View File

@ -0,0 +1,35 @@
import { types } from '@storybook/addons';
export const previewProps = {
id: 'string',
api: {
on: () => {},
emit: () => {},
off: () => {},
},
storyId: 'string',
path: 'string',
viewMode: 'story',
location: {},
baseUrl: 'http://example.com',
queryParams: {},
getElements: type =>
type === types.TAB
? [
{
id: 'notes',
type: types.TAB,
title: 'Notes',
route: ({ storyId }) => `/info/${storyId}`,
match: ({ viewMode }) => viewMode === 'info',
render: () => null,
},
]
: [],
options: {
isFullscreen: false,
isToolshown: true,
},
actions: {},
withLoader: false,
};

View File

@ -1,46 +1,13 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { types } from '@storybook/addons';
import { Preview } from './preview';
import { previewProps } from './preview.mockdata';
export const previewProps = {
id: 'string',
api: {
on: () => {},
emit: () => {},
off: () => {},
},
storyId: 'string',
path: 'string',
viewMode: 'story',
location: {},
baseUrl: 'http://example.com',
queryParams: {},
getElements: type =>
type === types.TAB
? [
{
id: 'notes',
type: types.TAB,
title: 'Notes',
route: ({ storyId }) => `/info/${storyId}`, // todo add type
match: ({ viewMode }) => viewMode === 'info', // todo add type
render: () => null,
},
]
: [],
options: {
isFullscreen: false,
isToolshown: true,
},
actions: {},
withLoader: false,
export default {
title: 'UI/Preview/Preview',
component: Preview,
};
storiesOf('UI/Preview/Preview', module)
.addParameters({
component: Preview,
})
.add('no tabs', () => <Preview {...previewProps} getElements={() => []} />)
.add('with tabs', () => <Preview {...previewProps} />);
export const noTabs = () => <Preview {...previewProps} getElements={() => []} />;
export const withTabs = () => <Preview {...previewProps} />;

View File

@ -1,15 +1,14 @@
import React, { Component, Fragment } from 'react';
import memoize from 'memoizerific';
import { styled } from '@storybook/theming';
import { API } from '@storybook/api';
import { API, Consumer, Combo, State } from '@storybook/api';
import { SET_CURRENT_STORY } from '@storybook/core-events';
import addons, { types, Types, Addon } from '@storybook/addons';
import addons, { types, Addon } from '@storybook/addons';
import merge from '@storybook/api/dist/lib/merge';
import { Loader } from '@storybook/components';
import { Helmet } from 'react-helmet-async';
import { Toolbar } from './toolbar';
import { Toolbar, defaultTools, defaultToolsExtra, createTabsTool } from './toolbar';
import * as S from './components';
@ -17,20 +16,9 @@ import { ZoomProvider, ZoomConsumer } from './zoom';
import { IFrame } from './iframe';
import { PreviewProps, ApplyWrappersProps, IframeRenderer } from './PreviewProps';
import { getTools } from './getTools';
import { defaultWrappers, ApplyWrappers } from './ApplyWrappers';
export const DesktopOnly = styled.span({
// Hides full screen icon at mobile breakpoint defined in app.js
'@media (max-width: 599px)': {
display: 'none',
},
});
export const stringifyQueryParams = (queryParams: Record<string, string>) =>
Object.entries(queryParams).reduce((acc, [k, v]) => {
return `${acc}&${k}=${v}`;
}, '');
import { defaultWrappers, ApplyWrappers } from './wrappers';
import { stringifyQueryParams } from './stringifyQueryParams';
export const renderIframe: IframeRenderer = (
storyId,
@ -50,16 +38,79 @@ export const renderIframe: IframeRenderer = (
/>
);
export const getElementList = memoize(
10
)((getFn: API['getElements'], type: Types, base: Partial<Addon>[]) =>
base.concat(Object.values(getFn(type)))
const getWrapper = memoize(1)((getFn: API['getElements']) =>
Object.values(getFn<Addon>(types.PREVIEW))
);
const getTabs = memoize(1)((getFn: API['getElements']) => Object.values(getFn<Addon>(types.TAB)));
const getTools = memoize(1)((getFn: API['getElements']) => Object.values(getFn<Addon>(types.TOOL)));
const getToolsExtra = memoize(1)((getFn: API['getElements']) =>
Object.values(getFn<Addon>(types.TOOLEXTRA))
);
const getDocumentTitle = (description: string) => {
return description ? `${description} ⋅ Storybook` : 'Storybook';
};
const mapper = ({ state, api }: Combo) => ({
storyId: state.storyId,
viewMode: state.viewMode,
customCanvas: api.renderPreview,
queryParams: state.customQueryParams,
getElements: api.getElements,
isLoading: !state.storiesConfigured,
});
const createCanvas = (id: string, baseUrl = 'iframe.html', withLoader = true): Addon => ({
id: 'canvas',
title: 'Canvas',
route: p => `/story/${p.storyId}`,
match: p => !!(p.viewMode && p.viewMode.match(/^(story|docs)$/)),
render: p => {
return (
<Consumer filter={mapper}>
{({
customCanvas,
storyId,
viewMode,
queryParams,
getElements,
isLoading,
}: ReturnType<typeof mapper>) => (
<ZoomConsumer>
{({ value: scale }) => {
const wrappers = [...defaultWrappers, ...getWrapper(getElements)];
const data = [storyId, viewMode, id, baseUrl, scale, queryParams] as Parameters<
IframeRenderer
>;
const content = customCanvas ? customCanvas(...data) : renderIframe(...data);
const props = {
viewMode,
active: p.active,
wrappers,
id,
storyId,
baseUrl,
queryParams,
scale,
customCanvas,
} as ApplyWrappersProps;
return (
<>
{withLoader && isLoading && <Loader id="preview-loader" role="progressbar" />}
<ApplyWrappers {...props}>{content}</ApplyWrappers>
</>
);
}}
</ZoomConsumer>
)}
</Consumer>
);
},
});
class Preview extends Component<PreviewProps> {
shouldComponentUpdate({
storyId,
@ -91,113 +142,35 @@ class Preview extends Component<PreviewProps> {
}
render() {
const { props } = this;
const {
id,
location,
queryParams,
getElements,
api,
options,
viewMode = undefined,
docsOnly = false,
storyId = undefined,
path = undefined,
description = undefined,
baseUrl = 'iframe.html',
customCanvas = undefined,
parameters = undefined,
isLoading = true,
withLoader = true,
} = this.props;
const toolbarHeight = options.isToolshown ? 40 : 0;
const wrappers = getElementList(getElements, types.PREVIEW, defaultWrappers);
let panels = getElementList(getElements, types.TAB, [
{
route: p => `/story/${p.storyId}`,
match: p => !!(p.viewMode && p.viewMode.match(/^(story|docs)$/)),
render: p => (
<ZoomConsumer>
{({ value: scale }) => {
const props = {
viewMode,
active: p.active,
wrappers,
id,
storyId,
baseUrl,
queryParams,
scale,
customCanvas,
} as ApplyWrappersProps;
} = props;
const viewMode = docsOnly && props.viewMode === 'story' ? 'docs' : props.viewMode;
const { isToolshown } = options;
const data = [storyId, viewMode, id, baseUrl, scale, queryParams] as Parameters<
IframeRenderer
>;
const allTabs = [createCanvas(id, baseUrl, withLoader), ...getTabs(getElements)];
const tabs = filterTabs(allTabs, parameters);
const content = customCanvas ? customCanvas(...data) : renderIframe(...data);
const tools = [...defaultTools, ...getTools(getElements)];
const toolsExtra = [...defaultToolsExtra, ...getToolsExtra(getElements)];
return (
<>
{withLoader && isLoading && <Loader id="preview-loader" role="progressbar" />}
<ApplyWrappers {...props}>{content}</ApplyWrappers>
</>
);
}}
</ZoomConsumer>
),
title: 'Canvas',
id: 'canvas',
},
]);
const { previewTabs } = addons.getConfig();
const parametersTabs = parameters ? parameters.previewTabs : undefined;
if (previewTabs || parametersTabs) {
// deep merge global and local settings
const tabs = merge(previewTabs, parametersTabs);
const arrTabs = Object.keys(tabs).map((key, index) => ({
index,
...(typeof tabs[key] === 'string' ? { title: tabs[key] } : tabs[key]),
id: key,
}));
panels = panels
.filter(panel => {
const t = arrTabs.find(tab => tab.id === panel.id);
return t === undefined || t.id === 'canvas' || !t.hidden;
})
.map((panel, index) => ({ ...panel, index }))
.sort((p1, p2) => {
const tab_1 = arrTabs.find(tab => tab.id === p1.id);
const index_1 = tab_1 ? tab_1.index : arrTabs.length + p1.index;
const tab_2 = arrTabs.find(tab => tab.id === p2.id);
const index_2 = tab_2 ? tab_2.index : arrTabs.length + p2.index;
return index_1 - index_2;
})
.map(panel => {
const t = arrTabs.find(tab => tab.id === panel.id);
if (t) {
return {
...panel,
title: t.title || panel.title,
disabled: t.disabled,
hidden: t.hidden,
};
}
return panel;
});
}
const { left, right } = getTools(
getElements,
queryParams,
panels,
api,
options,
storyId,
const { left, right } = filterTools(tools, toolsExtra, tabs, {
viewMode,
docsOnly,
storyId,
location,
path,
baseUrl
);
});
return (
<ZoomProvider>
@ -208,15 +181,15 @@ class Preview extends Component<PreviewProps> {
</Helmet>
)}
{(left || right) && (
<Toolbar key="toolbar" shown={options.isToolshown} border>
<Toolbar key="toolbar" shown={isToolshown} border>
<Fragment key="left">{left}</Fragment>
<Fragment key="right">{right}</Fragment>
</Toolbar>
)}
<S.FrameWrap key="frame" offset={toolbarHeight}>
{panels.map(p => (
<S.FrameWrap key="frame" offset={isToolshown ? 40 : 0}>
{tabs.map((p, i) => (
// @ts-ignore
<Fragment key={p.id || p.key}>
<Fragment key={p.id || p.key || i}>
{p.render({ active: p.match({ storyId, viewMode, location, path }) })}
</Fragment>
))}
@ -228,3 +201,99 @@ class Preview extends Component<PreviewProps> {
}
export { Preview };
function filterTabs(panels: Addon[], parameters: Record<string, any>) {
const { previewTabs } = addons.getConfig();
const parametersTabs = parameters ? parameters.previewTabs : undefined;
if (previewTabs || parametersTabs) {
// deep merge global and local settings
const tabs = merge(previewTabs, parametersTabs);
const arrTabs = Object.keys(tabs).map((key, index) => ({
index,
...(typeof tabs[key] === 'string' ? { title: tabs[key] } : tabs[key]),
id: key,
}));
return panels
.filter(panel => {
const t = arrTabs.find(tab => tab.id === panel.id);
return t === undefined || t.id === 'canvas' || !t.hidden;
})
.map((panel, index) => ({ ...panel, index } as Addon))
.sort((p1, p2) => {
const tab_1 = arrTabs.find(tab => tab.id === p1.id);
// @ts-ignore
const index_1 = tab_1 ? tab_1.index : arrTabs.length + p1.index;
const tab_2 = arrTabs.find(tab => tab.id === p2.id);
// @ts-ignore
const index_2 = tab_2 ? tab_2.index : arrTabs.length + p2.index;
return index_1 - index_2;
})
.map(panel => {
const t = arrTabs.find(tab => tab.id === panel.id);
if (t) {
return {
...panel,
title: t.title || panel.title,
disabled: t.disabled,
hidden: t.hidden,
} as Addon;
}
return panel;
});
}
return panels;
}
function filterTools(
tools: Addon[],
toolsExtra: Addon[],
tabs: Addon[],
{
viewMode,
storyId,
location,
path,
}: {
viewMode: State['viewMode'];
storyId: State['storyId'];
location: State['location'];
path: State['path'];
}
) {
const tabsTool = createTabsTool(tabs);
const toolsLeft = [tabs.filter(p => !p.hidden).length > 1 ? tabsTool : null, ...tools];
const toolsRight = [...toolsExtra];
// if its a docsOnly page, even the 'story' view mode is considered 'docs'
const filter = (item: Partial<Addon>) =>
item &&
(!item.match ||
item.match({
storyId,
viewMode,
location,
path,
}));
const displayItems = (list: Partial<Addon>[]) =>
list.reduce(
(acc, item, index) =>
item ? (
// @ts-ignore
<Fragment key={item.id || item.key || `f-${index}`}>
{acc}
{item.render({}) || item}
</Fragment>
) : (
acc
),
null
);
const left = displayItems(toolsLeft.filter(filter));
const right = displayItems(toolsRight.filter(filter));
return { left, right };
}

View File

@ -0,0 +1,4 @@
export const stringifyQueryParams = (queryParams: Record<string, string>) =>
Object.entries(queryParams).reduce((acc, [k, v]) => {
return `${acc}&${k}=${v}`;
}, '');

View File

@ -1,7 +1,16 @@
import React, { FunctionComponent } from 'react';
import React, { FunctionComponent, Fragment } from 'react';
import copy from 'copy-to-clipboard';
import { styled } from '@storybook/theming';
import { FlexBar } from '@storybook/components';
import { FlexBar, IconButton, Icons, Separator, TabButton, TabBar } from '@storybook/components';
import { Consumer, Combo } from '@storybook/api';
import { Addon } from '@storybook/addons';
import { stringifyQueryParams } from './stringifyQueryParams';
import { ZoomConsumer, Zoom } from './zoom';
import * as S from './components';
const Bar: FunctionComponent<{ shown: boolean } & Record<string, any>> = ({ shown, ...props }) => (
<FlexBar {...props} />
@ -19,3 +28,134 @@ export const Toolbar = styled(Bar)(
tranform: shown ? 'translateY(0px)' : 'translateY(-40px)',
})
);
const fullScreenMapper = ({ api, state }: Combo) => ({
toggle: api.toggleFullscreen,
value: state.layout.isFullscreen,
});
export const fullScreenTool: Addon = {
title: 'fullscreen',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={fullScreenMapper}>
{({ toggle, value }: ReturnType<typeof fullScreenMapper>) => (
<S.DesktopOnly>
<IconButton
key="full"
onClick={toggle as any}
title={value ? 'Exit full screen' : 'Go full screen'}
>
<Icons icon={value ? 'close' : 'expand'} />
</IconButton>
</S.DesktopOnly>
)}
</Consumer>
),
};
const copyMapper = ({ state }: Combo) => ({
origin: state.location.origin,
pathname: state.location.pathname,
storyId: state.storyId,
baseUrl: 'iframe.html',
queryParams: state.customQueryParams,
});
export const copyTool: Addon = {
title: 'copy',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={copyMapper}>
{({ baseUrl, storyId, origin, pathname, queryParams }: ReturnType<typeof copyMapper>) => (
<IconButton
key="copy"
onClick={() =>
copy(`${origin}${pathname}${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`)
}
title="Copy canvas link"
>
<Icons icon="copy" />
</IconButton>
)}
</Consumer>
),
};
const ejectMapper = ({ state }: Combo) => ({
baseUrl: 'iframe.html',
storyId: state.storyId,
queryParams: state.customQueryParams,
});
export const ejectTool: Addon = {
title: 'eject',
match: p => p.viewMode === 'story',
render: () => (
<Consumer filter={ejectMapper}>
{({ baseUrl, storyId, queryParams }: ReturnType<typeof ejectMapper>) => (
<IconButton
key="opener"
href={`${baseUrl}?id=${storyId}${stringifyQueryParams(queryParams)}`}
target="_blank"
title="Open canvas in new tab"
>
<Icons icon="share" />
</IconButton>
)}
</Consumer>
),
};
const zoomTool: Addon = {
title: 'zoom',
match: p => p.viewMode === 'story',
render: () => (
<Fragment>
<ZoomConsumer>
{({ set, value }) => (
<Zoom key="zoom" set={(v: number) => set(value * v)} reset={() => set(1)} />
)}
</ZoomConsumer>
<Separator />
</Fragment>
),
};
const tabsMapper = ({ state }: Combo) => ({
viewMode: state.docsOnly,
storyId: state.storyId,
path: state.path,
location: state.location,
});
export const createTabsTool = (tabs: Addon[]): Addon => ({
title: 'title',
render: () => (
<Consumer filter={tabsMapper}>
{({ viewMode, storyId, path, location }: ReturnType<typeof tabsMapper>) => (
<Fragment>
<TabBar key="tabs">
{tabs
.filter(p => !p.hidden)
.map((t, index) => {
const to = t.route({ storyId, viewMode, path, location });
const isActive = path === to;
return (
<S.UnstyledLink key={t.id || `l${index}`} to={to}>
<TabButton disabled={t.disabled} active={isActive}>
{t.title}
</TabButton>
</S.UnstyledLink>
);
})}
</TabBar>
<Separator />
</Fragment>
)}
</Consumer>
),
});
export const defaultTools: Addon[] = [zoomTool];
export const defaultToolsExtra: Addon[] = [fullScreenTool, ejectTool, copyTool];

View File

@ -18,6 +18,7 @@ export const ApplyWrappers: FunctionComponent<ApplyWrappersProps> = ({
</Fragment>
);
};
const IframeWrapper = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,

View File

@ -39,7 +39,6 @@ const mapper = ({ api, state }: Combo) => {
docsOnly: (parameters && parameters.docsOnly) as boolean,
location,
parameters,
isLoading: !state.storiesConfigured,
};
};
@ -58,11 +57,6 @@ const PreviewConnected = React.memo<{ id: string; withLoader: boolean }>(props =
...props,
baseUrl: getBaseUrl(),
...fromState,
...(fromState.api.renderPreview
? {
customCanvas: fromState.api.renderPreview,
}
: {}),
} as PreviewProps;
return <Preview {...p} />;