Skip to content
4 changes: 4 additions & 0 deletions code/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const config = defineMain({
directory: '../core/src/highlight',
titlePrefix: 'highlight',
},
{
directory: '../core/src/actions/containers',
titlePrefix: 'actions',
},
{
directory: '../addons/a11y/src',
titlePrefix: 'addons/accessibility',
Expand Down
199 changes: 199 additions & 0 deletions code/core/src/actions/containers/ActionLogger/ActionLogger.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import React from 'react';

import type { Meta, StoryObj } from '@storybook/react-vite';

import type { API, State } from 'storybook/manager-api';
import { ManagerContext } from 'storybook/manager-api';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { styled } from 'storybook/theming';

import type { ActionDisplay } from '../../models/index.ts';
import { EVENT_ID } from '../../constants.ts';
import ActionLogger from './index.tsx';

const StyledWrapper = styled.div(({ theme }) => ({
backgroundColor: theme.background.content,
color: theme.color.defaultText,
display: 'block',
height: '400px',
position: 'relative',
overflow: 'auto',
}));

/**
* A minimal event-emitter mock for the Storybook manager API.
* The container calls `api.on(EVENT_ID, handler)` to register its `addAction`
* callback. We capture that handler so that play functions can call
* `api.emit(EVENT_ID, action)` to drive the container's state.
*/
function createMockApi(): API {
const listeners: Record<string, Set<(...args: any[]) => void>> = {};

const api = {
on(event: string, handler: (...args: any[]) => void) {
if (!listeners[event]) {
listeners[event] = new Set();
}
listeners[event].add(handler);
},
off(event: string, handler: (...args: any[]) => void) {
listeners[event]?.delete(handler);
},
emit(event: string, ...args: any[]) {
listeners[event]?.forEach((h) => h(...args));
},
getCurrentParameter: fn().mockName('api::getCurrentParameter').mockReturnValue(undefined),
getDocsUrl: fn().mockName('api::getDocsUrl'),
getData: fn().mockName('api::getData'),
};

return api as unknown as API;
}

function makeAction(name: string, args: any[], id: string, limit: number = 50): ActionDisplay {
return {
id,
data: { name, args },
count: 0,
options: { limit, clearOnStoryChange: true },
};
}

/**
* Helper to emit actions into the container via the mock API.
* Yields to the event loop after each emission so React can process state updates.
* Callers should still synchronize on actual UI state (e.g., via `waitFor` / `findBy*`).
*/
async function emitActions(api: API, actions: ActionDisplay[]) {
for (const action of actions) {
api.emit(EVENT_ID, action);
// Yield to allow React to process the state update without relying on a fixed timeout
await Promise.resolve();
}
}

const meta = {
title: 'ActionLogger',
component: ActionLogger,
loaders: [() => ({ api: createMockApi() })],
render(args, { loaded: { api } }) {
const managerContext = {
state: {} as State,
api,
};

return (
<ManagerContext.Provider value={managerContext}>
<StyledWrapper id="panel-tab-content">
<ActionLogger {...args} api={api} />
</StyledWrapper>
</ManagerContext.Provider>
);
},
parameters: { layout: 'fullscreen' },
args: {
active: true,
},
} as Meta<typeof ActionLogger>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Empty: Story = {};

export const SingleAction: Story = {
play: async ({ loaded: { api }, canvas }) => {
await emitActions(api, [makeAction('onClick', [{ target: 'button' }], 'action-click')]);

await waitFor(() => expect(canvas.getByText('onClick')).toBeInTheDocument());
},
};

export const RepeatedAction: Story = {
play: async ({ loaded: { api }, canvas }) => {
const action = makeAction('onClick', [{ target: 'button' }], 'action-click');

// Emit the same action 5 times — the container deduplicates via count
await emitActions(api, [action, action, action, action, action]);

await waitFor(() => expect(canvas.getByText('5')).toBeInTheDocument());
},
};

export const MultipleActions: Story = {
play: async ({ loaded: { api }, canvas }) => {
await emitActions(api, [
makeAction('onClick', [{ target: 'button' }], 'action-1'),
makeAction('onChange', ['new value'], 'action-2'),
makeAction('onSubmit', [{ formData: { name: 'test' } }], 'action-3'),
]);

await waitFor(() => expect(canvas.getByText('onClick')).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('onChange')).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('onSubmit')).toBeInTheDocument());
},
};

export const LimitDiscardsOldest: Story = {
name: 'Limit discards oldest actions',
play: async ({ loaded: { api }, canvas }) => {
const limit = 3;

// Emit 5 actions with a limit of 3 — only the last 3 should remain
await emitActions(api, [
makeAction('onFirst', ['1st'], 'action-1', limit),
makeAction('onSecond', ['2nd'], 'action-2', limit),
makeAction('onThird', ['3rd'], 'action-3', limit),
makeAction('onFourth', ['4th'], 'action-4', limit),
makeAction('onFifth', ['5th'], 'action-5', limit),
]);

// The newest 3 should be visible
await waitFor(() => expect(canvas.getByText('onThird')).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('onFourth')).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('onFifth')).toBeInTheDocument());

// The oldest 2 should have been discarded
expect(canvas.queryByText('onFirst')).not.toBeInTheDocument();
expect(canvas.queryByText('onSecond')).not.toBeInTheDocument();
},
};

