diff --git a/code/core/src/actions/containers/ActionLogger/index.test.ts b/code/core/src/actions/containers/ActionLogger/index.test.ts new file mode 100644 index 000000000000..0c1e2207f24d --- /dev/null +++ b/code/core/src/actions/containers/ActionLogger/index.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import type { ActionDisplay } from '../../models'; +import { applyActionToList } from './index'; + +const makeAction = (id: string, args: any[], limit = 50): ActionDisplay => ({ + id, + count: 0, + data: { name: 'onClick', args }, + options: { limit }, +}); + +describe('applyActionToList', () => { + it('returns an empty list when limit is zero', () => { + const first = makeAction('1', [1], 0); + const second = makeAction('2', [2], 0); + + const next = applyActionToList(applyActionToList([], first), second); + + expect(next).toEqual([]); + }); + + it('keeps the most recent actions when the limit is reached', () => { + const limit = 2; + const first = makeAction('1', [1], limit); + const second = makeAction('2', [2], limit); + const third = makeAction('3', [3], limit); + + const next = applyActionToList(applyActionToList(applyActionToList([], first), second), third); + + expect(next).toHaveLength(2); + expect(next.map((entry) => entry.id)).toEqual(['2', '3']); + }); + + it('increments count immutably when the latest action data matches', () => { + const previous = { ...makeAction('1', ['same']), count: 1 }; + const incoming = makeAction('2', ['same']); + + const next = applyActionToList([previous], incoming); + + expect(next).toHaveLength(1); + expect(next[0]).not.toBe(previous); + expect(next[0]).toMatchObject({ id: '1', count: 2 }); + expect(previous.count).toBe(1); + }); +}); diff --git a/code/core/src/actions/containers/ActionLogger/index.tsx b/code/core/src/actions/containers/ActionLogger/index.tsx index b56398b11b24..21b9ec2307ae 100644 --- a/code/core/src/actions/containers/ActionLogger/index.tsx +++ b/code/core/src/actions/containers/ActionLogger/index.tsx @@ -24,6 +24,26 @@ const safeDeepEqual = (a: any, b: any): boolean => { } }; +export const applyActionToList = ( + prevActions: ActionDisplay[], + action: ActionDisplay +): ActionDisplay[] => { + const limit = action.options.limit ?? Number.POSITIVE_INFINITY; + if (limit <= 0) { + return []; + } + const previous = prevActions.length ? prevActions[prevActions.length - 1] : null; + + if (previous && safeDeepEqual(previous.data, action.data)) { + const updated = [...prevActions]; + updated[updated.length - 1] = { ...previous, count: previous.count + 1 }; + return updated.slice(-limit); + } + + const newAction = { ...action, count: 1 }; + return [...prevActions, newAction].slice(-limit); +}; + export default function ActionLogger({ active, api }: ActionLoggerProps) { const [actions, setActions] = useState([]); const parameter = useParameter(PARAM_KEY); @@ -35,17 +55,7 @@ export default function ActionLogger({ active, api }: ActionLoggerProps) { }, [api]); const addAction = useCallback((action: ActionDisplay) => { - setActions((prevActions) => { - const newActions = [...prevActions]; - const previous = newActions.length && newActions[newActions.length - 1]; - if (previous && safeDeepEqual(previous.data, action.data)) { - previous.count++; - } else { - action.count = 1; - newActions.push(action); - } - return newActions.slice(0, action.options.limit); - }); + setActions((prevActions) => applyActionToList(prevActions, action)); }, []); const handleStoryChange = useCallback(() => { diff --git a/code/core/template/stories/basics.stories.ts b/code/core/template/stories/basics.stories.ts index 354bacc466c9..921a236a4dc9 100644 --- a/code/core/template/stories/basics.stories.ts +++ b/code/core/template/stories/basics.stories.ts @@ -2,6 +2,9 @@ import { global as globalThis } from '@storybook/global'; import { action } from 'storybook/actions'; +const optionLimitAction = action('onClick', { limit: 3 }); +let optionLimitSequence = 0; + export default { component: globalThis.__TEMPLATE_COMPONENTS__.Button, args: { @@ -88,6 +91,12 @@ export const OptionPersist = { export const OptionDepth = { args: { onClick: action('onClick', { depth: 2 }) }, }; +export const OptionLimit = { + args: { + onClick: () => optionLimitAction({ seq: optionLimitSequence++ }), + label: 'Click me repeatedly (limit: 3)', + }, +}; export const Disabled = { args: { onClick: action('onCLick') }, diff --git a/docs/essentials/actions.mdx b/docs/essentials/actions.mdx index 93f0cd63d9ea..7991228a2ca2 100644 --- a/docs/essentials/actions.mdx +++ b/docs/essentials/actions.mdx @@ -104,10 +104,20 @@ import { action } from 'storybook/actions'; #### `action` -Type: `(name?: string) => void` +Type: `(name?: string, options?: ActionOptions) => void` Allows you to create an action that appears in the actions panel of the Storybook UI when clicked. The action function takes an optional name parameter, which is used to identify the action in the UI. +You can optionally pass an options object to control how events are stored in the panel. Supported fields include `limit`, `depth`, and `clearOnStoryChange` (plus serializer-related options from `telejson`). + +##### `options.limit` + +Type: `number` + +Default: `50` + +Maximum number of entries retained in the Actions panel for this action. Once the limit is reached, Storybook keeps the **most recent** entries and drops older ones. + {/* prettier-ignore-start */}