Merge branch 'next' into test-polish

This commit is contained in:
Gert Hengeveld 2025-03-17 14:24:10 +01:00 committed by GitHub
commit 6b505e1a72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
145 changed files with 2289 additions and 1009 deletions

View File

@ -1,3 +1,19 @@
## 8.6.6
- Angular: Make sure that polyfills are loaded before the storybook is loaded - [#30811](https://github.com/storybookjs/storybook/pull/30811), thanks @kasperpeulen!
- CSF: Fix CSF subcomponent type - [#30729](https://github.com/storybookjs/storybook/pull/30729), thanks @filipemelo2002!
## 8.6.5
- Addon A11y: Promote @storybook/global to full dependency - [#30723](https://github.com/storybookjs/storybook/pull/30723), thanks @mrginglymus!
- Angular: Add `@angular-devkit/build-angular` to installed packages - [#30790](https://github.com/storybookjs/storybook/pull/30790), thanks @kasperpeulen!
- CLI: Fix test install in RNW projects - [#30786](https://github.com/storybookjs/storybook/pull/30786), thanks @shilman!
- Core: Replace 'min' instead of 'm' in printDuration - [#30668](https://github.com/storybookjs/storybook/pull/30668), thanks @wlewis-formative!
- Next.js: Use latest version when init in empty directory - [#30659](https://github.com/storybookjs/storybook/pull/30659), thanks @valentinpalkovic!
- Svelte: Fix Vite crashing on virtual module imports - [#26838](https://github.com/storybookjs/storybook/pull/26838), thanks @rChaoz!
- Svelte: Fix automatic argTypes inference coming up empty with `svelte2tsx@0.7.35` - [#30784](https://github.com/storybookjs/storybook/pull/30784), thanks @JReinhold!
- Universal Store: Don't use `crypto.randomUUID` - [#30781](https://github.com/storybookjs/storybook/pull/30781), thanks @JReinhold!
## 8.6.4
- Manager: Add Content-Type to fix Cloud IDEs - [#30606](https://github.com/storybookjs/storybook/pull/30606), thanks @GCHQDeveloper548!

View File

@ -1,3 +1,10 @@
## 9.0.0-alpha.5
- Angular: Make sure that polyfills are loaded before the storybook is loaded - [#30811](https://github.com/storybookjs/storybook/pull/30811), thanks @kasperpeulen!
- CSF: Fix CSF subcomponent type - [#30729](https://github.com/storybookjs/storybook/pull/30729), thanks @filipemelo2002!
- Ember: Fix `ember-template-compiler` import for ember 6+ - [#30682](https://github.com/storybookjs/storybook/pull/30682), thanks @leoeuclids!
- React: Remove react import in template files - [#30757](https://github.com/storybookjs/storybook/pull/30757), thanks @kasperpeulen!
## 9.0.0-alpha.4
- Automigrate: Prefer framework import - [#30785](https://github.com/storybookjs/storybook/pull/30785), thanks @ndelangen!

View File

@ -1,10 +1,12 @@
<h1>Migration</h1>
- [From version 8.x to 9.0.0](#from-version-8x-to-900)
- [Manager builder removed alias for `util`, `assert` and `process`](#manager-builder-removed-alias-for-util-assert-and-process)
- [Actions addon moved to core](#actions-addon-moved-to-core)
- [Dropped support for legacy packages](#dropped-support-for-legacy-packages)
- [Dropped support for TypeScript \< 4.9](#dropped-support-for-typescript--49)
- [Test addon renamed from experimental to stable](#test-addon-renamed-from-experimental-to-stable)
- [Experimental Status API has turned into a Status Store](#experimental-status-api-has-turned-into-a-status-store)
- [From version 8.5.x to 8.6.x](#from-version-85x-to-86x)
- [Angular: Support experimental zoneless support](#angular-support-experimental-zoneless-support)
- [Addon-a11y: Replaced experimental `ally-test` tag behavior with `parameters.a11y.test`](#addon-a11y-replaced-experimental-ally-test-tag-behavior-with-parametersa11ytest)
@ -122,17 +124,17 @@
- [Tab addons cannot manually route, Tool addons can filter their visibility via tabId](#tab-addons-cannot-manually-route-tool-addons-can-filter-their-visibility-via-tabid)
- [Removed `config` preset](#removed-config-preset-1)
- [From version 7.5.0 to 7.6.0](#from-version-750-to-760)
- [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated)
- [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated)
- [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated)
- [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop)
- [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react)
- [CommonJS with Vite is deprecated](#commonjs-with-vite-is-deprecated)
- [Using implicit actions during rendering is deprecated](#using-implicit-actions-during-rendering-is-deprecated)
- [typescript.skipBabel deprecated](#typescriptskipbabel-deprecated)
- [Primary doc block accepts of prop](#primary-doc-block-accepts-of-prop)
- [Addons no longer need a peer dependency on React](#addons-no-longer-need-a-peer-dependency-on-react)
- [From version 7.4.0 to 7.5.0](#from-version-740-to-750)
- [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated)
- [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers)
- [`storyStoreV6` and `storiesOf` is deprecated](#storystorev6-and-storiesof-is-deprecated)
- [`storyIndexers` is replaced with `experimental_indexers`](#storyindexers-is-replaced-with-experimental_indexers)
- [From version 7.0.0 to 7.2.0](#from-version-700-to-720)
- [Addon API is more type-strict](#addon-api-is-more-type-strict)
- [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated)
- [Addon API is more type-strict](#addon-api-is-more-type-strict)
- [Addon-controls hideNoControlsWarning parameter is deprecated](#addon-controls-hidenocontrolswarning-parameter-is-deprecated)
- [From version 6.5.x to 7.0.0](#from-version-65x-to-700)
- [7.0 breaking changes](#70-breaking-changes)
- [Dropped support for Node 15 and below](#dropped-support-for-node-15-and-below)
@ -158,7 +160,7 @@
- [Deploying build artifacts](#deploying-build-artifacts)
- [Dropped support for file URLs](#dropped-support-for-file-urls)
- [Serving with nginx](#serving-with-nginx)
- [Ignore story files from node\_modules](#ignore-story-files-from-node_modules)
- [Ignore story files from node_modules](#ignore-story-files-from-node_modules)
- [7.0 Core changes](#70-core-changes)
- [7.0 feature flags removed](#70-feature-flags-removed)
- [Story context is prepared before for supporting fine grained updates](#story-context-is-prepared-before-for-supporting-fine-grained-updates)
@ -172,7 +174,7 @@
- [Addon-interactions: Interactions debugger is now default](#addon-interactions-interactions-debugger-is-now-default)
- [7.0 Vite changes](#70-vite-changes)
- [Vite builder uses Vite config automatically](#vite-builder-uses-vite-config-automatically)
- [Vite cache moved to node\_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook)
- [Vite cache moved to node_modules/.cache/.vite-storybook](#vite-cache-moved-to-node_modulescachevite-storybook)
- [7.0 Webpack changes](#70-webpack-changes)
- [Webpack4 support discontinued](#webpack4-support-discontinued)
- [Babel mode v7 exclusively](#babel-mode-v7-exclusively)
@ -223,7 +225,7 @@
- [Dropped addon-docs manual babel configuration](#dropped-addon-docs-manual-babel-configuration)
- [Dropped addon-docs manual configuration](#dropped-addon-docs-manual-configuration)
- [Autoplay in docs](#autoplay-in-docs)
- [Removed STORYBOOK\_REACT\_CLASSES global](#removed-storybook_react_classes-global)
- [Removed STORYBOOK_REACT_CLASSES global](#removed-storybook_react_classes-global)
- [7.0 Deprecations and default changes](#70-deprecations-and-default-changes)
- [storyStoreV7 enabled by default](#storystorev7-enabled-by-default)
- [`Story` type deprecated](#story-type-deprecated)
@ -438,6 +440,16 @@
## From version 8.x to 9.0.0
### Manager builder removed alias for `util`, `assert` and `process`
These dependencies (often used accidentally) were polyfilled to mocks or browser equivalents by storybook's manager builder.
Starting with Storybook `9.0`, we no longer alias these anymore.
Adding these aliases meant storybook core, had to depend on these packages, which have a deep dependency graph, added to every storybook project.
If you addon fails to load after this change, we recommend looking at implementing the alias at compile time of your addon, or alternatively look at other bundling config to ensure the correct entries/packages/dependencies are used.
### Actions addon moved to core
The actions addon has been moved from `@storybook/addon-actions` to Storybook core. You no longer need to install it separately or include it in your addons list. As a consequence, `@storybook/addon-actions` is not part of `@storybook/addon-essentials` anymore.
@ -517,6 +529,33 @@ export default {
The public API remains the same, so no additional changes should be needed in your test files or configuration.
### Experimental Status API has turned into a Status Store
The experimental status API previously available at `api.experimental_updateStatus` and `api.getCurrentStoryStatus` has changed, to a store that works both on the server, in the manager and in the preview.
You can use the new Status Store by importing `experimental_getStatusStore` from either `storybook/internal/core-server`, `storybook/manager-api` or `storybook/preview-api`:
```diff
+ import { experimental_getStatusStore } from 'storybook/manager-api';
+ import { StatusValue } from 'storybook/internal/types';
+ const myStatusStore = experimental_getStatusStore(MY_ADDON_ID);
addons.register(MY_ADDON_ID, (api) => {
- api.experimental_updateStatus({
- someStoryId: {
- status: 'success',
- title: 'Component tests',
- description: 'Works!',
- }
- });
+ myStatusStore.set([{
+ value: StatusValue.SUCCESS
+ title: 'Component tests',
+ description: 'Works!',
+ }]);
```
## From version 8.5.x to 8.6.x
### Angular: Support experimental zoneless support

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-a11y",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Test component compliance with web accessibility standards",
"keywords": [
"a11y",

View File

@ -13,7 +13,7 @@ import {
import type { AxeResults } from 'axe-core';
import * as api from 'storybook/manager-api';
import { EVENTS, TEST_PROVIDER_ID } from '../constants';
import { EVENTS } from '../constants';
import { A11yContextProvider, useA11yContext } from './A11yContext';
vi.mock('storybook/manager-api');
@ -65,18 +65,15 @@ describe('A11yContext', () => {
const getCurrentStoryData = vi.fn();
const getParameters = vi.fn();
const getCurrentStoryStatus = vi.fn();
beforeEach(() => {
mockedApi.useAddonState.mockImplementation((_, defaultState) => React.useState(defaultState));
mockedApi.useChannel.mockReturnValue(vi.fn());
getCurrentStoryData.mockReturnValue({ id: storyId, type: 'story' });
getParameters.mockReturnValue({});
getCurrentStoryStatus.mockReturnValue({ [TEST_PROVIDER_ID]: { status: 'success' } });
mockedApi.useStorybookApi.mockReturnValue({
getCurrentStoryData,
getParameters,
getCurrentStoryStatus,
} as any);
mockedApi.useParameter.mockReturnValue({ manual: false });
mockedApi.useStorybookState.mockReturnValue({ storyId } as any);
@ -156,7 +153,7 @@ describe('A11yContext', () => {
it('should set discrepancy to cliFailedButModeManual when in manual mode', () => {
mockedApi.useParameter.mockReturnValue({ manual: true });
getCurrentStoryStatus.mockReturnValue({ [TEST_PROVIDER_ID]: { status: 'error' } });
mockedApi.experimental_useStatusStore.mockReturnValue('status-value:error');
const Component = () => {
const { discrepancy } = useA11yContext();
@ -172,9 +169,9 @@ describe('A11yContext', () => {
expect(getByTestId('discrepancy').textContent).toBe('cliFailedButModeManual');
});
it('should set discrepancy to cliFailedButModeManual when in manual mode (set via globals', () => {
it('should set discrepancy to cliFailedButModeManual when in manual mode (set via globals)', () => {
mockedApi.useGlobals.mockReturnValue([{ a11y: { manual: true } }] as any);
getCurrentStoryStatus.mockReturnValue({ [TEST_PROVIDER_ID]: { status: 'error' } });
mockedApi.experimental_useStatusStore.mockReturnValue('status-value:error');
const Component = () => {
const { discrepancy } = useA11yContext();
@ -192,7 +189,7 @@ describe('A11yContext', () => {
it('should set discrepancy to cliPassedBrowserFailed', () => {
mockedApi.useParameter.mockReturnValue({ manual: true });
getCurrentStoryStatus.mockReturnValue({ [TEST_PROVIDER_ID]: { status: 'success' } });
mockedApi.experimental_useStatusStore.mockReturnValue('status-value:success');
const Component = () => {
const { discrepancy } = useA11yContext();

View File

@ -11,6 +11,7 @@ import { HIGHLIGHT } from '@storybook/addon-highlight';
import type { AxeResults, Result } from 'axe-core';
import {
experimental_useStatusStore,
useAddonState,
useChannel,
useGlobals,
@ -100,7 +101,9 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
const [highlighted, setHighlighted] = useState<string[]>([]);
const { storyId } = useStorybookState();
const storyStatus = api.getCurrentStoryStatus();
const currentStoryA11yStatusValue = experimental_useStatusStore(
(allStatuses) => allStatuses[storyId]?.[TEST_PROVIDER_ID]?.value
);
const handleToggleHighlight = useCallback((target: string[], highlight: boolean) => {
setHighlighted((prevHighlighted) =>
@ -194,26 +197,24 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
}, [emit, highlighted, tab]);
const discrepancy: TestDiscrepancy = useMemo(() => {
const storyStatusA11y = storyStatus?.[TEST_PROVIDER_ID]?.status;
if (storyStatusA11y) {
if (storyStatusA11y === 'success' && results.violations.length > 0) {
return 'cliPassedBrowserFailed';
}
if (storyStatusA11y === 'error' && results.violations.length === 0) {
if (status === 'ready' || status === 'ran') {
return 'browserPassedCliFailed';
}
if (status === 'manual') {
return 'cliFailedButModeManual';
}
}
if (!currentStoryA11yStatusValue) {
return null;
}
if (currentStoryA11yStatusValue === 'status-value:success' && results.violations.length > 0) {
return 'cliPassedBrowserFailed';
}
if (currentStoryA11yStatusValue === 'status-value:error' && results.violations.length === 0) {
if (status === 'ready' || status === 'ran') {
return 'browserPassedCliFailed';
}
if (status === 'manual') {
return 'cliFailedButModeManual';
}
}
return null;
}, [results.violations.length, status, storyStatus]);
}, [results.violations.length, status, currentStoryA11yStatusValue]);
return (
<A11yContext.Provider

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-backgrounds",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Switch backgrounds to view components in different settings",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-controls",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Interact with component inputs dynamically in the Storybook UI",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-docs",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Document component usage and properties in Markdown",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-essentials",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Curated addons to bring out the best of Storybook",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-mdx-gfm",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "GitHub Flavored Markdown in Storybook",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-highlight",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Highlight DOM nodes within your stories",
"keywords": [
"storybook-addons",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-interactions",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Automate, test and debug user interactions",
"keywords": [
"storybook-addons",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-jest",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "React storybook addon that show component jest report",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-links",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Link stories together to build demos and prototypes with your UI components",
"keywords": [
"storybook-addons",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-measure",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Inspect layouts by visualizing the box model",
"keywords": [
"storybook-addons",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-onboarding",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook Addon Onboarding - Introduces a new onboarding experience",
"keywords": [
"storybook-addons",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-outline",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Outline all elements with CSS to help with layout placement and alignment",
"keywords": [
"storybook-addons",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-storysource",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "View a storys source code to see how it works and paste into your app",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-test",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook addon for testing components",
"keywords": [
"storybook-addons",

View File

@ -9,13 +9,22 @@ import {
UNHANDLED_ERRORS_WHILE_PLAYING,
} from 'storybook/internal/core-events';
import { type Call, CallStates, EVENTS, type LogItem } from 'storybook/internal/instrumenter';
import type { API_StatusValue } from 'storybook/internal/types';
import type { StatusValue } from 'storybook/internal/types';
import { global } from '@storybook/global';
import { useAddonState, useChannel, useParameter, useStorybookState } from 'storybook/manager-api';
import {
experimental_useStatusStore,
useAddonState,
useChannel,
useParameter,
} from 'storybook/manager-api';
import { ADDON_ID, STORYBOOK_ADDON_TEST_CHANNEL, TEST_PROVIDER_ID } from '../constants';
import {
ADDON_ID,
STATUS_TYPE_ID_COMPONENT_TEST,
STORYBOOK_ADDON_TEST_CHANNEL,
} from '../constants';
import { InteractionsPanel } from './InteractionsPanel';
const INITIAL_CONTROL_STATES = {
@ -26,11 +35,11 @@ const INITIAL_CONTROL_STATES = {
end: false,
};
const statusMap: Record<CallStates, API_StatusValue> = {
[CallStates.DONE]: 'success',
[CallStates.ERROR]: 'error',
[CallStates.ACTIVE]: 'pending',
[CallStates.WAITING]: 'pending',
const statusMap: Record<CallStates, StatusValue> = {
[CallStates.DONE]: 'status-value:success',
[CallStates.ERROR]: 'status-value:error',
[CallStates.ACTIVE]: 'status-value:pending',
[CallStates.WAITING]: 'status-value:pending',
};
export const getInteractions = ({
@ -85,7 +94,13 @@ export const getInteractions = ({
};
export const Panel = memo<{ storyId: string }>(function PanelMemoized({ storyId }) {
const { status: storyStatuses } = useStorybookState();
const { statusValue, testRunId } = experimental_useStatusStore((state) => {
const storyStatus = state[storyId]?.[STATUS_TYPE_ID_COMPONENT_TEST];
return {
statusValue: storyStatus?.value,
testRunId: storyStatus?.data?.testRunId,
};
});
// shared state
const [addonState, set] = useAddonState(ADDON_ID, {
@ -244,9 +259,6 @@ export const Panel = memo<{ storyId: string }>(function PanelMemoized({ storyId
// @ts-expect-error TODO
interactions.some((v) => v.status === CallStates.ERROR);
const storyStatus = storyStatuses[storyId]?.[TEST_PROVIDER_ID];
const storyTestStatus = storyStatus?.status;
const browserTestStatus = useMemo<CallStates | undefined>(() => {
if (!isPlaying && (interactions.length > 0 || hasException)) {
return hasException ? CallStates.ERROR : CallStates.DONE;
@ -254,14 +266,12 @@ export const Panel = memo<{ storyId: string }>(function PanelMemoized({ storyId
return isPlaying ? CallStates.ACTIVE : undefined;
}, [isPlaying, interactions, hasException]);
const { testRunId } = storyStatus?.data || {};
useEffect(() => {
const isMismatch =
browserTestStatus &&
storyTestStatus &&
storyTestStatus !== 'pending' &&
storyTestStatus !== statusMap[browserTestStatus];
statusValue &&
statusValue !== 'status-value:pending' &&
statusValue !== statusMap[browserTestStatus];
if (isMismatch) {
const timeout = setTimeout(
@ -286,7 +296,7 @@ export const Panel = memo<{ storyId: string }>(function PanelMemoized({ storyId
} else {
setResultMismatch(false);
}
}, [emit, browserTestStatus, storyTestStatus, storyId, testRunId]);
}, [emit, browserTestStatus, statusValue, storyId, testRunId]);
if (isErrored) {
return <Fragment key="component-tests" />;

View File

@ -5,6 +5,8 @@ export const TEST_PROVIDER_ID = `${ADDON_ID}/test-provider`;
export const PANEL_ID = `${ADDON_ID}/panel`;
export const STORYBOOK_ADDON_TEST_CHANNEL = 'STORYBOOK_ADDON_TEST_CHANNEL';
export const A11Y_PANEL_ID = 'storybook/a11y/panel';
export const TUTORIAL_VIDEO_LINK = 'https://youtu.be/Waht9qq7AoA';
export const DOCUMENTATION_LINK = 'writing-tests/test-addon';
export const DOCUMENTATION_DISCREPANCY_LINK = `${DOCUMENTATION_LINK}#what-happens-when-there-are-different-test-results-in-multiple-environments`;
@ -48,3 +50,6 @@ export const storeOptions = {
};
export const STORE_CHANNEL_EVENT_NAME = `UNIVERSAL_STORE:${storeOptions.id}`;
export const STATUS_TYPE_ID_COMPONENT_TEST = 'storybook/component-test';
export const STATUS_TYPE_ID_A11Y = 'storybook/a11y';

View File

@ -4,3 +4,18 @@ import * as testUtils from 'storybook/test';
import { storeOptions } from './constants';
export const store = testUtils.mocked(new experimental_MockUniversalStore(storeOptions, testUtils));
export const componentTestStatusStore = {
get: testUtils.fn(() => ({})),
set: testUtils.fn(),
onStatusChange: testUtils.fn(() => () => {}),
onSelect: testUtils.fn(() => () => {}),
unset: testUtils.fn(),
};
export const a11yStatusStore = {
get: testUtils.fn(() => ({})),
set: testUtils.fn(),
onStatusChange: testUtils.fn(() => () => {}),
onSelect: testUtils.fn(() => () => {}),
unset: testUtils.fn(),
};

View File

@ -1,8 +1,16 @@
import { experimental_UniversalStore } from 'storybook/manager-api';
import { experimental_UniversalStore, experimental_getStatusStore } from 'storybook/manager-api';
import { type StoreState, storeOptions } from './constants';
import {
STATUS_TYPE_ID_A11Y,
STATUS_TYPE_ID_COMPONENT_TEST,
type StoreState,
storeOptions,
} from './constants';
export const store = experimental_UniversalStore.create<StoreState>({
...storeOptions,
leader: (globalThis as any).CONFIG_TYPE === 'PRODUCTION',
});
export const componentTestStatusStore = experimental_getStatusStore(STATUS_TYPE_ID_COMPONENT_TEST);
export const a11yStatusStore = experimental_getStatusStore(STATUS_TYPE_ID_A11Y);

View File

@ -1,14 +1,10 @@
import React, { useState } from 'react';
import { AddonPanel } from 'storybook/internal/components';
import {
type API_StatusObject,
type API_StatusValue,
type Addon_TestProviderType,
Addon_TypesEnum,
} from 'storybook/internal/types';
import type { StatusValue } from 'storybook/internal/types';
import { type Addon_TestProviderType, Addon_TypesEnum } from 'storybook/internal/types';
import { store } from '#manager-store';
import { a11yStatusStore, componentTestStatusStore, store } from '#manager-store';
import type { Combo } from 'storybook/manager-api';
import { Consumer, addons, types } from 'storybook/manager-api';
@ -16,24 +12,38 @@ import { GlobalErrorContext, GlobalErrorModal } from './components/GlobalErrorMo
import { Panel } from './components/Panel';
import { PanelTitle } from './components/PanelTitle';
import { TestProviderRender } from './components/TestProviderRender';
import { ADDON_ID, type Details, PANEL_ID, TEST_PROVIDER_ID } from './constants';
import {
A11Y_PANEL_ID,
ADDON_ID,
type Details,
PANEL_ID,
STATUS_TYPE_ID_A11Y,
STATUS_TYPE_ID_COMPONENT_TEST,
TEST_PROVIDER_ID,
} from './constants';
import type { TestStatus } from './node/reporter';
const statusMap: Record<TestStatus, API_StatusValue> = {
failed: 'error',
passed: 'success',
pending: 'pending',
warning: 'warn',
skipped: 'unknown',
const statusMap: Record<TestStatus, StatusValue> = {
pending: 'status-value:pending',
passed: 'status-value:success',
warning: 'status-value:warning',
failed: 'status-value:error',
skipped: 'status-value:unknown',
};
addons.register(ADDON_ID, (api) => {
const storybookBuilder = (globalThis as any).STORYBOOK_BUILDER || '';
if (storybookBuilder.includes('vite')) {
const openTestsPanel = () => {
api.setSelectedPanel(PANEL_ID);
const openPanel = (panelId: string) => {
api.setSelectedPanel(panelId);
api.togglePanel(true);
};
componentTestStatusStore.onSelect(() => {
openPanel(PANEL_ID);
});
a11yStatusStore.onSelect(() => {
openPanel(A11Y_PANEL_ID);
});
addons.add(TEST_PROVIDER_ID, {
type: Addon_TypesEnum.experimental_TEST_PROVIDER,
@ -89,62 +99,47 @@ addons.register(ADDON_ID, (api) => {
}
if (update.details?.testResults) {
(async () => {
await api.experimental_updateStatus(
TEST_PROVIDER_ID,
Object.fromEntries(
// @ts-expect-error: TODO: Fix types
update.details.testResults.flatMap((testResult) =>
testResult.results
.filter(({ storyId }) => storyId)
.map(({ storyId, status, testRunId, ...rest }) => [
storyId,
{
title: 'Component tests',
status: statusMap[status],
description:
'failureMessages' in rest && rest.failureMessages
? rest.failureMessages.join('\n')
: '',
data: { testRunId },
onClick: openTestsPanel,
sidebarContextMenu: false,
} satisfies API_StatusObject,
])
)
)
);
await api.experimental_updateStatus(
'storybook/addon-a11y/test-provider',
Object.fromEntries(
// @ts-expect-error: TODO: Fix types
update.details.testResults.flatMap((testResult) =>
testResult.results
.filter(({ storyId }) => storyId)
.map(({ storyId, testRunId, reports }) => {
const a11yReport = reports.find((r: any) => r.type === 'a11y');
return [
storyId,
a11yReport
? ({
title: 'Accessibility tests',
description: '',
status: statusMap[a11yReport.status],
data: { testRunId },
onClick: () => {
api.setSelectedPanel('storybook/a11y/panel');
api.togglePanel(true);
},
sidebarContextMenu: false,
} satisfies API_StatusObject)
: null,
];
})
)
)
);
})();
componentTestStatusStore.set(
update.details.testResults.flatMap((testResult) =>
testResult.results
.filter(({ storyId }) => storyId)
.map(({ storyId, status, testRunId, ...rest }) => {
return {
storyId,
typeId: STATUS_TYPE_ID_COMPONENT_TEST,
value: statusMap[status],
title: 'Component tests',
description:
'failureMessages' in rest && rest.failureMessages
? rest.failureMessages.join('\n')
: '',
data: { testRunId },
sidebarContextMenu: false,
};
})
)
);
a11yStatusStore.set(
update.details.testResults.flatMap((testResult) =>
testResult.results
.filter(({ storyId, reports }) => {
const a11yReport = reports.find((r: any) => r.type === 'a11y');
return storyId && a11yReport;
})
.map(({ storyId, testRunId, reports }) => {
const a11yReport = reports.find((r: any) => r.type === 'a11y')!;
return {
storyId,
typeId: STATUS_TYPE_ID_A11Y,
value: statusMap[a11yReport.status],
title: 'Accessibility tests',
description: '',
data: { testRunId },
sidebarContextMenu: false,
};
})
)
);
}
return updated;

View File

@ -58,7 +58,14 @@ const mockChannel = new Channel({ transport });
describe('bootTestRunner', () => {
it('should execute vitest.js', async () => {
runTestRunner(mockChannel);
expect(execaNode).toHaveBeenCalledWith(expect.stringMatching(/vitest\.mjs$/));
expect(execaNode).toHaveBeenCalledWith(expect.stringMatching(/vitest\.mjs$/), {
env: {
NODE_ENV: 'test',
TEST: 'true',
VITEST: 'true',
},
extendEnv: true,
});
});
it('should log stdout and stderr', async () => {

View File

@ -67,7 +67,10 @@ const bootTestRunner = async (channel: Channel) => {
const startChildProcess = () =>
new Promise<void>((resolve, reject) => {
child = execaNode(vitestModulePath);
child = execaNode(vitestModulePath, {
env: { VITEST: 'true', TEST: 'true', NODE_ENV: process.env.NODE_ENV ?? 'test' },
extendEnv: true,
});
stderr = [];
child.stdout?.on('data', log);

View File

@ -7,7 +7,6 @@ import type {
TestingModuleProgressReportPayload,
TestingModuleProgressReportProgress,
} from 'storybook/internal/core-events';
import type { API_StatusUpdate } from 'storybook/internal/types';
import type { Suite } from '@vitest/runner';
import { throttle } from 'es-toolkit';
@ -92,8 +91,6 @@ const getErrorOrigin = (error: VitestError): string => {
};
export class StorybookReporter implements Reporter {
testStatusData: API_StatusUpdate = {};
start = 0;
ctx!: Vitest;

View File

@ -1,15 +1,18 @@
import { createRequire } from 'node:module';
import process from 'node:process';
import { Channel } from 'storybook/internal/channels';
import { experimental_UniversalStore } from 'storybook/internal/core-server';
import type { StoreState } from '../constants';
import { storeOptions } from '../constants';
import { TestManager } from './test-manager';
process.env.TEST = 'true';
process.env.VITEST = 'true';
process.env.NODE_ENV ??= 'test';
const require = createRequire(import.meta.url);
// we need to require core-server here, because its ESM output is not valid
// eslint-disable-next-line @typescript-eslint/naming-convention
const { experimental_UniversalStore } = require('storybook/internal/core-server') as {
experimental_UniversalStore: typeof import('storybook/internal/core-server').experimental_UniversalStore;
};
const channel: Channel = new Channel({
async: true,

View File

@ -1,4 +1,5 @@
/* eslint-disable no-underscore-dangle */
import { createRequire } from 'node:module';
import { dirname } from 'node:path';
import type { Plugin } from 'vitest/config';
@ -10,10 +11,10 @@ import {
normalizeStories,
validateConfigurationFiles,
} from 'storybook/internal/common';
import {
StoryIndexGenerator,
experimental_loadStorybook,
mapStaticDir,
import type {
experimental_loadStorybook as ExperimentalLoadStorybookType,
mapStaticDir as MapStaticDirType,
StoryIndexGenerator as StoryIndexGeneratorType,
} from 'storybook/internal/core-server';
import { readConfig, vitestTransform } from 'storybook/internal/csf-tools';
import { MainFileMissingError } from 'storybook/internal/server-errors';
@ -30,6 +31,17 @@ import type { PluginOption } from 'vite';
import { withoutVitePlugins } from '../../../../builders/builder-vite/src/utils/without-vite-plugins';
import type { InternalOptions, UserOptions } from './types';
const require = createRequire(import.meta.url);
// we need to require core-server here, because its ESM output is not valid
// eslint-disable-next-line @typescript-eslint/naming-convention
const { StoryIndexGenerator, experimental_loadStorybook, mapStaticDir } =
require('storybook/internal/core-server') as {
StoryIndexGenerator: typeof StoryIndexGeneratorType;
experimental_loadStorybook: typeof ExperimentalLoadStorybookType;
mapStaticDir: typeof MapStaticDirType;
};
const WORKING_DIR = process.cwd();
const defaultOptions: UserOptions = {

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-themes",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Switch between multiple themes for you components in Storybook",
"keywords": [
"css",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-toolbars",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Create your own toolbar items that control story rendering",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/addon-viewport",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Build responsive components by adjusting Storybooks viewport size and orientation",
"keywords": [
"addon",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/builder-vite",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "A plugin to run and build Storybooks with Vite",
"homepage": "https://github.com/storybookjs/storybook/tree/next/code/builders/builder-vite/#readme",
"bugs": {

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/builder-webpack5",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook framework-agnostic API",
"keywords": [
"storybook"

View File

@ -1,6 +1,6 @@
{
"name": "storybook",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook framework-agnostic API",
"keywords": [
"storybook"
@ -22,6 +22,10 @@
"sideEffects": false,
"type": "module",
"imports": {
"#manager-status-store": {
"storybook": "./src/manager/status-store.mock.ts",
"default": "./src/manager/status-store.ts"
},
"#utils": {
"storybook": "./template/stories/utils.mock.ts",
"default": "./template/stories/utils.ts"
@ -444,15 +448,12 @@
"@vitest/expect": "2.0.5",
"@vitest/spy": "2.0.5",
"better-opn": "^3.0.2",
"browser-assert": "^1.2.1",
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0",
"esbuild-register": "^3.5.0",
"jsdoc-type-pratt-parser": "^4.0.0",
"polished": "^4.2.2",
"process": "^0.11.10",
"recast": "^0.23.5",
"semver": "^7.6.2",
"util": "^0.12.5",
"uuid": "^9.0.0",
"ws": "^8.18.0"
},
@ -526,7 +527,6 @@
"ejs": "^3.1.10",
"es-toolkit": "^1.22.0",
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0",
"esbuild-plugin-alias": "^0.2.1",
"execa": "^8.0.1",
"fd-package-json": "^1.2.0",
"fetch-retry": "^6.0.0",

View File

@ -12,7 +12,7 @@ export const getEntries = (cwd: string) => {
define('src/theming/index.ts', ['browser', 'node'], true, ['react'], [], [], true),
define('src/theming/create.ts', ['browser', 'node'], true, ['react'], [], [], true),
define('src/core-server/index.ts', ['node'], true),
define('src/core-server/index.ts', ['node'], true, ['react']),
define('src/core-server/presets/common-preset.ts', ['node'], false),
define('src/core-server/presets/common-manager.ts', ['browser'], false),
define('src/core-server/presets/common-override-preset.ts', ['node'], false),

View File

@ -7,7 +7,6 @@ import { logger } from 'storybook/internal/node-logger';
import { globalExternals } from '@fal-works/esbuild-plugin-global-externals';
import { pnpPlugin } from '@yarnpkg/esbuild-plugin-pnp';
import aliasPlugin from 'esbuild-plugin-alias';
import sirv from 'sirv';
import type {
@ -82,15 +81,7 @@ export const getConfig: ManagerBuilder['getConfig'] = async (options) => {
tsconfig: tsconfigPath,
legalComments: 'external',
plugins: [
aliasPlugin({
process: require.resolve('process/browser.js'),
util: require.resolve('util/util.js'),
assert: require.resolve('browser-assert'),
}),
globalExternals(globalsModuleInfoMap),
pnpPlugin(),
],
plugins: [globalExternals(globalsModuleInfoMap), pnpPlugin()],
banner: {
js: 'try{',

View File

@ -1,66 +1,66 @@
// auto generated file, do not edit
export default {
'@storybook/addon-a11y': '9.0.0-alpha.4',
'@storybook/addon-backgrounds': '9.0.0-alpha.4',
'@storybook/addon-controls': '9.0.0-alpha.4',
'@storybook/addon-docs': '9.0.0-alpha.4',
'@storybook/addon-essentials': '9.0.0-alpha.4',
'@storybook/addon-mdx-gfm': '9.0.0-alpha.4',
'@storybook/addon-highlight': '9.0.0-alpha.4',
'@storybook/addon-interactions': '9.0.0-alpha.4',
'@storybook/addon-jest': '9.0.0-alpha.4',
'@storybook/addon-links': '9.0.0-alpha.4',
'@storybook/addon-measure': '9.0.0-alpha.4',
'@storybook/addon-onboarding': '9.0.0-alpha.4',
'@storybook/addon-outline': '9.0.0-alpha.4',
'@storybook/addon-storysource': '9.0.0-alpha.4',
'@storybook/addon-test': '9.0.0-alpha.4',
'@storybook/addon-themes': '9.0.0-alpha.4',
'@storybook/addon-toolbars': '9.0.0-alpha.4',
'@storybook/addon-viewport': '9.0.0-alpha.4',
'@storybook/builder-vite': '9.0.0-alpha.4',
'@storybook/builder-webpack5': '9.0.0-alpha.4',
storybook: '9.0.0-alpha.4',
'@storybook/angular': '9.0.0-alpha.4',
'@storybook/ember': '9.0.0-alpha.4',
'@storybook/experimental-nextjs-vite': '9.0.0-alpha.4',
'@storybook/html-vite': '9.0.0-alpha.4',
'@storybook/html-webpack5': '9.0.0-alpha.4',
'@storybook/nextjs': '9.0.0-alpha.4',
'@storybook/preact-vite': '9.0.0-alpha.4',
'@storybook/preact-webpack5': '9.0.0-alpha.4',
'@storybook/react-native-web-vite': '9.0.0-alpha.4',
'@storybook/react-vite': '9.0.0-alpha.4',
'@storybook/react-webpack5': '9.0.0-alpha.4',
'@storybook/server-webpack5': '9.0.0-alpha.4',
'@storybook/svelte-vite': '9.0.0-alpha.4',
'@storybook/svelte-webpack5': '9.0.0-alpha.4',
'@storybook/sveltekit': '9.0.0-alpha.4',
'@storybook/vue3-vite': '9.0.0-alpha.4',
'@storybook/vue3-webpack5': '9.0.0-alpha.4',
'@storybook/web-components-vite': '9.0.0-alpha.4',
'@storybook/web-components-webpack5': '9.0.0-alpha.4',
'@storybook/blocks': '9.0.0-alpha.4',
sb: '9.0.0-alpha.4',
'@storybook/cli': '9.0.0-alpha.4',
'@storybook/codemod': '9.0.0-alpha.4',
'@storybook/core-webpack': '9.0.0-alpha.4',
'create-storybook': '9.0.0-alpha.4',
'@storybook/csf-plugin': '9.0.0-alpha.4',
'@storybook/react-dom-shim': '9.0.0-alpha.4',
'@storybook/source-loader': '9.0.0-alpha.4',
'@storybook/preset-create-react-app': '9.0.0-alpha.4',
'@storybook/preset-html-webpack': '9.0.0-alpha.4',
'@storybook/preset-preact-webpack': '9.0.0-alpha.4',
'@storybook/preset-react-webpack': '9.0.0-alpha.4',
'@storybook/preset-server-webpack': '9.0.0-alpha.4',
'@storybook/preset-svelte-webpack': '9.0.0-alpha.4',
'@storybook/preset-vue3-webpack': '9.0.0-alpha.4',
'@storybook/html': '9.0.0-alpha.4',
'@storybook/preact': '9.0.0-alpha.4',
'@storybook/react': '9.0.0-alpha.4',
'@storybook/server': '9.0.0-alpha.4',
'@storybook/svelte': '9.0.0-alpha.4',
'@storybook/vue3': '9.0.0-alpha.4',
'@storybook/web-components': '9.0.0-alpha.4',
'@storybook/addon-a11y': '9.0.0-alpha.5',
'@storybook/addon-backgrounds': '9.0.0-alpha.5',
'@storybook/addon-controls': '9.0.0-alpha.5',
'@storybook/addon-docs': '9.0.0-alpha.5',
'@storybook/addon-essentials': '9.0.0-alpha.5',
'@storybook/addon-mdx-gfm': '9.0.0-alpha.5',
'@storybook/addon-highlight': '9.0.0-alpha.5',
'@storybook/addon-interactions': '9.0.0-alpha.5',
'@storybook/addon-jest': '9.0.0-alpha.5',
'@storybook/addon-links': '9.0.0-alpha.5',
'@storybook/addon-measure': '9.0.0-alpha.5',
'@storybook/addon-onboarding': '9.0.0-alpha.5',
'@storybook/addon-outline': '9.0.0-alpha.5',
'@storybook/addon-storysource': '9.0.0-alpha.5',
'@storybook/addon-test': '9.0.0-alpha.5',
'@storybook/addon-themes': '9.0.0-alpha.5',
'@storybook/addon-toolbars': '9.0.0-alpha.5',
'@storybook/addon-viewport': '9.0.0-alpha.5',
'@storybook/builder-vite': '9.0.0-alpha.5',
'@storybook/builder-webpack5': '9.0.0-alpha.5',
storybook: '9.0.0-alpha.5',
'@storybook/angular': '9.0.0-alpha.5',
'@storybook/ember': '9.0.0-alpha.5',
'@storybook/experimental-nextjs-vite': '9.0.0-alpha.5',
'@storybook/html-vite': '9.0.0-alpha.5',
'@storybook/html-webpack5': '9.0.0-alpha.5',
'@storybook/nextjs': '9.0.0-alpha.5',
'@storybook/preact-vite': '9.0.0-alpha.5',
'@storybook/preact-webpack5': '9.0.0-alpha.5',
'@storybook/react-native-web-vite': '9.0.0-alpha.5',
'@storybook/react-vite': '9.0.0-alpha.5',
'@storybook/react-webpack5': '9.0.0-alpha.5',
'@storybook/server-webpack5': '9.0.0-alpha.5',
'@storybook/svelte-vite': '9.0.0-alpha.5',
'@storybook/svelte-webpack5': '9.0.0-alpha.5',
'@storybook/sveltekit': '9.0.0-alpha.5',
'@storybook/vue3-vite': '9.0.0-alpha.5',
'@storybook/vue3-webpack5': '9.0.0-alpha.5',
'@storybook/web-components-vite': '9.0.0-alpha.5',
'@storybook/web-components-webpack5': '9.0.0-alpha.5',
'@storybook/blocks': '9.0.0-alpha.5',
sb: '9.0.0-alpha.5',
'@storybook/cli': '9.0.0-alpha.5',
'@storybook/codemod': '9.0.0-alpha.5',
'@storybook/core-webpack': '9.0.0-alpha.5',
'create-storybook': '9.0.0-alpha.5',
'@storybook/csf-plugin': '9.0.0-alpha.5',
'@storybook/react-dom-shim': '9.0.0-alpha.5',
'@storybook/source-loader': '9.0.0-alpha.5',
'@storybook/preset-create-react-app': '9.0.0-alpha.5',
'@storybook/preset-html-webpack': '9.0.0-alpha.5',
'@storybook/preset-preact-webpack': '9.0.0-alpha.5',
'@storybook/preset-react-webpack': '9.0.0-alpha.5',
'@storybook/preset-server-webpack': '9.0.0-alpha.5',
'@storybook/preset-svelte-webpack': '9.0.0-alpha.5',
'@storybook/preset-vue3-webpack': '9.0.0-alpha.5',
'@storybook/html': '9.0.0-alpha.5',
'@storybook/preact': '9.0.0-alpha.5',
'@storybook/react': '9.0.0-alpha.5',
'@storybook/server': '9.0.0-alpha.5',
'@storybook/svelte': '9.0.0-alpha.5',
'@storybook/vue3': '9.0.0-alpha.5',
'@storybook/web-components': '9.0.0-alpha.5',
};

View File

@ -14,3 +14,7 @@ export { loadStorybook as experimental_loadStorybook } from './load';
export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock';
export {
getStatusStoreByTypeId as experimental_getStatusStore,
fullStatusStore as internal_fullStatusStore,
} from './stores/status';

View File

@ -0,0 +1,23 @@
import { createStatusStore } from '../../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../shared/status-store';
import { UniversalStore } from '../../shared/universal-store';
const statusStore = createStatusStore({
universalStatusStore:
/*
This is a temporary workaround, to ensure that the store is not created in the
vitest sub-process in addon-test, even though it imports from core-server
If it was created in the sub-process, it would try to connect to the leader in the dev server
before it was ready.
This will be fixed when we do the planned UniversalStore v0.2.
*/
process.env.VITEST !== 'true'
? UniversalStore.create({
...UNIVERSAL_STATUS_STORE_OPTIONS,
leader: true,
})
: ({} as any),
environment: 'server',
});
export const { fullStatusStore, getStatusStoreByTypeId } = statusStore;

View File

@ -186,7 +186,7 @@ export interface Renderer {
// component: (args: this['T']) => string;
// This generic type will eventually be filled in with TArgs
// Credits to Michael Arnaldi.
T?: unknown;
T?: any;
}
/** @deprecated - Use `Renderer` */

View File

@ -111,7 +111,7 @@ export class Instrumenter {
constructor() {
// Restore state from the parent window in case the iframe was reloaded.
// @ts-expect-error (TS doesn't know about this global variable)
this.state = global.window?.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {};
this.state = global.window?.parent?.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {};
// When called from `start`, isDebugging will be true.
const resetState = ({

View File

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import type { API_PreparedStoryIndex, StoryIndexV2, StoryIndexV3 } from 'storybook/internal/types';
import {
type API_PreparedStoryIndex,
type StatusesByStoryIdAndTypeId,
type StoryIndexV2,
type StoryIndexV3,
} from 'storybook/internal/types';
import type { State } from '../root';
import { mockEntries } from '../tests/mockStoriesEntries';
@ -250,9 +255,25 @@ describe('transformStoryIndexToStoriesHash', () => {
someFilter: () => false,
};
const status: State['status'] = {
'1': { someStatus: { status: 'error', title: 'broken', description: 'very bad' } },
'2': { someStatus: { status: 'success', title: 'perfect', description: 'nice' } },
const allStatuses: StatusesByStoryIdAndTypeId = {
'1': {
someStatus: {
typeId: 'someStatus',
storyId: '1',
value: 'status-value:error',
title: 'broken',
description: 'very bad',
},
},
'2': {
someStatus: {
typeId: 'someStatus',
storyId: '2',
value: 'status-value:success',
title: 'perfect',
description: 'nice',
},
},
};
const options = {
@ -261,7 +282,7 @@ describe('transformStoryIndexToStoriesHash', () => {
} as any,
docsOptions: { docsMode: false },
filters,
status,
allStatuses,
};
// Act - transform the index to hashes

View File

@ -1,6 +1,5 @@
import { sanitize } from 'storybook/internal/csf';
import type {
API_BaseEntry,
API_ComponentEntry,
API_DocsEntry,
API_GroupEntry,
@ -15,6 +14,7 @@ import type {
Parameters,
SetStoriesPayload,
SetStoriesStoryData,
StatusesByStoryIdAndTypeId,
StoryId,
StoryIndexV2,
StoryIndexV3,
@ -46,12 +46,6 @@ export const denormalizeStoryParameters = ({
})) as SetStoriesStoryData;
};
export const transformSetStoriesStoryDataToStoriesHash = (
data: SetStoriesStoryData,
options: ToStoriesHashOptions
) =>
transformStoryIndexToStoriesHash(transformSetStoriesStoryDataToPreparedStoryIndex(data), options);
export const transformSetStoriesStoryDataToPreparedStoryIndex = (
stories: SetStoriesStoryData
): API_PreparedStoryIndex => {
@ -172,12 +166,12 @@ type ToStoriesHashOptions = {
provider: API_Provider<API>;
docsOptions: DocsOptions;
filters: State['filters'];
status: State['status'];
allStatuses: StatusesByStoryIdAndTypeId;
};
export const transformStoryIndexToStoriesHash = (
input: API_PreparedStoryIndex | StoryIndexV2 | StoryIndexV3,
{ provider, docsOptions, filters, status }: ToStoriesHashOptions
{ provider, docsOptions, filters, allStatuses }: ToStoriesHashOptions
): API_IndexHash | any => {
if (!input.v) {
throw new Error('Composition: Missing stories.json version');
@ -193,16 +187,16 @@ export const transformStoryIndexToStoriesHash = (
let result = true;
// All stories with a failing status should always show up, regardless of the applied filters
const storyStatus = status[entry.id];
if (Object.values(storyStatus ?? {}).some(({ status: s }) => s === 'error')) {
const storyStatuses = allStatuses[entry.id] ?? {};
if (Object.values(storyStatuses).some(({ value }) => value === 'status-value:error')) {
return result;
}
Object.values(filters).forEach((filter: any) => {
Object.values(filters).forEach((filter) => {
if (result === false) {
return;
}
result = filter({ ...entry, status: storyStatus });
result = filter({ ...entry, statuses: storyStatuses });
});
return result;

View File

@ -318,14 +318,14 @@ export const init: ModuleFn<SubAPI, SubState> = (
provider,
docsOptions,
filters,
status: {},
allStatuses: {},
});
// @ts-expect-error (could be undefined)
index = transformStoryIndexToStoriesHash(storyIndex, {
provider,
docsOptions,
filters: {},
status: {},
allStatuses: {},
});
}

View File

@ -29,9 +29,6 @@ import type {
API_LeafEntry,
API_LoadedRefData,
API_PreparedStoryIndex,
API_StatusObject,
API_StatusState,
API_StatusUpdate,
API_StoryEntry,
API_ViewMode,
Args,
@ -44,6 +41,7 @@ import type {
StoryName,
StoryPreparedPayload,
} from 'storybook/internal/types';
import type { StatusByTypeId } from 'storybook/internal/types';
import { global } from '@storybook/global';
@ -57,6 +55,7 @@ import {
} from '../lib/stories';
import type { ModuleFn } from '../lib/types';
import type { ComposedRef } from '../root';
import { fullStatusStore } from '../stores/status';
const { fetch } = global;
const STORY_INDEX_PATH = './index.json';
@ -74,7 +73,6 @@ export interface SubState extends API_LoadedRefData {
storyId: StoryId;
internal_index?: API_PreparedStoryIndex;
viewMode: API_ViewMode;
status: API_StatusState;
filters: Record<string, API_FilterFunction>;
}
@ -269,23 +267,6 @@ export interface SubAPI {
* @returns {Promise<void>} A promise that resolves when the preview has been set as initialized.
*/
setPreviewInitialized: (ref?: ComposedRef) => Promise<void>;
/**
* Returns the current status of the stories.
*
* @returns {API_StatusState} The current status of the stories.
*/
getCurrentStoryStatus: () => Record<string, API_StatusObject>;
/**
* Updates the status of a collection of stories.
*
* @param {string} addonId - The ID of the addon to update.
* @param {StatusUpdate} update - An object containing the updated status information.
* @returns {Promise<void>} A promise that resolves when the status has been updated.
*/
experimental_updateStatus: (
addonId: string,
update: API_StatusUpdate | ((state: API_StatusState) => API_StatusUpdate)
) => Promise<void>;
/**
* Updates the filtering of the index.
*
@ -567,18 +548,19 @@ export const init: ModuleFn<SubAPI, SubState> = ({
// The story index we receive on fetchStoryIndex is not, but all the prepared fields are optional
// so we can cast one to the other easily enough
setIndex: async (input) => {
const { filteredIndex: oldFilteredHash, index: oldHash, status, filters } = store.getState();
const { filteredIndex: oldFilteredHash, index: oldHash, filters } = store.getState();
const allStatuses = fullStatusStore.getAll();
const newFilteredHash = transformStoryIndexToStoriesHash(input, {
provider,
docsOptions,
status,
filters,
allStatuses,
});
const newHash = transformStoryIndexToStoriesHash(input, {
provider,
docsOptions,
status,
filters: {},
allStatuses,
});
await store.setState({
@ -667,50 +649,6 @@ export const init: ModuleFn<SubAPI, SubState> = ({
}
},
getCurrentStoryStatus: () => {
const { status, storyId } = store.getState();
return status[storyId as StoryId];
},
/* EXPERIMENTAL APIs */
experimental_updateStatus: async (id, input) => {
const { status, internal_index: index } = store.getState();
const newStatus = { ...status };
const update = typeof input === 'function' ? input(status) : input;
if (!id || Object.keys(update).length === 0) {
return;
}
Object.entries(update).forEach(([storyId, value]) => {
if (!storyId || typeof value !== 'object') {
return;
}
newStatus[storyId] = { ...(newStatus[storyId] || {}) };
if (value === null) {
delete newStatus[storyId][id];
} else {
newStatus[storyId][id] = value;
}
if (Object.keys(newStatus[storyId]).length === 0) {
delete newStatus[storyId];
}
});
await store.setState({ status: newStatus }, { persistence: 'session' });
if (index) {
// We need to re-prepare the index
await api.setIndex(index);
const refs = await fullAPI.getRefs();
Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => {
fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true);
});
}
},
experimental_setFilter: async (id, filterFunction) => {
await store.setState({ filters: { ...store.getState().filters, [id]: filterFunction } });
@ -936,6 +874,23 @@ export const init: ModuleFn<SubAPI, SubState> = ({
}
});
fullStatusStore.onAllStatusChange(async () => {
// re-apply the filters when the statuses change
const { internal_index: index } = store.getState();
if (!index) {
return;
}
// apply new filters by setting the index again
await api.setIndex(index);
const refs = await fullAPI.getRefs();
Object.entries(refs).forEach(([refId, { internal_index, ...ref }]) => {
fullAPI.setRef(refId, { ...ref, storyIndex: internal_index }, true);
});
});
const config = provider.getConfig();
return {
@ -945,7 +900,6 @@ export const init: ModuleFn<SubAPI, SubState> = ({
viewMode: initialViewMode,
hasCalledSetOptions: false,
previewInitialized: false,
status: {},
filters: config?.sidebar?.filters || {},
},
init: async () => {

View File

@ -527,3 +527,9 @@ export { typesX as types };
/* deprecated */
export { mockChannel, type Addon, type AddonStore } from './lib/addons';
export {
getStatusStoreByTypeId as experimental_getStatusStore,
useStatusStore as experimental_useStatusStore,
fullStatusStore as internal_fullStatusStore,
} from './stores/status';

View File

@ -0,0 +1,12 @@
import { createStatusStore } from '../../../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../../shared/status-store';
import { useUniversalStore } from '../../../shared/universal-store/use-universal-store-manager';
import { experimental_MockUniversalStore } from '../../root';
const mockStatusStore = createStatusStore({
universalStatusStore: new experimental_MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
useUniversalStore,
environment: 'manager',
});
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore } = mockStatusStore;

View File

@ -0,0 +1,15 @@
import { createStatusStore } from '../../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../shared/status-store';
import { UniversalStore } from '../../shared/universal-store';
import { useUniversalStore } from '../../shared/universal-store/use-universal-store-manager';
const statusStore = createStatusStore({
universalStatusStore: UniversalStore.create({
...UNIVERSAL_STATUS_STORE_OPTIONS,
leader: globalThis.CONFIG_TYPE === 'PRODUCTION',
}),
useUniversalStore,
environment: 'manager',
});
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore } = statusStore;

View File

@ -1242,7 +1242,7 @@ describe('Refs API', () => {
provider: provider as any,
docsOptions: {},
filters: {},
status: {},
allStatuses: {},
};
const initialState: Partial<State> = {
refs: {

View File

@ -15,7 +15,7 @@ import {
STORY_SPECIFIED,
UPDATE_STORY_ARGS,
} from 'storybook/internal/core-events';
import type { API_StoryEntry } from 'storybook/internal/types';
import { type API_StoryEntry, StatusValue } from 'storybook/internal/types';
import { global } from '@storybook/global';
@ -26,6 +26,7 @@ import type { ModuleArgs } from '../lib/types';
import { init as initStories } from '../modules/stories';
import type { API, State } from '../root';
import type Store from '../store';
import { fullStatusStore } from '../stores/status';
import { docsEntries, mockEntries, navigationEntries, preparedEntries } from './mockStoriesEntries';
const mockGetEntries = vi.fn();
@ -1342,195 +1343,6 @@ describe('stories API', () => {
);
});
});
describe('experimental_updateStatus', () => {
it('is included in the initial state', () => {
const moduleArgs = createMockModuleArgs({});
const { state } = initStories(moduleArgs as unknown as ModuleArgs);
expect(state).toEqual(
expect.objectContaining({
status: {},
})
);
});
it('updates a story', async () => {
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;
await api.setIndex({ v: 5, entries: mockEntries });
await expect(
api.experimental_updateStatus('a-addon-id', {
'a-story-id': {
status: 'pending',
title: 'an addon title',
description: 'an addon description',
},
})
).resolves.not.toThrow();
expect(store.getState().status).toMatchInlineSnapshot(`
{
"a-story-id": {
"a-addon-id": {
"description": "an addon description",
"status": "pending",
"title": "an addon title",
},
},
}
`);
});
it('skips updating index, if index is unset', async () => {
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;
await expect(
api.experimental_updateStatus('a-addon-id', {
'a-story-id': {
status: 'pending',
title: 'an addon title',
description: 'an addon description',
},
})
).resolves.not.toThrow();
expect(store.getState().status).toMatchInlineSnapshot(`
{
"a-story-id": {
"a-addon-id": {
"description": "an addon description",
"status": "pending",
"title": "an addon title",
},
},
}
`);
});
it('updates multiple stories', async () => {
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;
await api.setIndex({ v: 5, entries: mockEntries });
await expect(
api.experimental_updateStatus('a-addon-id', {
'a-story-id': {
status: 'pending',
title: 'an addon title',
description: 'an addon description',
},
'another-story-id': { status: 'success', title: 'a addon title', description: '' },
})
).resolves.not.toThrow();
expect(store.getState().status).toMatchInlineSnapshot(`
{
"a-story-id": {
"a-addon-id": {
"description": "an addon description",
"status": "pending",
"title": "an addon title",
},
},
"another-story-id": {
"a-addon-id": {
"description": "",
"status": "success",
"title": "a addon title",
},
},
}
`);
});
it('delete when value is null', async () => {
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;
await api.setIndex({ v: 5, entries: mockEntries });
await expect(
api.experimental_updateStatus('a-addon-id', {
'a-story-id': {
status: 'pending',
title: 'an addon title',
description: 'an addon description',
},
'another-story-id': { status: 'success', title: 'a addon title', description: '' },
})
).resolves.not.toThrow();
// do a second update, this time with null
await expect(
api.experimental_updateStatus('a-addon-id', {
'a-story-id': null!,
'another-story-id': { status: 'success', title: 'a addon title', description: '' },
})
).resolves.not.toThrow();
expect(store.getState().status).toMatchInlineSnapshot(`
{
"another-story-id": {
"a-addon-id": {
"description": "",
"status": "success",
"title": "a addon title",
},
},
}
`);
});
it('updates with a function', async () => {
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;
await api.setIndex({ v: 5, entries: mockEntries });
// setup initial state
await expect(
api.experimental_updateStatus('a-addon-id', () => ({
'a-story-id': {
status: 'pending',
title: 'an addon title',
description: 'an addon description',
},
'another-story-id': { status: 'success', title: 'a addon title', description: '' },
}))
).resolves.not.toThrow();
// use existing state in function
await expect(
api.experimental_updateStatus('a-addon-id', (current: any) => {
return Object.fromEntries(
Object.entries(current).map(([k, v]: any) => [
k,
{ ...v['a-addon-id'], status: 'success' },
])
);
})
).resolves.not.toThrow();
expect(store.getState().status).toMatchInlineSnapshot(`
{
"a-story-id": {
"a-addon-id": {
"description": "an addon description",
"status": "success",
"title": "an addon title",
},
},
"another-story-id": {
"a-addon-id": {
"description": "",
"status": "success",
"title": "a addon title",
},
},
}
`);
});
});
describe('experimental_setFilter', () => {
it('is included in the initial state', async () => {
const moduleArgs = createMockModuleArgs({});
@ -1617,6 +1429,7 @@ describe('stories API', () => {
});
it('can filter on status', async () => {
vi.mock('../stores/status');
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;
@ -1624,25 +1437,34 @@ describe('stories API', () => {
await api.setIndex({ v: 5, entries: navigationEntries });
await api.experimental_setFilter(
'myCustomFilter',
(item: any) =>
item.status !== undefined &&
Object.values(item.status).some((v: any) => v.status === 'pending')
(item) =>
item.statuses !== undefined &&
Object.values(item.statuses).some((status) => status.value === 'status-value:pending')
);
// empty, because there are no stories with status
expect(store.getState().filteredIndex).toMatchInlineSnapshot('{}');
// setting status should update the index
await api.experimental_updateStatus('a-addon-id', {
'a--1': {
status: 'pending',
fullStatusStore.set([
{
typeId: 'a-addon-id',
storyId: 'a--1',
value: 'status-value:pending',
title: 'an addon title',
description: 'an addon description',
},
'a--2': { status: 'success', title: 'a addon title', description: '' },
});
{
typeId: 'a-addon-id',
storyId: 'a--2',
value: 'status-value:success',
title: 'an addon title',
description: 'an addon description',
},
]);
expect(store.getState().filteredIndex).toMatchInlineSnapshot(`
await vi.waitFor(() => {
expect(store.getState().filteredIndex).toMatchInlineSnapshot(`
{
"a": {
"children": [
@ -1670,6 +1492,7 @@ describe('stories API', () => {
},
}
`);
});
});
it('persists filter when index is updated', async () => {

View File

@ -1 +1 @@
export const version = '9.0.0-alpha.4';
export const version = '9.0.0-alpha.5';

View File

@ -1,3 +1,4 @@
import type { Status, StatusTypeId } from './shared/status-store';
import { StorybookError } from './storybook-error';
/**
@ -43,3 +44,22 @@ export class UncaughtManagerError extends StorybookError {
this.stack = data.error.stack;
}
}
export class StatusTypeIdMismatchError extends StorybookError {
constructor(
public data: {
status: Status;
typeId: StatusTypeId;
}
) {
super({
category: Category.MANAGER_API,
code: 1,
message: `Status has typeId "${data.status.typeId}" but was added to store with typeId "${data.typeId}". Full status: ${JSON.stringify(
data.status,
null,
2
)}`,
});
}
}

View File

@ -90,7 +90,7 @@ export const useContextMenu = (context: API_HashEntry, links: Link[], api: API)
}}
tooltip={<LiveContextMenu context={context} links={links} />}
>
<FloatingStatusButton type="button" status={'pending'}>
<FloatingStatusButton type="button" status="status-value:pending">
<EllipsisIcon />
</FloatingStatusButton>
</PositionedWithTooltip>

View File

@ -60,6 +60,7 @@ const refs: Record<string, RefType> = {
type: 'lazy',
// @ts-expect-error (invalid input)
filteredIndex,
allStatuses: {},
},
empty: {
id: 'empty',
@ -68,6 +69,7 @@ const refs: Record<string, RefType> = {
type: 'lazy',
filteredIndex: {},
previewInitialized: false,
allStatuses: {},
},
startInjected_unknown: {
id: 'startInjected_unknown',
@ -77,6 +79,7 @@ const refs: Record<string, RefType> = {
previewInitialized: false,
// @ts-expect-error (invalid input)
filteredIndex,
allStatuses: {},
},
startInjected_loading: {
id: 'startInjected_loading',
@ -86,6 +89,7 @@ const refs: Record<string, RefType> = {
previewInitialized: false,
// @ts-expect-error (invalid input)
filteredIndex,
allStatuses: {},
},
startInjected_ready: {
id: 'startInjected_ready',
@ -95,6 +99,7 @@ const refs: Record<string, RefType> = {
previewInitialized: true,
// @ts-expect-error (invalid input)
filteredIndex,
allStatuses: {},
},
versions: {
id: 'versions',
@ -105,6 +110,7 @@ const refs: Record<string, RefType> = {
filteredIndex,
versions: { '1.0.0': 'https://example.com/v1', '2.0.0': 'https://example.com' },
previewInitialized: true,
allStatuses: {},
},
versionsMissingCurrent: {
id: 'versions_missing_current',
@ -115,6 +121,7 @@ const refs: Record<string, RefType> = {
filteredIndex,
versions: { '1.0.0': 'https://example.com/v1', '2.0.0': 'https://example.com/v2' },
previewInitialized: true,
allStatuses: {},
},
error: {
id: 'error',
@ -123,6 +130,7 @@ const refs: Record<string, RefType> = {
type: 'lazy',
indexError,
previewInitialized: true,
allStatuses: {},
},
auth: {
id: 'Authentication',
@ -131,6 +139,7 @@ const refs: Record<string, RefType> = {
type: 'lazy',
loginUrl: 'https://example.com',
previewInitialized: true,
allStatuses: {},
},
long: {
id: 'long',
@ -154,6 +163,7 @@ const refs: Record<string, RefType> = {
type: 'lazy',
// @ts-expect-error (invalid input)
filteredIndex,
allStatuses: {},
},
};

View File

@ -2,7 +2,6 @@ import type { FC, MutableRefObject } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { transparentize } from 'polished';
import type { State } from 'storybook/manager-api';
import { useStorybookApi, useStorybookState } from 'storybook/manager-api';
import { styled } from 'storybook/theming';
@ -74,99 +73,99 @@ const CollapseButton = styled.button(({ theme }) => ({
},
}));
export const Ref: FC<RefType & RefProps & { status?: State['status'] }> = React.memo(
function Ref(props) {
const { docsOptions } = useStorybookState();
const api = useStorybookApi();
const {
filteredIndex: index,
id: refId,
title = refId,
isLoading: isLoadingMain,
isBrowsing,
selectedStoryId,
highlightedRef,
setHighlighted,
loginUrl,
type,
expanded = true,
indexError,
previewInitialized,
} = props;
const length = useMemo(() => (index ? Object.keys(index).length : 0), [index]);
const indicatorRef = useRef<HTMLElement>(null);
export const Ref: FC<RefType & RefProps> = React.memo(function Ref(props) {
const { docsOptions } = useStorybookState();
const api = useStorybookApi();
const {
filteredIndex: index,
id: refId,
title = refId,
isLoading: isLoadingMain,
isBrowsing,
selectedStoryId,
highlightedRef,
setHighlighted,
loginUrl,
type,
expanded = true,
indexError,
previewInitialized,
allStatuses,
} = props;
const isMain = refId === DEFAULT_REF_ID;
const isLoadingInjected =
(type === 'auto-inject' && !previewInitialized) || type === 'server-checked';
const isLoading = isLoadingMain || isLoadingInjected || type === 'unknown';
const isError = !!indexError;
const isEmpty = !isLoading && length === 0;
const isAuthRequired = !!loginUrl && length === 0;
const length = useMemo(() => (index ? Object.keys(index).length : 0), [index]);
const indicatorRef = useRef<HTMLElement>(null);
const state = getStateType(isLoading, isAuthRequired, isError, isEmpty);
const [isExpanded, setExpanded] = useState<boolean>(expanded);
const isMain = refId === DEFAULT_REF_ID;
const isLoadingInjected =
(type === 'auto-inject' && !previewInitialized) || type === 'server-checked';
const isLoading = isLoadingMain || isLoadingInjected || type === 'unknown';
const isError = !!indexError;
const isEmpty = !isLoading && length === 0;
const isAuthRequired = !!loginUrl && length === 0;
useEffect(() => {
if (index && selectedStoryId && index[selectedStoryId]) {
setExpanded(true);
}
}, [setExpanded, index, selectedStoryId]);
const state = getStateType(isLoading, isAuthRequired, isError, isEmpty);
const [isExpanded, setExpanded] = useState<boolean>(expanded);
const handleClick = useCallback(() => setExpanded((value) => !value), [setExpanded]);
useEffect(() => {
if (index && selectedStoryId && index[selectedStoryId]) {
setExpanded(true);
}
}, [setExpanded, index, selectedStoryId]);
const setHighlightedItemId = useCallback(
(itemId: string) => setHighlighted({ itemId, refId }),
[setHighlighted]
);
const handleClick = useCallback(() => setExpanded((value) => !value), [setExpanded]);
const onSelectStoryId = useCallback(
// @ts-expect-error (non strict)
(storyId: string) => api && api.selectStory(storyId, undefined, { ref: !isMain && refId }),
[api, isMain, refId]
);
const setHighlightedItemId = useCallback(
(itemId: string) => setHighlighted({ itemId, refId }),
[setHighlighted]
);
return (
<>
{isMain || (
<RefHead
aria-label={`${isExpanded ? 'Hide' : 'Show'} ${title} stories`}
aria-expanded={isExpanded}
>
<CollapseButton data-action="collapse-ref" onClick={handleClick}>
<CollapseIcon isExpanded={isExpanded} />
<RefTitle title={title}>{title}</RefTitle>
</CollapseButton>
<RefIndicator {...props} state={state} ref={indicatorRef} />
</RefHead>
)}
{isExpanded && (
<Wrapper data-title={title} isMain={isMain}>
{/* @ts-expect-error (non strict) */}
{state === 'auth' && <AuthBlock id={refId} loginUrl={loginUrl} />}
{/* @ts-expect-error (non strict) */}
{state === 'error' && <ErrorBlock error={indexError} />}
{state === 'loading' && <LoaderBlock isMain={isMain} />}
{state === 'empty' && <EmptyBlock isMain={isMain} />}
{state === 'ready' && (
<Tree
status={props.status}
isBrowsing={isBrowsing}
isMain={isMain}
refId={refId}
// @ts-expect-error (non strict)
data={index}
// @ts-expect-error (non strict)
docsMode={docsOptions.docsMode}
selectedStoryId={selectedStoryId}
onSelectStoryId={onSelectStoryId}
highlightedRef={highlightedRef}
setHighlightedItemId={setHighlightedItemId}
/>
)}
</Wrapper>
)}
</>
);
}
);
const onSelectStoryId = useCallback(
// @ts-expect-error (non strict)
(storyId: string) => api && api.selectStory(storyId, undefined, { ref: !isMain && refId }),
[api, isMain, refId]
);
return (
<>
{isMain || (
<RefHead
aria-label={`${isExpanded ? 'Hide' : 'Show'} ${title} stories`}
aria-expanded={isExpanded}
>
<CollapseButton data-action="collapse-ref" onClick={handleClick}>
<CollapseIcon isExpanded={isExpanded} />
<RefTitle title={title}>{title}</RefTitle>
</CollapseButton>
<RefIndicator {...props} state={state} ref={indicatorRef} />
</RefHead>
)}
{isExpanded && (
<Wrapper data-title={title} isMain={isMain}>
{/* @ts-expect-error (non strict) */}
{state === 'auth' && <AuthBlock id={refId} loginUrl={loginUrl} />}
{/* @ts-expect-error (non strict) */}
{state === 'error' && <ErrorBlock error={indexError} />}
{state === 'loading' && <LoaderBlock isMain={isMain} />}
{state === 'empty' && <EmptyBlock isMain={isMain} />}
{state === 'ready' && (
<Tree
allStatuses={allStatuses}
isBrowsing={isBrowsing}
isMain={isMain}
refId={refId}
// @ts-expect-error (non strict)
data={index}
// @ts-expect-error (non strict)
docsMode={docsOptions.docsMode}
selectedStoryId={selectedStoryId}
onSelectStoryId={onSelectStoryId}
highlightedRef={highlightedRef}
setHighlightedItemId={setHighlightedItemId}
/>
)}
</Wrapper>
)}
</>
);
});

View File

@ -8,6 +8,7 @@ import { ManagerContext } from 'storybook/manager-api';
import { IconSymbols } from './IconSymbols';
import { Search } from './Search';
import type { SearchProps } from './Search';
import { SearchResults } from './SearchResults';
import { noResults } from './SearchResults.stories';
import { DEFAULT_REF_ID } from './Sidebar';
@ -15,7 +16,7 @@ import { index } from './mockdata.large';
import type { Selection } from './types';
const refId = DEFAULT_REF_ID;
const data = { [refId]: { id: refId, url: '/', index, previewInitialized: true } };
const data = { [refId]: { id: refId, url: '/', index, previewInitialized: true, allStatuses: {} } };
const dataset = { hash: data, entries: Object.entries(data) };
const getLastViewed = () =>
Object.values(index)
@ -38,9 +39,8 @@ const meta = {
} satisfies Meta<typeof Search>;
export default meta;
const baseProps = {
const baseProps: Omit<SearchProps, 'children'> = {
dataset,
clearLastViewed: action('clear'),
getLastViewed: () => [] as Selection[],
};

View File

@ -12,7 +12,7 @@ import Fuse from 'fuse.js';
import { shortcutToHumanString, useStorybookApi } from 'storybook/manager-api';
import { styled } from 'storybook/theming';
import { getGroupStatus, getHighestStatus } from '../../utils/status';
import { getGroupStatus, getMostCriticalStatusValue } from '../../utils/status';
import { scrollIntoView, searchItem } from '../../utils/tree';
import { useLayout } from '../layout/LayoutProvider';
import { DEFAULT_REF_ID } from './Sidebar';
@ -153,7 +153,7 @@ const Actions = styled.div({
const FocusContainer = styled.div({ outline: 0 });
export const Search = React.memo<{
export type SearchProps = {
children: SearchChildrenFn;
dataset: CombinedDataset;
enableShortcuts?: boolean;
@ -161,7 +161,9 @@ export const Search = React.memo<{
initialQuery?: string;
searchBarContent?: ReactNode;
searchFieldContent?: ReactNode;
}>(function Search({
};
export const Search = React.memo<SearchProps>(function Search({
children,
dataset,
enableShortcuts = true,
@ -177,20 +179,19 @@ export const Search = React.memo<{
const searchShortcut = api ? shortcutToHumanString(api.getShortcutKeys().search) : '/';
const makeFuse = useCallback(() => {
const list = dataset.entries.reduce<SearchItem[]>((acc, [refId, { index, status }]) => {
// @ts-expect-error (non strict)
const groupStatus = getGroupStatus(index || {}, status);
const list = dataset.entries.reduce<SearchItem[]>((acc, [refId, { index, allStatuses }]) => {
const groupStatus = getGroupStatus(index || {}, allStatuses ?? {});
if (index) {
acc.push(
...Object.values(index).map((item) => {
const statusValue =
status && status[item.id]
? getHighestStatus(Object.values(status[item.id] || {}).map((s) => s.status))
: null;
const storyStatuses = allStatuses?.[item.id];
const mostCriticalStatusValue = storyStatuses
? getMostCriticalStatusValue(Object.values(storyStatuses).map((s) => s.value))
: null;
return {
...searchItem(item, dataset.hash[refId]),
status: statusValue || groupStatus[item.id] || null,
status: mostCriticalStatusValue ?? groupStatus[item.id] ?? null,
};
})
);

View File

@ -25,7 +25,7 @@ export default {
};
const combinedDataset = (refs: Record<string, StoriesHash>): CombinedDataset => {
const hash: Refs = Object.entries(refs).reduce(
const hash = Object.entries(refs).reduce(
(acc, [refId, index]) =>
Object.assign(acc, {
[refId]: {
@ -35,6 +35,7 @@ const combinedDataset = (refs: Record<string, StoriesHash>): CombinedDataset =>
url: 'iframe.html',
ready: true,
error: false,
allStatuses: {},
},
}),
{}

View File

@ -1,13 +1,15 @@
import React from 'react';
import type { API_StatusState, Addon_SidebarTopType } from 'storybook/internal/types';
import { type Addon_SidebarTopType } from 'storybook/internal/types';
import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { IndexHash, State } from 'storybook/manager-api';
import type { IndexHash } from 'storybook/manager-api';
import { ManagerContext } from 'storybook/manager-api';
import { expect, fn, userEvent, within } from 'storybook/test';
import { internal_fullStatusStore } from '../../status-store.mock';
import { LayoutProvider } from '../layout/LayoutProvider';
import { standardData as standardHeaderData } from './Heading.stories';
import { IconSymbols } from './IconSymbols';
@ -85,7 +87,7 @@ const meta = {
storyId,
refId: DEFAULT_REF_ID,
refs: {},
status: {},
allStatuses: {},
showCreateStoryButton: true,
isDevelopment: true,
},
@ -100,6 +102,9 @@ const meta = {
),
],
globals: { sb_theme: 'side-by-side' },
beforeEach: () => {
internal_fullStatusStore.unset();
},
} satisfies Meta<typeof Sidebar>;
export default meta;
@ -114,6 +119,7 @@ const refs: Record<string, RefType> = {
type: 'lazy',
filteredIndex: index,
previewInitialized: true,
allStatuses: {},
},
};
@ -229,7 +235,7 @@ export const WithRefEmpty: Story = {
export const StatusesCollapsed: Story = {
args: {
status: Object.entries(index).reduce<State['status']>((acc, [id, item]) => {
allStatuses: Object.entries(index).reduce((acc, [id, item]) => {
if (item.type !== 'story') {
return acc;
}
@ -238,21 +244,32 @@ export const StatusesCollapsed: Story = {
return {
...acc,
[id]: {
addonA: { status: 'warn', title: 'Addon A', description: 'We just wanted you to know' },
addonB: { status: 'error', title: 'Addon B', description: 'This is a big deal!' },
addonA: {
typeId: 'addonA',
storyId: id,
value: 'status-value:warning',
title: 'Addon A',
description: 'We just wanted you to know',
},
addonB: {
typeId: 'addonB',
storyId: id,
value: 'status-value:error',
title: 'Addon B',
description: 'This is a big deal!',
},
},
};
} satisfies StatusesByStoryIdAndTypeId;
}
return acc;
}, {}),
}, {} as StatusesByStoryIdAndTypeId),
},
};
export const StatusesOpen: Story = {
...StatusesCollapsed,
args: {
...StatusesCollapsed.args,
status: Object.entries(index).reduce<State['status']>((acc, [id, item]) => {
allStatuses: Object.entries(index).reduce((acc, [id, item]) => {
if (item.type !== 'story') {
return acc;
}
@ -260,11 +277,23 @@ export const StatusesOpen: Story = {
return {
...acc,
[id]: {
addonA: { status: 'warn', title: 'Addon A', description: 'We just wanted you to know' },
addonB: { status: 'error', title: 'Addon B', description: 'This is a big deal!' },
addonA: {
typeId: 'addonA',
storyId: id,
value: 'status-value:warning',
title: 'Addon A',
description: 'We just wanted you to know',
},
addonB: {
typeId: 'addonB',
storyId: id,
value: 'status-value:error',
title: 'Addon B',
description: 'This is a big deal!',
},
},
};
}, {}),
} satisfies StatusesByStoryIdAndTypeId;
}, {} as StatusesByStoryIdAndTypeId),
},
};
@ -289,29 +318,24 @@ export const Searching: Story = {
};
export const Bottom: Story = {
decorators: [
(storyFn) => (
<ManagerContext.Provider
value={{
...managerContext,
state: {
...managerContext.state,
status: {
[storyId]: {
vitest: { status: 'warn', title: '', description: '' },
vta: { status: 'error', title: '', description: '' },
},
'root-1-child-a2--grandchild-a1-2': {
vitest: { status: 'warn', title: '', description: '' },
},
} satisfies API_StatusState,
},
}}
>
{storyFn()}
</ManagerContext.Provider>
),
],
beforeEach: () => {
internal_fullStatusStore.set([
{
storyId,
typeId: 'vitest',
value: 'status-value:warning',
title: 'Vitest',
description: 'Vitest',
},
{
storyId,
typeId: 'vta',
value: 'status-value:error',
title: 'VTA',
description: 'VTA',
},
]);
},
};
/**

View File

@ -8,6 +8,7 @@ import {
WithTooltip,
} from 'storybook/internal/components';
import type { API_LoadedRefData, Addon_SidebarTopType, StoryIndex } from 'storybook/internal/types';
import type { StatusesByStoryIdAndTypeId } from 'storybook/internal/types';
import { global } from '@storybook/global';
import { PlusIcon } from '@storybook/icons';
@ -87,7 +88,7 @@ const useCombination = (
index: SidebarProps['index'],
indexError: SidebarProps['indexError'],
previewInitialized: SidebarProps['previewInitialized'],
status: SidebarProps['status'],
allStatuses: StatusesByStoryIdAndTypeId,
refs: SidebarProps['refs']
): CombinedDataset => {
const hash = useMemo(
@ -97,14 +98,14 @@ const useCombination = (
filteredIndex: index,
indexError,
previewInitialized,
status,
allStatuses,
title: null,
id: DEFAULT_REF_ID,
url: 'iframe.html',
},
...refs,
}),
[refs, index, indexError, previewInitialized, status]
[refs, index, indexError, previewInitialized, allStatuses]
);
// @ts-expect-error (non strict)
return useMemo(() => ({ hash, entries: Object.entries(hash) }), [hash]);
@ -114,7 +115,7 @@ const isRendererReact = global.STORYBOOK_RENDERER === 'react';
export interface SidebarProps extends API_LoadedRefData {
refs: State['refs'];
status: State['status'];
allStatuses: StatusesByStoryIdAndTypeId;
menu: any[];
extra: Addon_SidebarTopType[];
storyId?: string;
@ -133,7 +134,7 @@ export const Sidebar = React.memo(function Sidebar({
index,
indexJson,
indexError,
status,
allStatuses,
previewInitialized,
menu,
extra,
@ -147,7 +148,7 @@ export const Sidebar = React.memo(function Sidebar({
const [isFileSearchModalOpen, setIsFileSearchModalOpen] = useState(false);
// @ts-expect-error (non strict)
const selected: Selection = useMemo(() => storyId && { storyId, refId }, [storyId, refId]);
const dataset = useCombination(index, indexError, previewInitialized, status, refs);
const dataset = useCombination(index, indexError, previewInitialized, allStatuses, refs);
const isLoading = !index && !indexError;
const lastViewedProps = useLastViewed(selected);
const { isMobile } = useLayout();

View File

@ -66,12 +66,14 @@ const managerContext: any = {
},
};
export default {
const meta = {
component: SidebarBottomBase,
title: 'Sidebar/SidebarBottom',
args: {
isDevelopment: true,
warningCount: 0,
errorCount: 0,
notifications: [],
api: {
on: fn(),
off: fn(),
@ -87,48 +89,36 @@ export default {
layout: 'fullscreen',
},
decorators: [
(storyFn) => (
<div style={{ position: 'relative', width: '100vw', height: '100vh' }}>
<div style={{ height: 300, background: 'orangered' }} />
{storyFn()}
</div>
),
(storyFn) => (
<ManagerContext.Provider value={managerContext}>{storyFn()}</ManagerContext.Provider>
),
],
} as Meta<typeof SidebarBottomBase>;
} satisfies Meta<typeof SidebarBottomBase>;
export const Errors = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Errors: Story = {
args: {
status: {
one: { 'sidebar-bottom-filter': { status: 'error' } },
two: { 'sidebar-bottom-filter': { status: 'error' } },
},
errorCount: 2,
},
};
export const Warnings = {
export const Warnings: Story = {
args: {
status: {
one: { 'sidebar-bottom-filter': { status: 'warn' } },
two: { 'sidebar-bottom-filter': { status: 'warn' } },
},
warningCount: 2,
},
};
export const Both = {
export const Both: Story = {
args: {
status: {
one: { 'sidebar-bottom-filter': { status: 'warn' } },
two: { 'sidebar-bottom-filter': { status: 'warn' } },
three: { 'sidebar-bottom-filter': { status: 'error' } },
four: { 'sidebar-bottom-filter': { status: 'error' } },
},
errorCount: 2,
warningCount: 2,
},
};
export const DynamicHeight: StoryObj = {
export const DynamicHeight: Story = {
decorators: [
(storyFn) => (
<ManagerContext.Provider

View File

@ -8,6 +8,7 @@ import {
} from 'storybook/internal/core-events';
import { type API_FilterFunction } from 'storybook/internal/types';
import { experimental_useStatusStore } from '#manager-status-store';
import { type API, type State, useStorybookApi, useStorybookState } from 'storybook/manager-api';
import { styled } from 'storybook/theming';
@ -20,12 +21,14 @@ const SIDEBAR_BOTTOM_SPACER_ID = 'sidebar-bottom-spacer';
const SIDEBAR_BOTTOM_WRAPPER_ID = 'sidebar-bottom-wrapper';
const filterNone: API_FilterFunction = () => true;
const filterWarn: API_FilterFunction = ({ status = {} }) =>
Object.values(status).some((value) => value?.status === 'warn');
const filterError: API_FilterFunction = ({ status = {} }) =>
Object.values(status).some((value) => value?.status === 'error');
const filterBoth: API_FilterFunction = ({ status = {} }) =>
Object.values(status).some((value) => value?.status === 'warn' || value?.status === 'error');
const filterWarn: API_FilterFunction = ({ statuses = {} }) =>
Object.values(statuses).some(({ value }) => value === 'status-value:warning');
const filterError: API_FilterFunction = ({ statuses = {} }) =>
Object.values(statuses).some(({ value }) => value === 'status-value:error');
const filterBoth: API_FilterFunction = ({ statuses = {} }) =>
Object.values(statuses).some(({ value }) =>
['status-value:warning', 'status-value:error'].includes(value as any)
);
const getFilter = (warningsActive = false, errorsActive = false) => {
if (warningsActive && errorsActive) {
@ -74,14 +77,16 @@ const Content = styled.div(({ theme }) => ({
interface SidebarBottomProps {
api: API;
notifications: State['notifications'];
status: State['status'];
errorCount: number;
warningCount: number;
isDevelopment?: boolean;
}
export const SidebarBottomBase = ({
api,
notifications = [],
status = {},
errorCount,
warningCount,
isDevelopment,
}: SidebarBottomProps) => {
const spacerRef = useRef<HTMLDivElement | null>(null);
@ -90,15 +95,6 @@ export const SidebarBottomBase = ({
const [errorsActive, setErrorsActive] = useState(false);
const { testProviders } = useStorybookState();
const warnings = Object.values(status).filter((statusByAddonId) =>
Object.values(statusByAddonId).some((value) => value?.status === 'warn')
);
const errors = Object.values(status).filter((statusByAddonId) =>
Object.values(statusByAddonId).some((value) => value?.status === 'error')
);
const hasWarnings = warnings.length > 0;
const hasErrors = errors.length > 0;
useEffect(() => {
if (spacerRef.current && wrapperRef.current) {
const resizeObserver = new ResizeObserver(() => {
@ -112,9 +108,9 @@ export const SidebarBottomBase = ({
}, []);
useEffect(() => {
const filter = getFilter(hasWarnings && warningsActive, hasErrors && errorsActive);
const filter = getFilter(warningCount > 0 && warningsActive, errorCount > 0 && errorsActive);
api.experimental_setFilter('sidebar-bottom-filter', filter);
}, [api, hasWarnings, hasErrors, warningsActive, errorsActive]);
}, [api, warningCount, errorCount, warningsActive, errorsActive]);
// Register listeners before the first render
useLayoutEffect(() => {
@ -149,7 +145,7 @@ export const SidebarBottomBase = ({
}, [api, testProviders]);
const testProvidersArray = Object.values(testProviders || {});
if (!hasWarnings && !hasErrors && !testProvidersArray.length && !notifications.length) {
if (!warningCount && !errorCount && !testProvidersArray.length && !notifications.length) {
return null;
}
@ -166,10 +162,10 @@ export const SidebarBottomBase = ({
clearStatuses: () => {
// TODO
},
errorCount: errors.length,
errorCount,
errorsActive,
setErrorsActive,
warningCount: warnings.length,
warningCount,
warningsActive,
setWarningsActive,
}}
@ -182,13 +178,31 @@ export const SidebarBottomBase = ({
export const SidebarBottom = ({ isDevelopment }: { isDevelopment?: boolean }) => {
const api = useStorybookApi();
const { notifications, status } = useStorybookState();
const { notifications } = useStorybookState();
const { errorCount, warningCount } = experimental_useStatusStore((statuses) => {
return Object.values(statuses).reduce(
(counts, storyStatuses) => {
Object.values(storyStatuses).forEach((status) => {
if (status.value === 'status-value:error') {
counts.errorCount += 1;
}
if (status.value === 'status-value:warning') {
counts.warningCount += 1;
}
});
return counts;
},
{ errorCount: 0, warningCount: 0 }
);
});
return (
<SidebarBottomBase
api={api}
notifications={notifications}
status={status}
errorCount={errorCount}
warningCount={warningCount}
isDevelopment={isDevelopment}
/>
);

View File

@ -1,11 +1,11 @@
import { IconButton } from 'storybook/internal/components';
import type { API_StatusValue } from 'storybook/internal/types';
import type { StatusValue } from 'storybook/internal/types';
import type { Theme } from '@emotion/react';
import { darken, lighten, transparentize } from 'polished';
import { styled } from 'storybook/theming';
const withStatusColor = ({ theme, status }: { theme: Theme; status: API_StatusValue }) => {
const withStatusColor = ({ theme, status }: { theme: Theme; status: StatusValue }) => {
const defaultColor =
theme.base === 'light'
? transparentize(0.3, theme.color.defaultText)
@ -13,23 +13,23 @@ const withStatusColor = ({ theme, status }: { theme: Theme; status: API_StatusVa
return {
color: {
pending: defaultColor,
success: theme.color.positive,
error: theme.color.negative,
warn: theme.color.warning,
unknown: defaultColor,
'status-value:pending': defaultColor,
'status-value:success': theme.color.positive,
'status-value:error': theme.color.negative,
'status-value:warning': theme.color.warning,
'status-value:unknown': defaultColor,
}[status],
};
};
export const StatusLabel = styled.div<{ status: API_StatusValue }>(withStatusColor, {
export const StatusLabel = styled.div<{ status: StatusValue }>(withStatusColor, {
margin: 3,
});
export const StatusButton = styled(IconButton)<{
height?: number;
width?: number;
status: API_StatusValue;
status: StatusValue;
selectedItem?: boolean;
}>(
withStatusColor,

View File

@ -1,9 +1,9 @@
import { createContext, useContext } from 'react';
import type {
API_StatusObject,
API_StatusState,
API_StatusValue,
Status,
StatusValue,
StatusesByStoryIdAndTypeId,
StoryId,
} from 'storybook/internal/types';
@ -14,31 +14,45 @@ import { getDescendantIds } from '../../utils/tree';
export const StatusContext = createContext<{
data?: StoriesHash;
status?: API_StatusState;
groupStatus?: Record<StoryId, API_StatusValue>;
allStatuses?: StatusesByStoryIdAndTypeId;
groupStatus?: Record<StoryId, StatusValue>;
}>({});
export const useStatusSummary = (item: Item) => {
const { data, status, groupStatus } = useContext(StatusContext);
const { data, allStatuses, groupStatus } = useContext(StatusContext);
const summary: {
counts: Record<API_StatusValue, number>;
statuses: Record<API_StatusValue, Record<StoryId, API_StatusObject[]>>;
counts: Record<StatusValue, number>;
statusesByValue: Record<StatusValue, Record<StoryId, Status[]>>;
} = {
counts: { pending: 0, success: 0, error: 0, warn: 0, unknown: 0 },
statuses: { pending: {}, success: {}, error: {}, warn: {}, unknown: {} },
counts: {
'status-value:pending': 0,
'status-value:success': 0,
'status-value:error': 0,
'status-value:warning': 0,
'status-value:unknown': 0,
},
statusesByValue: {
'status-value:pending': {},
'status-value:success': {},
'status-value:error': {},
'status-value:warning': {},
'status-value:unknown': {},
},
};
if (
data &&
status &&
allStatuses &&
groupStatus &&
['pending', 'warn', 'error'].includes(groupStatus[item.id])
['status-value:pending', 'status-value:warning', 'status-value:error'].includes(
groupStatus[item.id]
)
) {
for (const storyId of getDescendantIds(data, item.id, false)) {
for (const value of Object.values(status[storyId] || {})) {
summary.counts[value.status]++;
summary.statuses[value.status][storyId] = summary.statuses[value.status][storyId] || [];
summary.statuses[value.status][storyId].push(value);
for (const status of Object.values(allStatuses[storyId] ?? {})) {
summary.counts[status.value]++;
summary.statusesByValue[status.value][storyId] ??= [];
summary.statusesByValue[status.value][storyId].push(status);
}
}
}

View File

@ -3,7 +3,13 @@ import React, { useCallback, useMemo, useRef } from 'react';
import { Button, IconButton, ListItem } from 'storybook/internal/components';
import { PRELOAD_ENTRIES } from 'storybook/internal/core-events';
import { type API_HashEntry, type API_StatusValue, type StoryId } from 'storybook/internal/types';
import type { StatusValue } from 'storybook/internal/types';
import {
type API_HashEntry,
type StatusByTypeId,
type StatusesByStoryIdAndTypeId,
type StoryId,
} from 'storybook/internal/types';
import {
CollapseIcon as CollapseIconSvg,
@ -15,12 +21,14 @@ import {
} from '@storybook/icons';
import { darken, lighten } from 'polished';
import { useStorybookApi } from 'storybook/manager-api';
import {
internal_fullStatusStore as fullStatusStore,
useStorybookApi,
} from 'storybook/manager-api';
import type {
API,
ComponentEntry,
GroupEntry,
State,
StoriesHash,
StoryEntry,
} from 'storybook/manager-api';
@ -28,7 +36,7 @@ import { styled, useTheme } from 'storybook/theming';
import type { Link } from '../../../components/components/tooltip/TooltipLinkList';
import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants';
import { getGroupStatus, getHighestStatus, statusMapping } from '../../utils/status';
import { getGroupStatus, getMostCriticalStatusValue, statusMapping } from '../../utils/status';
import {
createId,
getAncestorIds,
@ -155,15 +163,14 @@ interface NodeProps {
docsMode: boolean;
isOrphan: boolean;
isDisplayed: boolean;
color: string | undefined;
isSelected: boolean;
isFullyExpanded?: boolean;
isExpanded: boolean;
setExpanded: (action: ExpandAction) => void;
setFullyExpanded?: () => void;
onSelectStoryId: (itemId: string) => void;
status: State['status'][keyof State['status']];
groupStatus: Record<StoryId, API_StatusValue>;
statuses: StatusByTypeId;
groupStatus: Record<StoryId, StatusValue>;
api: API;
collapsedData: Record<string, API_HashEntry>;
}
@ -188,23 +195,29 @@ const PendingStatusIcon: FC<ComponentProps<typeof SyncIcon>> = (props) => {
return <SyncIcon {...props} size={12} color={theme.color.defaultText} />;
};
const StatusIconMap = {
success: <SuccessStatusIcon />,
error: <ErrorStatusIcon />,
warn: <WarnStatusIcon />,
pending: <PendingStatusIcon />,
unknown: null,
const StatusIconMap: Record<StatusValue, React.ReactNode | null> = {
'status-value:success': <SuccessStatusIcon />,
'status-value:error': <ErrorStatusIcon />,
'status-value:warning': <WarnStatusIcon />,
'status-value:pending': <PendingStatusIcon />,
'status-value:unknown': null,
};
export const ContextMenu = {
ListItem,
};
const statusOrder: API_StatusValue[] = ['success', 'error', 'warn', 'pending', 'unknown'];
const statusOrder: StatusValue[] = [
'status-value:success',
'status-value:error',
'status-value:warning',
'status-value:pending',
'status-value:unknown',
];
const Node = React.memo<NodeProps>(function Node({
item,
status,
statuses,
groupStatus,
refId,
docsMode,
@ -219,7 +232,7 @@ const Node = React.memo<NodeProps>(function Node({
api,
}) {
const { isDesktop, isMobile, setMobileMenuOpen } = useLayout();
const { counts, statuses } = useStatusSummary(item);
const { counts, statusesByValue } = useStatusSummary(item);
if (!isDisplayed) {
return null;
@ -227,45 +240,49 @@ const Node = React.memo<NodeProps>(function Node({
const statusLinks = useMemo<Link[]>(() => {
if (item.type === 'story' || item.type === 'docs') {
return Object.entries(status || {})
.filter(([, value]) => value.sidebarContextMenu !== false)
.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],
return Object.entries(statuses)
.filter(([, status]) => status.sidebarContextMenu !== false)
.sort((a, b) => statusOrder.indexOf(a[1].value) - statusOrder.indexOf(b[1].value))
.map(([typeId, status]) => ({
id: typeId,
title: status.title,
description: status.description,
'aria-label': `Test status for ${status.title}: ${status.value}`,
icon: StatusIconMap[status.value],
onClick: () => {
onSelectStoryId(item.id);
value.onClick?.();
fullStatusStore.selectStatuses([status]);
},
}));
}
if (item.type === 'component' || item.type === 'group') {
const links: Link[] = [];
if (counts.error) {
const errorCount = counts['status-value:error'];
const warningCount = counts['status-value:warning'];
if (errorCount) {
links.push({
id: 'errors',
icon: StatusIconMap.error,
title: `${counts.error} ${counts.error === 1 ? 'story' : 'stories'} with errors`,
icon: StatusIconMap['status-value:error'],
title: `${errorCount} ${errorCount === 1 ? 'story' : 'stories'} with errors`,
onClick: () => {
const [firstStoryId, [firstError]] = Object.entries(statuses.error)[0];
const [firstStoryId] = Object.entries(statusesByValue['status-value:error'])[0];
onSelectStoryId(firstStoryId);
firstError.onClick?.();
const errorStatuses = Object.values(statusesByValue['status-value:error']).flat();
fullStatusStore.selectStatuses(errorStatuses);
},
});
}
if (counts.warn) {
if (warningCount) {
links.push({
id: 'warnings',
icon: StatusIconMap.warn,
title: `${counts.warn} ${counts.warn === 1 ? 'story' : 'stories'} with warnings`,
icon: StatusIconMap['status-value:warning'],
title: `${warningCount} ${warningCount === 1 ? 'story' : 'stories'} with warnings`,
onClick: () => {
const [firstStoryId, [firstWarning]] = Object.entries(statuses.warn)[0];
const [firstStoryId] = Object.entries(statusesByValue['status-value:warning'])[0];
onSelectStoryId(firstStoryId);
firstWarning.onClick?.();
const warningStatuses = Object.values(statusesByValue['status-value:warning']).flat();
fullStatusStore.selectStatuses(warningStatuses);
},
});
}
@ -273,16 +290,7 @@ const Node = React.memo<NodeProps>(function Node({
}
return [];
}, [
counts.error,
counts.warn,
item.id,
item.type,
onSelectStoryId,
status,
statuses.error,
statuses.warn,
]);
}, [counts, item.id, item.type, onSelectStoryId, statuses, statusesByValue]);
const id = createId(item.id, refId);
const contextMenu =
@ -293,7 +301,9 @@ const Node = React.memo<NodeProps>(function Node({
if (item.type === 'story' || item.type === 'docs') {
const LeafNode = item.type === 'docs' ? DocumentNode : StoryNode;
const statusValue = getHighestStatus(Object.values(status || {}).map((s) => s.status));
const statusValue = getMostCriticalStatusValue(
Object.values(statuses || {}).map((s) => s.value)
);
const [icon, textColor] = statusMapping[statusValue];
return (
@ -335,7 +345,7 @@ const Node = React.memo<NodeProps>(function Node({
{contextMenu.node}
{icon ? (
<StatusButton
aria-label={`Test status: ${statusValue}`}
aria-label={`Test status: ${statusValue.replace('status-value:', '')}`}
role="status"
type="button"
status={statusValue}
@ -472,7 +482,7 @@ const Root = React.memo<NodeProps & { expandableDescendants: string[] }>(functio
export const Tree = React.memo<{
isBrowsing: boolean;
isMain: boolean;
status?: State['status'];
allStatuses?: StatusesByStoryIdAndTypeId;
refId: string;
data: StoriesHash;
docsMode: boolean;
@ -485,7 +495,7 @@ export const Tree = React.memo<{
isMain,
refId,
data,
status,
allStatuses,
docsMode,
highlightedRef,
setHighlightedItemId,
@ -614,8 +624,10 @@ export const Tree = React.memo<{
onSelectStoryId,
});
// @ts-expect-error (non strict)
const groupStatus = useMemo(() => getGroupStatus(collapsedData, status), [collapsedData, status]);
const groupStatus = useMemo(
() => getGroupStatus(collapsedData, allStatuses ?? {}),
[collapsedData, allStatuses]
);
const treeItems = useMemo(() => {
return collapsedItems.map((itemId) => {
@ -657,8 +669,7 @@ export const Tree = React.memo<{
collapsedData={collapsedData}
key={id}
item={item}
// @ts-expect-error (non strict)
status={status?.[itemId]}
statuses={allStatuses?.[itemId] ?? {}}
groupStatus={groupStatus}
refId={refId}
docsMode={docsMode}
@ -685,10 +696,10 @@ export const Tree = React.memo<{
refId,
selectedStoryId,
setExpanded,
status,
allStatuses,
]);
return (
<StatusContext.Provider value={{ data, status, groupStatus }}>
<StatusContext.Provider value={{ data, allStatuses, groupStatus }}>
<Container ref={containerRef} hasOrphans={isMain && orphanIds.length > 0}>
<IconSymbols />
{treeItems}

View File

@ -26,7 +26,7 @@ const factory = (props: Partial<SidebarProps>): RenderResult => {
index={{}}
previewInitialized
refs={{}}
status={{}}
allStatuses={{}}
extra={[]}
{...props}
/>

View File

@ -1,10 +1,10 @@
import type { API_StatusState, API_StatusValue } from 'storybook/internal/types';
import type { StatusValue, StatusesByStoryIdAndTypeId } from 'storybook/internal/types';
import type { ControllerStateAndHelpers } from 'downshift';
import type { State, StoriesHash } from 'storybook/manager-api';
export type Refs = State['refs'];
export type RefType = Refs[keyof Refs] & { status?: API_StatusState };
export type RefType = Refs[keyof Refs] & { allStatuses?: StatusesByStoryIdAndTypeId };
export type Item = StoriesHash[keyof StoriesHash];
export type Dataset = Record<string, Item>;
@ -44,7 +44,7 @@ export interface ExpandType {
moreCount: number;
}
export type SearchItem = Item & { refId: string; path: string[]; status?: API_StatusValue };
export type SearchItem = Item & { refId: string; path: string[]; status?: StatusValue };
export type SearchResult = Fuse.FuseResultWithMatches<SearchItem> &
Fuse.FuseResultWithScore<SearchItem>;

View File

@ -3,7 +3,7 @@ import React, { useMemo } from 'react';
import { Addon_TypesEnum } from 'storybook/internal/types';
import type { Combo, StoriesHash } from 'storybook/manager-api';
import { Consumer } from 'storybook/manager-api';
import { Consumer, experimental_useStatusStore } from 'storybook/manager-api';
import type { SidebarProps as SidebarComponentProps } from '../components/sidebar/Sidebar';
import { Sidebar as SidebarComponent } from '../components/sidebar/Sidebar';
@ -28,7 +28,6 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) {
// eslint-disable-next-line @typescript-eslint/naming-convention
internal_index,
filteredIndex: index,
status,
indexError,
previewInitialized,
refs,
@ -57,7 +56,6 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) {
indexJson: internal_index,
index,
indexError,
status,
previewInitialized,
refs,
storyId,
@ -73,7 +71,11 @@ const Sidebar = React.memo(function Sideber({ onMenuClick }: SidebarProps) {
return (
<Consumer filter={mapper}>
{(fromState) => {
return <SidebarComponent {...fromState} onMenuClick={onMenuClick} />;
const allStatuses = experimental_useStatusStore();
return (
<SidebarComponent {...fromState} allStatuses={allStatuses} onMenuClick={onMenuClick} />
);
}}
</Consumer>
);

View File

@ -317,8 +317,11 @@ export default {
'eventToShortcut',
'experimental_MockUniversalStore',
'experimental_UniversalStore',
'experimental_getStatusStore',
'experimental_requestResponse',
'experimental_useStatusStore',
'experimental_useUniversalStore',
'internal_fullStatusStore',
'isMacLike',
'isShortcutTaken',
'keyToSymbol',
@ -663,6 +666,7 @@ export default {
'storybook/internal/manager-errors': [
'Category',
'ProviderDoesNotExtendBaseProviderError',
'StatusTypeIdMismatchError',
'UncaughtManagerError',
],
'storybook/internal/router': [
@ -696,8 +700,11 @@ export default {
'eventToShortcut',
'experimental_MockUniversalStore',
'experimental_UniversalStore',
'experimental_getStatusStore',
'experimental_requestResponse',
'experimental_useStatusStore',
'experimental_useUniversalStore',
'internal_fullStatusStore',
'isMacLike',
'isShortcutTaken',
'keyToSymbol',

View File

@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {
experimental_MockUniversalStore,
experimental_useUniversalStore,
} from 'storybook/manager-api';
import * as testUtils from 'storybook/test';
import {
type StatusStoreEvent,
type StatusesByStoryIdAndTypeId,
createStatusStore,
} from '../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../shared/status-store';
import type { UniversalStore } from '../shared/universal-store';
export const {
fullStatusStore: internal_fullStatusStore,
getStatusStoreByTypeId: experimental_getStatusStore,
useStatusStore: experimental_useStatusStore,
} = createStatusStore({
universalStatusStore: new experimental_MockUniversalStore(
UNIVERSAL_STATUS_STORE_OPTIONS,
testUtils
) as unknown as UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>,
useUniversalStore: experimental_useUniversalStore,
environment: 'manager',
});

View File

@ -0,0 +1,5 @@
export {
internal_fullStatusStore,
experimental_getStatusStore,
experimental_useStatusStore,
} from 'storybook/manager-api';

View File

@ -2,16 +2,32 @@
import { describe, expect, it } from 'vitest';
import { mockDataset } from '../components/sidebar/mockdata';
import { getGroupStatus, getHighestStatus } from './status';
import { getGroupStatus, getMostCriticalStatusValue } from './status';
describe('getHighestStatus', () => {
it('default value', () => {
expect(getHighestStatus([])).toBe('unknown');
expect(getMostCriticalStatusValue([])).toBe('status-value:unknown');
});
it('should return the highest status', () => {
expect(getHighestStatus(['success', 'error', 'warn', 'pending'])).toBe('error');
expect(getHighestStatus(['error', 'error', 'warn', 'pending'])).toBe('error');
expect(getHighestStatus(['warn', 'pending'])).toBe('warn');
expect(
getMostCriticalStatusValue([
'status-value:success',
'status-value:error',
'status-value:warning',
'status-value:pending',
])
).toBe('status-value:error');
expect(
getMostCriticalStatusValue([
'status-value:error',
'status-value:error',
'status-value:warning',
'status-value:pending',
])
).toBe('status-value:error');
expect(getMostCriticalStatusValue(['status-value:warning', 'status-value:pending'])).toBe(
'status-value:warning'
);
});
});
@ -22,14 +38,22 @@ describe('getGroupStatus', () => {
it('should return a color', () => {
expect(
getGroupStatus(mockDataset.withRoot, {
'group-1--child-b1': { a: { status: 'warn', description: '', title: '' } },
'group-1--child-b1': {
a: {
storyId: 'group-1--child-b1',
typeId: 'a',
value: 'status-value:warning',
description: '',
title: '',
},
},
})
).toMatchInlineSnapshot(`
{
"group-1": "warn",
"root-1-child-a1": "unknown",
"root-1-child-a2": "unknown",
"root-3-child-a2": "unknown",
"group-1": "status-value:warning",
"root-1-child-a1": "status-value:unknown",
"root-1-child-a2": "status-value:unknown",
"root-3-child-a2": "status-value:unknown",
}
`);
});
@ -37,16 +61,28 @@ describe('getGroupStatus', () => {
expect(
getGroupStatus(mockDataset.withRoot, {
'group-1--child-b1': {
a: { status: 'warn', description: '', title: '' },
b: { status: 'error', description: '', title: '' },
a: {
storyId: 'group-1--child-b1',
typeId: 'a',
value: 'status-value:warning',
description: '',
title: '',
},
b: {
storyId: 'group-1--child-b1',
typeId: 'b',
value: 'status-value:error',
description: '',
title: '',
},
},
})
).toMatchInlineSnapshot(`
{
"group-1": "error",
"root-1-child-a1": "unknown",
"root-1-child-a2": "unknown",
"root-3-child-a2": "unknown",
"group-1": "status-value:error",
"root-1-child-a1": "status-value:unknown",
"root-1-child-a2": "status-value:unknown",
"root-3-child-a2": "status-value:unknown",
}
`);
});

View File

@ -1,7 +1,8 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { API_HashEntry, API_StatusState, API_StatusValue } from 'storybook/internal/types';
import type { StatusValue } from 'storybook/internal/types';
import { type API_HashEntry, type StatusesByStoryIdAndTypeId } from 'storybook/internal/types';
import { CircleIcon } from '@storybook/icons';
@ -24,23 +25,29 @@ const LoadingIcons = styled(SmallIcons)(({ theme: { animation, color, base } })
color: base === 'light' ? color.mediumdark : color.darker,
}));
export const statusPriority: API_StatusValue[] = ['unknown', 'pending', 'success', 'warn', 'error'];
export const statusMapping: Record<API_StatusValue, [ReactElement | null, string | null]> = {
unknown: [null, null],
pending: [<LoadingIcons key="icon" />, 'currentColor'],
success: [
export const statusPriority: StatusValue[] = [
'status-value:unknown',
'status-value:pending',
'status-value:success',
'status-value:warning',
'status-value:error',
];
export const statusMapping: Record<StatusValue, [ReactElement | null, string | null]> = {
['status-value:unknown']: [null, null],
['status-value:pending']: [<LoadingIcons key="icon" />, 'currentColor'],
['status-value:success']: [
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
<UseSymbol type="success" />
</svg>,
'currentColor',
],
warn: [
['status-value:warning']: [
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
<UseSymbol type="warning" />
</svg>,
'#A15C20',
],
error: [
['status-value:error']: [
<svg key="icon" viewBox="0 0 14 14" width="14" height="14">
<UseSymbol type="error" />
</svg>,
@ -48,10 +55,10 @@ export const statusMapping: Record<API_StatusValue, [ReactElement | null, string
],
};
export const getHighestStatus = (statuses: API_StatusValue[]): API_StatusValue => {
export const getMostCriticalStatusValue = (statusValues: StatusValue[]): StatusValue => {
return statusPriority.reduce(
(acc, status) => (statuses.includes(status) ? status : acc),
'unknown'
(acc, value) => (statusValues.includes(value) ? value : acc),
'status-value:unknown'
);
};
@ -59,18 +66,18 @@ export function getGroupStatus(
collapsedData: {
[x: string]: Partial<API_HashEntry>;
},
status: API_StatusState
): Record<string, API_StatusValue> {
return Object.values(collapsedData).reduce<Record<string, API_StatusValue>>((acc, item) => {
allStatuses: StatusesByStoryIdAndTypeId
): Record<string, StatusValue> {
return Object.values(collapsedData).reduce<Record<string, StatusValue>>((acc, item) => {
if (item.type === 'group' || item.type === 'component') {
// @ts-expect-error (non strict)
const leafs = getDescendantIds(collapsedData as any, item.id, false)
.map((id) => collapsedData[id])
.filter((i) => i.type === 'story');
const combinedStatus = getHighestStatus(
const combinedStatus = getMostCriticalStatusValue(
// @ts-expect-error (non strict)
leafs.flatMap((story) => Object.values(status?.[story.id] || {})).map((s) => s.status)
leafs.flatMap((story) => Object.values(allStatuses[story.id] || {})).map((s) => s.value)
);
if (combinedStatus) {

View File

@ -52,7 +52,7 @@ export const getDescendantIds = memoize(1000)((
}, [] as string[]);
});
export function getPath(item: Item, ref: RefType): string[] {
export function getPath(item: Item, ref: Pick<RefType, 'id' | 'title' | 'index'>): string[] {
// @ts-expect-error (non strict)
const parent = item.type !== 'root' && item.parent ? ref.index[item.parent] : null;
@ -62,7 +62,7 @@ export function getPath(item: Item, ref: RefType): string[] {
return ref.id === DEFAULT_REF_ID ? [] : [ref.title || ref.id];
}
export const searchItem = (item: Item, ref: RefType): SearchItem => {
export const searchItem = (item: Item, ref: Parameters<typeof getPath>[1]): SearchItem => {
return { ...item, refId: ref.id, path: getPath(item, ref) };
};

View File

@ -30,9 +30,15 @@ export { addons, mockChannel } from './addons';
/** ADDON ANNOTATIONS TYPE HELPER */
export { definePreview } from './addons';
export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
export { useUniversalStore as experimental_useUniversalStore } from '../shared/universal-store/use-universal-store-preview';
export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock';
// TODO: Universal Stores are disabled in the preview, until we get automatic leader negotiation in place
// export { UniversalStore as experimental_UniversalStore } from '../shared/universal-store';
// export { useUniversalStore as experimental_useUniversalStore } from '../shared/universal-store/use-universal-store-preview';
// export { MockUniversalStore as experimental_MockUniversalStore } from '../shared/universal-store/mock';
// export {
// getStatusStoreByTypeId as experimental_getStatusStore,
// useStatusStore as experimental_useStatusStore,
// fullStatusStore as internal_fullStatusStore,
// } from './stores/status';
/** DOCS API */
export { DocsContext } from './preview-web';

View File

@ -0,0 +1,15 @@
import { createStatusStore } from '../../shared/status-store';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from '../../shared/status-store';
import { UniversalStore } from '../../shared/universal-store';
import { useUniversalStore } from '../../shared/universal-store/use-universal-store-preview';
const statusStore = createStatusStore({
universalStatusStore: UniversalStore.create({
...UNIVERSAL_STATUS_STORE_OPTIONS,
leader: false,
}),
useUniversalStore,
environment: 'preview',
});
export const { fullStatusStore, getStatusStoreByTypeId, useStatusStore } = statusStore;

View File

@ -1,5 +1,7 @@
import { dedent } from 'ts-dedent';
import type { Status } from './shared/status-store';
import type { StatusTypeId } from './shared/status-store';
import { StorybookError } from './storybook-error';
/**
@ -273,6 +275,25 @@ export class NoStoryMountedError extends StorybookError {
}
}
export class StatusTypeIdMismatchError extends StorybookError {
constructor(
public data: {
status: Status;
typeId: StatusTypeId;
}
) {
super({
category: Category.PREVIEW_API,
code: 16,
message: `Status has typeId "${data.status.typeId}" but was added to store with typeId "${data.typeId}". Full status: ${JSON.stringify(
data.status,
null,
2
)}`,
});
}
}
export class NextJsSharpError extends StorybookError {
constructor() {
super({

View File

@ -1,6 +1,8 @@
import picocolors from 'picocolors';
import { dedent } from 'ts-dedent';
import type { Status } from './shared/status-store';
import type { StatusTypeId } from './shared/status-store';
import { StorybookError } from './storybook-error';
/**
@ -424,6 +426,25 @@ export class MainFileEvaluationError extends StorybookError {
}
}
export class StatusTypeIdMismatchError extends StorybookError {
constructor(
public data: {
status: Status;
typeId: StatusTypeId;
}
) {
super({
category: Category.CORE_SERVER,
code: 16,
message: `Status has typeId "${data.status.typeId}" but was added to store with typeId "${data.typeId}". Full status: ${JSON.stringify(
data.status,
null,
2
)}`,
});
}
}
export class GenerateNewProjectOnInitError extends StorybookError {
constructor(
public data: { error: unknown | Error; packageManager: string; projectType: string }

View File

@ -0,0 +1,71 @@
import { describe, expectTypeOf, it } from 'vitest';
import {
type Status,
type StatusesByStoryIdAndTypeId,
UNIVERSAL_STATUS_STORE_OPTIONS,
createStatusStore,
} from '.';
import type { StoryId } from '../../types';
import { MockUniversalStore } from '../universal-store/mock';
import { useUniversalStore } from '../universal-store/use-universal-store-manager';
const { fullStatusStore, getStatusStoreByTypeId, useStatusStore } = createStatusStore({
universalStatusStore: MockUniversalStore.create(UNIVERSAL_STATUS_STORE_OPTIONS),
useUniversalStore,
environment: 'manager',
});
const typedStatusStore = getStatusStoreByTypeId('test');
describe('Status Store', () => {
it('getAll should return typed statuses', () => {
const statuses = fullStatusStore.getAll();
expectTypeOf(statuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
const typedStatuses = typedStatusStore.getAll();
expectTypeOf(typedStatuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
});
it('set should accept typed statuses', () => {
expectTypeOf(fullStatusStore.set).parameter(0).toEqualTypeOf<Status[]>();
expectTypeOf(typedStatusStore.set).parameter(0).toEqualTypeOf<Status[]>();
});
it('unset should accept storyIds or no parameters', () => {
expectTypeOf(fullStatusStore.unset).parameter(0).toEqualTypeOf<StoryId[] | undefined>();
expectTypeOf(typedStatusStore.unset).parameter(0).toEqualTypeOf<StoryId[] | undefined>();
});
it('onStatusChange should accept a callback with typed parameters', () => {
fullStatusStore.onAllStatusChange((statuses, previousStatuses) => {
expectTypeOf(statuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
expectTypeOf(previousStatuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
});
typedStatusStore.onAllStatusChange((statuses, previousStatuses) => {
expectTypeOf(statuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
expectTypeOf(previousStatuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
});
});
it('onSelect should accept a callback with typed parameters', () => {
fullStatusStore.onSelect((statuses) => {
expectTypeOf(statuses).toEqualTypeOf<Status[]>();
});
typedStatusStore.onSelect((statuses) => {
expectTypeOf(statuses).toEqualTypeOf<Status[]>();
});
});
it('useStatusStore should return typed statuses', () => {
// Without selector
const allStatuses = useStatusStore();
expectTypeOf(allStatuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
// With selector
const selectedState = useStatusStore((statuses) => {
expectTypeOf(statuses).toEqualTypeOf<StatusesByStoryIdAndTypeId>();
return 1;
});
expectTypeOf(selectedState).toEqualTypeOf<number>();
});
});

View File

@ -0,0 +1,722 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MockUniversalStore } from '../universal-store/mock';
import { useUniversalStore } from '../universal-store/use-universal-store-manager';
import {
type Status,
type StatusStoreEvent,
StatusValue,
type StatusesByStoryIdAndTypeId,
createStatusStore,
} from './index';
import { UNIVERSAL_STATUS_STORE_OPTIONS } from './index';
const story1Type1Status: Status = {
storyId: 'story-1',
typeId: 'type-1',
value: 'status-value:success',
title: 'Success',
description: 'Success description',
};
const story1Type2Status: Status = {
storyId: 'story-1',
typeId: 'type-2',
value: 'status-value:error',
title: 'Error',
description: 'Error description',
};
const story2Type1Status: Status = {
storyId: 'story-2',
typeId: 'type-1',
value: 'status-value:pending',
title: 'Pending',
description: 'Pending description',
};
const story2Type2Status: Status = {
storyId: 'story-2',
typeId: 'type-2',
value: 'status-value:unknown',
title: 'Unknown',
description: 'Unknown description',
};
const initialState: StatusesByStoryIdAndTypeId = {
'story-1': {
'type-1': story1Type1Status,
'type-2': story1Type2Status,
},
'story-2': {
'type-1': story2Type1Status,
'type-2': story2Type2Status,
},
};
describe('statusStore', () => {
afterEach(() => {
vi.clearAllMocks();
});
describe('fullStatusStore', () => {
describe('get', () => {
it('should return all statuses', () => {
// Arrange - set up the store with initial state
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Act - get all statuses
const result = fullStatusStore.getAll();
// Assert - all statuses should be returned
expect(result).toEqual(initialState);
});
});
describe('set', () => {
it('should add new statuses', () => {
// Arrange - create a status store
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
environment: 'manager',
});
// Act - set the status
fullStatusStore.set([story1Type1Status]);
const result = fullStatusStore.getAll();
// Assert - the status should be added
expect(result).toEqual({
'story-1': {
'type-1': story1Type1Status,
},
});
});
it('should update existing statuses with the same storyId and typeId', () => {
// Arrange - create a status store
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
environment: 'manager',
});
// Create an updated version of the status
const updatedStatus: Status = {
...story1Type1Status,
value: 'status-value:error',
title: 'Updated Title',
description: 'Updated Description',
};
// Act - set the initial status, then update it
fullStatusStore.set([story1Type1Status]);
fullStatusStore.set([updatedStatus]);
const result = fullStatusStore.getAll();
// Assert - the status should be updated
expect(result).toEqual({
'story-1': {
'type-1': updatedStatus,
},
});
});
it('should update existing statuses and add new ones in a single operation', () => {
// Arrange - create a status store with initial statuses
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore<
StatusesByStoryIdAndTypeId,
StatusStoreEvent
>({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState: {
'story-1': {
'type-1': story1Type1Status,
},
'story-2': {
'type-2': story2Type2Status,
},
},
}),
environment: 'manager',
});
// Create an updated version of an existing status
const updatedStatus: Status = {
...story1Type1Status,
value: 'status-value:error',
title: 'Updated Title',
};
// Act - update one status and add a new one
fullStatusStore.set([updatedStatus, story2Type1Status]);
const result = fullStatusStore.getAll();
// Assert - the existing status should be updated and the new one added
expect(result).toEqual({
'story-1': {
'type-1': updatedStatus,
},
'story-2': {
'type-1': story2Type1Status,
'type-2': story2Type2Status,
},
});
});
});
describe('onStatusChange', () => {
it('should call listener when status is added', () => {
// Arrange - set up the store and a mock subscriber
const mockSubscriber = vi.fn();
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
environment: 'manager',
});
const unsubscribe = fullStatusStore.onAllStatusChange(mockSubscriber);
// Act - set statuses to trigger the subscriber
fullStatusStore.set([story1Type1Status]);
// Assert - the subscriber should be called with the statuses and previous statuses
expect(mockSubscriber).toHaveBeenCalledWith(
{ 'story-1': { 'type-1': story1Type1Status } },
{}
);
unsubscribe();
});
it('should call listener when status is updated', () => {
// Arrange - set up the store and a mock subscriber
const mockSubscriber = vi.fn();
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore<
StatusesByStoryIdAndTypeId,
StatusStoreEvent
>({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState: {
'story-1': {
'type-1': story1Type1Status,
},
},
}),
environment: 'manager',
});
const unsubscribe = fullStatusStore.onAllStatusChange(mockSubscriber);
// Act - update the existing status
const updatedStatus = {
...story1Type1Status,
value: 'status-value:error',
title: 'Updated Title',
} as const;
fullStatusStore.set([updatedStatus]);
// Assert - the subscriber should be called with the updated status and previous status
expect(mockSubscriber).toHaveBeenCalledWith(
{ 'story-1': { 'type-1': updatedStatus } },
{ 'story-1': { 'type-1': story1Type1Status } }
);
unsubscribe();
});
it('should call listener when status is unset', () => {
// Arrange - set up the store and a mock subscriber
const mockSubscriber = vi.fn();
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore<
StatusesByStoryIdAndTypeId,
StatusStoreEvent
>({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState: {
'story-1': {
'type-1': story1Type1Status,
},
},
}),
environment: 'manager',
});
const unsubscribe = fullStatusStore.onAllStatusChange(mockSubscriber);
// Act - unset the status
fullStatusStore.unset([story1Type1Status.storyId]);
// Assert - the subscriber should be called with the unset status and previous statuses
expect(mockSubscriber).toHaveBeenCalledWith(
{},
{ 'story-1': { 'type-1': story1Type1Status } }
);
unsubscribe();
});
});
describe('onSelect', () => {
it('should call listener when statuses are selected', () => {
// Arrange - set up the store with initial state and a mock listener
const mockListener = vi.fn();
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
const unsubscribe = fullStatusStore.onSelect(mockListener);
// Act - select statuses
const selectedStatuses = [story1Type1Status, story2Type2Status];
fullStatusStore.selectStatuses(selectedStatuses);
// Assert - the listener should be called with the selected statuses
expect(mockListener).toHaveBeenCalledWith(selectedStatuses);
// Clean up
unsubscribe();
});
});
describe('unset', () => {
it('should unset all statuses when typeIds and storyIds are not provided', () => {
// Arrange - set up the store with initial state
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Act - unset without a predicate
fullStatusStore.unset();
const result = fullStatusStore.getAll();
// Assert - all statuses should be removed
expect(result).toEqual({});
});
it('should unset statuses by storyIds', () => {
// Arrange - set up the store with initial state
const { fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Act - unset with a storyIds filter
fullStatusStore.unset(['story-1']);
const result = fullStatusStore.getAll();
// Assert - only statuses with matching storyId should be removed
expect(result).toEqual({
'story-2': {
'type-1': story2Type1Status,
'type-2': story2Type2Status,
},
});
});
});
});
describe('getStatusStoreByTypeId', () => {
describe('set', () => {
it('should add new statuses of the specified typeId', () => {
// Arrange - create a status store
const { getStatusStoreByTypeId, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
environment: 'manager',
});
// Act - get a status store for type-1 and set a status
const type1StatusStore = getStatusStoreByTypeId('type-1');
type1StatusStore.set([story1Type1Status]);
// Assert - the status should be added to the full store
const fullResult = fullStatusStore.getAll();
expect(fullResult).toEqual({
'story-1': {
'type-1': story1Type1Status,
},
});
// Assert - the status should be accessible from the type-specific store
const typeResult = type1StatusStore.getAll();
expect(typeResult).toEqual({
'story-1': {
'type-1': story1Type1Status,
},
});
});
it('should update existing statuses with the same storyId and typeId', () => {
// Arrange - create a status store
const { getStatusStoreByTypeId } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
environment: 'manager',
});
// Create an updated version of the status
const updatedStatus: Status = {
...story1Type1Status,
value: 'status-value:error',
title: 'Updated Title',
description: 'Updated Description',
};
// Act - get a status store for type-1, set the initial status, then update it
const type1StatusStore = getStatusStoreByTypeId('type-1');
type1StatusStore.set([story1Type1Status]);
type1StatusStore.set([updatedStatus]);
const result = type1StatusStore.getAll();
// Assert - the status should be updated
expect(result).toEqual({
'story-1': {
'type-1': updatedStatus,
},
});
});
it('should update existing statuses and add new ones in a single operation', () => {
// Arrange - create a status store
const { getStatusStoreByTypeId, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Get the type-specific store
const type1StatusStore = getStatusStoreByTypeId('type-1');
// Create an updated version of the existing status
const updatedStatus: Status = {
...story1Type1Status,
value: 'status-value:error',
title: 'Updated Title',
description: 'Updated Description',
};
// Act - update existing status and add a new one in the same operation
type1StatusStore.set([updatedStatus, story2Type1Status]);
// Assert - all statuses should be in the full store
const result = type1StatusStore.getAll();
expect(result).toEqual({
'story-1': {
'type-1': updatedStatus,
'type-2': story1Type2Status,
},
'story-2': {
'type-1': story2Type1Status,
'type-2': story2Type2Status,
},
});
});
it('should error when setting statuses with wrong typeId', () => {
// Arrange - create a status store
const { getStatusStoreByTypeId, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
environment: 'manager',
});
// Act & Assert - get a status store for type-1 and try to set a status with type-2, expect it to throw
const type1StatusStore = getStatusStoreByTypeId('type-1');
expect(() => type1StatusStore.set([story1Type2Status])).toThrowErrorMatchingInlineSnapshot(`
[SB_MANAGER_API_0001 (StatusTypeIdMismatchError): Status has typeId "type-2" but was added to store with typeId "type-1". Full status: {
"storyId": "story-1",
"typeId": "type-2",
"value": "status-value:error",
"title": "Error",
"description": "Error description"
}]
`);
});
});
describe('unset', () => {
it('should unset all statuses of the specified typeId when no storyIds are provided', () => {
// Arrange - set up the store with initial state
const { getStatusStoreByTypeId, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Act - get a status store for type-1 and unset without a predicate
const type1StatusStore = getStatusStoreByTypeId('type-1');
type1StatusStore.unset();
// Assert - statuses with other typeIds should remain
const fullResult = fullStatusStore.getAll();
expect(fullResult).toEqual({
'story-1': {
'type-2': story1Type2Status,
},
'story-2': {
'type-2': story2Type2Status,
},
});
});
it('should unset statuses by storyIds', () => {
// Arrange - set up the store with initial state
const { getStatusStoreByTypeId } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Act - get a status store for type-1 and unset with a storyIds filter
const type1StatusStore = getStatusStoreByTypeId('type-1');
type1StatusStore.unset(['story-1']);
const result = type1StatusStore.getAll();
// Assert - only statuses with typeId 'type-1' and storyId 'story-1' should be removed
expect(result).toEqual({
'story-1': { 'type-2': story1Type2Status },
'story-2': { 'type-1': story2Type1Status, 'type-2': story2Type2Status },
});
});
});
describe('onSelect', () => {
it('should call listener when statuses of the specified typeId are selected', () => {
// Arrange - set up the store with initial state and a mock listener
const mockListener = vi.fn();
const { getStatusStoreByTypeId, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Get a type-specific store and subscribe to selections
const type1StatusStore = getStatusStoreByTypeId('type-1');
const unsubscribe = type1StatusStore.onSelect(mockListener);
// Act - select statuses including one with the matching typeId
const selectedStatuses = [story1Type1Status, story2Type2Status];
fullStatusStore.selectStatuses(selectedStatuses);
// Assert - the listener should be called with the selected statuses
expect(mockListener).toHaveBeenCalledWith(selectedStatuses);
// Clean up
unsubscribe();
});
it('should not call listener when selected statuses do not include the specified typeId', () => {
// Arrange - set up the store with initial state and a mock listener
const mockListener = vi.fn();
const { getStatusStoreByTypeId, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
environment: 'manager',
});
// Get a type-specific store and subscribe to selections
const type1StatusStore = getStatusStoreByTypeId('type-1');
const unsubscribe = type1StatusStore.onSelect(mockListener);
// Act - select statuses without any matching typeId
const selectedStatuses = [story1Type2Status, story2Type2Status];
fullStatusStore.selectStatuses(selectedStatuses);
// Assert - the listener should not be called
expect(mockListener).not.toHaveBeenCalled();
// Clean up
unsubscribe();
});
});
});
describe('useStatusStore', () => {
it('should be returned when useUniversalStore is provided', () => {
// Act - create a status store with the mock
const { useStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore(UNIVERSAL_STATUS_STORE_OPTIONS),
useUniversalStore,
environment: 'manager',
});
// Assert - useStatusStore should be defined
expect(useStatusStore).toBeDefined();
});
it('should return all statuses when no selector is provided', () => {
// Arrange - create a status store
const { useStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
useUniversalStore,
environment: 'manager',
});
// Act - get a status store for type-1 and render the hook
const { result } = renderHook(() => useStatusStore());
// Assert - initial statuses should be returned
expect(result.current).toEqual(initialState);
});
it('should filter statuses based on selector', () => {
// Arrange - create a status store
const { useStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
useUniversalStore,
environment: 'manager',
});
// Create a selector that only returns SUCCESS statuses
const successSelector = (statuses: StatusesByStoryIdAndTypeId) => {
const result: StatusesByStoryIdAndTypeId = {};
Object.entries(statuses).forEach(([storyId, typeStatuses]) => {
Object.entries(typeStatuses).forEach(([typeId, status]) => {
if (status.value === 'status-value:success') {
if (!result[storyId]) {
result[storyId] = {};
}
result[storyId][typeId] = status;
}
});
});
return result;
};
// Act - render the hook with the selector
const { result } = renderHook(() => useStatusStore(successSelector));
// Assert - only SUCCESS statuses should be returned
expect(result.current).toEqual({
'story-1': {
'type-1': story1Type1Status,
},
});
});
it('should re-render when statuses matching the selector change', async () => {
// Arrange - create a status store
const { useStatusStore, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
useUniversalStore,
environment: 'manager',
});
const renderCounter = vi.fn();
// Create a selector that only returns statuses for story-1
const story1Selector = (statuses: StatusesByStoryIdAndTypeId) => statuses['story-1'] || {};
// Act - render the hook with the selector
const { result } = renderHook(() => {
renderCounter();
return useStatusStore(story1Selector);
});
// Assert - initial render
expect(renderCounter).toHaveBeenCalledTimes(1);
expect(result.current).toEqual({
'type-1': story1Type1Status,
'type-2': story1Type2Status,
});
// Act - update a status for story-1
const updatedStory1Type1Status = {
...story1Type1Status,
value: 'status-value:error',
title: 'Updated Error',
description: 'Updated error description',
} as const;
act(() => {
fullStatusStore.set([updatedStory1Type1Status]);
});
// Assert - the hook should re-render with the updated status
expect(renderCounter).toHaveBeenCalledTimes(2);
expect(result.current).toEqual({
'type-1': updatedStory1Type1Status,
'type-2': story1Type2Status,
});
});
it('should not re-render when statuses not matching the selector change', async () => {
// Arrange - create a status store
const { useStatusStore, fullStatusStore } = createStatusStore({
universalStatusStore: new MockUniversalStore({
...UNIVERSAL_STATUS_STORE_OPTIONS,
initialState,
}),
useUniversalStore,
environment: 'manager',
});
const renderCounter = vi.fn();
// Create a selector that only returns statuses for story-1
const story1Selector = (statuses: StatusesByStoryIdAndTypeId) => statuses['story-1'] || {};
// Act - render the hook with the selector
const { result } = renderHook(() => {
renderCounter();
return useStatusStore(story1Selector);
});
// Assert - initial render
expect(renderCounter).toHaveBeenCalledTimes(1);
expect(result.current).toEqual({
'type-1': story1Type1Status,
'type-2': story1Type2Status,
});
// Act - update a status for story-2 (which doesn't match the selector)
const updatedStory2Type1Status = {
...story2Type1Status,
value: 'status-value:error',
title: 'Updated Error',
description: 'Updated error description',
} as const;
act(() => {
fullStatusStore.set([updatedStory2Type1Status]);
});
// Assert - the hook should not re-render since the change doesn't affect the selected data
expect(renderCounter).toHaveBeenCalledTimes(1);
expect(result.current).toEqual({
'type-1': story1Type1Status,
'type-2': story1Type2Status,
});
});
});
});

View File

@ -0,0 +1,227 @@
import type { StoryId } from 'storybook/internal/csf';
import { StatusTypeIdMismatchError as ManagerStatusTypeIdMismatchError } from '../../manager-errors';
import { StatusTypeIdMismatchError as PreviewStatusTypeIdMismatchError } from '../../preview-errors';
import { StatusTypeIdMismatchError as ServerStatusTypeIdMismatchError } from '../../server-errors';
import type { UniversalStore } from '../universal-store';
import type { StoreOptions } from '../universal-store/types';
import type { useUniversalStore as managerUseUniversalStore } from '../universal-store/use-universal-store-manager';
export type StatusValue =
| 'status-value:pending'
| 'status-value:success'
| 'status-value:error'
| 'status-value:warning'
| 'status-value:unknown';
export type StatusTypeId = string;
export type StatusByTypeId = Record<StatusTypeId, Status>;
export type StatusesByStoryIdAndTypeId = Record<StoryId, StatusByTypeId>;
export interface Status {
value: StatusValue;
typeId: StatusTypeId;
storyId: StoryId;
title: string;
description: string;
data?: any;
sidebarContextMenu?: boolean;
}
export const UNIVERSAL_STATUS_STORE_OPTIONS: StoreOptions<StatusesByStoryIdAndTypeId> = {
id: 'storybook/status',
leader: true,
initialState: {},
} as const;
const StatusStoreEventType = {
SELECT: 'select',
} as const;
export type StatusStoreEvent = {
type: typeof StatusStoreEventType.SELECT;
payload: Status[];
};
export type StatusStore = {
getAll: () => StatusesByStoryIdAndTypeId;
set: (statuses: Status[]) => void;
onAllStatusChange: (
listener: (
statuses: StatusesByStoryIdAndTypeId,
previousStatuses: StatusesByStoryIdAndTypeId
) => void
) => () => void;
onSelect: (listener: (selectedStatuses: Status[]) => void) => () => void;
unset: (storyIds?: StoryId[]) => void;
};
type FullStatusStore = StatusStore & {
selectStatuses: (statuses: Status[]) => void;
typeId: undefined;
};
export type StatusStoreByTypeId = StatusStore & {
typeId: StatusTypeId;
};
export type StatusStoreEnvironment = 'server' | 'manager' | 'preview';
export type UseStatusStore = <T = StatusesByStoryIdAndTypeId>(
selector?: (statuses: StatusesByStoryIdAndTypeId) => T
) => T;
export function createStatusStore(params: {
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
useUniversalStore?: never;
environment: StatusStoreEnvironment;
}): {
getStatusStoreByTypeId: (typeId: StatusTypeId) => StatusStoreByTypeId;
fullStatusStore: FullStatusStore;
};
export function createStatusStore(params: {
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
useUniversalStore: typeof managerUseUniversalStore;
environment: StatusStoreEnvironment;
}): {
getStatusStoreByTypeId: (typeId: StatusTypeId) => StatusStoreByTypeId;
fullStatusStore: FullStatusStore;
useStatusStore: UseStatusStore;
};
export function createStatusStore({
universalStatusStore,
useUniversalStore,
environment,
}: {
universalStatusStore: UniversalStore<StatusesByStoryIdAndTypeId, StatusStoreEvent>;
useUniversalStore?: typeof managerUseUniversalStore;
environment: StatusStoreEnvironment;
}): {
getStatusStoreByTypeId: (typeId: StatusTypeId) => StatusStoreByTypeId;
fullStatusStore: FullStatusStore;
useStatusStore?: UseStatusStore;
} {
const fullStatusStore: FullStatusStore = {
getAll() {
return universalStatusStore.getState();
},
set(statuses) {
universalStatusStore.setState((state) => {
// Create a new state object to merge with the current state
const newState = { ...state };
// Process each status and merge it into the appropriate storyId record
for (const status of statuses) {
const { storyId, typeId } = status;
newState[storyId] = { ...(newState[storyId] ?? {}), [typeId]: status };
}
return newState;
});
},
onAllStatusChange(
listener: (
statuses: StatusesByStoryIdAndTypeId,
prevStatuses: StatusesByStoryIdAndTypeId
) => void
): ReturnType<typeof universalStatusStore.onStateChange> {
return universalStatusStore.onStateChange((state, prevState) => {
listener(state, prevState);
});
},
onSelect(listener) {
return universalStatusStore.subscribe(StatusStoreEventType.SELECT, (event) => {
listener(event.payload);
});
},
selectStatuses: (statuses: Status[]) => {
universalStatusStore.send({ type: StatusStoreEventType.SELECT, payload: statuses });
},
unset(storyIds?: StoryId[]): void {
// If no storyIds are provided, remove all statuses
if (!storyIds) {
universalStatusStore.setState({});
return;
}
universalStatusStore.setState((state) => {
const newState = { ...state };
for (const storyId of storyIds) {
delete newState[storyId];
}
return newState;
});
},
typeId: undefined,
};
const getStatusStoreByTypeId = (typeId: StatusTypeId): StatusStoreByTypeId => ({
getAll: fullStatusStore.getAll,
set(statuses): void {
universalStatusStore.setState((state) => {
// Create a new state object to merge with the current state
const newState = { ...state };
// Process each status and merge it into the appropriate storyId record
for (const status of statuses) {
const { storyId } = status;
if (status.typeId !== typeId) {
// Validate that all statuses have the correct typeId
switch (environment) {
case 'server':
throw new ServerStatusTypeIdMismatchError({
status,
typeId,
});
case 'manager':
throw new ManagerStatusTypeIdMismatchError({
status,
typeId,
});
case 'preview':
default:
throw new PreviewStatusTypeIdMismatchError({
status,
typeId,
});
}
}
newState[storyId] = { ...(newState[storyId] ?? {}), [typeId]: status };
}
return newState;
});
},
onAllStatusChange: fullStatusStore.onAllStatusChange,
onSelect(listener) {
return universalStatusStore.subscribe(StatusStoreEventType.SELECT, (event) => {
if (event.payload.some((status) => status.typeId === typeId)) {
listener(event.payload);
}
});
},
unset(storyIds?: StoryId[]): void {
universalStatusStore.setState((state) => {
const newState = { ...state };
for (const storyId in newState) {
if (newState[storyId]?.[typeId] && (!storyIds || storyIds?.includes(storyId))) {
const { [typeId]: omittedStatus, ...storyStatusesWithoutTypeId } = newState[storyId];
newState[storyId] = storyStatusesWithoutTypeId;
}
}
return newState;
});
},
typeId,
});
if (!useUniversalStore) {
return { getStatusStoreByTypeId, fullStatusStore };
}
return {
getStatusStoreByTypeId,
fullStatusStore,
useStatusStore: <T = StatusesByStoryIdAndTypeId>(
selector?: (statuses: StatusesByStoryIdAndTypeId) => T
) => useUniversalStore(universalStatusStore, selector as any)[0] as T,
};
}

View File

@ -131,6 +131,99 @@ describe('useUniversalStore - Manager', () => {
expect(thirdState).toEqual(20);
});
it('should re-render when the selector changes', () => {
// Arrange - create a store
const store = UniversalStore.create({
id: 'env1:test',
leader: true,
initialState: { count: 0, selectedCount: 10, otherValue: 5 },
});
const renderCounter = vi.fn();
// Initial render with a selector for selectedCount
const { result, rerender } = renderHook(
({ selector }) => {
renderCounter();
return useUniversalStoreManager(store, selector);
},
{ initialProps: { selector: (state: any) => state.selectedCount } }
);
// Assert - initial render
expect(renderCounter).toHaveBeenCalledTimes(1);
const [firstState] = result.current;
expect(firstState).toEqual(10);
// Act - change the selector to a different property
rerender({ selector: (state: any) => state.otherValue });
// Assert - should re-render with the new selected state
expect(renderCounter).toHaveBeenCalledTimes(2);
const [secondState] = result.current;
expect(secondState).toEqual(5);
// Act - update the store state
act(() => store.setState({ count: 1, selectedCount: 10, otherValue: 15 }));
// Assert - should re-render because the newly selected state changed
expect(renderCounter).toHaveBeenCalledTimes(3);
const [thirdState] = result.current;
expect(thirdState).toEqual(15);
});
it('should re-render when the universalStore changes', () => {
// Arrange - create initial store
const initialStore = UniversalStore.create({
id: 'env1:test1',
leader: true,
initialState: { count: 0, selectedCount: 10 },
});
const renderCounter = vi.fn();
// Initial render with the first store
const { result, rerender } = renderHook(
({ store }) => {
renderCounter();
return useUniversalStoreManager(store);
},
{ initialProps: { store: initialStore } }
);
// Assert - initial render
expect(renderCounter).toHaveBeenCalledTimes(1);
const [firstState] = result.current;
expect(firstState).toEqual({ count: 0, selectedCount: 10 });
// Act - create a new store and rerender with it
const newStore = UniversalStore.create({
id: 'env1:test2',
leader: true,
initialState: { count: 5, selectedCount: 20 },
});
rerender({ store: newStore });
// Assert - should re-render with the new store's state
expect(renderCounter).toHaveBeenCalledTimes(2);
const [secondState] = result.current;
expect(secondState).toEqual({ count: 5, selectedCount: 20 });
// Act - update the new store's state
act(() => newStore.setState({ count: 10, selectedCount: 30 }));
// Assert - should re-render with the updated state
expect(renderCounter).toHaveBeenCalledTimes(3);
const [thirdState] = result.current;
expect(thirdState).toEqual({ count: 10, selectedCount: 30 });
// Act - update the old store's state (should have no effect)
act(() => initialStore.setState({ count: 100, selectedCount: 100 }));
// Assert - should not re-render as we're no longer using the initial store
expect(renderCounter).toHaveBeenCalledTimes(3);
const [fourthState] = result.current;
expect(fourthState).toEqual({ count: 10, selectedCount: 30 });
});
it('should set the state when the setter is called', () => {
// Arrange - create a store and render the hook
const store = UniversalStore.create({

View File

@ -36,10 +36,15 @@ export const useUniversalStore: {
universalStore: TUniversalStore,
selector?: (state: TState) => TSelectedState
): [TSelectedState, TUniversalStore['setState']] => {
const snapshotRef = React.useRef<TSelectedState>(
selector ? selector(universalStore.getState()) : universalStore.getState()
);
const subscribe = React.useCallback<Parameters<(typeof React)['useSyncExternalStore']>[0]>(
(listener) =>
universalStore.onStateChange((state, previousState) => {
if (!selector) {
snapshotRef.current = state;
listener();
return;
}
@ -48,16 +53,26 @@ export const useUniversalStore: {
const hasChanges = !isEqual(selectedState, selectedPreviousState);
if (hasChanges) {
snapshotRef.current = selectedState;
listener();
}
}),
[universalStore, selector]
);
const getSnapshot = React.useCallback(
() => (selector ? selector(universalStore.getState()) : universalStore.getState()),
[universalStore, selector]
);
const getSnapshot = React.useCallback(() => {
const currentState = universalStore.getState();
const selectedState = selector ? selector(currentState) : currentState;
// Compare with the previous snapshot to maintain referential equality
if (isEqual(selectedState, snapshotRef.current)) {
return snapshotRef.current;
}
// Update the snapshot reference when the selected state changes
snapshotRef.current = selectedState;
return snapshotRef.current;
}, [universalStore, selector]);
const state = React.useSyncExternalStore(subscribe, getSnapshot);

View File

@ -13,3 +13,4 @@ export * from './modules/composedStory';
export * from './modules/channelApi';
export * from './modules/frameworks';
export * from './modules/renderers';
export * from './modules/status';

View File

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { StatusByTypeId } from '../../../dist/types';
import type { DocsOptions } from './core-common';
import type { ArgTypes, Args, ComponentTitle, Parameters, Path, StoryId, Tag } from './csf';
import type { IndexEntry } from './indexer';
@ -116,20 +117,6 @@ export interface API_Versions {
current?: API_Version;
}
export type API_StatusValue = 'pending' | 'success' | 'error' | 'warn' | 'unknown';
export interface API_StatusObject {
status: API_StatusValue;
title: string;
description: string;
data?: any;
onClick?: () => void;
sidebarContextMenu?: boolean;
}
export type API_StatusState = Record<StoryId, Record<string, API_StatusObject>>;
export type API_StatusUpdate = Record<StoryId, API_StatusObject | null>;
export type API_FilterFunction = (
item: API_PreparedIndexEntry & { status: Record<string, API_StatusObject | null> }
item: API_PreparedIndexEntry & { statuses: StatusByTypeId }
) => boolean;

View File

@ -0,0 +1,10 @@
export type {
Status,
StatusValue,
StatusTypeId,
StatusByTypeId,
StatusesByStoryIdAndTypeId,
StatusStore,
StatusStoreByTypeId,
UseStatusStore,
} from '../../shared/status-store';

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/angular",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for Angular: Develop Angular components in isolation with hot reloading.",
"keywords": [
"storybook",

View File

@ -87,9 +87,9 @@ exports.getWebpackConfig = async (baseConfig, { builderOptions, builderContext }
/** Merge baseConfig Webpack with angular-cli Webpack */
const entry = [
...(cliConfig.entry.polyfills ?? []),
...baseConfig.entry,
...(cliConfig.entry.styles ?? []),
...(cliConfig.entry.polyfills ?? []),
];
// Don't use storybooks styling rules because we have to use rules created by @angular-devkit/build-angular

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/ember",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for Ember: Develop Ember Component in isolation with Hot Reloading.",
"homepage": "https://github.com/storybookjs/storybook/tree/next/code/frameworks/ember",
"bugs": {

View File

@ -1,7 +1,7 @@
import type { PresetProperty, PresetPropertyFn } from 'storybook/internal/types';
import type { TransformOptions } from '@babel/core';
import { precompile } from 'ember-source/dist/ember-template-compiler';
import { precompile } from 'ember-source/dist/ember-template-compiler.js';
import { findDistFile } from '../util';

View File

@ -1,4 +1,4 @@
declare module 'ember-source/dist/ember-template-compiler';
declare module 'ember-source/dist/ember-template-compiler.js';
declare var STORYBOOK_ENV: 'ember';
declare var STORYBOOK_NAME: any;

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/experimental-nextjs-vite",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for Next.js and Vite",
"keywords": [
"storybook",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/html-vite",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for HTML and Vite: Develop HTML in isolation with Hot Reloading.",
"keywords": [
"storybook"

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/html-webpack5",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for HTML: View HTML snippets in isolation with Hot Reloading.",
"keywords": [
"storybook"

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/nextjs",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for Next.js",
"keywords": [
"storybook",

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/preact-vite",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for Preact and Vite: Develop Preact components in isolation with Hot Reloading.",
"keywords": [
"storybook"

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/preact-webpack5",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Storybook for Preact: Develop Preact Component in isolation.",
"keywords": [
"storybook"

View File

@ -1,6 +1,6 @@
{
"name": "@storybook/react-native-web-vite",
"version": "9.0.0-alpha.4",
"version": "9.0.0-alpha.5",
"description": "Develop react-native components an isolated web environment with hot reloading.",
"keywords": [
"storybook"

Some files were not shown because too many files have changed in this diff Show More