hoist testProvider state to root, add WIP sidebar contextMenu

This commit is contained in:
Norbert de Langen 2024-11-08 16:53:33 +01:00
parent 1abc35379e
commit c69c1f30a7
10 changed files with 205 additions and 113 deletions

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { IconButton } from 'storybook/internal/components';
import { addons } from 'storybook/internal/manager-api';
@ -14,22 +14,26 @@ addons.setConfig({
},
});
// TEMP, to demo new api
addons.add('my-addon', {
type: Addon_TypesEnum.experimental_CONTEXT,
render(props) {
console.log({ props });
return <div>My Test</div>;
},
});
// // TEMP, to demo new api
// addons.add('my-addon', {
// type: Addon_TypesEnum.experimental_CONTEXT,
// render(props) {
// console.log({ props });
// if (props.entry.type === 'docs') {
// return null;
// }
// return <div>My Test</div>;
// },
// });
// TEMP, to set status once, to have the status bullet show up
addons.register('my-addon', (api) => {
addons.add('my-addon2', {
type: Addon_TypesEnum.TOOL,
title: 'My Addon 2',
render(props) {
console.log({ props });
render() {
return (
<IconButton
onClick={() => {

View File

@ -93,6 +93,9 @@ addons.register(ADDON_ID, (api) => {
watchable: true,
name: 'Component tests',
contextMenu: ({ context, state }) => {
return <div>Testing {state.running ? '!' : '?'}</div>;
},
title: ({ crashed, failed }) =>
crashed || failed ? 'Component tests failed' : 'Component tests',
description: ({ failed, running, watching, progress, crashed, error }) => {

View File

@ -121,7 +121,7 @@ const WithTooltipPure = ({
}
);
const tooltipComponent = (
const tooltipComponent = isVisible ? (
<Tooltip
placement={state?.placement}
ref={setTooltipRef}
@ -133,7 +133,7 @@ const WithTooltipPure = ({
{/* @ts-expect-error (non strict) */}
{typeof tooltip === 'function' ? tooltip({ onHide: () => onVisibleChange(false) }) : tooltip}
</Tooltip>
);
) : null;
return (
<>

View File

@ -9,16 +9,16 @@ export type TestProviderState = Addon_TestProviderState;
export type TestProviders = Record<TestProviderId, TestProviderConfig & TestProviderState>;
export type TestingModuleRunRequestStories = {
id: string;
name: string;
id: string; // button--primary
name: string; // Primary
};
export type TestingModuleRunRequestPayload = {
providerId: TestProviderId;
payload: {
stories: TestingModuleRunRequestStories[];
importPath: string;
componentPath: string;
importPath: string; // ./.../button.stories.tsx
componentPath: string; // ./.../button.tsx
}[];
};

View File

@ -20,7 +20,6 @@ import { global } from '@storybook/global';
import { logger } from '@storybook/core/client-logger';
import { SET_CONFIG } from '@storybook/core/core-events';
import type { Addon_ContextType } from '../../types';
import type { API } from '../root';
import { mockChannel } from './storybook-channel-mock';
@ -96,7 +95,6 @@ export class AddonStore {
| Omit<Addon_SidebarBottomType, 'id'>
| Omit<Addon_TestProviderType, 'id'>
| Omit<Addon_PageType, 'id'>
| Omit<Addon_ContextType, 'id'>
| Omit<Addon_WrapperType, 'id'>
): void {
const { type } = addon;

View File

@ -0,0 +1,100 @@
import { Addon_TypesEnum } from '@storybook/core/types';
import {
TESTING_MODULE_CANCEL_TEST_RUN_REQUEST,
TESTING_MODULE_RUN_ALL_REQUEST,
type TestProviderId,
type TestProviderState,
type TestProviders,
} from '@storybook/core/core-events';
import type { ModuleFn } from '../lib/types';
export type SubState = {
testProviders: TestProviders;
};
const STORAGE_KEY = '@storybook/manager/test-providers';
const initialTestProviderState: TestProviderState = {
details: {} as { [key: string]: any },
cancellable: false,
cancelling: false,
running: false,
watching: false,
failed: false,
crashed: false,
};
export type SubAPI = {
getTestproviderState(id: string): TestProviderState | undefined;
updateTestproviderState(id: TestProviderId, update: Partial<TestProviderState>): void;
clearTestproviderState(id: TestProviderId): void;
runTestprovider(id: TestProviderId): void;
cancelTestprovider(id: TestProviderId): void;
};
export const init: ModuleFn = ({ store, fullAPI }) => {
let sessionState: TestProviders = {};
try {
sessionState = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
} catch (_) {
//
}
const state: SubState = {
testProviders: sessionState,
};
const api: SubAPI = {
getTestproviderState(id) {
const { testProviders } = store.getState();
return testProviders?.[id];
},
updateTestproviderState(id, update) {
return store.setState(
({ testProviders }) => {
return { testProviders: { ...testProviders, [id]: { ...testProviders[id], ...update } } };
},
{ persistence: 'session' }
);
},
clearTestproviderState(id) {
const update = {
cancelling: false,
running: true,
failed: false,
crashed: false,
progress: undefined,
};
return store.setState(
({ testProviders }) => {
return { testProviders: { ...testProviders, [id]: { ...testProviders[id], ...update } } };
},
{ persistence: 'session' }
);
},
runTestprovider(id) {
fullAPI.emit(TESTING_MODULE_RUN_ALL_REQUEST, { providerId: id });
return () => api.cancelTestprovider(id);
},
cancelTestprovider(id) {
api.updateTestproviderState(id, { cancelling: true });
fullAPI.emit(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, { providerId: id });
},
};
const initModule = async () => {
const initialState = Object.fromEntries(
Object.entries(fullAPI.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER)).map(
([id, config]) => [id, { ...config, ...initialTestProviderState, ...sessionState[id] }]
)
);
store.setState({ testProviders: initialState }, { persistence: 'session' });
};
return { init: initModule, state, api };
};

View File

@ -50,6 +50,7 @@ import { noArrayMerge } from './lib/merge';
import type { ModuleFn } from './lib/types';
import * as addons from './modules/addons';
import * as channel from './modules/channel';
import * as testproviders from './modules/experimental_testmodule';
import * as globals from './modules/globals';
import * as layout from './modules/layout';
import * as notifications from './modules/notifications';
@ -79,6 +80,7 @@ export type State = layout.SubState &
stories.SubState &
refs.SubState &
notifications.SubState &
testproviders.SubState &
version.SubState &
url.SubState &
shortcuts.SubState &
@ -98,6 +100,7 @@ export type API = addons.SubAPI &
globals.SubAPI &
layout.SubAPI &
notifications.SubAPI &
testproviders.SubAPI &
shortcuts.SubAPI &
settings.SubAPI &
version.SubAPI &
@ -178,6 +181,7 @@ class ManagerProvider extends Component<ManagerProviderProps, State> {
addons,
layout,
notifications,
testproviders,
settings,
shortcuts,
stories,

View File

@ -104,18 +104,14 @@ export const SidebarBottomBase = ({
const spacerRef = useRef<HTMLDivElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [warningsActive, setWarningsActive] = useState(false);
const { testProviders } = useStorybookState();
const {
updateTestproviderState: updateTestProvider,
clearTestproviderState,
runTestprovider: onRunTests,
cancelTestprovider: onCancelTests,
} = useStorybookApi();
const [errorsActive, setErrorsActive] = useState(false);
const [testProviders, setTestProviders] = useState<TestProviders>(() => {
let sessionState: TestProviders = {};
try {
sessionState = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
} catch (_) {}
return Object.fromEntries(
Object.entries(api.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER)).map(
([id, config]) => [id, { ...config, ...initialTestProviderState, ...sessionState[id] }]
)
);
});
const warnings = Object.values(status).filter((statusByAddonId) =>
Object.values(statusByAddonId).some((value) => value?.status === 'warn')
@ -126,45 +122,14 @@ export const SidebarBottomBase = ({
const hasWarnings = warnings.length > 0;
const hasErrors = errors.length > 0;
const updateTestProvider = useCallback(
(id: TestProviderId, update: Partial<TestProviderState>) =>
setTestProviders((state) => {
const newValue = { ...state, [id]: { ...state[id], ...update } };
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
return newValue;
}),
[]
);
const clearState = useCallback(
({ providerId }: { providerId: TestProviderId }) => {
updateTestProvider(providerId, {
cancelling: false,
running: true,
failed: false,
crashed: false,
progress: undefined,
});
clearTestproviderState(providerId);
api.experimental_updateStatus(providerId, (state = {}) =>
Object.fromEntries(Object.keys(state).map((key) => [key, null]))
);
},
[api, updateTestProvider]
);
const onRunTests = useCallback(
(id: TestProviderId) => {
api.emit(TESTING_MODULE_RUN_ALL_REQUEST, { providerId: id });
},
[api]
);
const onCancelTests = useCallback(
(id: TestProviderId) => {
updateTestProvider(id, { cancelling: true });
api.emit(TESTING_MODULE_CANCEL_TEST_RUN_REQUEST, { providerId: id });
},
[api, updateTestProvider]
[api, clearTestproviderState]
);
const onSetWatchMode = useCallback(
@ -214,14 +179,14 @@ export const SidebarBottomBase = ({
}
};
api.getChannel()?.on(TESTING_MODULE_CRASH_REPORT, onCrashReport);
api.getChannel()?.on(TESTING_MODULE_RUN_ALL_REQUEST, clearState);
api.getChannel()?.on(TESTING_MODULE_PROGRESS_REPORT, onProgressReport);
api.on(TESTING_MODULE_CRASH_REPORT, onCrashReport);
api.on(TESTING_MODULE_RUN_ALL_REQUEST, clearState);
api.on(TESTING_MODULE_PROGRESS_REPORT, onProgressReport);
return () => {
api.getChannel()?.off(TESTING_MODULE_CRASH_REPORT, onCrashReport);
api.getChannel()?.off(TESTING_MODULE_PROGRESS_REPORT, onProgressReport);
api.getChannel()?.off(TESTING_MODULE_RUN_ALL_REQUEST, clearState);
api.off(TESTING_MODULE_CRASH_REPORT, onCrashReport);
api.off(TESTING_MODULE_PROGRESS_REPORT, onProgressReport);
api.off(TESTING_MODULE_RUN_ALL_REQUEST, clearState);
};
}, [api, testProviders, updateTestProvider, clearState]);

View File

@ -170,6 +170,14 @@ const Node = React.memo<NodeProps>(function Node({
return null;
}
const StatusIconMap = {
success: <StatusPassIcon color={theme.color.positive} />,
error: <StatusFailIcon color={theme.color.negative} />,
warn: <StatusWarnIcon color={theme.color.warning} />,
pending: <SyncIcon size={12} color={theme.color.defaultText} />,
unknown: null,
};
const id = createId(item.id, refId);
if (item.type === 'story' || item.type === 'docs') {
const LeafNode = item.type === 'docs' ? DocumentNode : StoryNode;
@ -179,6 +187,42 @@ const Node = React.memo<NodeProps>(function Node({
const statusOrder: API_StatusValue[] = ['success', 'error', 'warn', 'pending', 'unknown'];
function createLinks(onHide: () => void): Link[] | Link[][] {
const elements = api.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER);
const links: Link[] = Object.entries(elements)
.filter(([k, e]) => e.contextMenu)
.map(([k, e]) => {
const R = e.contextMenu;
const state = api.getTestproviderState(k);
console.log({ R, k, e, s: state });
return {
id: k,
content: R && state ? <R context={item} state={state} /> : null,
};
});
links.push(
...Object.entries(status || {})
.sort((a, b) => statusOrder.indexOf(a[1].status) - statusOrder.indexOf(b[1].status))
.map(([addonId, value]) => ({
id: addonId,
title: value.title,
description: value.description,
'aria-label': `Test status for ${value.title}: ${value.status}`,
icon: StatusIconMap[value.status],
onClick: () => {
onSelectStoryId(item.id);
value.onClick?.();
onHide();
},
}))
);
return links;
}
return (
<LeafNodeStyleWrapper
key={id}
@ -220,32 +264,7 @@ const Node = React.memo<NodeProps>(function Node({
closeOnTriggerHidden
onClick={(event) => event.stopPropagation()}
placement="bottom"
tooltip={({ onHide }) => (
<TooltipLinkList
links={Object.entries(status || {})
.sort(
(a, b) => statusOrder.indexOf(a[1].status) - statusOrder.indexOf(b[1].status)
)
.map(([addonId, value]) => ({
id: addonId,
title: value.title,
description: value.description,
'aria-label': `Test status for ${value.title}: ${value.status}`,
icon: {
success: <StatusPassIcon color={theme.color.positive} />,
error: <StatusFailIcon color={theme.color.negative} />,
warn: <StatusWarnIcon color={theme.color.warning} />,
pending: <SyncIcon size={12} color={theme.color.defaultText} />,
unknown: null,
}[value.status],
onClick: () => {
onSelectStoryId(item.id);
value.onClick?.();
onHide();
},
}))}
/>
)}
tooltip={({ onHide }) => <TooltipLinkList links={createLinks(onHide)} />}
>
<StatusButton
aria-label={`Test status: ${statusValue}`}
@ -308,15 +327,21 @@ const Node = React.memo<NodeProps>(function Node({
const color = itemStatus ? statusMapping[itemStatus][1] : null;
const BranchNode = item.type === 'component' ? ComponentNode : GroupNode;
const elements = api.getElements(Addon_TypesEnum.experimental_CONTEXT);
function createLinks(onHide: () => void): Link[] | Link[][] {
const elements = api.getElements(Addon_TypesEnum.experimental_TEST_PROVIDER);
const links: Link[] = Object.entries(elements)
.filter(([k, e]) => e.contextMenu)
.map(([k, e]) => {
const R = e.contextMenu;
const createLinks: (onHide: () => void) => ComponentProps<typeof TooltipLinkList>['links'] = (
onHide
) => {
const links: Link[] = Object.entries(elements).map(([k, e]) => ({
id: k,
content: e.render({ entry: item }),
}));
const state = api.getTestproviderState(k);
console.log({ R, k, e, s: state });
return {
id: k,
content: R && state ? <R context={item} state={state} /> : null,
};
});
if (counts.error) {
links.push({
id: 'errors',
@ -344,7 +369,7 @@ const Node = React.memo<NodeProps>(function Node({
});
}
return links;
};
}
return (
<LeafNodeStyleWrapper

View File

@ -2,7 +2,7 @@
import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react';
import type { TestingModuleProgressReportProgress } from '../../core-events';
import type { Addon } from '../../manager-api';
import type { Addon, StoryEntry } from '../../manager-api';
import type { RenderData as RouterData } from '../../router/types';
import type { ThemeVars } from '../../theming/types';
import type { API_SidebarOptions } from './api';
@ -327,7 +327,6 @@ export interface Addon_RenderOptions {
export type Addon_Type =
| Addon_BaseType
| Addon_PageType
| Addon_ContextType
| Addon_WrapperType
| Addon_SidebarBottomType
| Addon_SidebarTopType
@ -429,12 +428,6 @@ export interface Addon_PageType {
*/
render: FC;
}
export interface Addon_ContextType {
type: Addon_TypesEnum.experimental_CONTEXT;
/** The unique id. */
id: string;
render: FC<{ entry: API_HashEntry }>;
}
export interface Addon_WrapperType {
type: Addon_TypesEnum.PREVIEW;
@ -482,6 +475,7 @@ export interface Addon_TestProviderType<
name: string;
title: (state: Addon_TestProviderState<Details>) => ReactNode;
description: (state: Addon_TestProviderState<Details>) => ReactNode;
contextMenu?: FC<{ context: API_HashEntry; state: Addon_TestProviderState<Details> }>;
mapStatusUpdate?: (
state: Addon_TestProviderState<Details>
) => API_StatusUpdate | ((state: API_StatusState) => API_StatusUpdate);
@ -517,7 +511,6 @@ type Addon_TypeBaseNames = Exclude<
export interface Addon_TypesMapping extends Record<Addon_TypeBaseNames, Addon_BaseType> {
[Addon_TypesEnum.PREVIEW]: Addon_WrapperType;
[Addon_TypesEnum.experimental_CONTEXT]: Addon_ContextType;
[Addon_TypesEnum.experimental_PAGE]: Addon_PageType;
[Addon_TypesEnum.experimental_SIDEBAR_BOTTOM]: Addon_SidebarBottomType;
[Addon_TypesEnum.experimental_SIDEBAR_TOP]: Addon_SidebarTopType;