Merge branch 'next' into norbert/sb-798-figure-out-plan-for-package-structure-rework

This commit is contained in:
Norbert de Langen 2022-11-16 16:20:48 +01:00
commit 6b9215730c
No known key found for this signature in database
GPG Key ID: FD0E78AF9A837762
16 changed files with 457 additions and 78 deletions

View File

@ -403,7 +403,7 @@ jobs:
- report-workflow-on-failure
chromatic-internal-storybooks:
executor:
class: medium
class: medium+
name: sb_node_16_browsers
steps:
# switched this to the CircleCI helper to get the full git history for TurboSnap

View File

@ -1,4 +1,3 @@
/* eslint-disable jest/no-standalone-expect */
import React from 'react';
import type { StoryObj, Meta } from '@storybook/react';
import { CallStates } from '@storybook/instrumenter';

View File

@ -1,4 +1,3 @@
/* eslint-disable jest/no-standalone-expect */
import globalThis from 'global';
import {
within,

View File

@ -76,9 +76,9 @@
"publish:debug": "npm run publish:latest -- --npm-tag=debug --no-push",
"publish:latest": "lerna publish --exact --concurrency 1 --force-publish",
"publish:next": "npm run publish:latest -- --npm-tag=next",
"storybook:blocks": "BLOCKS_ONLY=true yarn storybook:ui",
"storybook:blocks:build": "BLOCKS_ONLY=true yarn storybook:ui:build",
"storybook:blocks:chromatic": "BLOCKS_ONLY=true yarn storybook:ui:chromatic --project-token=${CHROMATIC_TOKEN_STORYBOOK_BLOCKS:-MISSING_PROJECT_TOKEN}",
"storybook:blocks": "STORYBOOK_BLOCKS_ONLY=true yarn storybook:ui",
"storybook:blocks:build": "STORYBOOK_BLOCKS_ONLY=true yarn storybook:ui:build",
"storybook:blocks:chromatic": "STORYBOOK_BLOCKS_ONLY=true yarn storybook:ui:chromatic --project-token=${CHROMATIC_TOKEN_STORYBOOK_BLOCKS:-MISSING_PROJECT_TOKEN}",
"storybook:ui": "NODE_OPTIONS=\"--preserve-symlinks --preserve-symlinks-main\" ./lib/cli/bin/index.js dev --port 6006 --config-dir ./ui/.storybook --no-manager-cache",
"storybook:ui:build": "NODE_OPTIONS=\"--preserve-symlinks --preserve-symlinks-main\" ./lib/cli/bin/index.js build --config-dir ./ui/.storybook",
"storybook:ui:chromatic": "yarn chromatic --build-script-name storybook:ui:build --storybook-config-dir ./ui/.storybook --storybook-base-dir ./code/ui --project-token=${CHROMATIC_TOKEN_STORYBOOK_UI:-MISSING_PROJECT_TOKEN} --only-changed --exit-zero-on-changes --exit-once-uploaded",

View File

@ -2,7 +2,7 @@ import { vite as csfPlugin } from '@storybook/csf-plugin';
import pluginTurbosnap from 'vite-plugin-turbosnap';
import type { StorybookConfig } from '../../frameworks/react-vite/dist';
const isBlocksOnly = process.env.BLOCKS_ONLY === 'true';
const isBlocksOnly = process.env.STORYBOOK_BLOCKS_ONLY === 'true';
const allStories = [
{
@ -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,
@ -34,14 +56,17 @@ const config: StorybookConfig = {
core: {
disableTelemetry: true,
},
viteFinal: (vite, { configType }) => ({
...vite,
features: {
interactionsDebugger: true,
},
viteFinal: (viteConfig, { configType }) => ({
...viteConfig,
plugins: [
...(vite.plugins || []),
...(viteConfig.plugins || []),
csfPlugin({}),
configType === 'PRODUCTION' ? pluginTurbosnap({ rootDir: vite.root || '' }) : [],
configType === 'PRODUCTION' ? pluginTurbosnap({ rootDir: viteConfig.root || '' }) : [],
],
optimizeDeps: { ...vite.optimizeDeps, force: true },
optimizeDeps: { ...viteConfig.optimizeDeps, force: true },
}),
};

View File

@ -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 = {
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'
);
},
},
],
},
};

View File

