Merge pull request #16481 from storybookjs/feature/interactions-feature-flag

Interactions: move step debugger behind a feature flag
This commit is contained in:
Yann Braga 2021-10-27 17:34:27 +02:00 committed by GitHub
commit e260ec5f38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 272 additions and 84 deletions

View File

@ -0,0 +1,88 @@
import React from 'react';
import { ComponentStoryObj, ComponentMeta } from '@storybook/react';
import { CallStates } from '@storybook/instrumenter';
import { styled } from '@storybook/theming';
import { getCall } from './mocks';
import { AddonPanelPure } from './Panel';
const StyledWrapper = styled.div(({ theme }) => ({
backgroundColor: theme.background.content,
color: theme.color.defaultText,
display: 'block',
height: '100%',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
overflow: 'auto',
}));
export default {
title: 'Addons/Interactions/Panel',
component: AddonPanelPure,
decorators: [
(Story: any) => (
<StyledWrapper id="panel-tab-content">
<Story />
</StyledWrapper>
),
],
parameters: {
layout: 'fullscreen',
},
args: {
calls: new Map(),
endRef: null,
fileName: 'addon-interactions.stories.tsx',
hasException: false,
hasNext: false,
hasPrevious: true,
interactions: [getCall(CallStates.DONE)],
isDisabled: false,
isPlaying: false,
showTabIcon: false,
isDebuggingEnabled: true,
// prop for the AddonPanel used as wrapper of Panel
active: true,
},
} as ComponentMeta<typeof AddonPanelPure>;
type Story = ComponentStoryObj<typeof AddonPanelPure>;
export const Passing: Story = {
args: {
interactions: [getCall(CallStates.DONE)],
},
};
export const Paused: Story = {
args: {
isPlaying: true,
interactions: [getCall(CallStates.WAITING)],
},
};
export const Playing: Story = {
args: {
isPlaying: true,
interactions: [getCall(CallStates.ACTIVE)],
},
};
export const Failed: Story = {
args: {
hasException: true,
interactions: [getCall(CallStates.ERROR)],
},
};
export const WithDebuggingDisabled: Story = {
args: { isDebuggingEnabled: false },
};
export const NoInteractions: Story = {
args: {
interactions: [],
},
};

View File