export const LimitWithRepeatedActions: Story = {
name: 'Limit with repeated (deduplicated) actions',
play: async ({ loaded: { api }, canvas }) => {
const limit = 3;

// Emit 2 unique actions, then repeat the last one — should stay within limit
await emitActions(api, [
makeAction('onAlpha', ['a'], 'action-a', limit),
makeAction('onBeta', ['b'], 'action-b', limit),
makeAction('onBeta', ['b'], 'action-c', limit), // same data, should increment count
]);

await waitFor(() => expect(canvas.getByText('onAlpha')).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('onBeta')).toBeInTheDocument());
// The repeated action should show count 2
await waitFor(() => expect(canvas.getByText('2')).toBeInTheDocument());
},
};

export const ClearActions: Story = {
name: 'Clear button removes all actions',
play: async ({ loaded: { api }, canvas }) => {
await emitActions(api, [
makeAction('onClick', [{ target: 'button' }], 'action-1'),
makeAction('onChange', ['value'], 'action-2'),
]);

await waitFor(() => expect(canvas.getByText('onClick')).toBeInTheDocument());

// Click the Clear button
const clearButton = canvas.getByText('Clear');
await userEvent.click(clearButton);

// Actions should be gone
expect(canvas.queryByText('onClick')).not.toBeInTheDocument();
expect(canvas.queryByText('onChange')).not.toBeInTheDocument();
},
};
17 changes: 11 additions & 6 deletions code/core/src/actions/containers/ActionLogger/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,20 @@ export default function ActionLogger({ active, api }: ActionLoggerProps) {

const addAction = useCallback((action: ActionDisplay) => {
setActions((prevActions) => {
const newActions = [...prevActions];
const previous = newActions.length && newActions[newActions.length - 1];
const limit = action.options.limit ?? 50;
const previous = prevActions.length ? prevActions[prevActions.length - 1] : null;

if (previous && safeDeepEqual(previous.data, action.data)) {
previous.count++;
const updated = [...prevActions];
updated[updated.length - 1] = {
...previous,
count: previous.count + 1,
};
return updated.slice(-limit);
} else {
action.count = 1;
newActions.push(action);
const newAction = { ...action, count: 1 };
return [...prevActions, newAction].slice(-limit);
}
return newActions.slice(0, action.options.limit);
});
}, []);

Expand Down
35 changes: 28 additions & 7 deletions docs/essentials/actions.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: 'Actions'
title: "Actions"
sidebar:
order: 1
title: Actions
Expand All @@ -10,6 +10,7 @@ Actions are used to show that an event handler (callback) has been called, and t
<Video src="../_assets/essentials/addon-actions-demo-optimized.mp4" />

## Story args

Actions work via supplying special Storybook-generated mock functions to your story's event handler args. There are two ways to get an action arg:

### Via storybook/test fn spies
Expand Down Expand Up @@ -48,8 +49,6 @@ If you need more granular control over which `argTypes` are matched, you can adj

This will bind a standard HTML event handler to the outermost HTML element rendered by your component and trigger an action when the event is called for a given selector. The format is `<eventname> <selector>`. The selector is optional; it defaults to all elements.



## Non-story function calls

You can still use the actions panel if you need to log function calls that are unrelated to any story. This can be helpful for debugging or logging purposes. There are two main ways to do this: `spyOn` from `storybook/test` or the `action` function from `storybook/actions`. For basic logging, we recommend creating a function spy, and for more complex scenarios, you can use the `action` function directly.
Expand All @@ -60,14 +59,12 @@ Mocks and spies from `storybook/test` are automatically logged as actions. The e

<CodeSnippets path="actions-spyon-basic-example.md" />


### Via the `action` function

To filter which function calls are logged, you can override the `spyOn` function's behavior by providing a custom implementation that calls the `action` function from `storybook/actions` only if it matches a specific condition to prevent it from logging all calls to the function it spies on.
To filter which function calls are logged, you can override the `spyOn` function's behavior by providing a custom implementation that calls the `action` function from `storybook/actions` only if it matches a specific condition to prevent it from logging all calls to the function it spies on.

<CodeSnippets path="actions-filtering-example.md" />


## API

### Parameters
Expand Down Expand Up @@ -99,7 +96,7 @@ Controls how many levels deep the action tree is initially expanded. Useful when
### Exports

```js
import { action } from 'storybook/actions';
import { action } from "storybook/actions";
```

#### `action`
Expand All @@ -113,3 +110,27 @@ Allows you to create an action that appears in the actions panel of the Storyboo
<CodeSnippets path="addon-actions-action-function.md" />

{/* prettier-ignore-end */}

#### `configureActions`

```js
import { configureActions } from "storybook/actions";
```

Type: `(options?: ActionOptions) => void`

Configures the global behavior of the actions addon. Call this in your `.storybook/preview.*` file or anywhere before actions are used. Available options:

- **`limit`** (`number`, default: `50`): Maximum number of action entries to display. When the limit is reached, the oldest actions are discarded.
- **`clearOnStoryChange`** (`boolean`, default: `true`): Clear the actions panel when navigating to a different story.
- **`depth`** (`number`, default: `10`): Serialization depth for action arguments.

```js
// .storybook/preview.js
import { configureActions } from "storybook/actions";

configureActions({
limit: 20,
clearOnStoryChange: false,
});
```
Loading