diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d73b1c4b..7f2cce85 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,20 +1,19 @@ -import { ManagerContext } from 'storybook/manager-api'; import type { Decorator, Loader, Preview } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { graphql, HttpResponse } from 'msw'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import React from 'react'; +import { ManagerContext } from 'storybook/manager-api'; +import { fn, sb } from 'storybook/test'; import { - Global, - ThemeProvider, convert, createReset, + Global, styled, + ThemeProvider, themes, useTheme, } from 'storybook/theming'; -import { HttpResponse, graphql } from 'msw'; -import { initialize, mswLoader } from 'msw-storybook-addon'; -import React from 'react'; -import { AuthProvider } from '../src/AuthContext'; import { baseModes } from '../src/modes'; import { UninstallProvider } from '../src/screens/Uninstalled/UninstallContext'; import { RunBuildProvider } from '../src/screens/VisualTests/RunBuildContext'; @@ -23,6 +22,10 @@ import { storyWrapper } from '../src/utils/storyWrapper'; import { TelemetryProvider } from '../src/utils/TelemetryContext'; import { useSessionState } from '../src/utils/useSessionState'; +sb.mock(import('../src/utils/useAuth.ts')); +sb.mock(import('../src/utils/useSharedState.ts')); +sb.mock(import('../src/utils/useTestProviderStore.ts')); + // Initialize MSW initialize({ onUnhandledRequest(req) { @@ -127,15 +130,6 @@ const withTelemetry = storyWrapper(TelemetryProvider, () => ({ value: fn().mockName('telemetry'), })); -const withAuth = storyWrapper(AuthProvider, () => ({ - value: { - accessToken: 'token', - setAccessToken: fn(), - subdomain: 'www', - setSubdomain: fn(), - }, -})); - const withManagerApi = storyWrapper(ManagerContext.Provider, ({ argsByTarget }) => ({ value: { api: { ...argsByTarget['manager-api'] }, @@ -213,7 +207,6 @@ const preview: Preview = { withTheme, withGraphQLClient, withTelemetry, - withAuth, withUninstall, withManagerApi, withRunBuild, diff --git a/package.json b/package.json index 35aa3add..65320887 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@neoconfetti/react": "^1.0.0", - "chromatic": "^13.3.4", + "chromatic": "^14.0.0", "filesize": "^10.0.12", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" @@ -68,9 +68,9 @@ "@graphql-typed-document-node/core": "^3.2.0", "@parcel/watcher": "^2.4.1", "@storybook/addon-designs": "^11.1.1", - "@storybook/addon-docs": "^10.2.1", + "@storybook/addon-docs": "0.0.0-pr-33653-sha-6047da63", "@storybook/icons": "^2.0.1", - "@storybook/react-vite": "^10.2.1", + "@storybook/react-vite": "0.0.0-pr-33653-sha-6047da63", "@types/jsonfile": "^6.1.1", "@types/node": "^22.13.5", "@types/pluralize": "^0.0.29", @@ -91,7 +91,7 @@ "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-simple-import-sort": "^12.1.1", - "eslint-plugin-storybook": "^10.2.1", + "eslint-plugin-storybook": "0.0.0-pr-33653-sha-6047da63", "graphql": "^16.8.1", "msw": "^2.0.0", "msw-storybook-addon": "^2.0.6", @@ -105,7 +105,7 @@ "react-dom": "^18.3.1", "react-joyride": "^2.7.2", "rimraf": "^3.0.2", - "storybook": "^10.2.1", + "storybook": "0.0.0-pr-33653-sha-6047da63", "ts-dedent": "^2.2.0", "tsup": "^6.6.3", "typescript": "^5.7.3", @@ -119,7 +119,7 @@ "zx": "^1.14.1" }, "peerDependencies": { - "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + "storybook": "0.0.0-pr-33653-sha-6047da63" }, "packageManager": "yarn@4.12.0", "engines": { @@ -146,7 +146,8 @@ }, "bundler": { "exportEntries": [ - "./src/index.ts" + "./src/index.ts", + "./src/disableSnapshots.ts" ], "managerEntries": [ "./src/manager.tsx" diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx deleted file mode 100644 index 3967f3f5..00000000 --- a/src/AuthContext.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { createContext } from 'react'; - -import { useRequiredContext } from './utils/useRequiredContext'; - -interface AuthState { - accessToken: string | null; - setAccessToken: (accessToken: string | null) => void; - subdomain: string; - setSubdomain: (subdomain: string) => void; -} - -export const AuthContext = createContext(null); - -export const AuthProvider = ({ - children, - value, -}: { - children: React.ReactNode; - value: AuthState; -}) => { - return {children}; -}; - -export const useAuthState = () => useRequiredContext(AuthContext, 'AuthState'); diff --git a/src/Panel.tsx b/src/Panel.tsx index 7818caad..dc680ca0 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -1,8 +1,7 @@ import React, { useCallback } from 'react'; import type { API } from 'storybook/manager-api'; -import { experimental_getStatusStore, useChannel, useStorybookState } from 'storybook/manager-api'; +import { experimental_getStatusStore, useStorybookState } from 'storybook/manager-api'; -import { AuthProvider } from './AuthContext'; import { Spinner } from './components/design-system'; import { ADDON_ID, @@ -27,12 +26,13 @@ import { ControlsProvider } from './screens/VisualTests/ControlsContext'; import { RunBuildProvider } from './screens/VisualTests/RunBuildContext'; import { VisualTests } from './screens/VisualTests/VisualTests'; import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from './types'; -import { createClient, GraphQLClientProvider, useAccessToken } from './utils/graphQLClient'; +import { createClient, GraphQLClientProvider } from './utils/graphQLClient'; import { TelemetryProvider } from './utils/TelemetryContext'; +import { useAuth } from './utils/useAuth'; import { useBuildEvents } from './utils/useBuildEvents'; import { useChannelFetch } from './utils/useChannelFetch'; import { useProjectId } from './utils/useProjectId'; -import { clearSessionState, useSessionState } from './utils/useSessionState'; +import { useSessionState } from './utils/useSessionState'; import { useSharedState } from './utils/useSharedState'; interface PanelProps { @@ -42,15 +42,8 @@ interface PanelProps { const statusStore = experimental_getStatusStore(ADDON_ID); -export const Panel = ({ active }: PanelProps) => { - const [accessToken, updateAccessToken] = useAccessToken(); - const setAccessToken = useCallback( - (token: string | null) => { - updateAccessToken(token); - if (!token) clearSessionState('authenticationScreen', 'exchangeParameters'); - }, - [updateAccessToken] - ); +export const Panel = ({ active, api }: PanelProps) => { + const [auth] = useAuth(); const { storyId } = useStorybookState(); const [gitInfo] = useSharedState(GIT_INFO); @@ -60,7 +53,7 @@ export const Panel = ({ active }: PanelProps) => { const [localBuildProgress, setLocalBuildProgress] = useSharedState(LOCAL_BUILD_PROGRESS); const [, setOutdated] = useSharedState(IS_OUTDATED); - const emit = useChannel({}); + const { emit } = api; const updateBuildStatus = useCallback((statuses) => { statusStore.unset(); @@ -80,30 +73,30 @@ export const Panel = ({ active }: PanelProps) => { // If the user creates a project in a dialog (either during login or later, it get set here) const [createdProjectId, setCreatedProjectId] = useSessionState('createdProjectId'); const [addonUninstalled, setAddonUninstalled] = useSharedState(REMOVE_ADDON); - const [subdomain, setSubdomain] = useSessionState('subdomain', 'www'); const trackEvent = useCallback((data: any) => emit(TELEMETRY, data), [emit]); - const { isRunning, startBuild, stopBuild } = useBuildEvents({ localBuildProgress, accessToken }); + const { isRunning, startBuild, stopBuild } = useBuildEvents({ + localBuildProgress, + accessToken: auth.token, + }); const channelFetch = useChannelFetch(); const fetch = globalThis.LOGLEVEL === 'debug' ? globalThis.fetch : channelFetch; const withProviders = (children: React.ReactNode) => ( - - - - - - - - - + + + + + + + ); @@ -125,13 +118,9 @@ export const Panel = ({ active }: PanelProps) => { } // Render the Authentication flow if the user is not signed in. - if (!accessToken) { + if (!auth.token) { return withProviders( - + ); } diff --git a/src/ShareProviderRender.stories.tsx b/src/ShareProviderRender.stories.tsx new file mode 100644 index 00000000..08f88e08 --- /dev/null +++ b/src/ShareProviderRender.stories.tsx @@ -0,0 +1,197 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { API } from 'storybook/manager-api'; +import { fn, mocked } from 'storybook/test'; + +import { + GIT_INFO, + IS_OFFLINE, + LOCAL_BUILD_PROGRESS, + PROJECT_INFO, + TEST_PROVIDER_ID, +} from './constants'; +import { ShareProviderRender } from './ShareProviderRender'; +import { GraphQLClientProvider } from './utils/graphQLClient'; +import { MockChannel } from './utils/MockChannel'; +import { storyWrapper } from './utils/storyWrapper'; +import { AuthValue, useAuth } from './utils/useAuth'; +import { useSharedState } from './utils/useSharedState'; +import { getTestProviderStore, useTestProviderStore } from './utils/useTestProviderStore'; + +const channel = new MockChannel(); + +const api = { + getChannel: () => channel, + getAddonState: fn(), + setAddonState: fn(), + on: fn(), + off: fn(), + emit: fn(), +} as unknown as API; + +const meta = { + component: ShareProviderRender, + args: { + ...api, + api, + auth: { + token: null, + isOpen: false, + subdomain: 'www', + screen: 'welcome', + exchangeParameters: null, + } as AuthValue, + [PROJECT_INFO]: undefined, + [GIT_INFO]: undefined, + [LOCAL_BUILD_PROGRESS]: undefined, + [IS_OFFLINE]: false, + }, + argTypes: { + auth: { type: 'object', target: 'auth' }, + [TEST_PROVIDER_ID]: { + control: 'select', + options: [ + 'test-provider-state:pending', + 'test-provider-state:running', + 'test-provider-state:completed', + 'test-provider-state:aborted', + ], + target: 'test-provider-store', + }, + [LOCAL_BUILD_PROGRESS]: { type: 'object', target: 'shared-state' }, + [PROJECT_INFO]: { type: 'object', target: 'shared-state' }, + [GIT_INFO]: { type: 'object', target: 'shared-state' }, + [IS_OFFLINE]: { type: 'boolean', target: 'shared-state' }, + getAddonState: { type: 'function', target: 'manager-api' }, + setAddonState: { type: 'function', target: 'manager-api' }, + on: { type: 'function', target: 'manager-api' }, + off: { type: 'function', target: 'manager-api' }, + emit: { type: 'function', target: 'manager-api' }, + }, + beforeEach: ({ argsByTarget }) => { + mocked(useAuth).mockImplementation(() => [ + { + token: null, + isOpen: false, + subdomain: 'www', + screen: 'welcome', + exchangeParameters: null, + ...argsByTarget['auth'].auth, + }, + fn().mockName(`setAuth`), + ]); + mocked(useSharedState).mockImplementation((key: string) => [ + argsByTarget['shared-state'][key], + fn().mockName(`set:${key}`), + ]); + mocked(useTestProviderStore).mockImplementation( + () => argsByTarget['test-provider-store']?.[TEST_PROVIDER_ID] ?? 'test-provider-state:pending' + ); + mocked(getTestProviderStore).mockImplementation(() => ({ + getState: () => + argsByTarget['test-provider-store']?.[TEST_PROVIDER_ID] ?? 'test-provider-state:pending', + setState: fn(), + runWithState: fn(), + testProviderId: 'test-provider-id', + onRunAll: fn(), + onClearAll: fn(), + settingsChanged: fn(), + })); + }, + decorators: [storyWrapper(GraphQLClientProvider)], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Welcome: Story = {}; + +export const Login: Story = { + args: { + [PROJECT_INFO]: { projectId: '123' }, + }, +}; + +export const SetupGit: Story = { + args: { + ...Login.args, + auth: { token: 'test-token' }, + }, +}; + +export const Ready: Story = { + args: { + ...SetupGit.args, + [GIT_INFO]: { + slug: 'test-slug', + branch: 'test-branch', + commit: 'test-commit', + committedAt: 1717334400, + uncommittedHash: '', + userEmail: '', + userEmailHash: '', + repositoryRootDir: 'root', + }, + }, +}; + +export const Starting: Story = { + args: { + ...Ready.args, + [TEST_PROVIDER_ID]: 'test-provider-state:running', + }, +}; + +export const Uploading: Story = { + args: { + ...Starting.args, + [LOCAL_BUILD_PROGRESS]: { + currentStep: 'upload', + stepProgress: { upload: { numerator: 1_200_000, denominator: 2_300_000 } }, + }, + }, +}; + +export const Verifying: Story = { + args: { + ...Uploading.args, + [LOCAL_BUILD_PROGRESS]: { + currentStep: 'verify', + }, + }, +}; + +export const Completed: Story = { + args: { + ...Ready.args, + [TEST_PROVIDER_ID]: 'test-provider-state:succeeded', + [LOCAL_BUILD_PROGRESS]: { + currentStep: 'complete', + }, + }, +}; + +export const Crashed: Story = { + args: { + ...Ready.args, + [TEST_PROVIDER_ID]: 'test-provider-state:crashed', + [LOCAL_BUILD_PROGRESS]: { + currentStep: 'error', + }, + }, +}; + +export const Aborted: Story = { + args: { + ...Ready.args, + [LOCAL_BUILD_PROGRESS]: { + currentStep: 'aborted', + }, + }, +}; + +export const Offline: Story = { + args: { + ...Ready.args, + [IS_OFFLINE]: true, + }, +}; diff --git a/src/ShareProviderRender.tsx b/src/ShareProviderRender.tsx new file mode 100644 index 00000000..bfa29885 --- /dev/null +++ b/src/ShareProviderRender.tsx @@ -0,0 +1,329 @@ +import pluralize from 'pluralize'; +import React, { useCallback, useRef, useState } from 'react'; +import { Button, Link } from 'storybook/internal/components'; +import type { API } from 'storybook/manager-api'; +import { experimental_getStatusStore, useStorybookState } from 'storybook/manager-api'; +import { styled } from 'storybook/theming'; + +import { BUILD_STEP_CONFIG } from './buildSteps'; +import { Spinner } from './components/design-system'; +import { + ADDON_ID, + CONFIG_INFO, + GIT_INFO, + GIT_INFO_ERROR, + IS_OFFLINE, + LOCAL_BUILD_PROGRESS, + REMOVE_ADDON, + TELEMETRY, + TEST_PROVIDER_ID, +} from './constants'; +import { Authentication } from './screens/Authentication/Authentication'; +import { GitError } from './screens/Errors/GitError'; +import { LinkedProject } from './screens/LinkProject/LinkedProject'; +import { LinkingProjectFailed } from './screens/LinkProject/LinkingProjectFailed'; +import { LinkProject } from './screens/LinkProject/LinkProject'; +import { NoDevServer } from './screens/NoDevServer/NoDevServer'; +import { NoNetwork } from './screens/NoNetwork/NoNetwork'; +import { UninstallProvider } from './screens/Uninstalled/UninstallContext'; +import { Uninstalled } from './screens/Uninstalled/Uninstalled'; +import { ControlsProvider } from './screens/VisualTests/ControlsContext'; +import { RunBuildProvider } from './screens/VisualTests/RunBuildContext'; +import { + ConfigInfoPayload, + GitInfoPayload, + LocalBuildProgress, + UpdateStatusFunction, +} from './types'; +import { createClient, GraphQLClientProvider } from './utils/graphQLClient'; +import { TelemetryProvider } from './utils/TelemetryContext'; +import { useAuth } from './utils/useAuth'; +import { useBuildEvents } from './utils/useBuildEvents'; +import { useChannelFetch } from './utils/useChannelFetch'; +import { useProjectId } from './utils/useProjectId'; +import { useSessionState } from './utils/useSessionState'; +import { useSharedState } from './utils/useSharedState'; +import { useTestProviderStore } from './utils/useTestProviderStore'; + +const Container = styled.div(() => ({ + display: 'flex', + justifyContent: 'space-between', + padding: 24, + maxWidth: 500, + gap: 16, +})); + +const Content = styled.div(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 8, + fontSize: theme.typography.size.s1, + color: theme.color.defaultText, +})); + +const Title = styled.div(({ theme }) => ({ + fontWeight: theme.typography.weight.bold, + lineHeight: '20px', +})); + +const Description = styled.div(({ theme }) => ({ + color: theme.textMutedColor, +})); + +const statusStore = experimental_getStatusStore(ADDON_ID); + +export const ShareProviderRender = ({ api }: { api: API }) => { + const { addNotification, getStoryHrefs } = api; + const { storyId } = useStorybookState(); + + const { + loading: projectInfoLoading, + projectId, + configFile, + updateProject, + projectUpdatingFailed, + projectIdUpdated, + clearProjectIdUpdated, + } = useProjectId(); + + const [auth] = useAuth(); + const isLoggedIn = !!auth.token; + + const [isOffline] = useSharedState(IS_OFFLINE); + const [localBuildProgress] = useSharedState(LOCAL_BUILD_PROGRESS); + + const [configInfo] = useSharedState(CONFIG_INFO); + const hasConfigProblem = Object.keys(configInfo?.problems || {}).length > 0; + + const [gitInfoError] = useSharedState(GIT_INFO_ERROR); + + const [copied, setCopied] = useState(false); + + const lastStep = useRef(localBuildProgress?.currentStep); + + const testProviderState = useTestProviderStore( + (state) => state[TEST_PROVIDER_ID] ?? 'test-provider-state:pending' + ); + + const { isRunning, startBuild, stopBuild } = useBuildEvents({ + localBuildProgress, + accessToken: auth.token, + }); + + let warning: string | undefined; + if (isOffline) warning = 'Not available offline'; + if (hasConfigProblem) warning = 'Configuration problem'; + if (gitInfoError) warning = 'Git synchronization problem'; + if (!isLoggedIn) warning = 'Login required'; + if (!projectId) warning = 'Set up visual tests'; + + const isRunnable = !warning && testProviderState !== 'test-provider-state:crashed'; + + const startBuildIfPossible = useCallback(() => { + if (isRunnable) { + startBuild(true); + } + }, [isRunnable, startBuild]); + + // const clickNotification = useCallback(({ onDismiss }: { onDismiss: () => void }) => { + // onDismiss(); + // }, []); + + // useEffect(() => { + // if (localBuildProgress?.currentStep === lastStep.current) return; + // lastStep.current = localBuildProgress?.currentStep; + + // if (localBuildProgress?.currentStep === 'error') { + // addNotification({ + // id: `${ADDON_ID}/build-error/${Date.now()}`, + // content: { + // headline: 'Build error', + // subHeadline: 'Check the Storybook process on the command line for more details.', + // }, + // icon: , + // onClick: clickNotification, + // }); + // } + + // if (localBuildProgress?.currentStep === 'limited') { + // addNotification({ + // id: `${ADDON_ID}/build-limited/${Date.now()}`, + // content: { + // headline: 'Build limited', + // subHeadline: + // 'Your account has insufficient snapshots remaining to run this build. Visit your billing page to find out more.', + // }, + // icon: , + // onClick: clickNotification, + // }); + // } + // }, [addNotification, clickNotification, localBuildProgress?.currentStep]); + + const clickWarning = useCallback(() => {}, []); + + let description: string | React.ReactNode; + switch (true) { + case !!warning: + description = {warning}; + break; + case testProviderState === 'test-provider-state:running': + description = localBuildProgress?.storybookUrl + ? `Succesfully published` + : localBuildProgress + ? BUILD_STEP_CONFIG[localBuildProgress.currentStep].renderProgress(localBuildProgress) + : 'Starting...'; + break; + case localBuildProgress?.currentStep === 'aborted': + description = 'Aborted by user'; + break; + case localBuildProgress?.currentStep === 'complete': + description = localBuildProgress.errorCount + ? `Encountered ${pluralize('component error', localBuildProgress.errorCount, true)}` + : `Succesfully published`; + break; + default: + description = 'No snapshots will be taken'; + } + + const copyLink = () => { + const networkAddress = (globalThis as any).STORYBOOK_NETWORK_ADDRESS; + (globalThis as any).STORYBOOK_NETWORK_ADDRESS = localBuildProgress!.storybookUrl!; + const { managerHref } = getStoryHrefs(storyId, { base: 'network' }); + navigator.clipboard.writeText(managerHref); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + (globalThis as any).STORYBOOK_NETWORK_ADDRESS = networkAddress; + }; + + const [gitInfo] = useSharedState(GIT_INFO); + const { emit } = api; + + // If the user creates a project in a dialog (either during login or later, it get set here) + const [createdProjectId, setCreatedProjectId] = useSessionState('createdProjectId'); + const [addonUninstalled, setAddonUninstalled] = useSharedState(REMOVE_ADDON); + + const trackEvent = useCallback((data: any) => emit(TELEMETRY, data), [emit]); + + const channelFetch = useChannelFetch(); + const fetch = globalThis.LOGLEVEL === 'debug' ? globalThis.fetch : channelFetch; + const withProviders = (children: React.ReactNode) => ( + + + + + +
{children}
+
+
+
+
+
+ ); + + if (addonUninstalled) { + return withProviders(null); + } + + if (globalThis.CONFIG_TYPE !== 'DEVELOPMENT') { + return withProviders(); + } + + if (isOffline) { + return withProviders(); + } + + // Render the Authentication flow if the user is not signed in. + if (!auth.token) { + return withProviders( + + ); + } + + if (gitInfoError || !gitInfo) { + return withProviders(); + } + + // Momentarily wait on addonState (should be very fast) + if (projectInfoLoading) { + return ; + } + + if (!projectId) { + return withProviders( + + ); + } + + if (projectUpdatingFailed) { + // These should always be set when we get this error + if (!configFile) throw new Error(`Missing config file after configuration failure`); + return withProviders(); + } + + if (projectIdUpdated) { + // This should always be set when we succeed + if (!configFile) throw new Error(`Missing config file after configuration success`); + + return withProviders( + + ); + } + + return ( + + +
+ Upload a build to share + {description} +
+ {localBuildProgress?.storybookUrl ? ( + + ) : warning ? null : testProviderState === 'test-provider-state:running' ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/TestProviderRender.tsx b/src/TestProviderRender.tsx index fc2d7e87..19b15095 100644 --- a/src/TestProviderRender.tsx +++ b/src/TestProviderRender.tsx @@ -5,10 +5,10 @@ import React, { useCallback, useContext, useEffect, useRef } from 'react'; import { Link } from 'storybook/internal/components'; import { Button, ProgressSpinner } from 'storybook/internal/components'; import { + type API, experimental_getTestProviderStore, experimental_useStatusStore, experimental_useTestProviderStore, - useStorybookApi, useStorybookState, } from 'storybook/manager-api'; import { color, styled } from 'storybook/theming'; @@ -25,8 +25,8 @@ import { TEST_PROVIDER_ID, } from './constants'; import { ConfigInfoPayload, LocalBuildProgress } from './types'; -import { useAccessToken } from './utils/graphQLClient'; import { TelemetryContext } from './utils/TelemetryContext'; +import { useAuth } from './utils/useAuth'; import { useBuildEvents } from './utils/useBuildEvents'; import { useProjectId } from './utils/useProjectId'; import { useSharedState } from './utils/useSharedState'; @@ -67,8 +67,12 @@ const StopIcon = styled(StopAltIcon)({ width: 10, }); -export const TestProviderRender = () => { - const { addNotification, selectStory, setOptions, togglePanel } = useStorybookApi(); +interface Props { + api: API; +} + +export const TestProviderRender = ({ api }: Props) => { + const { addNotification, selectStory, setOptions, togglePanel } = api; const warningStatusCount = experimental_useStatusStore( (allStatuses) => Object.values(allStatuses) @@ -78,8 +82,8 @@ export const TestProviderRender = () => { const trackEvent = useContext(TelemetryContext); const { projectId } = useProjectId(); - const [accessToken] = useAccessToken(); - const isLoggedIn = !!accessToken; + const [auth] = useAuth(); + const isLoggedIn = !!auth.token; const [isOffline, setOffline] = useSharedState(IS_OFFLINE); const [isOutdated] = useSharedState(IS_OUTDATED); @@ -99,7 +103,7 @@ export const TestProviderRender = () => { const { startBuild, stopBuild } = useBuildEvents({ localBuildProgress, - accessToken, + accessToken: auth.token, }); let warning: string | undefined; diff --git a/src/components/FooterMenu.tsx b/src/components/FooterMenu.tsx index 966aaf27..a5c186f5 100644 --- a/src/components/FooterMenu.tsx +++ b/src/components/FooterMenu.tsx @@ -3,14 +3,14 @@ import React from 'react'; import { ActionList, PopoverProvider } from 'storybook/internal/components'; import { experimental_getStatusStore } from 'storybook/manager-api'; -import { useAuthState } from '../AuthContext'; import { ADDON_ID, PROJECT_INFO } from '../constants'; import { useControlsDispatch } from '../screens/VisualTests/ControlsContext'; import { ProjectInfoPayload } from '../types'; +import { useAuth } from '../utils/useAuth'; import { useSharedState } from '../utils/useSharedState'; export const FooterMenu = () => { - const { accessToken, setAccessToken, subdomain } = useAuthState(); + const [auth, setAuth] = useAuth(); const { toggleConfig } = useControlsDispatch(); const [projectInfo] = useSharedState(PROJECT_INFO); const statusStore = experimental_getStatusStore(ADDON_ID); @@ -54,7 +54,7 @@ export const FooterMenu = () => { @@ -66,13 +66,13 @@ export const FooterMenu = () => { )} - {accessToken && ( + {auth.token && ( { statusStore.unset(); - setAccessToken(null); + setAuth((s) => ({ ...s, token: null })); onHide(); }} > diff --git a/src/constants.ts b/src/constants.ts index 6cbbf08b..df67a728 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,6 +8,7 @@ export const PACKAGE_NAME = '@chromatic-com/storybook'; export const ADDON_ID = 'chromaui/addon-visual-tests'; export const PANEL_ID = `${ADDON_ID}/panel`; +export const SHARE_PROVIDER_ID = `${ADDON_ID}/share-provider`; export const TEST_PROVIDER_ID = `${ADDON_ID}/test-provider`; export const ACCESS_TOKEN_KEY = `${ADDON_ID}/access-token/${CHROMATIC_BASE_URL}`; export const DEV_BUILD_ID_KEY = `${ADDON_ID}/dev-build-id`; diff --git a/src/disableSnapshots.ts b/src/disableSnapshots.ts new file mode 100644 index 00000000..6617e4e9 --- /dev/null +++ b/src/disableSnapshots.ts @@ -0,0 +1,9 @@ +import { definePreviewAddon } from 'storybook/internal/csf'; + +export default definePreviewAddon({ + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, +}); diff --git a/src/manager.tsx b/src/manager.tsx index 8c593b5e..2d1ffbe7 100644 --- a/src/manager.tsx +++ b/src/manager.tsx @@ -1,9 +1,14 @@ import React from 'react'; -import { type Addon_TestProviderType, Addon_TypesEnum } from 'storybook/internal/types'; +import { + type Addon_ShareProviderType, + type Addon_TestProviderType, + Addon_TypesEnum, +} from 'storybook/internal/types'; import { addons, experimental_getStatusStore } from 'storybook/manager-api'; -import { ADDON_ID, PANEL_ID, PARAM_KEY, TEST_PROVIDER_ID } from './constants.ts'; +import { ADDON_ID, PANEL_ID, PARAM_KEY, SHARE_PROVIDER_ID, TEST_PROVIDER_ID } from './constants.ts'; import { Panel } from './Panel'; +import { ShareProviderRender } from './ShareProviderRender'; import { TestProviderRender } from './TestProviderRender'; addons.register(ADDON_ID, (api) => { @@ -26,8 +31,19 @@ addons.register(ADDON_ID, (api) => { api.togglePanel(true); }); + addons.add(SHARE_PROVIDER_ID, { + type: Addon_TypesEnum.experimental_SHARE_PROVIDER, + title: 'Share', + order: -1, + render: () => ( +
+ +
+ ), + } satisfies Omit); + addons.add(TEST_PROVIDER_ID, { type: Addon_TypesEnum.experimental_TEST_PROVIDER, - render: () => , + render: () => , } satisfies Omit); }); diff --git a/src/preset.ts b/src/preset.ts index 5db3fa16..2306f17f 100644 --- a/src/preset.ts +++ b/src/preset.ts @@ -13,7 +13,7 @@ import { import type { Channel } from 'storybook/internal/channels'; import { experimental_getTestProviderStore } from 'storybook/internal/core-server'; import { telemetry } from 'storybook/internal/telemetry'; -import type { Options } from 'storybook/internal/types'; +import type { Options, StorybookConfig } from 'storybook/internal/types'; import { ADDON_ID, @@ -172,6 +172,7 @@ const watchConfigFile = async ( async function serverChannel(channel: Channel, options: Options & { configFile?: string }) { const { configFile, presets } = options; + const addonVersion = await getAddonVersion().catch(() => null); // Handle relayed fetch requests from the client ChannelFetch.subscribe(ADDON_ID, channel); @@ -229,11 +230,12 @@ async function serverChannel(channel: Channel, options: Options & { configFile?: channel ); - channel.on(START_BUILD, async ({ accessToken: userToken }) => { + channel.on(START_BUILD, async ({ accessToken: userToken, isPublishOnly = false }) => { const { projectId } = projectInfoState.value || {}; testProviderStore.runWithState(async () => { try { - await runChromaticBuild(localBuildProgress, { configFile, projectId, userToken }); + const chromaticOptions = { configFile, projectId, userToken }; + await runChromaticBuild(localBuildProgress, chromaticOptions, options, isPublishOnly); } catch (e) { console.error(`Failed to run Chromatic build, with error:\n${e}`); throw e; @@ -248,7 +250,7 @@ async function serverChannel(channel: Channel, options: Options & { configFile?: channel.on(TELEMETRY, async (event: Event) => { if ((await corePromise).disableTelemetry) return; - telemetry('addon-visual-tests' as any, { ...event, addonVersion: await getAddonVersion() }); + telemetry('addon-visual-tests' as any, { ...event, addonVersion }); }); const configInfoState = SharedState.subscribe(CONFIG_INFO, channel); @@ -280,19 +282,23 @@ async function serverChannel(channel: Channel, options: Options & { configFile?: } const config = { + previewAnnotations: async (input = []) => { + const disableSnapshotsPreset = join( + dirname(require.resolve('@chromatic-com/storybook/package.json')), + 'dist/disableSnapshots.mjs' + ); + return process.env.SB_PUBLISH_ONLY === 'true' ? [...input, disableSnapshotsPreset] : input; + }, managerEntries, experimental_serverChannel: serverChannel, - staticDirs: async (inputDirs: string[]) => [ - ...inputDirs, + staticDirs: async (inputDirs) => [ + ...(inputDirs || []), { from: join(dirname(require.resolve('@chromatic-com/storybook/package.json')), 'assets'), to: 'addon-visual-tests-assets', }, ], - env: async ( - env: Record, - { configType }: { configType: 'DEVELOPMENT' | 'PRODUCTION' } - ) => { + env: async (env, { configType }) => { if (configType === 'PRODUCTION') return env; return { @@ -300,6 +306,12 @@ const config = { CHROMATIC_BASE_URL, }; }, +} satisfies Partial & { + managerEntries: (entry: string[]) => string[]; + experimental_serverChannel: ( + channel: Channel, + options: Options & { configFile?: string } + ) => Promise; }; export default config; diff --git a/src/runChromaticBuild.ts b/src/runChromaticBuild.ts index 48e06b22..0ab8bcac 100644 --- a/src/runChromaticBuild.ts +++ b/src/runChromaticBuild.ts @@ -1,4 +1,14 @@ -import { Context, InitialContext, Options, run, TaskName } from 'chromatic/node'; +import { join } from 'node:path'; + +import { + Context, + InitialContext, + Options as ChromaticOptions, + run, + TaskName, +} from 'chromatic/node'; +import { buildStaticStandalone } from 'storybook/internal/core-server'; +import type { Options as StorybookOptions } from 'storybook/internal/types'; import { BUILD_STEP_CONFIG, @@ -58,7 +68,7 @@ export const onStartOrProgress = throw new Error('Unexpected missing value for localBuildProgress'); } - const { buildProgressPercentage, stepProgress, previousBuildProgress } = + const { buildProgressPercentage, stepProgress, previousBuildProgress, isPublishOnly } = localBuildProgress.value; // Ignore progress events for steps that have already completed @@ -106,9 +116,11 @@ export const onStartOrProgress = localBuildProgress.value = { buildId: ctx.announcedBuild?.id, branch: ctx.git?.branch, + isPublishOnly, buildProgressPercentage: Math.min(newPercentage, endPercentage), currentStep: ctx.task, stepProgress, + storybookUrl: ctx.build?.storybookUrl, }; }; @@ -128,13 +140,15 @@ export const onCompleteOrError = throw new Error('Unexpected missing value for localBuildProgress'); } - const { buildProgressPercentage, stepProgress } = localBuildProgress.value; + const { buildProgressPercentage, stepProgress, isPublishOnly } = localBuildProgress.value; const update = { buildId: ctx.announcedBuild?.id, branch: ctx.git?.branch, + isPublishOnly, buildProgressPercentage, stepProgress, previousBuildProgress: stepProgress, + storybookUrl: ctx.build?.storybookUrl, }; if (error) { @@ -177,13 +191,16 @@ export const onCompleteOrError = export const runChromaticBuild = async ( localBuildProgress: ReturnType>, - options: Partial + chromaticOptions: Partial, + storybookOptions: StorybookOptions, + publishOnly: boolean ) => { - if (!options.projectId) throw new Error('Missing projectId'); - if (!options.userToken) throw new Error('Missing userToken'); + if (!chromaticOptions.projectId) throw new Error('Missing projectId'); + if (!chromaticOptions.userToken) throw new Error('Missing userToken'); // Set initial progress state. JSON.parse avoids mutating the constant. - localBuildProgress.value = JSON.parse(INITIAL_BUILD_PAYLOAD_JSON); + localBuildProgress.value = JSON.parse(INITIAL_BUILD_PAYLOAD_JSON) as LocalBuildProgress; + localBuildProgress.value.isPublishOnly = publishOnly; // Timeout is defined here so it's shared between all handlers let timeout: ReturnType | undefined; @@ -193,13 +210,25 @@ export const runChromaticBuild = async ( process.env.SB_TESTBUILD = 'true'; + const publishOnlyConfig: Partial = {}; + + if (publishOnly) { + process.env.SB_PUBLISH_ONLY = 'true'; + publishOnlyConfig.storybookBuildDir = join(process.cwd(), 'storybook-build-standalone'); + await buildStaticStandalone({ + ...storybookOptions, + outputDir: publishOnlyConfig.storybookBuildDir, + }); + } + await run({ flags: { interactive: false, }, options: { - ...options, + ...chromaticOptions, ...CONFIG_OVERRIDES, + ...publishOnlyConfig, experimental_onTaskStart: onStartOrProgress(localBuildProgress, timeout), experimental_onTaskProgress: onStartOrProgress(localBuildProgress, timeout), experimental_onTaskComplete: onCompleteOrError(localBuildProgress, timeout), diff --git a/src/screens/Authentication/Authentication.stories.tsx b/src/screens/Authentication/Authentication.stories.tsx index 2cdf6773..13470352 100644 --- a/src/screens/Authentication/Authentication.stories.tsx +++ b/src/screens/Authentication/Authentication.stories.tsx @@ -1,13 +1,14 @@ // @ts-nocheck TODO: Address SB 8 type errors import type { Meta, StoryObj } from '@storybook/react-vite'; import { http, HttpResponse } from 'msw'; -import { findByRole, fn, userEvent } from 'storybook/test'; +import { fn, mocked } from 'storybook/test'; import { panelModes } from '../../modes'; import { GraphQLClientProvider } from '../../utils/graphQLClient'; -import { playAll } from '../../utils/playAll'; import { storyWrapper } from '../../utils/storyWrapper'; +import { type AuthValue, useAuth } from '../../utils/useAuth'; import { clearSessionState } from '../../utils/useSessionState'; +import { useSharedState } from '../../utils/useSharedState'; import { withFigmaDesign } from '../../utils/withFigmaDesign'; import { withSetup } from '../../utils/withSetup'; import { Authentication } from './Authentication'; @@ -19,6 +20,24 @@ const meta = { setAccessToken: fn().mockName('setAccessToken'), hasProjectId: false, }, + argTypes: { + auth: { + control: 'object', + target: 'auth', + }, + }, + beforeEach: ({ argsByTarget }) => { + const auth: AuthValue = { + token: 'token', + isOpen: false, + subdomain: 'www', + screen: 'welcome', + exchangeParameters: null, + ...argsByTarget['auth']?.auth, + }; + mocked(useAuth).mockImplementation(() => [auth, fn().mockName('setAuth')]); + mocked(useSharedState).mockImplementation((key: string) => [null, fn().mockName(`set:${key}`)]); + }, parameters: { chromatic: { modes: panelModes, @@ -66,37 +85,35 @@ export const HasProjectId = { } satisfies Story; export const SignIn = { + args: { + auth: { screen: 'signin' }, + }, parameters: withFigmaDesign( 'https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=304-317993&t=3EAIRe8423CpOQWY-4' ), - play: playAll(async ({ canvasElement }) => { - const button = await findByRole(canvasElement, 'button', { - name: /Get started/, - }); - await userEvent.click(button); - }), } satisfies Story; export const SSO = { + args: { + auth: { screen: 'subdomain' }, + }, parameters: withFigmaDesign( 'https://www.figma.com/file/p4ZIW7diUWC2l2DAf5xpYI/Storybook-Connect-plugin-(EXTERNAL-USE)?type=design&node-id=1-1734&t=ysgtc5qR40kqRKtI-4' ), - play: playAll(SignIn, async (context) => { - const button = await findByRole(context.canvasElement, 'button', { - name: 'Sign in with SSO', - }); - await userEvent.click(button); - }), } satisfies Story; export const Verify = { + args: { + auth: { + screen: 'verify', + exchangeParameters: { + user_code: '123123', + verificationUrl: + 'https://www.chromatic.com/connect/chromaui:addon-visual-tests?code=123123', + }, + }, + }, parameters: withFigmaDesign( 'https://www.figma.com/file/GFEbCgCVDtbZhngULbw2gP/Visual-testing-in-Storybook?type=design&node-id=304-318063&t=3EAIRe8423CpOQWY-4' ), - play: playAll(SignIn, async (context) => { - const button = await findByRole(context.canvasElement, 'button', { - name: 'Sign in with Chromatic', - }); - await userEvent.click(button); - }), } satisfies Story; diff --git a/src/screens/Authentication/Authentication.tsx b/src/screens/Authentication/Authentication.tsx index 309ef3b1..d31dd4ab 100644 --- a/src/screens/Authentication/Authentication.tsx +++ b/src/screens/Authentication/Authentication.tsx @@ -1,11 +1,10 @@ import React, { useCallback } from 'react'; -import { useAuthState } from '../../AuthContext'; import { Project } from '../../gql/graphql'; -import { initiateSignin, TokenExchangeParameters } from '../../utils/requestAccessToken'; +import { initiateSignin } from '../../utils/requestAccessToken'; import { useTelemetry } from '../../utils/TelemetryContext'; +import { useAuth } from '../../utils/useAuth'; import { useErrorNotification } from '../../utils/useErrorNotification'; -import { useSessionState } from '../../utils/useSessionState'; import { useUninstallAddon } from '../Uninstalled/UninstallContext'; import { SetSubdomain } from './SetSubdomain'; import { SignIn } from './SignIn'; @@ -13,74 +12,75 @@ import { Verify } from './Verify'; import { Welcome } from './Welcome'; interface AuthenticationProps { - setAccessToken: (token: string | null) => void; setCreatedProjectId: (projectId: Project['id']) => void; hasProjectId: boolean; } -type AuthenticationScreen = 'welcome' | 'signin' | 'subdomain' | 'verify'; - -export const Authentication = ({ - setAccessToken, - setCreatedProjectId, - hasProjectId, -}: AuthenticationProps) => { - const [screen, setScreen] = useSessionState( - 'authenticationScreen', - hasProjectId ? 'signin' : 'welcome' - ); - const [exchangeParameters, setExchangeParameters] = - useSessionState('exchangeParameters'); +export const Authentication = ({ setCreatedProjectId, hasProjectId }: AuthenticationProps) => { const onError = useErrorNotification(); const { uninstallAddon } = useUninstallAddon(); - const { setSubdomain } = useAuthState(); + const [auth, setAuth] = useAuth(); + + const screen = auth.screen; useTelemetry('Authentication', screen.charAt(0).toUpperCase() + screen.slice(1)); const initiateSignInAndMoveToVerify = useCallback( async (subdomain?: string) => { try { - setSubdomain(subdomain ?? 'www'); - setExchangeParameters(await initiateSignin(subdomain)); - setScreen('verify'); + const exchangeParameters = await initiateSignin(subdomain); + setAuth((s) => ({ + ...s, + subdomain: subdomain ?? 'www', + exchangeParameters, + screen: 'verify', + })); } catch (err: any) { onError('Sign in Error', err); } }, - [onError, setExchangeParameters, setScreen, setSubdomain] + [onError, setAuth] ); if (screen === 'welcome' && !hasProjectId) { - return setScreen('signin')} onUninstall={uninstallAddon} />; + return ( + setAuth((s) => ({ ...s, screen: 'signin' }))} + onUninstall={uninstallAddon} + /> + ); } if (screen === 'signin' || (screen === 'welcome' && hasProjectId)) { return ( setScreen('welcome') } : {})} + {...(!hasProjectId ? { onBack: () => setAuth((s) => ({ ...s, screen: 'welcome' })) } : {})} onSignIn={initiateSignInAndMoveToVerify} - onSignInWithSSO={() => setScreen('subdomain')} + onSignInWithSSO={() => setAuth((s) => ({ ...s, screen: 'subdomain' }))} /> ); } if (screen === 'subdomain') { return ( - setScreen('signin')} onSignIn={initiateSignInAndMoveToVerify} /> + setAuth((s) => ({ ...s, screen: 'signin' }))} + onSignIn={initiateSignInAndMoveToVerify} + /> ); } if (screen === 'verify') { - if (!exchangeParameters) { + if (!auth.exchangeParameters) { throw new Error('Expected to have a `exchangeParameters` if at `verify` step'); } return ( setScreen('signin')} + onBack={() => setAuth((s) => ({ ...s, screen: 'signin' }))} + setAuth={setAuth} hasProjectId={hasProjectId} - setAccessToken={setAccessToken} setCreatedProjectId={setCreatedProjectId} - exchangeParameters={exchangeParameters} + exchangeParameters={auth.exchangeParameters} /> ); } diff --git a/src/screens/Authentication/Verify.tsx b/src/screens/Authentication/Verify.tsx index 17d994da..b2fc72d9 100644 --- a/src/screens/Authentication/Verify.tsx +++ b/src/screens/Authentication/Verify.tsx @@ -12,6 +12,7 @@ import { graphql } from '../../gql'; import { Project } from '../../gql/graphql'; import { getFetchOptions } from '../../utils/graphQLClient'; import { fetchAccessToken, TokenExchangeParameters } from '../../utils/requestAccessToken'; +import { useAuth } from '../../utils/useAuth'; import { DialogHandler, useChromaticDialog } from '../../utils/useChromaticDialog'; import { useErrorNotification } from '../../utils/useErrorNotification'; import { AuthHeader } from './AuthHeader'; @@ -49,26 +50,25 @@ const ProjectCountQuery = graphql(/* GraphQL */ ` interface VerifyProps { onBack: () => void; hasProjectId: boolean; - setAccessToken: (token: string) => void; setCreatedProjectId: (projectId: Project['id']) => void; exchangeParameters: TokenExchangeParameters; + setAuth: ReturnType[1]; } export const Verify = ({ onBack, hasProjectId, - setAccessToken, + setAuth, setCreatedProjectId, exchangeParameters, }: VerifyProps) => { const client = useClient(); const onError = useErrorNotification(); - const { user_code: userCode, verificationUrl } = exchangeParameters; // Store the access token until we are ready to pass it to `setAccessToken` (at which point // the Panel will close the Authentication screen) - const accessToken = useRef(); + const accessToken = useRef(null); const openDialogRef = useRef<(url: string) => void>(); const closeDialogRef = useRef<() => void>(); @@ -95,7 +95,7 @@ export const Verify = ({ // The user has projects to choose from (or the project is already selected), // so send them to pick one if (data.viewer.projectCount > 0 || hasProjectId) { - setAccessToken(accessToken.current); + setAuth((s) => ({ ...s, token: accessToken.current })); closeDialogRef.current?.(); } else { // The user has no projects, so we need to get them to create one, then close the dialog @@ -115,7 +115,7 @@ export const Verify = ({ if (!accessToken.current) { onError('Unexpected missing access token', new Error()); } else { - setAccessToken(accessToken.current); + setAuth((s) => ({ ...s, token: accessToken.current })); setCreatedProjectId(`Project:${event.projectId}`); closeDialogRef.current?.(); } @@ -126,7 +126,7 @@ export const Verify = ({ exchangeParameters, client, hasProjectId, - setAccessToken, + setAuth, onError, setCreatedProjectId, ] diff --git a/src/screens/VisualTests/NoBuild.tsx b/src/screens/VisualTests/NoBuild.tsx index 99147705..db7d54d4 100644 --- a/src/screens/VisualTests/NoBuild.tsx +++ b/src/screens/VisualTests/NoBuild.tsx @@ -5,7 +5,6 @@ import { useParameter } from 'storybook/manager-api'; import { styled } from 'storybook/theming'; import { CombinedError } from 'urql'; -import { useAuthState } from '../../AuthContext'; import { BuildProgressInline } from '../../components/BuildProgressBarInline'; import { Button } from '../../components/Button'; import { ButtonStack } from '../../components/ButtonStack'; @@ -19,6 +18,7 @@ import { Stack } from '../../components/Stack'; import { Text } from '../../components/Text'; import { DOCS_URL } from '../../constants'; import { LocalBuildProgress } from '../../types'; +import { useAuth } from '../../utils/useAuth'; import { ErrorBox } from '../Errors/BuildError'; import { useRunBuildState } from './RunBuildContext'; @@ -43,7 +43,7 @@ export const NoBuild = ({ localBuildProgress, branch, }: NoBuildProps) => { - const { setAccessToken } = useAuthState(); + const [, setAuth] = useAuth(); const { isRunning, startBuild } = useRunBuildState(); const { disable, disableSnapshot, docsOnly } = useParameter('chromatic', {} as any); @@ -89,7 +89,7 @@ export const NoBuild = ({ ariaLabel={false} size="medium" variant="solid" - onClick={() => setAccessToken(null)} + onClick={() => setAuth((s) => ({ ...s, token: null }))} > Log out @@ -115,7 +115,7 @@ export const NoBuild = ({ ariaLabel={false} size="medium" variant="solid" - onClick={() => setAccessToken(null)} + onClick={() => setAuth((s) => ({ ...s, token: null }))} > Log out @@ -143,7 +143,11 @@ export const NoBuild = ({ - setAccessToken(null)} withArrow> + setAuth((s) => ({ ...s, token: null }))} + withArrow + > Switch account diff --git a/src/types.ts b/src/types.ts index 4b7da287..063cec9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,9 @@ export type LocalBuildProgress = { */ branch?: string; + /** Whether the build is a publish-only build (i.e. no visual tests) */ + isPublishOnly?: boolean; + /** Overall percentage of build progress */ buildProgressPercentage: number; @@ -90,4 +93,7 @@ export type LocalBuildProgress = { /** Progress tracking data from the previous build (if any) */ previousBuildProgress?: Record; + + /** The URL of the published Storybook instance */ + storybookUrl?: string; }; diff --git a/src/utils/graphQLClient.tsx b/src/utils/graphQLClient.tsx index e75594b3..35583095 100644 --- a/src/utils/graphQLClient.tsx +++ b/src/utils/graphQLClient.tsx @@ -1,14 +1,15 @@ import { authExchange } from '@urql/exchange-auth'; import React from 'react'; -import { useAddonState } from 'storybook/manager-api'; import { Client, ClientOptions, fetchExchange, mapExchange, Provider } from 'urql'; import { v4 as uuid } from 'uuid'; -import { ACCESS_TOKEN_KEY, ADDON_ID, CHROMATIC_API_URL } from '../constants'; +import { ACCESS_TOKEN_KEY, CHROMATIC_API_URL } from '../constants'; let currentToken: string | null; let currentTokenExpiration: number | null; -const setCurrentToken = (token: string | null) => { + +export const getCurrentToken = () => currentToken; +export const persistCurrentToken = (token: string | null) => { try { const { exp } = token ? JSON.parse(atob(token.split('.')[1])) : { exp: null }; currentToken = token; @@ -24,25 +25,7 @@ const setCurrentToken = (token: string | null) => { } }; -setCurrentToken(localStorage.getItem(ACCESS_TOKEN_KEY)); - -export const useAccessToken = () => { - // We use an object rather than a straight boolean here due to https://github.com/storybookjs/storybook/pull/23991 - const [{ token }, setTokenState] = useAddonState<{ token: string | null }>( - `${ADDON_ID}/accessToken`, - { token: currentToken } - ); - - const updateToken = React.useCallback( - (newToken: string | null) => { - setCurrentToken(newToken); - setTokenState({ token: currentToken }); - }, - [setTokenState] - ); - - return [token, updateToken] as const; -}; +persistCurrentToken(localStorage.getItem(ACCESS_TOKEN_KEY)); const sessionId = uuid(); @@ -63,7 +46,7 @@ export const createClient = (options?: Partial) => onResult(result) { // Not all queries contain the `viewer` field, in which case it will be `undefined`. // When we do retrieve the field but the token is invalid, it will be `null`. - if (result.data?.viewer === null) setCurrentToken(null); + if (result.data?.viewer === null) persistCurrentToken(null); }, }), authExchange(async (utils) => { @@ -81,7 +64,7 @@ export const createClient = (options?: Partial) => // If didAuthError returns true, clear the token. Ideally we should refresh the token here. // The operation will be retried automatically. async refreshAuth() { - setCurrentToken(null); + persistCurrentToken(null); }, // Prevent making a request if we know the token is missing, invalid or expired. diff --git a/src/utils/useAuth.ts b/src/utils/useAuth.ts new file mode 100644 index 00000000..e2255fe8 --- /dev/null +++ b/src/utils/useAuth.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react'; +import { useAddonState } from 'storybook/manager-api'; + +import { ADDON_ID } from '../constants'; +import { getCurrentToken, persistCurrentToken } from './graphQLClient'; +import { TokenExchangeParameters } from './requestAccessToken'; +import { clearSessionState, useSessionState } from './useSessionState'; + +export interface AuthValue { + token: string | null; + isOpen: boolean; + subdomain: string; + screen: 'welcome' | 'signin' | 'subdomain' | 'verify'; + exchangeParameters: TokenExchangeParameters | null; +} + +export const useAuth = () => { + const [subdomain, setSubdomain] = useSessionState('subdomain', 'www'); + const [exchangeParameters, setExchangeParameters] = + useSessionState('exchangeParameters', null); + + // We use an object rather than a straight boolean here due to https://github.com/storybookjs/storybook/pull/23991 + const [auth, setAuth] = useAddonState(`${ADDON_ID}/auth`, { + token: getCurrentToken(), + isOpen: false, + subdomain: subdomain, + screen: 'welcome', + exchangeParameters, + }); + + useEffect(() => { + if (!auth.token) { + clearSessionState('authenticationScreen', 'exchangeParameters'); + } else { + persistCurrentToken(auth.token); + } + }, [auth.token]); + + useEffect(() => { + setSubdomain(auth.subdomain); + }, [auth.subdomain, setSubdomain]); + + useEffect(() => { + setExchangeParameters(auth.exchangeParameters); + }, [auth.exchangeParameters, setExchangeParameters]); + + return [auth, setAuth] as const; +}; diff --git a/src/utils/useBuildEvents.ts b/src/utils/useBuildEvents.ts index de9afadd..b49d9c10 100644 --- a/src/utils/useBuildEvents.ts +++ b/src/utils/useBuildEvents.ts @@ -30,11 +30,11 @@ export const useBuildEvents = ({ () => debounce( 'startBuild', - () => { + (isPublishOnly = false) => { setDisallowed(false); setStarting(true); - emit(START_BUILD, { accessToken }); - trackEvent?.({ action: 'startBuild' }); + emit(START_BUILD, { accessToken, isPublishOnly }); + trackEvent?.({ action: 'startBuild', isPublishOnly }); }, 1000, false diff --git a/src/utils/useTestProviderStore.ts b/src/utils/useTestProviderStore.ts new file mode 100644 index 00000000..30f01c25 --- /dev/null +++ b/src/utils/useTestProviderStore.ts @@ -0,0 +1,4 @@ +export { + experimental_getTestProviderStore as getTestProviderStore, + experimental_useTestProviderStore as useTestProviderStore, +} from 'storybook/manager-api'; diff --git a/tsconfig.json b/tsconfig.json index cb291335..15512b93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "noEmit": true, "allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true, "moduleResolution": "bundler", @@ -14,7 +15,7 @@ "module": "esnext", "noImplicitAny": true, "strict": true, - "rootDir": "./src", + "rootDir": ".", "skipLibCheck": true, "target": "ES2020", "plugins": [ @@ -24,5 +25,5 @@ } ] }, - "include": ["src/**/*", "types.d.ts"] + "include": ["src/**/*", "types.d.ts", ".storybook/**/*"] } diff --git a/yarn.lock b/yarn.lock index 99acebda..eeafea57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -452,9 +452,9 @@ __metadata: "@neoconfetti/react": "npm:^1.0.0" "@parcel/watcher": "npm:^2.4.1" "@storybook/addon-designs": "npm:^11.1.1" - "@storybook/addon-docs": "npm:^10.2.1" + "@storybook/addon-docs": "npm:0.0.0-pr-33653-sha-6047da63" "@storybook/icons": "npm:^2.0.1" - "@storybook/react-vite": "npm:^10.2.1" + "@storybook/react-vite": "npm:0.0.0-pr-33653-sha-6047da63" "@types/jsonfile": "npm:^6.1.1" "@types/node": "npm:^22.13.5" "@types/pluralize": "npm:^0.0.29" @@ -467,7 +467,7 @@ __metadata: "@vitest/coverage-v8": "npm:^3.0.8" auto: "npm:^11.0.5" boxen: "npm:^5.0.1" - chromatic: "npm:^13.3.4" + chromatic: "npm:^14.0.0" date-fns: "npm:^2.30.0" dedent: "npm:^0.7.0" eslint: "npm:^9.21.0" @@ -476,7 +476,7 @@ __metadata: eslint-plugin-prettier: "npm:^5.2.3" eslint-plugin-react-hooks: "npm:^5.0.0" eslint-plugin-simple-import-sort: "npm:^12.1.1" - eslint-plugin-storybook: "npm:^10.2.1" + eslint-plugin-storybook: "npm:0.0.0-pr-33653-sha-6047da63" filesize: "npm:^10.0.12" graphql: "npm:^16.8.1" jsonfile: "npm:^6.1.0" @@ -492,7 +492,7 @@ __metadata: react-dom: "npm:^18.3.1" react-joyride: "npm:^2.7.2" rimraf: "npm:^3.0.2" - storybook: "npm:^10.2.1" + storybook: "npm:0.0.0-pr-33653-sha-6047da63" strip-ansi: "npm:^7.1.0" ts-dedent: "npm:^2.2.0" tsup: "npm:^6.6.3" @@ -506,7 +506,7 @@ __metadata: zod: "npm:^3.22.2" zx: "npm:^1.14.1" peerDependencies: - storybook: ^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 + storybook: 0.0.0-pr-33653-sha-6047da63 languageName: unknown linkType: soft @@ -2725,45 +2725,45 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-docs@npm:^10.2.1": - version: 10.2.1 - resolution: "@storybook/addon-docs@npm:10.2.1" +"@storybook/addon-docs@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "@storybook/addon-docs@npm:0.0.0-pr-33653-sha-6047da63" dependencies: "@mdx-js/react": "npm:^3.0.0" - "@storybook/csf-plugin": "npm:10.2.1" + "@storybook/csf-plugin": "npm:0.0.0-pr-33653-sha-6047da63" "@storybook/icons": "npm:^2.0.1" - "@storybook/react-dom-shim": "npm:10.2.1" + "@storybook/react-dom-shim": "npm:0.0.0-pr-33653-sha-6047da63" react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^10.2.1 - checksum: 10c0/0592910380b0d366f90ef81c86cc46c615920b2057913e403a9bde2885d81de5155243ad83a32a4292472f370a08956d43816bccdde11880c19fb076549b17f9 + storybook: ^0.0.0-pr-33653-sha-6047da63 + checksum: 10c0/ac62129a9a8794b76480ccd5892ec765c1c7b26ab7ebe65554e16abe218e6f496fecbc0f0d85d258969c8dca5e867c56ed2dcb8cda507ae86ef46052109783e2 languageName: node linkType: hard -"@storybook/builder-vite@npm:10.2.1": - version: 10.2.1 - resolution: "@storybook/builder-vite@npm:10.2.1" +"@storybook/builder-vite@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "@storybook/builder-vite@npm:0.0.0-pr-33653-sha-6047da63" dependencies: - "@storybook/csf-plugin": "npm:10.2.1" + "@storybook/csf-plugin": "npm:0.0.0-pr-33653-sha-6047da63" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^10.2.1 + storybook: ^0.0.0-pr-33653-sha-6047da63 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/119d235ce358528a383aa98af664d252a3557c7e34923411025636b35586c21b4777d256dd3a5bed7401fc09e3c06b1355985c16bb583dba925e329aa1c0e156 + checksum: 10c0/7ad3c2ffbac66da5fb6764e7a66c381702205303ea1b1ae278ffb0466168995cbdc04aff7f1b7c98c64d7eeca237e1d585bb64feb88f30423a98e2b114198773 languageName: node linkType: hard -"@storybook/csf-plugin@npm:10.2.1": - version: 10.2.1 - resolution: "@storybook/csf-plugin@npm:10.2.1" +"@storybook/csf-plugin@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "@storybook/csf-plugin@npm:0.0.0-pr-33653-sha-6047da63" dependencies: unplugin: "npm:^2.3.5" peerDependencies: esbuild: "*" rollup: "*" - storybook: ^10.2.1 + storybook: ^0.0.0-pr-33653-sha-6047da63 vite: "*" webpack: "*" peerDependenciesMeta: @@ -2775,7 +2775,7 @@ __metadata: optional: true webpack: optional: true - checksum: 10c0/7eb06fff05d2e0efa8fede3c3f2f7a8045464ceaec37aca48bc1ee046719bc225f7fb0ac8cd7b83e8fe25400ab3a25f967fc9d302f46f4363faa445af0e9dbf4 + checksum: 10c0/ddf42f4b7d2572a087028fcd92e0e8e6a568b4d0f3f8888cef2a8f2f82561ca4f27efeda3378e964f6577404bcdc58ff82120b552955ac31f5c2fcfa0496ad58 languageName: node linkType: hard @@ -2796,25 +2796,25 @@ __metadata: languageName: node linkType: hard -"@storybook/react-dom-shim@npm:10.2.1": - version: 10.2.1 - resolution: "@storybook/react-dom-shim@npm:10.2.1" +"@storybook/react-dom-shim@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "@storybook/react-dom-shim@npm:0.0.0-pr-33653-sha-6047da63" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.1 - checksum: 10c0/fd675ac99a8fec0e20da3aceabda8a9f307f0d8b30c74eb80081fe79143bdf61001357439fa7c30106be7977f76aa556f3890d57b1b59113037c9438e4231749 + storybook: ^0.0.0-pr-33653-sha-6047da63 + checksum: 10c0/5b20be6104b1f77399c5f935bea1a6863fddce5cbd103bf1a16c911b5baab9bc6c4e8740922f294fe7233247eb787a072aa83287793df962d1adf7f542cef28b languageName: node linkType: hard -"@storybook/react-vite@npm:^10.2.1": - version: 10.2.1 - resolution: "@storybook/react-vite@npm:10.2.1" +"@storybook/react-vite@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "@storybook/react-vite@npm:0.0.0-pr-33653-sha-6047da63" dependencies: "@joshwooding/vite-plugin-react-docgen-typescript": "npm:^0.6.3" "@rollup/pluginutils": "npm:^5.0.2" - "@storybook/builder-vite": "npm:10.2.1" - "@storybook/react": "npm:10.2.1" + "@storybook/builder-vite": "npm:0.0.0-pr-33653-sha-6047da63" + "@storybook/react": "npm:0.0.0-pr-33653-sha-6047da63" empathic: "npm:^2.0.0" magic-string: "npm:^0.30.0" react-docgen: "npm:^8.0.0" @@ -2823,28 +2823,28 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.1 + storybook: ^0.0.0-pr-33653-sha-6047da63 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/8fcd4a3eee67b363fd7f6f9d72d16a2037ab191c95161b9d7b2fe204299c5a9afed094dcaedb9309df02fa95b954ad7cd22af4555f0855f04ef79e0714d96190 + checksum: 10c0/3cac23782047c966a0b9f41f72b3ccc19691a5eb8182863bd1c6a804f16ed5c7def06b0c1549ab85f074025a9723b6d92d88cdf6555e2fe66e90c7d36f32ffd9 languageName: node linkType: hard -"@storybook/react@npm:10.2.1": - version: 10.2.1 - resolution: "@storybook/react@npm:10.2.1" +"@storybook/react@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "@storybook/react@npm:0.0.0-pr-33653-sha-6047da63" dependencies: "@storybook/global": "npm:^5.0.0" - "@storybook/react-dom-shim": "npm:10.2.1" + "@storybook/react-dom-shim": "npm:0.0.0-pr-33653-sha-6047da63" react-docgen: "npm:^8.0.2" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.1 + storybook: ^0.0.0-pr-33653-sha-6047da63 typescript: ">= 4.9.x" peerDependenciesMeta: typescript: optional: true - checksum: 10c0/dd3cdd61e4d2cad6cae7f4aa571decb7619a99768983f7c9e6ebb03434287b353234c6a416ec8cb89f8bfbb4b82c879b540caf41e4c2b4110425b00202028935 + checksum: 10c0/0bbba8f7c09b43f8cb8fa23f85c85b921b2ffc55f04277494399f38640ccf7becc65147dc3db2a0253329c3221a53f2d763a62c7169af395f39289e0bd35db3f languageName: node linkType: hard @@ -4252,9 +4252,9 @@ __metadata: languageName: node linkType: hard -"chromatic@npm:^13.3.4": - version: 13.3.4 - resolution: "chromatic@npm:13.3.4" +"chromatic@npm:^14.0.0": + version: 14.0.0 + resolution: "chromatic@npm:14.0.0" peerDependencies: "@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0 @@ -4267,7 +4267,7 @@ __metadata: chroma: dist/bin.js chromatic: dist/bin.js chromatic-cli: dist/bin.js - checksum: 10c0/1800c1640dbc168b621daeca5895698cb5a0a1def50b9d1ada5ea99ce242bf1f70d15065460948b168eedea1f56422553184f4cce1d01a7816f32c60054d704d + checksum: 10c0/ec3c1ae7ace74150a8a32b46bf57d7c89266ba9795d8504b8f174cf8f773ded2b2197f3e329f79cb7b06cbe694d2cc37660cc779d720c2739c29abfa2f558baf languageName: node linkType: hard @@ -5405,15 +5405,15 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-storybook@npm:^10.2.1": - version: 10.2.1 - resolution: "eslint-plugin-storybook@npm:10.2.1" +"eslint-plugin-storybook@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "eslint-plugin-storybook@npm:0.0.0-pr-33653-sha-6047da63" dependencies: "@typescript-eslint/utils": "npm:^8.48.0" peerDependencies: eslint: ">=8" - storybook: ^10.2.1 - checksum: 10c0/c60f7722daed8aa0859ea5a36bfdcf7abea80ace9fa96629ab50d0fc700343f7a7a07216aecf630077ea0c613b432425b243eaff1e7460cc1c4e6650f21881c7 + storybook: ^0.0.0-pr-33653-sha-6047da63 + checksum: 10c0/8a88972653e9cc88388d647754c8abe399014e11c61e3404000435fd6116c4e19234da9977536a01e00349c6c29f164d43ed8a03a395bb87f1225b2c08556116 languageName: node linkType: hard @@ -9549,9 +9549,9 @@ __metadata: languageName: node linkType: hard -"storybook@npm:^10.2.1": - version: 10.2.1 - resolution: "storybook@npm:10.2.1" +"storybook@npm:0.0.0-pr-33653-sha-6047da63": + version: 0.0.0-pr-33653-sha-6047da63 + resolution: "storybook@npm:0.0.0-pr-33653-sha-6047da63" dependencies: "@storybook/global": "npm:^5.0.0" "@storybook/icons": "npm:^2.0.1" @@ -9572,7 +9572,7 @@ __metadata: optional: true bin: storybook: ./dist/bin/dispatcher.js - checksum: 10c0/e749d291e597478385e638aec96c7670d2d1cf0d490ffc0f6d918aaf2f8949201074b585908a6519649cc92c8fa54fa59abb054d80b415309b47d0fc3a1ea648 + checksum: 10c0/c8e7002904512d7267c1888d359bd25ca4b5e18f2b840d502ab1638efecce8f03b0547ee6d853d1190c052cb0c9e1834afc1a22dce39c931cb07851a16645c5b languageName: node linkType: hard