@ -6,14 +6,38 @@ import { STORY_RENDER_PHASE_CHANGED } from '@storybook/core-events';
import { AddonPanel, Link, Placeholder } from '@storybook/components';
import { EVENTS, Call, CallStates, LogItem } from '@storybook/instrumenter';
import { styled } from '@storybook/theming';
import { StatusIcon } from './components/StatusIcon/StatusIcon';
import { Subnav } from './components/Subnav/Subnav';
import { Interaction } from './components/Interaction/Interaction';
interface PanelProps {
const { FEATURES } = global;
interface AddonPanelProps {
active: boolean;
}
interface InteractionsPanelProps {
active: boolean;
showTabIcon?: boolean;
interactions: (Call & { state?: CallStates })[];
isDisabled?: boolean;
hasPrevious?: boolean;
hasNext?: boolean;
fileName?: string;
hasException?: boolean;
isPlaying?: boolean;
calls: Map<string, any>;
endRef?: React.Ref<HTMLDivElement>;
isDebuggingEnabled?: boolean;
onStart?: () => void;
onPrevious?: () => void;
onNext?: () => void;
onEnd?: () => void;
onScrollToEnd?: () => void;
onInteractionClick?: (callId: string) => void;
}
const pendingStates = [CallStates.ACTIVE, CallStates.WAITING];
const completedStates = [CallStates.DONE, CallStates.ERROR];
@ -21,7 +45,80 @@ const TabIcon = styled(StatusIcon)({
marginLeft: 5,
});
export const Panel: React.FC<PanelProps> = (props) => {
export const AddonPanelPure: React.FC<InteractionsPanelProps> = React.memo(
({
showTabIcon,
interactions,
isDisabled,
hasPrevious,
hasNext,
fileName,
hasException,
isPlaying,
onStart,
onPrevious,
onNext,
onEnd,
onScrollToEnd,
calls,
onInteractionClick,
endRef,
isDebuggingEnabled,
...panelProps
}) => {
return (
<AddonPanel {...panelProps}>
{showTabIcon &&
ReactDOM.createPortal(
<TabIcon status={hasException ? CallStates.ERROR : CallStates.ACTIVE} />,
global.document.getElementById('tabbutton-interactions')
)}
{isDebuggingEnabled && interactions.length > 0 && (
<Subnav
isDisabled={isDisabled}
hasPrevious={hasPrevious}
hasNext={hasNext}
storyFileName={fileName}
status={
// eslint-disable-next-line no-nested-ternary
isPlaying ? CallStates.ACTIVE : hasException ? CallStates.ERROR : CallStates.DONE
}
onStart={onStart}
onPrevious={onPrevious}
onNext={onNext}
onEnd={onEnd}
onScrollToEnd={onScrollToEnd}
/>
)}
{interactions.map((call) => (
<Interaction
key={call.id}
call={call}
callsById={calls}
isDebuggingEnabled={isDebuggingEnabled}
isDisabled={isDisabled}
onClick={() => onInteractionClick(call.id)}
/>
))}
<div ref={endRef} />
{!isPlaying && interactions.length === 0 && (
<Placeholder>
No interactions found
<Link
href="https://storybook.js.org/docs/react/essentials/interactions"
target="_blank"
withArrow
>
Learn how to add interactions to your story
</Link>
</Placeholder>
)}
</AddonPanel>
);
}
);
export const Panel: React.FC<AddonPanelProps> = (props) => {
const [isLocked, setLock] = React.useState(false);
const [isPlaying, setPlaying] = React.useState(true);
const [scrollTarget, setScrollTarget] = React.useState<HTMLElement>();
@ -57,57 +154,48 @@ export const Panel: React.FC<PanelProps> = (props) => {
const [fileName] = storyFilePath.toString().split('/').slice(-1);
const scrollToTarget = () => scrollTarget?.scrollIntoView({ behavior: 'smooth', block: 'end' });
const isDebuggingEnabled = FEATURES.interactionsDebugger === true;
const isDebugging = log.some((item) => pendingStates.includes(item.state));
const hasPrevious = log.some((item) => completedStates.includes(item.state));
const hasNext = log.some((item) => item.state === CallStates.WAITING);
const hasActive = log.some((item) => item.state === CallStates.ACTIVE);
const hasException = log.some((item) => item.state === CallStates.ERROR);
const isDisabled = hasActive || isLocked || (isPlaying && !isDebugging);
const isDisabled = isDebuggingEnabled
? hasActive || isLocked || (isPlaying && !isDebugging)
: true;
const tabButton = global.document.getElementById('tabbutton-interactions');
const tabStatus = hasException ? CallStates.ERROR : CallStates.ACTIVE;
const showTabIcon = isDebugging || (!isPlaying && hasException);
const onStart = React.useCallback(() => emit(EVENTS.START, { storyId }), [storyId]);
const onPrevious = React.useCallback(() => emit(EVENTS.BACK, { storyId }), [storyId]);
const onNext = React.useCallback(() => emit(EVENTS.NEXT, { storyId }), [storyId]);
const onEnd = React.useCallback(() => emit(EVENTS.END, { storyId }), [storyId]);
const onInteractionClick = React.useCallback(
(callId: string) => emit(EVENTS.GOTO, { storyId, callId }),
[storyId]
);
return (
<AddonPanel {...props}>
{tabButton && showTabIcon && ReactDOM.createPortal(<TabIcon status={tabStatus} />, tabButton)}
{interactions.length > 0 && (
<Subnav
isDisabled={isDisabled}
hasPrevious={hasPrevious}
hasNext={hasNext}
storyFileName={fileName}
// eslint-disable-next-line no-nested-ternary
status={isPlaying ? CallStates.ACTIVE : hasException ? CallStates.ERROR : CallStates.DONE}
onStart={() => emit(EVENTS.START, { storyId })}
onPrevious={() => emit(EVENTS.BACK, { storyId })}
onNext={() => emit(EVENTS.NEXT, { storyId })}
onEnd={() => emit(EVENTS.END, { storyId })}
onScrollToEnd={scrollTarget && scrollToTarget}
/>
)}
{interactions.map((call) => (
<Interaction
key={call.id}
call={call}
callsById={calls.current}
onClick={() => emit(EVENTS.GOTO, { storyId, callId: call.id })}
isDisabled={isDisabled}
/>
))}
<div ref={endRef} />
{!isPlaying && interactions.length === 0 && (
<Placeholder>
No interactions found
<Link
href="https://storybook.js.org/docs/react/essentials/interactions"
target="_blank"
withArrow
>
Learn how to add interactions to your story
</Link>
</Placeholder>
)}
</AddonPanel>
<AddonPanelPure
showTabIcon={showTabIcon}
interactions={interactions}
isDisabled={isDisabled}
hasPrevious={hasPrevious}
hasNext={hasNext}
fileName={fileName}
hasException={hasException}
isPlaying={isPlaying}
calls={calls.current}
endRef={endRef}
isDebuggingEnabled={isDebuggingEnabled}
onStart={onStart}
onPrevious={onPrevious}
onNext={onNext}
onEnd={onEnd}
onInteractionClick={onInteractionClick}
onScrollToEnd={scrollTarget && scrollToTarget}
{...props}
/>
);
};

View File

@ -1,7 +1,8 @@
import { ComponentStoryObj, ComponentMeta } from '@storybook/react';
import { expect } from '@storybook/jest';
import { Call, CallStates } from '@storybook/instrumenter';
import { CallStates } from '@storybook/instrumenter';
import { userEvent, within } from '@storybook/testing-library';
import { getCall } from '../../mocks';
import { Interaction } from './Interaction';
@ -13,59 +14,31 @@ export default {
args: {
callsById: new Map(),
isDisabled: false,
isDebuggingEnabled: true,
},
} as ComponentMeta<typeof Interaction>;
const getCallMock = (state: CallStates): Call => {
const defaultData = {
id: 'addons-interactions-accountform--standard-email-filled [3] change',
path: ['fireEvent'],
method: 'change',
storyId: 'addons-interactions-accountform--standard-email-filled',
args: [
{
__callId__: 'addons-interactions-accountform--standard-email-filled [2] getByTestId',
retain: false,
},
{
target: {
value: 'michael@chromatic.com',
},
},
],
interceptable: true,
retain: false,
state,
};
const overrides = CallStates.ERROR
? { exception: { callId: '', stack: '', message: "Things didn't work!" } }
: {};
return { ...defaultData, ...overrides };
};
export const Active: Story = {
args: {
call: getCallMock(CallStates.ACTIVE),
call: getCall(CallStates.ACTIVE),
},
};
export const Waiting: Story = {
args: {
call: getCallMock(CallStates.WAITING),
call: getCall(CallStates.WAITING),
},
};
export const Failed: Story = {
args: {
call: getCallMock(CallStates.ERROR),
call: getCall(CallStates.ERROR),
},
};
export const Done: Story = {
args: {
call: getCallMock(CallStates.DONE),
call: getCall(CallStates.DONE),
},
};

View File

@ -65,11 +65,13 @@ export const Interaction = ({
callsById,
onClick,
isDisabled,
isDebuggingEnabled,
}: {
call: Call;
callsById: Map<Call['id'], Call>;
onClick: React.MouseEventHandler<HTMLElement>;
isDisabled: boolean;
isDebuggingEnabled?: boolean;
}) => {
const [isHovered, setIsHovered] = React.useState(false);
return (
@ -77,9 +79,9 @@ export const Interaction = ({
<RowLabel
call={call}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
disabled={isDisabled}
disabled={isDebuggingEnabled ? isDisabled : true}
onMouseEnter={() => isDebuggingEnabled && setIsHovered(true)}
onMouseLeave={() => isDebuggingEnabled && setIsHovered(false)}
>
<StatusIcon status={isHovered ? CallStates.ACTIVE : call.state} />
<MethodCallWrapper style={{ marginLeft: 6, marginBottom: 1 }}>

View File

@ -22,7 +22,7 @@ export default {
),
],
parameters: {
layout: 'fullscren',
layout: 'fullscreen',
},
};

View File

@ -23,7 +23,7 @@ export default {
),
],
parameters: {
layout: 'fullscren',
layout: 'fullscreen',
},
};

View File

@ -0,0 +1,30 @@
import { CallStates, Call } from '@storybook/instrumenter';
export const getCall = (state: CallStates): Call => {
const defaultData = {
id: 'addons-interactions-accountform--standard-email-filled [3] change',
path: ['fireEvent'],
method: 'change',
storyId: 'addons-interactions-accountform--standard-email-filled',
args: [
{
__callId__: 'addons-interactions-accountform--standard-email-filled [2] getByTestId',
retain: false,
},
{
target: {
value: 'michael@chromatic.com',
},
},
],
interceptable: true,
retain: false,
state,
};
const overrides = CallStates.ERROR
? { exception: { callId: '', stack: '', message: "Things didn't work!" } }
: {};
return { ...defaultData, ...overrides };
};

View File

@ -38,6 +38,7 @@ const config: StorybookConfig = {
logLevel: 'debug',
features: {
modernInlineRender: true,
interactionsDebugger: true,
},
};

View File

@ -154,6 +154,7 @@ export const parameters = {
restoreScroll: true,
},
},
actions: { argTypesRegex: '^on.*' },
options: {
storySort: (a, b) =>
a[1].kind === b[1].kind ? 0 : a[1].id.localeCompare(b[1].id, undefined, { numeric: true }),

View File

@ -314,6 +314,11 @@ export interface StorybookConfig {
*/
breakingChangesV7?: boolean;
/**
* Enable the step debugger functionality in Addon-interactions.
*/
interactionsDebugger?: boolean;
/**
* Use Storybook 7.0 babel config scheme
*/