Skip to content

Commit

Permalink
UI: Add links to documentation and videos in UI (#25565)
Browse files Browse the repository at this point in the history
- Add API function to get the versioned docs URL
- Add versioned link to docs into the sidebar menu
- Fix missing icons in sidebar menu
- Attach Storybook renderer to docs links
- Add education links to empty state of interaction tests panel
- Match icons across empty states
  • Loading branch information
Shaun Evening committed Jan 19, 2024
2 parents da24504 + a6fe163 commit 82baec1
Show file tree
Hide file tree
Showing 14 changed files with 431 additions and 34 deletions.
99 changes: 99 additions & 0 deletions code/addons/interactions/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Wrapper>
<Content>
<Title>Interaction testing</Title>
<Description>
Interaction tests allow you to verify the functional aspects of UIs. Write a play function
for your story and you&apos;ll see it run here.
</Description>
</Content>
<Links>
<Link href={TUTORIAL_VIDEO_LINK} target="_blank" withArrow>
<VideoIcon /> Watch 8m video
</Link>
<Divider />
<Consumer>
{({ state }) => (
<Link href={docsUrl} target="_blank" withArrow>
<DocumentIcon /> Read docs
</Link>
)}
</Consumer>
</Links>
</Wrapper>
);
};
17 changes: 3 additions & 14 deletions code/addons/interactions/src/components/InteractionsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -40,7 +40,7 @@ interface InteractionsPanelProps {
}

const Container = styled.div(({ theme }) => ({
minHeight: '100%',
height: '100%',
background: theme.background.content,
}));

Expand Down Expand Up @@ -153,18 +153,7 @@ export const InteractionsPanel: React.FC<InteractionsPanelProps> = React.memo(
</CaughtException>
)}
<div ref={endRef} />
{!isPlaying && !caughtException && interactions.length === 0 && (
<Placeholder>
No interactions found
<Link
href="https://storybook.js.org/docs/react/writing-stories/play-function"
target="_blank"
withArrow
>
Learn how to add interactions to your story
</Link>
</Placeholder>
)}
{!isPlaying && !caughtException && interactions.length === 0 && <Empty />}
</Container>
);
}
Expand Down
3 changes: 3 additions & 0 deletions code/addons/interactions/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
13 changes: 11 additions & 2 deletions code/builders/builder-manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StarterFunction> | ReturnType<BuilderFunction>;
Expand Down Expand Up @@ -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<string, any> = await buildFrameworkGlobalsFromOptions(options);

yield;

const html = await renderHTML(
Expand All @@ -177,7 +181,8 @@ const starter: StarterFunction = async function* starterGeneratorFn({
logLevel,
docsOptions,
tagsOptions,
options
options,
globals
);

yield;
Expand Down Expand Up @@ -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<string, any> = await buildFrameworkGlobalsFromOptions(options);

yield;

const html = await renderHTML(
Expand All @@ -266,7 +274,8 @@ const builder: BuilderFunction = async function* builderGeneratorFn({ startTime,
logLevel,
docsOptions,
tagsOptions,
options
options,
globals
);

await Promise.all([
Expand Down
48 changes: 48 additions & 0 deletions code/builders/builder-manager/src/utils/framework.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
51 changes: 51 additions & 0 deletions code/builders/builder-manager/src/utils/framework.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import path from 'path';
import type { Options } from '@storybook/types';

interface PropertyObject {
name: string;
options?: Record<string, any>;
}

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<string, any> = {};

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;
};
8 changes: 7 additions & 1 deletion code/builders/builder-manager/src/utils/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,15 @@ export const renderHTML = async (
logLevel: Promise<string>,
docsOptions: Promise<DocsOptions>,
tagsOptions: Promise<TagsOptions>,
{ versionCheck, previewUrl, configType, ignorePreview }: Options
{ versionCheck, previewUrl, configType, ignorePreview }: Options,
globals: Record<string, any>
) => {
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',
Expand All @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions code/lib/manager-api/src/modules/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*
Expand Down Expand Up @@ -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();
Expand Down
Loading

0 comments on commit 82baec1

Please sign in to comment.