Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Add an experimental API for adding sidebar filter functions at runtime #23722

Merged
merged 16 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from 14 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
29 changes: 29 additions & 0 deletions code/lib/manager-api/src/modules/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
API_ViewMode,
API_StatusState,
API_StatusUpdate,
API_FilterFunction,
} from '@storybook/types';
import {
PRELOAD_ENTRIES,
Expand All @@ -39,6 +40,7 @@ import {
STORY_MISSING,
DOCS_PREPARED,
SET_CURRENT_STORY,
SET_CONFIG,
} from '@storybook/core-events';
import { logger } from '@storybook/client-logger';

Expand Down Expand Up @@ -72,6 +74,7 @@ export interface SubState extends API_LoadedRefData {
storyId: StoryId;
viewMode: API_ViewMode;
status: API_StatusState;
filters: Record<string, API_FilterFunction>;
}

export interface SubAPI {
Expand Down Expand Up @@ -260,6 +263,14 @@ export interface SubAPI {
* @returns {Promise<void>} A promise that resolves when the status has been updated.
*/
experimental_updateStatus: (addonId: string, update: API_StatusUpdate) => Promise<void>;
/**
* Updates the filtering of the index.
*
* @param {string} addonId - The ID of the addon to update.
* @param {API_FilterFunction} filterFunction - A function that returns a boolean based on the story, index and status.
* @returns {Promise<void>} A promise that resolves when the state has been updated.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it should return an off function.

*/
experimental_setFilter: (addonId: string, filterFunction: API_FilterFunction) => Promise<void>;
}

const removedOptions = ['enableShortcuts', 'theme', 'showRoots'];
Expand Down Expand Up @@ -577,6 +588,9 @@ export const init: ModuleFn<SubAPI, SubState> = ({

await store.setState({ status: newStatus }, { persistence: 'session' });
},
experimental_setFilter: async (id, filterFunction) => {
await store.setState({ filters: { ...store.getState().filters, [id]: filterFunction } });
},
};

// On initial load, the local iframe will select the first story (or other "selection specifier")
Expand Down Expand Up @@ -748,6 +762,20 @@ export const init: ModuleFn<SubAPI, SubState> = ({
api.setPreviewInitialized(ref);
});

provider.channel.on(SET_CONFIG, () => {
const config = provider.getConfig();
if (config?.sidebar?.filters) {
store.setState({
filters: {
...store.getState().filters,
...config?.sidebar?.filters,
},
});
}
});

const config = provider.getConfig();

return {
api,
state: {
Expand All @@ -756,6 +784,7 @@ export const init: ModuleFn<SubAPI, SubState> = ({
hasCalledSetOptions: false,
previewInitialized: false,
status: {},
filters: config?.sidebar?.filters || {},
},
init: async () => {
if (FEATURES?.storyStoreV7) {
Expand Down
131 changes: 130 additions & 1 deletion code/lib/manager-api/src/tests/stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { EventEmitter } from 'events';
import { global } from '@storybook/global';

import type { API_StoryEntry } from '@storybook/types';
import type { API_IndexHash, API_StoryEntry } from '@storybook/types';
import { getEventMetadata as getEventMetadataOriginal } from '../lib/events';

import { init as initStories } from '../modules/stories';
Expand All @@ -25,6 +25,8 @@ import type { API, State } from '..';
import { mockEntries, docsEntries, preparedEntries, navigationEntries } from './mockStoriesEntries';
import type { ModuleArgs } from '../lib/types';

import { getAncestorIds } from '../../../../ui/manager/src/utils/tree';

const mockGetEntries = jest.fn();
const fetch = global.fetch as jest.Mock<ReturnType<typeof global.fetch>>;
const getEventMetadata = getEventMetadataOriginal as unknown as jest.Mock<
Expand Down Expand Up @@ -1270,4 +1272,131 @@ describe('experimental_updateStatus', () => {
}
`);
});
describe('experimental_setFilter', () => {
it('is included in the initial state', () => {
const moduleArgs = createMockModuleArgs({});
const { state } = initStories(moduleArgs as unknown as ModuleArgs);

expect(state).toEqual(
expect.objectContaining({
filters: {},
})
);
});
it('updates state', () => {
const moduleArgs = createMockModuleArgs({});
const { api } = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;

api.experimental_setFilter('myCustomFilter', () => true);

expect(store.getState()).toEqual(
expect.objectContaining({
filters: {
myCustomFilter: expect.any(Function),
},
})
);
});

it('can filter', () => {
const moduleArgs = createMockModuleArgs({});
const {
api,
state: { status },
} = initStories(moduleArgs as unknown as ModuleArgs);
const { store } = moduleArgs;

/**
* This function is a copy of the one in the containers/sidebar.ts file inside of ui/manager
* I'm hoping we can eventually merge this 2 packages so there's no odd looking import and no re-implementation.
*/
const applyFilters = (originalIndex: API_IndexHash) => {
if (!originalIndex) {
return originalIndex;
}

const filtered = new Set();
Object.values(originalIndex).forEach((item) => {
if (item.type === 'story' || item.type === 'docs') {
let result = true;

Object.values(filters).forEach((filter) => {
if (result === true) {
result = filter({ ...item, status: status[item.id] });
}
});

if (result) {
filtered.add(item.id);
getAncestorIds(originalIndex, item.id).forEach((id) => {
filtered.add(id);
});
}
}
});

return Object.fromEntries(
Object.entries(originalIndex).filter(([key]) => filtered.has(key))
);
};

api.experimental_setFilter('myCustomFilter', (item) => item.id.startsWith('a'));
api.setIndex({ v: 4, entries: navigationEntries });

const { index, filters } = store.getState();

const filtered = applyFilters(index);

expect(filtered).toMatchInlineSnapshot(`
Object {
"a": Object {
"children": Array [
"a--1",
"a--2",
],
"depth": 0,
"id": "a",
"isComponent": true,
"isLeaf": false,
"isRoot": false,
"name": "a",
"parent": undefined,
"renderLabel": undefined,
"type": "component",
},
"a--1": Object {
"depth": 1,
"id": "a--1",
"importPath": "./a.ts",
"isComponent": false,
"isLeaf": true,
"isRoot": false,
"kind": "a",
"name": "1",
"parent": "a",
"prepared": false,
"renderLabel": undefined,
"title": "a",
"type": "story",
},
"a--2": Object {
"depth": 1,
"id": "a--2",
"importPath": "./a.ts",
"isComponent": false,
"isLeaf": true,
"isRoot": false,
"kind": "a",
"name": "2",
"parent": "a",
"prepared": false,
"renderLabel": undefined,
"title": "a",
"type": "story",
},
}
`);
});
});
});
4 changes: 4 additions & 0 deletions code/lib/types/src/modules/api-stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,7 @@ export interface API_StatusObject {

export type API_StatusState = Record<StoryId, Record<string, API_StatusObject>>;
export type API_StatusUpdate = Record<StoryId, API_StatusObject>;

export type API_FilterFunction = (
item: API_IndexHash[keyof API_IndexHash] & { status: Record<string, API_StatusObject> }
yannbf marked this conversation as resolved.
Show resolved Hide resolved
) => boolean;
3 changes: 2 additions & 1 deletion code/lib/types/src/modules/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { RenderData } from '../../../router/src/types';
import type { Channel } from '../../../channels/src';
import type { ThemeVars } from '../../../theming/src/types';
import type { DocsOptions } from './core-common';
import type { API_HashEntry, API_IndexHash } from './api-stories';
import type { API_FilterFunction, API_HashEntry, API_IndexHash } from './api-stories';
import type { SetStoriesStory, SetStoriesStoryData } from './channelApi';
import type { Addon_BaseType, Addon_Collection, Addon_RenderOptions, Addon_Type } from './addons';
import type { StoryIndex } from './indexer';
Expand Down Expand Up @@ -112,6 +112,7 @@ export type API_ActiveTabsType = 'sidebar' | 'canvas' | 'addons';

export interface API_SidebarOptions {
showRoots?: boolean;
filters?: Record<string, API_FilterFunction>;
collapsedRoots?: string[];
renderLabel?: (item: API_HashEntry) => any;
}
Expand Down
5 changes: 3 additions & 2 deletions code/ui/.storybook/manager.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { addons, types } from '@storybook/manager-api';
import { IconButton, Icons } from '@storybook/components';
import startCase from 'lodash/startCase.js';
import React, { Fragment } from 'react';

addons.setConfig({
sidebar: {
renderLabel: ({ name, type }) => (type === 'story' ? name : startCase(name)),
// filters: {
// a: (item) => item.depth === 2,
// },
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
},
});
34 changes: 32 additions & 2 deletions code/ui/manager/src/containers/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import React, { useMemo } from 'react';

import type { Combo, StoriesHash } from '@storybook/manager-api';
import { Consumer } from '@storybook/manager-api';

import { Sidebar as SidebarComponent } from '../components/sidebar/Sidebar';
import { useMenu } from './menu';
import { getAncestorIds } from '../utils/tree';

export type Item = StoriesHash[keyof StoriesHash];

Expand All @@ -16,11 +17,12 @@ const Sidebar = React.memo(function Sideber() {
storyId,
refId,
layout: { showToolbar, isFullscreen, showPanel, showNav },
index,
index: originalIndex,
status,
indexError,
previewInitialized,
refs,
filters,
} = state;

const menu = useMenu(
Expand All @@ -36,6 +38,34 @@ const Sidebar = React.memo(function Sideber() {
const whatsNewNotificationsEnabled =
state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications;

const index = useMemo(() => {
if (!originalIndex) {
return originalIndex;
}

const filtered = new Set();
Object.values(originalIndex).forEach((item) => {
if (item.type === 'story' || item.type === 'docs') {
let result = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can result be called something more explicit?


Object.values(filters).forEach((filter) => {
if (result === true) {
result = filter({ ...item, status: status[item.id] });
}
});

if (result) {
filtered.add(item.id);
getAncestorIds(originalIndex, item.id).forEach((id) => {
filtered.add(id);
});
}
}
});

return Object.fromEntries(Object.entries(originalIndex).filter(([key]) => filtered.has(key)));
}, [originalIndex, filters, status]);

return {
title: name,
url,
Expand Down