diff --git a/code/addons/interactions/src/components/EmptyState.tsx b/code/addons/interactions/src/components/EmptyState.tsx new file mode 100644 index 000000000000..d4fa62c144a4 --- /dev/null +++ b/code/addons/interactions/src/components/EmptyState.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from '@storybook/components'; +import { DocumentIcon, VideoIcon } from '@storybook/icons'; +import { Consumer, useStorybookApi } from '@storybook/manager-api'; +import { styled } from '@storybook/theming'; + +import { DOCUMENTATION_LINK, TUTORIAL_VIDEO_LINK } from '../constants'; + +const Wrapper = styled.div(({ theme }) => ({ + height: '100%', + display: 'flex', + padding: 0, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + gap: 15, + background: theme.background.content, +})); + +const Content = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 4, + maxWidth: 415, +}); + +const Title = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.bold, + fontSize: theme.typography.size.s2 - 1, + textAlign: 'center', + color: theme.textColor, +})); + +const Description = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.regular, + fontSize: theme.typography.size.s2 - 1, + textAlign: 'center', + color: theme.textMutedColor, +})); + +const Links = styled.div(({ theme }) => ({ + display: 'flex', + fontSize: theme.typography.size.s2 - 1, + gap: 25, +})); + +const Divider = styled.div(({ theme }) => ({ + width: 1, + height: 16, + backgroundColor: theme.appBorderColor, +})); + +export const Empty = () => { + const [isLoading, setIsLoading] = useState(true); + const api = useStorybookApi(); + const docsUrl = api.getDocsUrl({ + subpath: DOCUMENTATION_LINK, + versioned: true, + renderer: true, + }); + + // We are adding a small delay to avoid flickering when the story is loading. + // It takes a bit of time for the controls to appear, so we don't want + // to show the empty state for a split second. + useEffect(() => { + const load = setTimeout(() => { + setIsLoading(false); + }, 100); + + return () => clearTimeout(load); + }, []); + + if (isLoading) return null; + + return ( + + + Interaction testing + + Interaction tests allow you to verify the functional aspects of UIs. Write a play function + for your story and you'll see it run here. + + + + + Watch 8m video + + + + {({ state }) => ( + + Read docs + + )} + + + + ); +}; diff --git a/code/addons/interactions/src/components/InteractionsPanel.tsx b/code/addons/interactions/src/components/InteractionsPanel.tsx index 0663656cc14b..a5b58d94b680 100644 --- a/code/addons/interactions/src/components/InteractionsPanel.tsx +++ b/code/addons/interactions/src/components/InteractionsPanel.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { Link, Placeholder } from '@storybook/components'; import { type Call, CallStates, type ControlStates } from '@storybook/instrumenter'; import { styled } from '@storybook/theming'; import { transparentize } from 'polished'; @@ -8,6 +7,7 @@ import { Subnav } from './Subnav'; import { Interaction } from './Interaction'; import { isTestAssertionError } from '../utils'; +import { Empty } from './EmptyState'; export interface Controls { start: (args: any) => void; @@ -40,7 +40,7 @@ interface InteractionsPanelProps { } const Container = styled.div(({ theme }) => ({ - minHeight: '100%', + height: '100%', background: theme.background.content, })); @@ -153,18 +153,7 @@ export const InteractionsPanel: React.FC = React.memo( )}
- {!isPlaying && !caughtException && interactions.length === 0 && ( - - No interactions found - - Learn how to add interactions to your story - - - )} + {!isPlaying && !caughtException && interactions.length === 0 && } ); } diff --git a/code/addons/interactions/src/constants.ts b/code/addons/interactions/src/constants.ts index 3c723e93852c..1b70ededc7c4 100644 --- a/code/addons/interactions/src/constants.ts +++ b/code/addons/interactions/src/constants.ts @@ -1,2 +1,5 @@ export const ADDON_ID = 'storybook/interactions'; export const PANEL_ID = `${ADDON_ID}/panel`; + +export const TUTORIAL_VIDEO_LINK = 'https://youtu.be/Waht9qq7AoA'; +export const DOCUMENTATION_LINK = 'writing-tests/interaction-testing'; diff --git a/code/builders/builder-manager/src/index.ts b/code/builders/builder-manager/src/index.ts index ef71811029f4..28e6fdd5a55e 100644 --- a/code/builders/builder-manager/src/index.ts +++ b/code/builders/builder-manager/src/index.ts @@ -24,6 +24,7 @@ import type { import { getData } from './utils/data'; import { safeResolve } from './utils/safeResolve'; import { readOrderedFiles } from './utils/files'; +import { buildFrameworkGlobalsFromOptions } from './utils/framework'; let compilation: Compilation; let asyncIterator: ReturnType | ReturnType; @@ -163,6 +164,9 @@ const starter: StarterFunction = async function* starterGeneratorFn({ const { cssFiles, jsFiles } = await readOrderedFiles(addonsDir, compilation?.outputFiles); + // Build additional global values + const globals: Record = await buildFrameworkGlobalsFromOptions(options); + yield; const html = await renderHTML( @@ -177,7 +181,8 @@ const starter: StarterFunction = async function* starterGeneratorFn({ logLevel, docsOptions, tagsOptions, - options + options, + globals ); yield; @@ -252,6 +257,9 @@ const builder: BuilderFunction = async function* builderGeneratorFn({ startTime, }); const { cssFiles, jsFiles } = await readOrderedFiles(addonsDir, compilation?.outputFiles); + // Build additional global values + const globals: Record = await buildFrameworkGlobalsFromOptions(options); + yield; const html = await renderHTML( @@ -266,7 +274,8 @@ const builder: BuilderFunction = async function* builderGeneratorFn({ startTime, logLevel, docsOptions, tagsOptions, - options + options, + globals ); await Promise.all([ diff --git a/code/builders/builder-manager/src/utils/framework.test.ts b/code/builders/builder-manager/src/utils/framework.test.ts new file mode 100644 index 000000000000..ecdcb42ead50 --- /dev/null +++ b/code/builders/builder-manager/src/utils/framework.test.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; +import { describe, it, expect } from 'vitest'; + +import { + pluckNameFromConfigProperty, + pluckStorybookPackageFromPath, + pluckThirdPartyPackageFromPath, +} from './framework'; + +describe('UTILITIES: Framework information', () => { + describe('UTILITY: pluckNameFromConfigProperty', () => { + it('should return undefined if the property is undefined', () => { + expect(pluckNameFromConfigProperty(undefined)).toBe(undefined); + }); + + it('should return the name if the property is a string', () => { + expect(pluckNameFromConfigProperty('foo')).toBe('foo'); + }); + + it('should return the name if the property is an object', () => { + expect(pluckNameFromConfigProperty({ name: 'foo' })).toBe('foo'); + }); + }); + + describe('UTILITY: pluckStorybookPackageFromPath', () => { + it('should return the package name if the path is a storybook package', () => { + const packagePath = path.join(process.cwd(), 'node_modules', '@storybook', 'foo'); + expect(pluckStorybookPackageFromPath(packagePath)).toBe('@storybook/foo'); + }); + + it('should return undefined if the path is not a storybook package', () => { + const packagePath = path.join(process.cwd(), 'foo'); + expect(pluckStorybookPackageFromPath(packagePath)).toBe(undefined); + }); + }); + + describe('UTILITY: pluckThirdPartyPackageFromPath', () => { + it('should return the package name if the path is a third party package', () => { + const packagePath = path.join(process.cwd(), 'node_modules', 'bar'); + expect(pluckThirdPartyPackageFromPath(packagePath)).toBe('bar'); + }); + + it('should return the given path if the path is not a third party package', () => { + const packagePath = path.join(process.cwd(), 'foo', 'bar', 'baz'); + expect(pluckThirdPartyPackageFromPath(packagePath)).toBe(packagePath); + }); + }); +}); diff --git a/code/builders/builder-manager/src/utils/framework.ts b/code/builders/builder-manager/src/utils/framework.ts new file mode 100644 index 000000000000..165b018ddf8f --- /dev/null +++ b/code/builders/builder-manager/src/utils/framework.ts @@ -0,0 +1,51 @@ +import path from 'path'; +import type { Options } from '@storybook/types'; + +interface PropertyObject { + name: string; + options?: Record; +} + +type Property = string | PropertyObject | undefined; + +export const pluckNameFromConfigProperty = (property: Property) => { + if (!property) { + return undefined; + } + + return typeof property === 'string' ? property : property.name; +}; + +// For replacing Windows backslashes with forward slashes +const normalizePath = (packagePath: string) => packagePath.replaceAll(path.sep, '/'); + +export const pluckStorybookPackageFromPath = (packagePath: string) => + normalizePath(packagePath).match(/(@storybook\/.*)$/)?.[1]; + +export const pluckThirdPartyPackageFromPath = (packagePath: string) => + normalizePath(packagePath).split('node_modules/')[1] ?? packagePath; + +export const buildFrameworkGlobalsFromOptions = async (options: Options) => { + const globals: Record = {}; + + const { renderer, builder } = await options.presets.apply('core'); + + const rendererName = pluckNameFromConfigProperty(renderer); + if (rendererName) { + globals.STORYBOOK_RENDERER = + pluckStorybookPackageFromPath(rendererName) ?? pluckThirdPartyPackageFromPath(rendererName); + } + + const builderName = pluckNameFromConfigProperty(builder); + if (builderName) { + globals.STORYBOOK_BUILDER = + pluckStorybookPackageFromPath(builderName) ?? pluckThirdPartyPackageFromPath(builderName); + } + + const framework = pluckNameFromConfigProperty(await options.presets.apply('framework')); + if (framework) { + globals.STORYBOOK_FRAMEWORK = framework; + } + + return globals; +}; diff --git a/code/builders/builder-manager/src/utils/template.ts b/code/builders/builder-manager/src/utils/template.ts index 4ccb2d50864a..dd2d1bc88e20 100644 --- a/code/builders/builder-manager/src/utils/template.ts +++ b/code/builders/builder-manager/src/utils/template.ts @@ -35,10 +35,15 @@ export const renderHTML = async ( logLevel: Promise, docsOptions: Promise, tagsOptions: Promise, - { versionCheck, previewUrl, configType, ignorePreview }: Options + { versionCheck, previewUrl, configType, ignorePreview }: Options, + globals: Record ) => { const titleRef = await title; const templateRef = await template; + const stringifiedGlobals = Object.entries(globals).reduce( + (transformed, [key, value]) => ({ ...transformed, [key]: JSON.stringify(value) }), + {} + ); return render(templateRef, { title: titleRef ? `${titleRef} - Storybook` : 'Storybook', @@ -54,6 +59,7 @@ export const renderHTML = async ( VERSIONCHECK: JSON.stringify(JSON.stringify(versionCheck), null, 2), PREVIEW_URL: JSON.stringify(previewUrl, null, 2), // global preview URL TAGS_OPTIONS: JSON.stringify(await tagsOptions, null, 2), + ...stringifiedGlobals, }, head: (await customHead) || '', ignorePreview, diff --git a/code/lib/manager-api/src/modules/versions.ts b/code/lib/manager-api/src/modules/versions.ts index a230da3a501f..a414c07fe073 100644 --- a/code/lib/manager-api/src/modules/versions.ts +++ b/code/lib/manager-api/src/modules/versions.ts @@ -23,6 +23,14 @@ const getVersionCheckData = memoize(1)((): API_Versions => { } }); +const normalizeRendererName = (renderer: string) => { + if (renderer.includes('vue')) { + return 'vue'; + } + + return renderer; +}; + export interface SubAPI { /** * Returns the current version of the Storybook Manager. @@ -36,6 +44,12 @@ export interface SubAPI { * @returns {API_Version} The latest version of the Storybook Manager. */ getLatestVersion: () => API_Version; + /** + * Returns the URL of the Storybook documentation for the current version. + * + * @returns {string} The URL of the Storybook Manager documentation. + */ + getDocsUrl: (options: { subpath?: string; versioned?: boolean; renderer?: boolean }) => string; /** * Checks if an update is available for the Storybook Manager. * @@ -73,6 +87,37 @@ export const init: ModuleFn = ({ store }) => { } return latest as API_Version; }, + // TODO: Move this to it's own "info" module later + getDocsUrl: ({ subpath, versioned, renderer }) => { + const { + versions: { latest, current }, + } = store.getState(); + + let url = 'https://storybook.js.org/docs/'; + + if (versioned && current?.version && latest?.version) { + const versionDiff = semver.diff(latest.version, current.version); + const isLatestDocs = versionDiff === 'patch' || versionDiff === null; + + if (!isLatestDocs) { + url += `${semver.major(current.version)}.${semver.minor(current.version)}/`; + } + } + + if (subpath) { + url += `${subpath}/`; + } + + if (renderer && typeof global.STORYBOOK_RENDERER !== 'undefined') { + const rendererName = (global.STORYBOOK_RENDERER as string).split('/').pop()?.toLowerCase(); + + if (rendererName) { + url += `?renderer=${normalizeRendererName(rendererName)}`; + } + } + + return url; + }, versionUpdateAvailable: () => { const latest = api.getLatestVersion(); const current = api.getCurrentVersion(); diff --git a/code/lib/manager-api/src/tests/versions.test.js b/code/lib/manager-api/src/tests/versions.test.js index 4f3e1cf93d7f..fbd29142b8d0 100644 --- a/code/lib/manager-api/src/tests/versions.test.js +++ b/code/lib/manager-api/src/tests/versions.test.js @@ -1,4 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { global } from '@storybook/global'; + import { init as initVersions } from '../modules/versions'; vi.mock('../version', () => ({ @@ -122,6 +124,135 @@ describe('versions API', () => { }); }); + describe('METHOD: getDocsUrl()', () => { + beforeEach(() => { + global.STORYBOOK_RENDERER = undefined; + }); + + it('returns the latest url when current version is latest', async () => { + const store = createMockStore(); + const { + init, + api, + state: initialState, + } = initVersions({ + store, + }); + + await init(); + + store.setState({ + ...initialState, + versions: { + ...initialState.versions, + current: { version: '7.6.1' }, + latest: { version: '7.6.1' }, + }, + }); + + expect(api.getDocsUrl({ versioned: true })).toEqual('https://storybook.js.org/docs/'); + }); + + it('returns the latest url when version has patch diff with latest', async () => { + const store = createMockStore(); + const { + init, + api, + state: initialState, + } = initVersions({ + store, + }); + + await init(); + + store.setState({ + ...initialState, + versions: { + ...initialState.versions, + current: { version: '7.6.1' }, + latest: { version: '7.6.10' }, + }, + }); + + expect(api.getDocsUrl({ versioned: true })).toEqual('https://storybook.js.org/docs/'); + }); + + it('returns the versioned url when current has different docs to latest', async () => { + const store = createMockStore(); + const { + init, + api, + state: initialState, + } = initVersions({ + store, + }); + + await init(); + + store.setState({ + ...initialState, + versions: { + ...initialState.versions, + current: { version: '7.2.5' }, + latest: { version: '7.6.10' }, + }, + }); + + expect(api.getDocsUrl({ versioned: true })).toEqual('https://storybook.js.org/docs/7.2/'); + }); + + it('returns the versioned url when current is a prerelease', async () => { + const store = createMockStore(); + const { + init, + api, + state: initialState, + } = initVersions({ + store, + }); + + await init(); + + store.setState({ + ...initialState, + versions: { + ...initialState.versions, + current: { version: '8.0.0-beta' }, + latest: { version: '7.6.10' }, + }, + }); + + expect(api.getDocsUrl({ versioned: true })).toEqual('https://storybook.js.org/docs/8.0/'); + }); + + it('returns a Url with a renderer query param when "renderer" is true', async () => { + const store = createMockStore(); + const { + init, + api, + state: initialState, + } = initVersions({ + store, + }); + store.setState({ + ...initialState, + versions: { + ...initialState.versions, + current: { version: '5.2.1' }, + latest: { version: '5.2.1' }, + }, + }); + + await init(); + + global.STORYBOOK_RENDERER = 'vue'; + + expect(api.getDocsUrl({ renderer: true })).toEqual( + 'https://storybook.js.org/docs/?renderer=vue' + ); + }); + }); + describe('versionUpdateAvailable', () => { it('matching version', async () => { const store = createMockStore(); diff --git a/code/lib/manager-api/src/typings.d.ts b/code/lib/manager-api/src/typings.d.ts index 63edef9413da..afbe02b0417f 100644 --- a/code/lib/manager-api/src/typings.d.ts +++ b/code/lib/manager-api/src/typings.d.ts @@ -8,3 +8,6 @@ declare var REFS: any; declare var VERSIONCHECK: any; declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined; declare var STORYBOOK_ADDON_STATE: Record; +declare var STORYBOOK_RENDERER: string | undefined; +declare var STORYBOOK_BUILDER: string | undefined; +declare var STORYBOOK_FRAMEWORK: string | undefined; diff --git a/code/ui/blocks/src/components/ArgsTable/ArgsTable.tsx b/code/ui/blocks/src/components/ArgsTable/ArgsTable.tsx index c0a9e8b0d0bc..70e4e1f0edd7 100644 --- a/code/ui/blocks/src/components/ArgsTable/ArgsTable.tsx +++ b/code/ui/blocks/src/components/ArgsTable/ArgsTable.tsx @@ -7,7 +7,7 @@ import { includeConditionalArg } from '@storybook/csf'; import { once } from '@storybook/client-logger'; import { IconButton, ResetWrapper, Link } from '@storybook/components'; -import { UndoIcon } from '@storybook/icons'; +import { DocumentIcon, UndoIcon } from '@storybook/icons'; import { ArgRow } from './ArgRow'; import { SectionRow } from './SectionRow'; import type { ArgType, ArgTypes, Args, Globals } from './types'; @@ -323,7 +323,7 @@ export const ArgsTable: FC = (props) => { {error}  - Read the docs + Read the docs ); diff --git a/code/ui/blocks/src/components/ArgsTable/Empty.tsx b/code/ui/blocks/src/components/ArgsTable/Empty.tsx index 796a3dcfba39..c4269a605f95 100644 --- a/code/ui/blocks/src/components/ArgsTable/Empty.tsx +++ b/code/ui/blocks/src/components/ArgsTable/Empty.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; import { styled } from '@storybook/theming'; import { Link } from '@storybook/components'; -import { VideoIcon } from '@storybook/icons'; +import { DocumentIcon, SupportIcon, VideoIcon } from '@storybook/icons'; interface EmptyProps { inAddonPanel?: boolean; @@ -92,21 +92,17 @@ export const Empty: FC = ({ inAddonPanel }) => { - Read docs + Read docs )} {!inAddonPanel && ( - - Learn how to set that up + + Learn how to set that up )} diff --git a/code/ui/manager/src/components/sidebar/Menu.stories.tsx b/code/ui/manager/src/components/sidebar/Menu.stories.tsx index ca89361f179e..98788518db4b 100644 --- a/code/ui/manager/src/components/sidebar/Menu.stories.tsx +++ b/code/ui/manager/src/components/sidebar/Menu.stories.tsx @@ -56,6 +56,7 @@ export const Expanded: Story = { getAddonsShortcuts: () => ({}), versionUpdateAvailable: () => false, isWhatsNewUnread: () => true, + getDocsUrl: () => 'https://storybook.js.org/docs/', }, false, false, @@ -99,6 +100,7 @@ export const ExpandedWithoutWhatsNew: Story = { getAddonsShortcuts: () => ({}), versionUpdateAvailable: () => false, isWhatsNewUnread: () => false, + getDocsUrl: () => 'https://storybook.js.org/docs/', }, false, false, diff --git a/code/ui/manager/src/container/Menu.tsx b/code/ui/manager/src/container/Menu.tsx index 2c8fb64c01a3..77df6aecdd01 100644 --- a/code/ui/manager/src/container/Menu.tsx +++ b/code/ui/manager/src/container/Menu.tsx @@ -5,7 +5,7 @@ import { Badge } from '@storybook/components'; import type { API, State } from '@storybook/manager-api'; import { shortcutToHumanString } from '@storybook/manager-api'; import { styled, useTheme } from '@storybook/theming'; -import { CheckIcon } from '@storybook/icons'; +import { CheckIcon, InfoIcon, ShareAltIcon, WandIcon } from '@storybook/icons'; const focusableUIElements = { storySearchField: 'storybook-explorer-searchfield', @@ -65,10 +65,22 @@ export const useMenu = ( id: 'about', title: 'About your Storybook', onClick: () => api.changeSettingsTab('about'), + icon: , }), [api] ); + const documentation = useMemo(() => { + const docsUrl = api.getDocsUrl({ versioned: true, renderer: true }); + + return { + id: 'documentation', + title: 'Documentation', + href: docsUrl, + icon: , + }; + }, [api]); + const whatsNewNotificationsEnabled = state.whatsNewData?.status === 'SUCCESS' && !state.disableWhatsNewNotifications; const isWhatsNewUnread = api.isWhatsNewUnread(); @@ -80,6 +92,7 @@ export const useMenu = ( right: whatsNewNotificationsEnabled && isWhatsNewUnread && ( Check it out ), + icon: , }), [api, whatsNewNotificationsEnabled, isWhatsNewUnread] ); @@ -104,7 +117,7 @@ export const useMenu = ( onClick: () => api.toggleNav(), active: isNavShown, right: enableShortcuts ? : null, - left: isNavShown ? : null, + icon: isNavShown ? : null, }), [api, enableShortcuts, shortcutKeys, isNavShown] ); @@ -116,7 +129,7 @@ export const useMenu = ( onClick: () => api.toggleToolbar(), active: showToolbar, right: enableShortcuts ? : null, - left: showToolbar ? : null, + icon: showToolbar ? : null, }), [api, enableShortcuts, shortcutKeys, showToolbar] ); @@ -128,7 +141,7 @@ export const useMenu = ( onClick: () => api.togglePanel(), active: isPanelShown, right: enableShortcuts ? : null, - left: isPanelShown ? : null, + icon: isPanelShown ? : null, }), [api, enableShortcuts, shortcutKeys, isPanelShown] ); @@ -150,7 +163,7 @@ export const useMenu = ( onClick: () => api.toggleFullscreen(), active: isFullscreen, right: enableShortcuts ? : null, - left: isFullscreen ? : null, + icon: isFullscreen ? : null, }), [api, enableShortcuts, shortcutKeys, isFullscreen] ); @@ -232,6 +245,7 @@ export const useMenu = ( () => [ about, ...(state.whatsNewData?.status === 'SUCCESS' ? [whatsNew] : []), + documentation, shortcuts, sidebarToggle, toolbarToogle, @@ -250,6 +264,7 @@ export const useMenu = ( about, state, whatsNew, + documentation, shortcuts, sidebarToggle, toolbarToogle,