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

Blocks: Canvas stories #19804

Merged
merged 12 commits into from
Nov 16, 2022
24 changes: 23 additions & 1 deletion code/ui/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,29 @@ const allStories = [
titlePrefix: '@storybook-blocks',
},
];
const blocksOnlyStories = ['../blocks/src/@(blocks|controls)/**/*.@(mdx|stories.@(tsx|ts|jsx|js))'];

/**
* match all stories in blocks/src/blocks and blocks/src/controls EXCEPT blocks/src/blocks/internal
* Examples:
*
* src/blocks/Canvas.stories.tsx - MATCH
* src/blocks/internal/InternalCanvas.stories.tsx - IGNORED, internal stories
* src/blocks/internal/nested/InternalCanvas.stories.tsx - IGNORED, internal stories
*
* src/blocks/Canvas.tsx - IGNORED, not story
* src/blocks/nested/Canvas.stories.tsx - MATCH
* src/blocks/nested/deep/Canvas.stories.tsx - MATCH
*
* src/controls/Boolean.stories.tsx - MATCH
* src/controls/Boolean.tsx - IGNORED, not story
*
* src/components/ColorPalette.stories.tsx - MATCH
* src/components/ColorPalette.tsx - IGNORED, not story
*/
const blocksOnlyStories = [
'../blocks/src/@(blocks|controls)/!(internal)/**/*.@(mdx|stories.@(tsx|ts|jsx|js))',
'../blocks/src/@(blocks|controls)/*.@(mdx|stories.@(tsx|ts|jsx|js))',
];

const config: StorybookConfig = {
stories: isBlocksOnly ? blocksOnlyStories : allStories,
Expand Down
83 changes: 83 additions & 0 deletions code/ui/blocks/src/blocks/Canvas.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Canvas } from './Canvas';
import { Story as StoryComponent } from './Story';
import * as BooleanStories from '../controls/Boolean.stories';

const meta: Meta<typeof Canvas> = {
component: Canvas,
parameters: {
relativeCsfPaths: ['../controls/Boolean.stories'],
},
render: (args) => {
return (
<Canvas {...args}>
<StoryComponent of={BooleanStories.Undefined} />
</Canvas>
);
},
};
export default meta;

type Story = StoryObj<typeof meta>;

export const BasicStory: Story = {};

export const WithSourceOpen: Story = {
args: {
withSource: 'open',
},
};
export const WithSourceClosed: Story = {
args: {
withSource: 'closed',
},
};

// TODO: what is the purpose of mdxSource exactly?
export const WithMdxSource: Story = {
Copy link
Contributor Author

@JReinhold JReinhold Nov 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mdxSource prop should be deprecated (and moved to internal stories) and renamed to something that falls in line with the naming in parameters, so source or code. I haven't quite figured out what the difference between the two yet.

any thoughts @tmeasday @shilman ?

name: 'With MDX Source',
args: {
withSource: 'open',
mdxSource: `const thisIsCustomSource = true;
if (isSyntaxHighlighted) {
console.log('syntax highlighting is working');
}`,
},
};

export const WithoutSource: Story = {
args: {
withSource: 'none',
},
};

export const WithToolbar: Story = {
args: {
withToolbar: true,
},
};
export const WithAdditionalActions: Story = {
args: {
additionalActions: [
{
title: 'Open in GitHub',
onClick: () => {
window.open(
'https://github.com/storybookjs/storybook/blob/next/code/ui/blocks/src/controls/Boolean.stories.tsx',
'_blank'
);
},
},
{
title: 'Go to documentation',
onClick: () => {
window.open(
'https://storybook.js.org/docs/react/essentials/controls#annotation',
'_blank'
);
},
},
],
},
};
66 changes: 28 additions & 38 deletions code/ui/blocks/src/blocks/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,66 @@
import type { FC, ReactElement, ReactNode, ReactNodeArray } from 'react';
import React, { useContext } from 'react';
import React, { Children, useContext } from 'react';
import type { FC, ReactElement, ReactNode } from 'react';
import type { Framework } from '@storybook/types';
import type { PreviewProps as PurePreviewProps } from '../components';
import { Preview as PurePreview, PreviewSkeleton } from '../components';
import type { DocsContextProps } from './DocsContext';
import { DocsContext } from './DocsContext';
import type { SourceContextProps } from './SourceContainer';
import { SourceContext } from './SourceContainer';
import { getSourceProps, SourceState } from './Source';
import { useSourceProps, SourceState } from './Source';
import { useStories } from './useStory';

