mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-09 00:19:13 +08:00
Merge branch 'next' into test-polish
This commit is contained in:
commit
6b505e1a72
16
CHANGELOG.md
16
CHANGELOG.md
@ -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!
|
||||
|
@ -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!
|
||||
|
63
MIGRATION.md
63
MIGRATION.md
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@storybook/addon-storysource",
|
||||
"version": "9.0.0-alpha.4",
|
||||
"version": "9.0.0-alpha.5",
|
||||
"description": "View a story’s source code to see how it works and paste into your app",
|
||||
"keywords": [
|
||||
"addon",
|
||||
|
@ -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",
|
||||
|
@ -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" />;
|
||||
|
@ -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';
|
||||
|
@ -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(),
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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 () => {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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 = {
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 Storybook’s viewport size and orientation",
|
||||
"keywords": [
|
||||
"addon",
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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),
|
||||
|
@ -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{',
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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';
|
||||
|
23
code/core/src/core-server/stores/status.ts
Normal file
23
code/core/src/core-server/stores/status.ts
Normal 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;
|
@ -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` */
|
||||
|
@ -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 = ({
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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: {},
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 () => {
|
||||
|
@ -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';
|
||||
|
12
code/core/src/manager-api/stores/__mocks__/status.ts
Normal file
12
code/core/src/manager-api/stores/__mocks__/status.ts
Normal 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;
|
15
code/core/src/manager-api/stores/status.ts
Normal file
15
code/core/src/manager-api/stores/status.ts
Normal 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;
|
@ -1242,7 +1242,7 @@ describe('Refs API', () => {
|
||||
provider: provider as any,
|
||||
docsOptions: {},
|
||||
filters: {},
|
||||
status: {},
|
||||
allStatuses: {},
|
||||
};
|
||||
const initialState: Partial<State> = {
|
||||
refs: {
|
||||
|
@ -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 () => {
|
||||
|
@ -1 +1 @@
|
||||
export const version = '9.0.0-alpha.4';
|
||||
export const version = '9.0.0-alpha.5';
|
||||
|
@ -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
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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[],
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
@ -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: {},
|
||||
},
|
||||
}),
|
||||
{}
|
||||
|
@ -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',
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -26,7 +26,7 @@ const factory = (props: Partial<SidebarProps>): RenderResult => {
|
||||
index={{}}
|
||||
previewInitialized
|
||||
refs={{}}
|
||||
status={{}}
|
||||
allStatuses={{}}
|
||||
extra={[]}
|
||||
{...props}
|
||||
/>
|
||||
|
@ -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>;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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',
|
||||
|
27
code/core/src/manager/status-store.mock.ts
Normal file
27
code/core/src/manager/status-store.mock.ts
Normal 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',
|
||||
});
|
5
code/core/src/manager/status-store.ts
Normal file
5
code/core/src/manager/status-store.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export {
|
||||
internal_fullStatusStore,
|
||||
experimental_getStatusStore,
|
||||
experimental_useStatusStore,
|
||||
} from 'storybook/manager-api';
|
@ -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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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) };
|
||||
};
|
||||
|
||||
|
@ -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';
|
||||
|
15
code/core/src/preview-api/stores/status.ts
Normal file
15
code/core/src/preview-api/stores/status.ts
Normal 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;
|
@ -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({
|
||||
|
@ -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 }
|
||||
|
71
code/core/src/shared/status-store/index.test-d.ts
Normal file
71
code/core/src/shared/status-store/index.test-d.ts
Normal 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>();
|
||||
});
|
||||
});
|
722
code/core/src/shared/status-store/index.test.ts
Normal file
722
code/core/src/shared/status-store/index.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
227
code/core/src/shared/status-store/index.ts
Normal file
227
code/core/src/shared/status-store/index.ts
Normal 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,
|
||||
};
|
||||
}
|
@ -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({
|
||||
|
@ -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);
|
||||
|
||||
|
@ -13,3 +13,4 @@ export * from './modules/composedStory';
|
||||
export * from './modules/channelApi';
|
||||
export * from './modules/frameworks';
|
||||
export * from './modules/renderers';
|
||||
export * from './modules/status';
|
||||
|
@ -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;
|
||||
|
10
code/core/src/types/modules/status.ts
Normal file
10
code/core/src/types/modules/status.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type {
|
||||
Status,
|
||||
StatusValue,
|
||||
StatusTypeId,
|
||||
StatusByTypeId,
|
||||
StatusesByStoryIdAndTypeId,
|
||||
StatusStore,
|
||||
StatusStoreByTypeId,
|
||||
UseStatusStore,
|
||||
} from '../../shared/status-store';
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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": {
|
||||
|
@ -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';
|
||||
|
||||
|
2
code/frameworks/ember/src/typings.d.ts
vendored
2
code/frameworks/ember/src/typings.d.ts
vendored
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user