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

Addon API: Add experimental page addon type #23307

Merged
merged 37 commits into from
Jul 13, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
683c480
add a new addon type: Page, refactor manager layout to allow for pages
ndelangen Jul 4, 2023
f28c739
apply router changes from https://github.com/storybookjs/storybook/pu…
ndelangen Jul 4, 2023
d31d7cb
make `/` the default path, even when there's no query in the URL
ndelangen Jul 4, 2023
2724a96
cleanup
ndelangen Jul 4, 2023
dbc587f
fix an ancient bug where navigating before preview load, the preview …
ndelangen Jul 4, 2023
40daf32
Merge branch 'norbert/ui-tabs-types-improvements' into norbert/page-a…
ndelangen Jul 4, 2023
6c26daf
cleanup
ndelangen Jul 4, 2023
4486548
fix
ndelangen Jul 4, 2023
ca586a4
fix
ndelangen Jul 4, 2023
3837bdc
Merge branch 'norbert/ui-tabs-types-improvements' into norbert/page-a…
ndelangen Jul 5, 2023
87e8e14
Merge branch 'norbert/ui-tabs-types-improvements' into norbert/page-a…
ndelangen Jul 6, 2023
2a8fe23
Merge branch 'norbert/page-addons-refactor' of github.com:storybookjs…
ndelangen Jul 6, 2023
155eb12
Merge branch 'norbert/ui-tabs-types-improvements' into norbert/page-a…
ndelangen Jul 6, 2023
2a22fc6
Merge branch 'norbert/ui-tabs-types-improvements' into norbert/page-a…
ndelangen Jul 6, 2023
c9dcee1
Merge branch 'norbert/ui-tabs-types-improvements' into norbert/page-a…
ndelangen Jul 6, 2023
ad0a0fa
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 7, 2023
13ec5f6
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 7, 2023
bce1c1e
rename
ndelangen Jul 7, 2023
d67aeba
cleanup
ndelangen Jul 7, 2023
ccf22aa
I like tests
ndelangen Jul 7, 2023
1af8f33
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 10, 2023
ad444c7
Update code/lib/preview-api/src/modules/preview-web/PreviewWithSelect…
ndelangen Jul 11, 2023
8b33973
remove the fake generic type argument
ndelangen Jul 11, 2023
dde330c
improve readability of api's stories module init
ndelangen Jul 11, 2023
36f4a7a
fix cyclical state setting
ndelangen Jul 11, 2023
9f79059
addon render function are now called like React elements, thus keys a…
ndelangen Jul 11, 2023
d9e2984
fix type issues
ndelangen Jul 11, 2023
80e9be5
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 11, 2023
e981fb2
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 12, 2023
9027184
Merge branch 'norbert/page-addons-refactor' of github.com:storybookjs…
ndelangen Jul 12, 2023
428e6ce
add test for early setting of selection in preview-web
ndelangen Jul 12, 2023
701d560
add migration documentation
ndelangen Jul 12, 2023
f0b368d
move test
ndelangen Jul 12, 2023
f2b43c9
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 12, 2023
2aa315e
Straighten out behaviour when messages are received during init
tmeasday Jul 13, 2023
df8bf31
fix
ndelangen Jul 13, 2023
d5b712b
Merge branch 'release/7.2' into norbert/page-addons-refactor
ndelangen Jul 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
35 changes: 20 additions & 15 deletions code/lib/manager-api/src/index.tsx
Expand Up @@ -184,7 +184,7 @@ class ManagerProvider extends Component<ManagerProviderProps, State> {
location,
path,
refId,
viewMode = props.docsOptions.docsMode ? 'docs' : 'story',
viewMode = props.docsOptions.docsMode ? 'docs' : props.viewMode,
singleStory,
storyId,
docsOptions,
Expand Down Expand Up @@ -419,36 +419,41 @@ const addonStateCache: {
// shared state
export function useSharedState<S>(stateId: string, defaultState?: S) {
const api = useStorybookApi();
const existingState = api.getAddonState<S>(stateId);
const existingState = api.getAddonState<S>(stateId) || addonStateCache[stateId];
const state = orDefault<S>(
existingState,
addonStateCache[stateId] ? addonStateCache[stateId] : defaultState
);
const setState = (s: S | API_StateMerger<S>, options?: Options) => {
// set only after the stories are loaded
if (addonStateCache[stateId]) {

if (api.getAddonState(stateId) !== state) {
api.setAddonState<S>(stateId, state).then((s) => {
addonStateCache[stateId] = s;
}
api.setAddonState<S>(stateId, s, options);
});
}

const setState = (s: S | API_StateMerger<S>, options?: Options) => {
const result = api.setAddonState<S>(stateId, s, options);
addonStateCache[stateId] = result;
return result;
};
const allListeners = useMemo(() => {
const stateChangeHandlers = {
[`${SHARED_STATE_CHANGED}-client-${stateId}`]: (s: S) => setState(s),
[`${SHARED_STATE_SET}-client-${stateId}`]: (s: S) => setState(s),
[`${SHARED_STATE_CHANGED}-client-${stateId}`]: setState,
[`${SHARED_STATE_SET}-client-${stateId}`]: setState,
};
const stateInitializationHandlers = {
[SET_STORIES]: () => {
[SET_STORIES]: async () => {
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
const currentState = api.getAddonState(stateId);
if (currentState) {
addonStateCache[stateId] = currentState;
api.emit(`${SHARED_STATE_SET}-manager-${stateId}`, currentState);
} else if (addonStateCache[stateId]) {
// this happens when HMR
setState(addonStateCache[stateId]);
await setState(addonStateCache[stateId]);
api.emit(`${SHARED_STATE_SET}-manager-${stateId}`, addonStateCache[stateId]);
} else if (defaultState !== undefined) {
// if not HMR, yet the defaults are from the manager
setState(defaultState);
await setState(defaultState);
// initialize addonStateCache after first load, so its available for subsequent HMR
addonStateCache[stateId] = defaultState;
api.emit(`${SHARED_STATE_SET}-manager-${stateId}`, defaultState);
Expand All @@ -472,9 +477,9 @@ export function useSharedState<S>(stateId: string, defaultState?: S) {
const emit = useChannel(allListeners);
return [
state,
(newStateOrMerger: S | API_StateMerger<S>, options?: Options) => {
setState(newStateOrMerger, options);
emit(`${SHARED_STATE_CHANGED}-manager-${stateId}`, newStateOrMerger);
async (newStateOrMerger: S | API_StateMerger<S>, options?: Options) => {
const result = await setState(newStateOrMerger, options);
emit(`${SHARED_STATE_CHANGED}-manager-${stateId}`, result);
},
] as [S, (newStateOrMerger: S | API_StateMerger<S>, options?: Options) => void];
}
Expand Down
55 changes: 46 additions & 9 deletions code/lib/manager-api/src/lib/addons.ts
Expand Up @@ -8,6 +8,8 @@ import type {
Addon_Elements,
Addon_Loaders,
Addon_Type,
Addon_BaseType,
Addon_PageType,
Addon_Types,
} from '@storybook/types';
import { Addon_TypesEnum } from '@storybook/types';
Expand Down Expand Up @@ -93,28 +95,56 @@ export class AddonStore {
this.serverChannel = channel;
};

getElements = (type: Addon_Types): Addon_Collection => {
getElements<T extends Addon_Types | Addon_TypesEnum.experimental_PAGE>(
type: T
): T extends Addon_TypesEnum.experimental_PAGE
? Addon_Collection<Addon_PageType>
: Addon_Collection<Addon_BaseType> {
if (!this.elements[type]) {
this.elements[type] = {};
}
// @ts-expect-error (Kaspar told me to do this)
return this.elements[type];
};
}

/**
* Adds a panel to the addon store.
* @param {string} id - The id of the panel.
* @param {Addon_Type} options - The options for the panel.
* @returns {void}
*
* @deprecated Use the 'add' method instead.
* @example
* addons.add('My Panel', {
* title: 'My Title',
* type: types.PANEL,
* render: () => <div>My Content</div>,
* });
*/
addPanel = (
id: string,
options: Omit<Addon_Type, 'type' | 'id'> & DeprecatedAddonWithId
options: Omit<Addon_BaseType, 'type' | 'id'> & DeprecatedAddonWithId
): void => {
this.add(id, {
type: Addon_TypesEnum.PANEL,
...options,
});
};

add = (id: string, addon: Omit<Addon_Type, 'id'> & DeprecatedAddonWithId) => {
/**
* Adds an addon to the addon store.
* @param {string} id - The id of the addon.
* @param {Addon_Type} addon - The addon to add.
* @returns {void}
*/
add(
id: string,
addon: Addon_BaseType | (Omit<Addon_PageType, 'id'> & DeprecatedAddonWithId)
): void {
const { type } = addon;
const collection = this.getElements(type);
collection[id] = { id, ...addon };
};
}

setConfig = (value: Addon_Config) => {
Object.assign(this.config, value);
Expand All @@ -129,11 +159,18 @@ export class AddonStore {

getConfig = () => this.config;

register = (name: string, registerCallback: (api: API) => void): void => {
if (this.loaders[name]) {
logger.warn(`${name} was loaded twice, this could have bad side-effects`);
/**
* Registers an addon loader function.
*
* @param {string} id - The id of the addon loader.
* @param {(api: API) => void} callback - The function that will be called to register the addon.
* @returns {void}
*/
register = (id: string, callback: (api: API) => void): void => {
if (this.loaders[id]) {
logger.warn(`${id} was loaded twice, this could have bad side-effects`);
}
this.loaders[name] = registerCallback;
this.loaders[id] = callback;
};

loadAddons = (api: any) => {
Expand Down
19 changes: 14 additions & 5 deletions code/lib/manager-api/src/modules/addons.ts
@@ -1,7 +1,8 @@
import type {
Addon_Type,
Addon_BaseType,
Addon_Collection,
Addon_PageType,
Addon_Types,
API_Collection,
API_Panels,
API_StateMerger,
} from '@storybook/types';
Expand All @@ -18,11 +19,19 @@ export interface SubAPI {
/**
* Returns a collection of elements of a specific type.
* @protected This is used internally in storybook's manager.
* @template FAKE - The type of the elements in the collection.
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
* @template T - The type of the elements in the collection.
* @param {Addon_Types} type - The type of the elements to retrieve.
* @param {Addon_Types | Addon_TypesEnum.experimental_PAGE} type - The type of the elements to retrieve.
* @returns {API_Collection<T>} - A collection of elements of the specified type.
*/
getElements: <T = Addon_Type>(type: Addon_Types) => API_Collection<T>;
getElements: <
FAKE = any,
T extends Addon_Types | Addon_TypesEnum.experimental_PAGE = Addon_Types
>(
type: T
) => T extends Addon_TypesEnum.experimental_PAGE
? Addon_Collection<Addon_PageType>
: Addon_Collection<Addon_BaseType>;
/**
* Returns a collection of all panels.
* This is the same as calling getElements('panel')
Expand Down Expand Up @@ -101,7 +110,7 @@ export const init: ModuleFn<SubAPI, SubState> = ({ provider, store, fullAPI }) =

const { parameters } = story;

const filteredPanels: API_Collection = {};
const filteredPanels: Addon_Collection<Addon_BaseType> = {};
Object.entries(allPanels).forEach(([id, panel]) => {
const { paramKey } = panel;
if (paramKey && parameters && parameters[paramKey] && parameters[paramKey].disable) {
Expand Down
16 changes: 15 additions & 1 deletion code/lib/manager-api/src/modules/settings.ts
@@ -1,7 +1,9 @@
import type { API_Settings } from '@storybook/types';
import type { API_Settings, StoryId } from '@storybook/types';
import type { ModuleFn } from '../index';

export interface SubAPI {
storeSelection: () => void;
retrieveSelection: () => StoryId;
/**
* Changes the active settings tab.
* @param path - The path of the settings page to navigate to. The path NOT should include the `/settings` prefix.
Expand Down Expand Up @@ -62,6 +64,18 @@ export const init: ModuleFn<SubAPI, SubState> = ({ store, navigate, fullAPI }) =

navigate(path);
},
retrieveSelection() {
const { settings } = store.getState();

return settings.lastTrackedStoryId;
},
storeSelection: async () => {
const { storyId, settings } = store.getState();

await store.setState({
settings: { ...settings, lastTrackedStoryId: storyId },
});
},
};

return { state: { settings: { lastTrackedStoryId: null } }, api };
Expand Down
25 changes: 19 additions & 6 deletions code/lib/manager-api/src/modules/stories.ts
Expand Up @@ -35,6 +35,7 @@ import {
CURRENT_STORY_WAS_SET,
STORY_MISSING,
DOCS_PREPARED,
SET_CURRENT_STORY,
} from '@storybook/core-events';
import { logger } from '@storybook/client-logger';

Expand Down Expand Up @@ -600,13 +601,25 @@ export const init: ModuleFn<SubAPI, SubState, true> = ({
const { sourceType } = getEventMetadata(this, fullAPI);

if (sourceType === 'local') {
if (fullAPI.isSettingsScreenActive()) return;

// Special case -- if we are already at the story being specified (i.e. the user started at a given story),
// we don't need to change URL. See https://github.com/storybookjs/storybook/issues/11677
const state = store.getState();
if (state.storyId !== storyId || state.viewMode !== viewMode) {
navigate(`/${viewMode}/${storyId}`);
/**
* When storybook starts, we want to navigate to the first story.
* But there are a few exceptions:
* - If the current storyId and viewMode are already set/correct.
* - If the user has navigated away already.
* - If the user started storybook with a specific page-URL like "/settings/about"
*/
if (state.path === '/' || state.viewMode === 'story' || state.viewMode === 'docs') {
if (
state.viewMode &&
state.storyId &&
state.viewMode !== viewMode &&
state.storyId !== storyId
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
) {
fullAPI.emit(SET_CURRENT_STORY, { storyId: state.storyId, viewMode: state.viewMode });
} else if (state.storyId !== storyId || state.viewMode !== viewMode) {
navigate(`/${viewMode}/${storyId}`);
}
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Expand Down
56 changes: 52 additions & 4 deletions code/lib/manager-api/src/tests/stories.test.ts
Expand Up @@ -699,8 +699,14 @@ describe('stories API', () => {
return false;
},
});
const store = createMockStore({});
const { init, api } = initStoriesAndSetState({ store, navigate, provider, fullAPI } as any);
const store = createMockStore({ viewMode: 'story' });
const { init, api } = initStoriesAndSetState({
store,
navigate,
provider,
fullAPI,
viewMode: 'story',
} as any);

Object.assign(fullAPI, api);
init();
Expand All @@ -717,7 +723,16 @@ describe('stories API', () => {
},
});
const store = createMockStore({ viewMode: 'story', storyId: 'a--1' });
initStoriesAndSetState({ store, navigate, provider, fullAPI } as any);
const { api, init } = initStoriesAndSetState({
store,
navigate,
provider,
fullAPI,
viewMode: 'story',
storyId: 'a--1',
} as any);
Object.assign(fullAPI, api);
init();

fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' });

Expand All @@ -732,7 +747,40 @@ describe('stories API', () => {
},
});
const store = createMockStore({ viewMode: 'settings', storyId: 'about' });
initStoriesAndSetState({ store, navigate, provider, fullAPI } as any);
const { api, init } = initStoriesAndSetState({
store,
navigate,
provider,
fullAPI,
viewMode: 'settings',
storyId: 'about',
} as any);
Object.assign(fullAPI, api);
init();

fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' });

expect(navigate).not.toHaveBeenCalled();
});

it('DOES not navigate if a custom page was selected', async () => {
const navigate = jest.fn();
const fullAPI = Object.assign(new EventEmitter(), {
isSettingsScreenActive() {
return true;
},
});
const store = createMockStore({ viewMode: 'custom', storyId: undefined });
const { api, init } = initStoriesAndSetState({
store,
navigate,
provider,
fullAPI,
viewMode: 'custom',
storyId: undefined,
} as any);
Object.assign(fullAPI, api);
init();

fullAPI.emit(STORY_SPECIFIED, { storyId: 'a--1', viewMode: 'story' });

Expand Down
Expand Up @@ -165,8 +165,8 @@ export class PreviewWithSelection<TFramework extends Renderer> extends Preview<T

const { id: storyId, type: viewMode } = entry;
this.selectionStore.setSelection({ storyId, viewMode });
this.channel.emit(STORY_SPECIFIED, this.selectionStore.selection);

this.channel.emit(STORY_SPECIFIED, this.selectionStore.selection);
this.channel.emit(CURRENT_STORY_WAS_SET, this.selectionStore.selection);

await this.renderSelection({ persistedArgs: args });
Expand Down Expand Up @@ -220,9 +220,15 @@ export class PreviewWithSelection<TFramework extends Renderer> extends Preview<T
}

async onSetCurrentStory(selection: { storyId: StoryId; viewMode?: ViewMode }) {
/**
* set state immediately.
* If the store is still in the init phase, we'll read it from the URL.
* But we might be receiving a SET_CURRENT_STORY event and we should load that story when we init.
*/
ndelangen marked this conversation as resolved.
Show resolved Hide resolved
this.selectionStore.setSelection({ viewMode: 'story', ...selection });

await this.storyStore.initializationPromise;

this.selectionStore.setSelection({ viewMode: 'story', ...selection });
this.channel.emit(CURRENT_STORY_WAS_SET, this.selectionStore.selection);
this.renderSelection();
}
Expand Down