export { SourceState };

type CanvasProps = PurePreviewProps & {
type CanvasProps = Omit<PurePreviewProps, 'isExpanded'> & {
withSource?: SourceState;
mdxSource?: string;
};

const getPreviewProps = (
const usePreviewProps = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turned out this was actually a hook, just not named as such (which surfaced errors)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff for this function may seem like a lot, but it maintains the exact same functionality and logic, it's just a refactoring. The refactoring was sparked by the need for useSourceProps hook to be called unconditionally, and so in an attempt to figure the whole thing out I simplified the logic paths.

{ withSource, mdxSource, children, ...props }: CanvasProps & { children?: ReactNode },
docsContext: DocsContextProps<Framework>,
sourceContext: SourceContextProps
) => {
let sourceState = withSource;
let isLoading = false;
if (sourceState === SourceState.NONE) {
return { isLoading, previewProps: props };
}
if (mdxSource) {
return {
isLoading,
previewProps: {
...props,
withSource: getSourceProps({ code: decodeURI(mdxSource) }, docsContext, sourceContext),
isExpanded: sourceState === SourceState.OPEN,
},
};
}
const childArray: ReactNodeArray = Array.isArray(children) ? children : [children];
const storyChildren = childArray.filter(
(c: ReactElement) => c.props && (c.props.id || c.props.name || c.props.of)
) as ReactElement[];
const targetIds = storyChildren.map(({ props: { id, of, name } }) => {
if (id) return id;
if (of) return docsContext.storyIdByModuleExport(of);
/*
get all story IDs by traversing through the children,
filter out any non-story children (e.g. text nodes)
and then get the id from each story depending on available props
*/
const storyIds = (Children.toArray(children) as ReactElement[])
.filter((c) => c.props && (c.props.id || c.props.name || c.props.of))
.map(({ props: { id, of, name } }) => {
if (id) return id;
if (of) return docsContext.storyIdByModuleExport(of);

return docsContext.storyIdByName(name);
});

const sourceProps = getSourceProps({ ids: targetIds }, docsContext, sourceContext);
if (!sourceState) sourceState = sourceProps.state;
const storyIds = targetIds.map((targetId) => {
return targetId;
});
return docsContext.storyIdByName(name);
});

const stories = useStories(storyIds, docsContext);
isLoading = stories.some((s) => !s);
const isLoading = stories.some((s) => !s);
const sourceProps = useSourceProps(
mdxSource ? { code: decodeURI(mdxSource) } : { ids: storyIds },
docsContext,
sourceContext
);
if (withSource === SourceState.NONE) {
return { isLoading, previewProps: props };
}

return {
isLoading,
previewProps: {
...props, // pass through columns etc.
withSource: sourceProps,
isExpanded: sourceState === SourceState.OPEN,
isExpanded: (withSource || sourceProps.state) === SourceState.OPEN,
},
};
};

export const Canvas: FC<CanvasProps> = (props) => {
const docsContext = useContext(DocsContext);
const sourceContext = useContext(SourceContext);
const { isLoading, previewProps } = getPreviewProps(props, docsContext, sourceContext);
const { isLoading, previewProps } = usePreviewProps(props, docsContext, sourceContext);
const { children } = props;

if (isLoading) return <PreviewSkeleton />;
Expand Down
7 changes: 3 additions & 4 deletions code/ui/blocks/src/blocks/Source.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ const getSnippet = (snippet: string, story?: Store_Story<any>): string => {
type SourceStateProps = { state: SourceState };
type PureSourceProps = ComponentProps<typeof PureSource>;

export const getSourceProps = (
export const useSourceProps = (
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also a hook, actually.

props: SourceProps,
docsContext: DocsContextProps<any>,
sourceContext: SourceContextProps
Expand All @@ -100,8 +100,7 @@ export const getSourceProps = (
const singleProps = props as SingleSourceProps;
const multiProps = props as MultiSourceProps;

let source = codeProps.code; // prefer user-specified code
let { format } = codeProps; // prefer user-specified code
let { format, code: source } = codeProps; // prefer user-specified code

const targetIds = multiProps.ids || [singleProps.id || primaryId];
const storyIds = targetIds.map((targetId) => {
Expand Down Expand Up @@ -151,6 +150,6 @@ export const getSourceProps = (
export const Source: FC<PureSourceProps> = (props) => {
const sourceContext = useContext(SourceContext);
const docsContext = useContext(DocsContext);
const sourceProps = getSourceProps(props, docsContext, sourceContext);
const sourceProps = useSourceProps(props, docsContext, sourceContext);
return <PureSource {...sourceProps} />;
};
120 changes: 120 additions & 0 deletions code/ui/blocks/src/blocks/internal/InternalCanvas.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/// <reference types="@types/jest" />;
/// <reference types="@testing-library/jest-dom" />;
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { Canvas } from '../Canvas';
import { Story as StoryComponent } from '../Story';
import * as BooleanStories from '../../controls/Boolean.stories';

const meta: Meta<typeof Canvas> = {
title: 'Blocks/Internal/Canvas',
component: Canvas,
parameters: {
relativeCsfPaths: ['../controls/Boolean.stories'],
},
render: (args) => {
return (
<Canvas {...args}>
<StoryComponent of={BooleanStories.Undefined} />
</Canvas>
);
},
};
export default meta;

type Story = StoryObj<typeof meta>;

const expectAmountOfStoriesInSource =
(amount: number): Story['play'] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);

// Arrange - find the "Show code" button
let showCodeButton = canvas.getByText('Show code');
await waitFor(() => {
showCodeButton = canvas.getByText('Show code');
expect(showCodeButton).toBeInTheDocument();
});

// Act - click button to show code
await userEvent.click(showCodeButton);

// Assert - check that the correct amount of stories' source is shown
await waitFor(async () => {
const booleanControlNodes = await canvas.findAllByText('BooleanControl');
expect(booleanControlNodes).toHaveLength(amount);
});
};

export const MultipleChildren: Story = {
render: (args) => {
return (
<Canvas {...args}>
<StoryComponent of={BooleanStories.True} />
<StoryComponent of={BooleanStories.False} />
</Canvas>
);
},
play: expectAmountOfStoriesInSource(2),
};

export const MultipleChildrenColumns: Story = {
args: {
isColumn: true,
},
render: (args) => {
return (
<Canvas {...args}>
<StoryComponent of={BooleanStories.True} />
<StoryComponent of={BooleanStories.False} />
</Canvas>
);
},
play: expectAmountOfStoriesInSource(2),
};

export const MultipleChildrenThreeColumns: Story = {
args: {
columns: 3,
},
render: (args) => {
return (
<Canvas {...args}>
<StoryComponent of={BooleanStories.True} />
<StoryComponent of={BooleanStories.True} />
<StoryComponent of={BooleanStories.True} />
<StoryComponent of={BooleanStories.False} />
<StoryComponent of={BooleanStories.False} />
<StoryComponent of={BooleanStories.False} />
<StoryComponent of={BooleanStories.Undefined} />
<StoryComponent of={BooleanStories.Undefined} />
<StoryComponent of={BooleanStories.Undefined} />
</Canvas>
);
},
play: expectAmountOfStoriesInSource(9),
};

export const MixedChildrenStories: Story = {
args: { isColumn: true },
render: (args) => {
return (
<Canvas {...args}>
<h1>Headline for Boolean Controls true</h1>
<StoryComponent of={BooleanStories.True} />
<h1>Headline for Boolean Controls undefined</h1>
<StoryComponent of={BooleanStories.Undefined} />
</Canvas>
);
},
play: async (args) => {
// this function will also expand the source code
await expectAmountOfStoriesInSource(2)(args);
const canvas = within(args.canvasElement);

// Assert - only find two headlines, those in the story, and none in the source code
expect(canvas.queryAllByText(/Headline for Boolean Controls/i)).toHaveLength(2);
},
};
8 changes: 8 additions & 0 deletions code/ui/blocks/src/blocks/internal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Internal `@storybook/blocks` Stories

This directory contains stories that are not suitable for public documentation, but that we still want to keep to ensure things don't break.

Some blocks have deprecated features that users shouldn't use moving forward, and these internal stories represents those.
That way we can still test them and ensure the features work, until they are removed for good.

This directory is not part of the (public) Blocks Storybook, but are included in the full UI Storybook.
7 changes: 7 additions & 0 deletions scripts/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,12 @@ module.exports = {
'import/extensions': ['error', 'always'],
},
},
{
files: ['*.stories.*'],
rules: {
// allow expect in stories
'jest/no-standalone-expect': ['off'],
},
},
],
};