@ -1,5 +1,5 @@
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 { Renderer } from '@storybook/types';
import type { PreviewProps as PurePreviewProps } from '../components';
import { Preview as PurePreview, PreviewSkeleton } from '../components';
@ -7,62 +7,52 @@ 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 = (
{ withSource, mdxSource, children, ...props }: CanvasProps & { children?: ReactNode },
docsContext: DocsContextProps<Renderer>,
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,
},
};
};
@ -70,7 +60,7 @@ const getPreviewProps = (
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 />;

View File

@ -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 = (
props: SourceProps,
docsContext: DocsContextProps<any>,
sourceContext: SourceContextProps
@ -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) => {
@ -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} />;
};

View File

@ -1,3 +1,5 @@
/// <reference types="vite/client" />
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Story as StoryComponent } from './Story';
@ -6,15 +8,109 @@ import * as BooleanStories from '../controls/Boolean.stories';
const meta: Meta<typeof StoryComponent> = {
component: StoryComponent,
parameters: {
relativeCsfPaths: ['../controls/Boolean.stories'],
relativeCsfPaths: ['../controls/Boolean.stories', '../blocks/Story.stories'],
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const BasicOf: Story = {
export const Of: Story = {
args: {
of: BooleanStories.Undefined,
},
};
export const OfWithMeta: Story = {
args: {
of: BooleanStories.True,
meta: BooleanStories.default,
},
};
const blocksAwareId = `${
import.meta.env.STORYBOOK_BLOCKS_ONLY === 'true' ? '' : 'storybook-blocks-'
}controls-boolean--false`;
export const Id: Story = {
args: {
id: blocksAwareId,
},
};
export const Name: Story = {
args: {
name: 'True',
},
};
export const SimpleSizeTest: Story = {
render: () => {
return (
<div
style={{
background: '#fd5c9355',
padding: '3rem',
height: '1000px',
width: '800px',
// a global decorator is applying a default padding that we want to negate here
margin: '-4rem -20px',
}}
>
<p>
This story does nothing. Its only purpose is to show how its size renders in different
conditions (inline/iframe/fixed height) when used in a <code>{'<Story />'}</code> block.
</p>
<p>
It has a fixed <code>height</code> of <code>1000px</code> and a fixed <code>width</code>{' '}
of <code>800px</code>
</p>
</div>
);
},
};
export const Inline: Story = {
args: {
of: SimpleSizeTest,
inline: true,
},
};
export const InlineWithHeight: Story = {
...Inline,
args: {
of: SimpleSizeTest,
inline: true,
height: '300px',
},
};
export const Iframe: Story = {
...Inline,
args: {
of: SimpleSizeTest,
inline: false,
},
};
export const IframeWithHeight: Story = {
...Inline,
args: {
of: SimpleSizeTest,
inline: false,
height: '300px',
},
};
export const WithDefaultInteractions: Story = {
args: {
of: BooleanStories.Toggling,
},
};
export const WithInteractionsAutoplayInStory: Story = {
args: {
of: BooleanStories.TogglingInDocs,
},
};
// TODO: types suggest that <Story /> can take ProjectAnnotations, but it doesn't seem to do anything with them
// Such as parameters, decorators, etc.
// they seem to be taken from the story itself, and not from the <Story /> call

View File

@ -1,4 +1,4 @@
import type { FC, ReactNode, ElementType, ComponentProps } from 'react';
import type { FC, ComponentProps } from 'react';
import React, { useContext, useRef, useEffect, useState } from 'react';
import type {
Renderer,
@ -25,7 +25,6 @@ type CommonProps = StoryAnnotations & {
type StoryDefProps = {
name: string;
children: ReactNode;
};
type StoryRefProps = {
@ -36,7 +35,6 @@ type StoryRefProps = {
type StoryImportProps = {
name: string;
story: ElementType;
};
export type StoryProps = (StoryDefProps | StoryRefProps | StoryImportProps) & CommonProps;
@ -49,8 +47,7 @@ export const getStoryId = (props: StoryProps, context: DocsContextProps): StoryI
}
const { name } = props as StoryDefProps;
const inputId = id;
return inputId || context.storyIdByName(name);
return id || context.storyIdByName(name);
};
export const getStoryProps = <TFramework extends Renderer>(
@ -87,14 +84,16 @@ const Story: FC<StoryProps> = (props) => {
const [showLoader, setShowLoader] = useState(true);
useEffect(() => {
let cleanup: () => void;
if (story && storyRef.current) {
const element = storyRef.current as HTMLElement;
const { autoplay } = story.parameters.docs || {};
cleanup = context.renderStoryToElement(story, element, { autoplay });
setShowLoader(false);
if (!(story && storyRef.current)) {
return () => {};
}
return () => cleanup && cleanup();
const element = storyRef.current as HTMLElement;
const { autoplay } = story.parameters.docs || {};
const cleanup = context.renderStoryToElement(story, element, { autoplay });
setShowLoader(false);
return () => {
cleanup();
};
}, [context, story]);
if (!story) {
@ -115,7 +114,7 @@ const Story: FC<StoryProps> = (props) => {
return (
<div id={storyBlockIdFromId(story.id)}>
{height ? (
<style>{`#story--${story.id} { min-height: ${height}px; transform: translateZ(0); overflow: auto }`}</style>
<style>{`#story--${story.id} { min-height: ${height}; transform: translateZ(0); overflow: auto }`}</style>
) : null}
{showLoader && <StorySkeleton />}
<div
@ -134,9 +133,4 @@ const Story: FC<StoryProps> = (props) => {
);
};
Story.defaultProps = {
children: null,
name: null,
};
export { Story };

View File

@ -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);
},
};

View File

@ -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.

View File

@ -1,10 +1,20 @@
import { expect } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { within, fireEvent } from '@storybook/testing-library';
import { addons } from '@storybook/addons';
import { RESET_STORY_ARGS, STORY_ARGS_UPDATED } from '@storybook/core-events';
import { BooleanControl } from './Boolean';
const meta = {
component: BooleanControl,
tags: ['docsPage'],
parameters: { withRawArg: 'value', controls: { include: ['value'] } },
parameters: {
withRawArg: 'value',
controls: { include: ['value'] },
notes: 'These are notes for the Boolean control stories',
info: 'This is info for the Boolean control stories',
jsx: { useBooleanShorthandSyntax: false },
},
args: { name: 'boolean' },
} as Meta<typeof BooleanControl>;
@ -26,3 +36,45 @@ export const Undefined: StoryObj<typeof BooleanControl> = {
value: undefined,
},
};
export const Toggling: StoryObj<typeof BooleanControl> = {
args: {
value: undefined,
},
play: async ({ canvasElement, id }) => {
const channel = addons.getChannel();
channel.emit(RESET_STORY_ARGS, { storyId: id });
await new Promise<void>((resolve) => {
channel.once(STORY_ARGS_UPDATED, resolve);
});
const canvas = within(canvasElement);
// from Undefined to False
const setBooleanControl = canvas.getByText('Set boolean');
await fireEvent.click(setBooleanControl);
let toggle = await canvas.findByTitle('Change to true');
expect(toggle).toBeInTheDocument();
// from False to True
await fireEvent.click(toggle);
toggle = await canvas.findByTitle('Change to false');
expect(toggle).toBeInTheDocument();
// from True to False
await fireEvent.click(toggle);
toggle = await canvas.findByTitle('Change to true');
expect(toggle).toBeInTheDocument();
},
};
export const TogglingInDocs: StoryObj<typeof BooleanControl> = {
...Toggling,
parameters: {
docs: {
autoplay: true,
},
},
};

View File

@ -86,6 +86,11 @@ const Label = styled.label(({ theme }) => ({
const parse = (value: string | null): boolean => value === 'true';
export type BooleanProps = ControlProps<BooleanValue> & BooleanConfig;
/**
* # Boolean control
* Renders a switch toggle with "True" or "False".
* or if the value is `undefined`, renders a button to set the boolean.
*/
export const BooleanControl: FC<BooleanProps> = ({ name, value, onChange, onBlur, onFocus }) => {
const onSetFalse = useCallback(() => onChange(false), [onChange]);
if (value === undefined) {
@ -95,13 +100,14 @@ export const BooleanControl: FC<BooleanProps> = ({ name, value, onChange, onBlur
</Form.Button>
);
}
const controlId = getControlId(name);
const parsedValue = typeof value === 'string' ? parse(value) : value;
return (
<Label htmlFor={name} title={parsedValue ? 'Change to false' : 'Change to true'}>
<Label htmlFor={controlId} title={parsedValue ? 'Change to false' : 'Change to true'}>
<input
id={getControlId(name)}
id={controlId}
type="checkbox"
onChange={(e) => onChange(e.target.checked)}
checked={parsedValue}

View File

@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "esnext",
"rootDir": "./src",
"types": ["jest"]
},

View File

@ -28,6 +28,13 @@ module.exports = {
'import/extensions': ['error', 'always'],
},
},
{
files: ['*.stories.*'],
rules: {
// allow expect in stories
'jest/no-standalone-expect': ['off'],
},
},
{
files: [
'*.js',