diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index 7f281f7d9b97..cd0f562308d9 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -100,6 +100,7 @@ enum events { SHARE_STORY_LINK = 'shareStoryLink', SHARE_ISOLATE_MODE = 'shareIsolateMode', SHARE_POPOVER_OPENED = 'sharePopoverOpened', + SIDEBAR_FILTER_CHANGED = 'sidebarFilterChanged', } // Enables: `import Events from ...` @@ -174,6 +175,7 @@ export const { SHARE_STORY_LINK, SHARE_ISOLATE_MODE, SHARE_POPOVER_OPENED, + SIDEBAR_FILTER_CHANGED, } = events; export * from './data/create-new-story.ts'; diff --git a/code/core/src/core-server/server-channel/telemetry-channel.test.ts b/code/core/src/core-server/server-channel/telemetry-channel.test.ts index cdd8e8b86337..592ac76f601a 100644 --- a/code/core/src/core-server/server-channel/telemetry-channel.test.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.test.ts @@ -1,6 +1,59 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makePayload } from './telemetry-channel.ts'; +import { SIDEBAR_FILTER_CHANGED } from 'storybook/internal/core-events'; + +import { initTelemetryChannel, makePayload } from './telemetry-channel.ts'; + +vi.mock('storybook/internal/telemetry', () => ({ + telemetry: vi.fn(), + getLastEvents: vi.fn().mockResolvedValue({}), + getSessionId: vi.fn().mockResolvedValue('test-session-id'), +})); + +const { telemetry } = await import('storybook/internal/telemetry'); + +describe('telemetry-channel', () => { + describe('SIDEBAR_FILTER_CHANGED', () => { + it('forwards sidebar-filter event to telemetry', () => { + const listeners: Record = {}; + const channel = { + on: (event: string, listener: Function) => { + listeners[event] = listener; + }, + } as any; + + initTelemetryChannel(channel, { disableTelemetry: false } as any); + + const payload = { + trigger: 'interaction' as const, + changed: { + filterType: 'status' as const, + filterId: 'status-value:new', + action: 'include' as const, + }, + activeTagFilters: { included: [], excluded: [] }, + activeStatusFilters: { included: ['status-value:new'], excluded: [] }, + storyCounts: { 'status-value:new': 3 }, + }; + + listeners[SIDEBAR_FILTER_CHANGED](payload); + expect(telemetry).toHaveBeenCalledWith('sidebar-filter', payload); + }); + + it('does not register listener when telemetry is disabled', () => { + const listeners: Record = {}; + const channel = { + on: (event: string, listener: Function) => { + listeners[event] = listener; + }, + } as any; + + initTelemetryChannel(channel, { disableTelemetry: true } as any); + + expect(listeners[SIDEBAR_FILTER_CHANGED]).toBeUndefined(); + }); + }); +}); describe('makePayload', () => { beforeEach(() => { diff --git a/code/core/src/core-server/server-channel/telemetry-channel.ts b/code/core/src/core-server/server-channel/telemetry-channel.ts index e4b2820611ea..9577a6a64f18 100644 --- a/code/core/src/core-server/server-channel/telemetry-channel.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.ts @@ -4,6 +4,7 @@ import { SHARE_ISOLATE_MODE, SHARE_POPOVER_OPENED, SHARE_STORY_LINK, + SIDEBAR_FILTER_CHANGED, } from 'storybook/internal/core-events'; import { type InitPayload, telemetry } from 'storybook/internal/telemetry'; import { type CacheEntry, getLastEvents } from 'storybook/internal/telemetry'; @@ -52,5 +53,8 @@ export function initTelemetryChannel(channel: Channel, options: Options) { channel.on(SHARE_ISOLATE_MODE, async () => { telemetry('share', { action: 'isolate-mode-opened' }); }); + channel.on(SIDEBAR_FILTER_CHANGED, async (payload) => { + telemetry('sidebar-filter', payload); + }); } } diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index 5facc030ffd5..288b1c895ffc 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -11,6 +11,7 @@ import { SET_FILTER, SET_INDEX, SET_STORIES, + SIDEBAR_FILTER_CHANGED, STORY_ARGS_UPDATED, STORY_CHANGED, STORY_INDEX_INVALIDATED, @@ -62,6 +63,7 @@ import type { ModuleFn } from '../lib/types.tsx'; import { buildNavigationUrl } from '../lib/url.ts'; import type { ComposedRef } from '../root.tsx'; import { fullStatusStore } from '../stores/status.ts'; +import { BUILT_IN_FILTERS } from '../../shared/constants/tags.ts'; import { computeStatusFilterFn, parseStatusesParam, serializeStatusesParam } from './statuses.ts'; import { computeStaticFilterFn, @@ -1248,6 +1250,54 @@ export const init: ModuleFn = ({ provider.channel?.on(STORY_INDEX_INVALIDATED, () => api.fetchIndex()); await api.fetchIndex(); + + // Emit telemetry if filters were set via URL params + const builtInTagIds = new Set(Object.keys(BUILT_IN_FILTERS)); + const builtInIncludedTags = initialIncluded.filter((id) => builtInTagIds.has(id)); + const builtInExcludedTags = initialExcluded.filter((id) => builtInTagIds.has(id)); + const hasBuiltInTagFilters = builtInIncludedTags.length > 0 || builtInExcludedTags.length > 0; + const hasStatusFilters = + initialIncludedStatuses.length > 0 || initialExcludedStatuses.length > 0; + + if (hasBuiltInTagFilters || hasStatusFilters) { + // Gather story counts from the now-loaded index and status store + const storyCounts: Record = {}; + const { internal_index: currentIndex } = store.getState(); + if (currentIndex) { + const entries = Object.values(currentIndex.entries); + // Count stories per active built-in tag filter + for (const tagId of [...builtInIncludedTags, ...builtInExcludedTags]) { + if (Object.hasOwn(BUILT_IN_FILTERS, tagId)) { + const filterDef = BUILT_IN_FILTERS[tagId as keyof typeof BUILT_IN_FILTERS]; + storyCounts[tagId] = entries.filter((entry) => filterDef(entry)).length; + } + } + } + // Count stories per active status filter + const allStatuses = fullStatusStore.getAll(); + for (const statusValue of [...initialIncludedStatuses, ...initialExcludedStatuses]) { + let count = 0; + Object.values(allStatuses).forEach((statusByTypeId) => { + Object.values(statusByTypeId).forEach((status) => { + if (status.value === statusValue) count++; + }); + }); + storyCounts[statusValue] = count; + } + + provider.channel?.emit(SIDEBAR_FILTER_CHANGED, { + trigger: 'url', + activeTagFilters: { + included: builtInIncludedTags, + excluded: builtInExcludedTags, + }, + activeStatusFilters: { + included: [...initialIncludedStatuses], + excluded: [...initialExcludedStatuses], + }, + storyCounts, + }); + } }, }; }; diff --git a/code/core/src/manager/components/sidebar/FilterPanel.tsx b/code/core/src/manager/components/sidebar/FilterPanel.tsx index 83963a05eee8..42e76f4404ab 100644 --- a/code/core/src/manager/components/sidebar/FilterPanel.tsx +++ b/code/core/src/manager/components/sidebar/FilterPanel.tsx @@ -1,6 +1,7 @@ import React, { Fragment, useCallback, useMemo } from 'react'; import { ActionList } from 'storybook/internal/components'; +import { SIDEBAR_FILTER_CHANGED } from 'storybook/internal/core-events'; import type { StatusValue, StatusesByStoryIdAndTypeId, StoryIndex } from 'storybook/internal/types'; import { BatchAcceptIcon, DocumentIcon, ShareAltIcon, SweepIcon, UndoIcon } from '@storybook/icons'; @@ -55,6 +56,57 @@ export const FilterPanel = ({ const { builtInEntries, tagEntries } = useTagFilterEntries(indexJson); const statusEntries = useStatusFilterEntries(allStatuses); + const emitFilterTelemetry = useCallback( + (changed: { filterType: 'tag' | 'status'; filterId: string; action: 'include' | 'exclude' | 'remove' }) => { + // Only include built-in tag filters in telemetry (not user-defined tags) + const builtInTagIds = new Set(builtInEntries.map((e) => e.id)); + const activeBuiltInIncluded = includedFilters.filter((id) => builtInTagIds.has(id)); + const activeBuiltInExcluded = excludedFilters.filter((id) => builtInTagIds.has(id)); + + // Gather story counts for all active filters + const storyCounts: Record = {}; + for (const entry of builtInEntries) { + if (activeBuiltInIncluded.includes(entry.id) || activeBuiltInExcluded.includes(entry.id)) { + storyCounts[entry.id] = entry.count; + } + } + for (const entry of statusEntries) { + if ( + includedStatusFilters.includes(entry.statusValue) || + excludedStatusFilters.includes(entry.statusValue) + ) { + storyCounts[entry.statusValue] = entry.count; + } + } + // Also include the count for the filter that was just changed, + // since the state arrays may not yet reflect the toggle + if (changed.action !== 'remove') { + if (changed.filterType === 'tag') { + const entry = builtInEntries.find((e) => e.id === changed.filterId); + if (entry) storyCounts[changed.filterId] = entry.count; + } else { + const entry = statusEntries.find((e) => e.statusValue === changed.filterId); + if (entry) storyCounts[changed.filterId] = entry.count; + } + } + + api.emit(SIDEBAR_FILTER_CHANGED, { + trigger: 'interaction', + changed, + activeTagFilters: { + included: activeBuiltInIncluded, + excluded: activeBuiltInExcluded, + }, + activeStatusFilters: { + included: [...includedStatusFilters], + excluded: [...excludedStatusFilters], + }, + storyCounts, + }); + }, + [api, builtInEntries, statusEntries, includedFilters, excludedFilters, includedStatusFilters, excludedStatusFilters] + ); + const toTagFilterItem = useCallback( (entry: TagFilterEntry): FilterItem | null => { if (entry.count === 0 && entry.type === 'built-in') return null; @@ -75,11 +127,27 @@ export const FilterPanel = ({ } else { api.addTagFilters([entry.id], false); } + if (entry.type === 'built-in') { + emitFilterTelemetry({ + filterType: 'tag', + filterId: entry.id, + action: isChecked ? 'remove' : 'include', + }); + } + }, + onInvert: () => { + api.addTagFilters([entry.id], !isExcluded); + if (entry.type === 'built-in') { + emitFilterTelemetry({ + filterType: 'tag', + filterId: entry.id, + action: !isExcluded ? 'exclude' : 'include', + }); + } }, - onInvert: () => api.addTagFilters([entry.id], !isExcluded), }; }, - [api, includedFilters, excludedFilters] + [api, includedFilters, excludedFilters, emitFilterTelemetry] ); const toStatusFilterItem = useCallback( @@ -102,11 +170,23 @@ export const FilterPanel = ({ } else { api.addStatusFilters([entry.statusValue], false); } + emitFilterTelemetry({ + filterType: 'status', + filterId: entry.statusValue, + action: isChecked ? 'remove' : 'include', + }); + }, + onInvert: () => { + api.addStatusFilters([entry.statusValue], !isExcluded); + emitFilterTelemetry({ + filterType: 'status', + filterId: entry.statusValue, + action: !isExcluded ? 'exclude' : 'include', + }); }, - onInvert: () => api.addStatusFilters([entry.statusValue], !isExcluded), }; }, - [api, includedStatusFilters, excludedStatusFilters, theme] + [api, includedStatusFilters, excludedStatusFilters, theme, emitFilterTelemetry] ); const builtInItems = useMemo( diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 8a81ca4ee010..846cc87f2214 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -44,6 +44,7 @@ export type EventType = | 'preview-first-load' | 'doctor' | 'share' + | 'sidebar-filter' | 'ghost-stories'; export interface Dependency { version: string | undefined;