Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions code/core/src/core-events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...`
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Function> = {};
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<string, Function> = {};
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(() => {
Expand Down
4 changes: 4 additions & 0 deletions code/core/src/core-server/server-channel/telemetry-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
}
}
50 changes: 50 additions & 0 deletions code/core/src/manager-api/modules/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SET_FILTER,
SET_INDEX,
SET_STORIES,
SIDEBAR_FILTER_CHANGED,
STORY_ARGS_UPDATED,
STORY_CHANGED,
STORY_INDEX_INVALIDATED,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1248,6 +1250,54 @@ export const init: ModuleFn<SubAPI, SubState> = ({
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<string, number> = {};
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,
});
}
},
};
};
88 changes: 84 additions & 4 deletions code/core/src/manager/components/sidebar/FilterPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, number> = {};
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;
Expand All @@ -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(
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions code/core/src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type EventType =
| 'preview-first-load'
| 'doctor'
| 'share'
| 'sidebar-filter'
| 'ghost-stories';
export interface Dependency {
version: string | undefined;
Expand Down
Loading