diff --git a/src/Panel.tsx b/src/Panel.tsx
index c84cebaf..73f0f51a 100644
--- a/src/Panel.tsx
+++ b/src/Panel.tsx
@@ -19,6 +19,7 @@ import { GitNotFound } from "./screens/GitNotFound/GitNotFound";
import { LinkedProject } from "./screens/LinkProject/LinkedProject";
import { LinkingProjectFailed } from "./screens/LinkProject/LinkingProjectFailed";
import { LinkProject } from "./screens/LinkProject/LinkProject";
+import { ControlsProvider } from "./screens/VisualTests/ControlsContext";
import { VisualTests } from "./screens/VisualTests/VisualTests";
import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "./types";
import { client, Provider, useAccessToken } from "./utils/graphQLClient";
@@ -143,17 +144,19 @@ export const Panel = ({ active, api }: PanelProps) => {
return (
- setLocalBuildProgress(undefined)}
- localBuildProgress={localBuildIsRightBranch ? localBuildProgress : undefined}
- startDevBuild={() => emit(START_BUILD)}
- setAccessToken={setAccessToken}
- setOutdated={setOutdated}
- updateBuildStatus={updateBuildStatus}
- projectId={projectId}
- gitInfo={gitInfo}
- storyId={storyId}
- />
+
+ setLocalBuildProgress(undefined)}
+ localBuildProgress={localBuildIsRightBranch ? localBuildProgress : undefined}
+ startDevBuild={() => emit(START_BUILD)}
+ setAccessToken={setAccessToken}
+ setOutdated={setOutdated}
+ updateBuildStatus={updateBuildStatus}
+ projectId={projectId}
+ gitInfo={gitInfo}
+ storyId={storyId}
+ />
+
);
diff --git a/src/screens/Authentication/Authentication.stories.tsx b/src/screens/Authentication/Authentication.stories.tsx
index 8ed8b273..b0630755 100644
--- a/src/screens/Authentication/Authentication.stories.tsx
+++ b/src/screens/Authentication/Authentication.stories.tsx
@@ -4,14 +4,15 @@ import { findByRole, userEvent } from "@storybook/testing-library";
import { http, HttpResponse } from "msw";
import { panelModes } from "../../modes";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
import { playAll } from "../../utils/playAll";
+import { storyWrapper } from "../../utils/storyWrapper";
import { withFigmaDesign } from "../../utils/withFigmaDesign";
import { Authentication } from "./Authentication";
const meta = {
component: Authentication,
- decorators: [storyWrapper],
+ decorators: [storyWrapper(GraphQLClientProvider)],
args: {
setAccessToken: action("setAccessToken"),
hasProjectId: false,
diff --git a/src/screens/GitNotFound/GitNotFound.stories.tsx b/src/screens/GitNotFound/GitNotFound.stories.tsx
index 0b9371bc..d4445275 100644
--- a/src/screens/GitNotFound/GitNotFound.stories.tsx
+++ b/src/screens/GitNotFound/GitNotFound.stories.tsx
@@ -1,12 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react";
-import React from "react";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
+import { storyWrapper } from "../../utils/storyWrapper";
import { GitNotFound } from "./GitNotFound";
const meta = {
component: GitNotFound,
- decorators: [storyWrapper],
+ decorators: [storyWrapper(GraphQLClientProvider)],
args: {
gitInfoError: new Error("Git info not found"),
},
diff --git a/src/screens/LinkProject/LinkProject.stories.ts b/src/screens/LinkProject/LinkProject.stories.ts
index 8418f636..d97dd524 100644
--- a/src/screens/LinkProject/LinkProject.stories.ts
+++ b/src/screens/LinkProject/LinkProject.stories.ts
@@ -5,14 +5,15 @@ import { delay, graphql, HttpResponse } from "msw";
import { SelectProjectsQueryQuery } from "../../gql/graphql";
import { panelModes } from "../../modes";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
import { playAll } from "../../utils/playAll";
+import { storyWrapper } from "../../utils/storyWrapper";
import { withFigmaDesign } from "../../utils/withFigmaDesign";
import { LinkProject } from "./LinkProject";
const meta = {
component: LinkProject,
- decorators: [storyWrapper],
+ decorators: [storyWrapper(GraphQLClientProvider)],
args: {
createdProjectId: undefined,
onUpdateProject: action("updateProject"),
diff --git a/src/screens/LinkProject/LinkedProject.stories.ts b/src/screens/LinkProject/LinkedProject.stories.ts
index 8cf4f1a5..3d01c803 100644
--- a/src/screens/LinkProject/LinkedProject.stories.ts
+++ b/src/screens/LinkProject/LinkedProject.stories.ts
@@ -4,7 +4,8 @@ import { graphql, HttpResponse } from "msw";
import { ProjectQueryQuery } from "../../gql/graphql";
import { panelModes } from "../../modes";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
+import { storyWrapper } from "../../utils/storyWrapper";
import { withFigmaDesign } from "../../utils/withFigmaDesign";
import { LinkedProject } from "./LinkedProject";
@@ -22,7 +23,7 @@ const meta = {
goToNext: action("goToNext"),
setAccessToken: action("setAccessToken"),
},
- decorators: [storyWrapper],
+ decorators: [storyWrapper(GraphQLClientProvider)],
parameters: {
chromatic: {
modes: panelModes,
diff --git a/src/screens/Onboarding/Onboarding.stories.tsx b/src/screens/Onboarding/Onboarding.stories.tsx
index 2b17a136..fa18bab7 100644
--- a/src/screens/Onboarding/Onboarding.stories.tsx
+++ b/src/screens/Onboarding/Onboarding.stories.tsx
@@ -2,13 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react";
import { graphql, HttpResponse } from "msw";
import { panelModes } from "../../modes";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
+import { storyWrapper } from "../../utils/storyWrapper";
import { withFigmaDesign } from "../../utils/withFigmaDesign";
import { Onboarding } from "./Onboarding";
const meta = {
component: Onboarding,
- decorators: [storyWrapper],
+ decorators: [storyWrapper(GraphQLClientProvider)],
parameters: {
chromatic: {
modes: panelModes,
diff --git a/src/screens/VisualTests/BuildContext.tsx b/src/screens/VisualTests/BuildContext.tsx
new file mode 100644
index 00000000..42887962
--- /dev/null
+++ b/src/screens/VisualTests/BuildContext.tsx
@@ -0,0 +1,154 @@
+import React, { createContext, useEffect, useMemo } from "react";
+import { useQuery } from "urql";
+
+import { getFragment } from "../../gql";
+import { StoryTestFieldsFragment, TestStatus } from "../../gql/graphql";
+import { GitInfoPayload } from "../../types";
+import { summarizeTests } from "../../utils/summarizeTests";
+import { statusMap } from "../../utils/testsToStatusUpdate";
+import { SelectedBuildInfo } from "../../utils/updateSelectedBuildInfo";
+import { useRequiredContext } from "../../utils/useRequiredContext";
+import { useTests } from "../../utils/useTests";
+import { useControlsDispatch } from "./ControlsContext";
+import {
+ FragmentLastBuildOnBranchBuildFields,
+ FragmentLastBuildOnBranchTestFields,
+ FragmentSelectedBuildFields,
+ FragmentStoryTestFields,
+ QueryBuild,
+} from "./graphql";
+
+export const useBuild = ({
+ projectId,
+ storyId,
+ gitInfo,
+ selectedBuildInfo,
+}: {
+ projectId: string;
+ storyId: string;
+ gitInfo: Pick<
+ GitInfoPayload,
+ "branch" | "slug" | "userEmailHash" | "commit" | "committedAt" | "uncommittedHash"
+ >;
+ selectedBuildInfo?: SelectedBuildInfo;
+}) => {
+ const [{ data, error: queryError, operation }, rerunQuery] = useQuery({
+ query: QueryBuild,
+ variables: {
+ projectId,
+ storyId,
+ testStatuses: Object.keys(statusMap) as any as TestStatus[],
+ branch: gitInfo.branch || "",
+ ...(gitInfo.slug ? { repositoryOwnerName: gitInfo.slug.split("/", 1)[0] } : {}),
+ gitUserEmailHash: gitInfo.userEmailHash,
+ selectedBuildId: selectedBuildInfo?.buildId || "",
+ hasSelectedBuildId: !!selectedBuildInfo,
+ },
+ });
+
+ // Poll for updates
+ useEffect(() => {
+ const interval = setInterval(rerunQuery, 5000);
+ return () => clearInterval(interval);
+ }, [rerunQuery]);
+
+ // When you change story, for a period the query will return the previous set of data, and indicate
+ // that with the operation being for the previous query.
+ const storyDataIsStale = operation && storyId && operation.variables.storyId !== storyId;
+
+ const lastBuildOnBranch = getFragment(
+ FragmentLastBuildOnBranchBuildFields,
+ data?.project?.lastBuildOnBranch
+ );
+
+ const lastBuildOnBranchStoryTests = [
+ ...getFragment(
+ FragmentLastBuildOnBranchTestFields,
+ lastBuildOnBranch && "testsForStory" in lastBuildOnBranch && lastBuildOnBranch.testsForStory
+ ? lastBuildOnBranch.testsForStory.nodes
+ : []
+ ),
+ ];
+
+ // If the last build is *newer* than the current head commit, we don't want to select it
+ // as our local code wouldn't yet have the changes made in that build.
+ const lastBuildOnBranchIsNewer = lastBuildOnBranch?.committedAt > gitInfo.committedAt;
+ const lastBuildOnBranchIsSelectable = !!lastBuildOnBranch && !lastBuildOnBranchIsNewer;
+
+ // If any tests for the current story are still in progress, we aren't ready to select the build
+ const lastBuildOnBranchIsReady =
+ !!lastBuildOnBranch &&
+ lastBuildOnBranchStoryTests.every((t) => t.status !== TestStatus.InProgress);
+
+ // If we didn't explicitly select a build, select the last build on the branch (if any)
+ const selectedBuild = getFragment(
+ FragmentSelectedBuildFields,
+ data?.selectedBuild ?? (lastBuildOnBranchIsReady ? data?.project?.lastBuildOnBranch : undefined)
+ );
+
+ return {
+ hasData: !!data && !storyDataIsStale,
+ hasProject: !!data?.project,
+ hasSelectedBuild: selectedBuild?.branch === gitInfo.branch,
+ lastBuildOnBranch,
+ lastBuildOnBranchIsNewer,
+ lastBuildOnBranchIsReady,
+ lastBuildOnBranchIsSelectable,
+ selectedBuild,
+ selectedBuildMatchesGit:
+ selectedBuild?.branch === gitInfo.branch &&
+ selectedBuild?.commit === gitInfo.commit &&
+ selectedBuild?.uncommittedHash === gitInfo.uncommittedHash,
+ rerunQuery,
+ queryError,
+ userCanReview: !!data?.viewer?.projectMembership?.userCanReview,
+ };
+};
+
+type BuildInfo = ReturnType | null;
+type SelectedStory =
+ | ({
+ hasTests: boolean;
+ tests: StoryTestFieldsFragment[];
+ summary: ReturnType;
+ } & ReturnType)
+ | null;
+
+export const BuildContext = createContext(null);
+export const StoryContext = createContext(null);
+
+export const useBuildState = () => useRequiredContext(BuildContext, "Build");
+export const useSelectedBuildState = () => {
+ const { selectedBuild } = useRequiredContext(BuildContext, "Build");
+ if (!selectedBuild) throw new Error("No selectedBuild on Build context");
+ return selectedBuild;
+};
+export const useSelectedStoryState = () => useRequiredContext(StoryContext, "Story");
+
+export const BuildProvider = ({
+ children,
+ watchState = null,
+}: {
+ children: React.ReactNode;
+ watchState?: BuildInfo;
+}) => {
+ const hasTests = !!watchState?.selectedBuild && "testsForStory" in watchState.selectedBuild;
+ const testsForStory =
+ watchState?.selectedBuild &&
+ "testsForStory" in watchState.selectedBuild &&
+ watchState.selectedBuild.testsForStory?.nodes;
+ const tests = [...getFragment(FragmentStoryTestFields, testsForStory || [])];
+ const summary = summarizeTests(tests);
+
+ const { toggleDiff } = useControlsDispatch();
+ useEffect(() => toggleDiff(summary.changeCount > 0), [toggleDiff, summary.changeCount]);
+
+ return (
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ watchState, [JSON.stringify(watchState)])}>
+
+ {children}
+
+
+ );
+};
diff --git a/src/screens/VisualTests/BuildResults.tsx b/src/screens/VisualTests/BuildResults.tsx
index 3b6e3d97..36ae18ba 100644
--- a/src/screens/VisualTests/BuildResults.tsx
+++ b/src/screens/VisualTests/BuildResults.tsx
@@ -1,6 +1,6 @@
-import { Icons, Link, TooltipNote, WithTooltip } from "@storybook/components";
+import { Icons, Link } from "@storybook/components";
import { styled } from "@storybook/theming";
-import React, { useState } from "react";
+import React from "react";
import { BuildProgressInline } from "../../components/BuildProgressBarInline";
import { Button } from "../../components/Button";
@@ -9,19 +9,13 @@ import { Eyebrow } from "../../components/Eyebrow";
import { Heading } from "../../components/Heading";
import { Section, Sections } from "../../components/layout";
import { Text as CenterText } from "../../components/Text";
-import { getFragment } from "../../gql";
-import {
- BuildStatus,
- LastBuildOnBranchBuildFieldsFragment,
- ReviewTestBatch,
- SelectedBuildFieldsFragment,
- StoryTestFieldsFragment,
- TestResult,
-} from "../../gql/graphql";
+import { BuildStatus, TestResult } from "../../gql/graphql";
import { LocalBuildProgress } from "../../types";
+import { useBuildState, useSelectedBuildState, useSelectedStoryState } from "./BuildContext";
import { BuildEyebrow } from "./BuildEyebrow";
-import { FragmentStoryTestFields } from "./graphql";
+import { useControlsDispatch, useControlsState } from "./ControlsContext";
import { RenderSettings } from "./RenderSettings";
+import { useReviewTestState } from "./ReviewTestContext";
import { SnapshotComparison } from "./SnapshotComparison";
import { Warnings } from "./Warnings";
@@ -29,16 +23,9 @@ interface BuildResultsProps {
branch: string;
dismissBuildError: () => void;
localBuildProgress?: LocalBuildProgress;
- selectedBuild: SelectedBuildFieldsFragment;
- storyId: string;
- lastBuildOnBranch?: LastBuildOnBranchBuildFieldsFragment;
- lastBuildOnBranchCompletedStory: boolean;
switchToLastBuildOnBranch?: () => void;
+ storyId: string;
startDevBuild: () => void;
- userCanReview: boolean;
- isReviewing: boolean;
- onAccept: (testId: StoryTestFieldsFragment["id"], batch?: ReviewTestBatch) => void;
- onUnaccept: (testId: string) => Promise;
setAccessToken: (accessToken: string | null) => void;
}
@@ -54,39 +41,31 @@ export const BuildResults = ({
branch,
dismissBuildError,
localBuildProgress,
- lastBuildOnBranch,
- lastBuildOnBranchCompletedStory,
switchToLastBuildOnBranch,
startDevBuild,
- userCanReview,
- isReviewing,
- onAccept,
- onUnaccept,
- selectedBuild,
storyId,
setAccessToken,
}: BuildResultsProps) => {
- const [settingsVisible, setSettingsVisible] = useState(false);
- const [warningsVisible, setWarningsVisible] = useState(false);
- const [baselineImageVisible, setBaselineImageVisible] = useState(false);
- const toggleBaselineImage = () => setBaselineImageVisible(!baselineImageVisible);
+ const { settingsVisible, warningsVisible } = useControlsState();
+ const { toggleSettings, toggleWarnings } = useControlsDispatch();
+
+ const { lastBuildOnBranch, lastBuildOnBranchIsReady, lastBuildOnBranchIsSelectable } =
+ useBuildState();
+
+ const selectedBuild = useSelectedBuildState();
+ const selectedStory = useSelectedStoryState();
+
+ const { buildIsReviewable, userCanReview } = useReviewTestState();
const isLocalBuildInProgress =
- localBuildProgress && localBuildProgress.currentStep !== "complete";
-
- const storyTests = [
- ...getFragment(
- FragmentStoryTestFields,
- selectedBuild && "testsForStory" in selectedBuild && selectedBuild.testsForStory
- ? selectedBuild.testsForStory.nodes
- : []
- ),
- ];
-
- const isReviewable = lastBuildOnBranch?.id === selectedBuild?.id;
- const isStorySuperseded = !isReviewable && lastBuildOnBranchCompletedStory;
+ !!localBuildProgress && localBuildProgress.currentStep !== "complete";
+
// Do we want to encourage them to switch to the next build?
- const shouldSwitchToLastBuildOnBranch = isStorySuperseded && !!switchToLastBuildOnBranch;
+ const shouldSwitchToLastBuildOnBranch =
+ !buildIsReviewable &&
+ lastBuildOnBranchIsReady &&
+ lastBuildOnBranchIsSelectable &&
+ !!switchToLastBuildOnBranch;
const lastBuildOnBranchInProgress = lastBuildOnBranch?.status === BuildStatus.InProgress;
const showBuildStatus =
@@ -94,7 +73,7 @@ export const BuildResults = ({
isLocalBuildInProgress ||
// Even if there's no build running, we need to tell them why they can't review, unless
// the story is superseded and the UI is already telling them
- (!isReviewable && !shouldSwitchToLastBuildOnBranch);
+ (!buildIsReviewable && !shouldSwitchToLastBuildOnBranch);
const localBuildProgressIsLastBuildOnBranch =
localBuildProgress && localBuildProgress?.buildId === lastBuildOnBranch?.id;
@@ -112,13 +91,12 @@ export const BuildResults = ({
/>
);
- // If there are no tests yet, there is no baseline for this story. User needs to create one.
- const isCompletelyNewStory = "testsForStory" in selectedBuild && storyTests.length === 0;
+ const isNewStory = selectedStory?.hasTests && selectedStory?.tests.length === 0;
const isLocalBuildProgressOnSelectedBuild =
selectedBuild.id !== `Build:${localBuildProgress?.buildId}`;
- if (isCompletelyNewStory) {
+ if (isNewStory) {
return (
@@ -151,7 +129,7 @@ export const BuildResults = ({
);
}
// It shouldn't be possible for one test to be skipped but not all of them
- const isSkipped = !!storyTests?.find((t) => t.result === TestResult.Skipped);
+ const isSkipped = !!selectedStory?.tests?.find((t) => t.result === TestResult.Skipped);
if (isSkipped) {
return (
@@ -183,14 +161,13 @@ export const BuildResults = ({
}
const { status } = selectedBuild;
- const startedAt = "startedAt" in selectedBuild && selectedBuild.startedAt;
const isSelectedBuildStarting = [
BuildStatus.Announced,
BuildStatus.Published,
BuildStatus.Prepared,
].includes(status);
const isBuildFailed = status === BuildStatus.Failed;
- const isReviewLocked = status === BuildStatus.Pending && (!userCanReview || !isReviewable);
+ const isReviewLocked = status === BuildStatus.Pending && (!userCanReview || !buildIsReviewable);
return (
@@ -219,25 +196,12 @@ export const BuildResults = ({
- setSettingsVisible(false)} />
+ toggleSettings(false)} />
- setWarningsVisible(false)} />
+ toggleWarnings(false)} />
);
diff --git a/src/screens/VisualTests/BuildResultsFooter.tsx b/src/screens/VisualTests/BuildResultsFooter.tsx
index 888f8aac..c74f28f8 100644
--- a/src/screens/VisualTests/BuildResultsFooter.tsx
+++ b/src/screens/VisualTests/BuildResultsFooter.tsx
@@ -3,57 +3,50 @@ import React from "react";
import { FooterMenu } from "../../components/FooterMenu";
import { IconButton } from "../../components/IconButton";
-import { Bar, Col, Section, Sections, Text } from "../../components/layout";
-import { SelectedBuildFieldsFragment } from "../../gql/graphql";
+import { Bar, Col, Section, Text } from "../../components/layout";
+import { useSelectedBuildState, useSelectedStoryState } from "./BuildContext";
+import { useControlsDispatch, useControlsState } from "./ControlsContext";
export const BuildResultsFooter = ({
- hasBaselineSnapshot,
setAccessToken,
- baselineImageVisible,
- toggleBaselineImage,
- selectedBuild,
- setWarningsVisible,
- warningsVisible,
- setSettingsVisible,
- settingsVisible,
}: {
- hasBaselineSnapshot: boolean;
setAccessToken: (token: string | null) => void;
- baselineImageVisible: boolean;
- toggleBaselineImage: () => void;
- selectedBuild: SelectedBuildFieldsFragment;
- setWarningsVisible: (visible: boolean) => void;
- warningsVisible: boolean;
- setSettingsVisible: (visible: boolean) => void;
- settingsVisible: boolean;
-}) => (
-
-
- {hasBaselineSnapshot && (
-
- }
- trigger="hover"
- hasChrome={false}
- >
- toggleBaselineImage()}>
-
-
-
-
- )}
-
- {baselineImageVisible ? (
-
- Baseline Build {selectedBuild.number} on {selectedBuild.branch}
-
- ) : (
-
- Latest Build {selectedBuild.number} on {selectedBuild.branch}
-
+}) => {
+ const { baselineImageVisible } = useControlsState();
+ const { toggleBaselineImage } = useControlsDispatch();
+ const selectedBuild = useSelectedBuildState();
+ const storyState = useSelectedStoryState();
+
+ const hasBaselineSnapshot = !!storyState.selectedComparison?.baseCapture?.captureImage;
+
+ return (
+
+
+ {hasBaselineSnapshot && (
+
+ }
+ trigger="hover"
+ hasChrome={false}
+ >
+ toggleBaselineImage()}>
+
+
+
+
)}
-
- {/*
+
+ {baselineImageVisible ? (
+
+ Baseline Build {selectedBuild.number} on {selectedBuild.branch}
+
+ ) : (
+
+ Latest Build {selectedBuild.number} on {selectedBuild.branch}
+
+ )}
+
+ {/*
}
trigger="hover"
@@ -71,7 +64,7 @@ export const BuildResultsFooter = ({
*/}
- {/*
+ {/*
}
trigger="hover"
@@ -90,9 +83,10 @@ export const BuildResultsFooter = ({
*/}
-
-
-
-
-
-);
+
+
+
+
+
+ );
+};
diff --git a/src/screens/VisualTests/ControlsContext.tsx b/src/screens/VisualTests/ControlsContext.tsx
new file mode 100644
index 00000000..d73fa940
--- /dev/null
+++ b/src/screens/VisualTests/ControlsContext.tsx
@@ -0,0 +1,70 @@
+import React, { createContext, useMemo, useReducer } from "react";
+
+import { useRequiredContext } from "../../utils/useRequiredContext";
+
+const initialControls = {
+ settingsVisible: false,
+ warningsVisible: false,
+ baselineImageVisible: false,
+ focusVisible: false,
+ diffVisible: false,
+};
+
+type State = typeof initialControls;
+
+const toggle =
+ (key: keyof State) =>
+ (state: State, visible?: boolean): State => ({
+ ...state,
+ [key]: typeof visible === "boolean" ? visible : !state[key],
+ });
+
+const handlers = {
+ toggleDiff: toggle("diffVisible"),
+ toggleFocus: toggle("focusVisible"),
+ toggleSettings: toggle("settingsVisible"),
+ toggleWarnings: toggle("warningsVisible"),
+ toggleBaselineImage: toggle("baselineImageVisible"),
+} as const;
+
+type Action = { type: keyof typeof handlers; payload?: any };
+
+const controlsReducer = (state: State, action: Action) =>
+ handlers[action.type](state, action.payload);
+
+export const ControlsContext = createContext(initialControls);
+export const ControlsDispatchContext = createContext>(() => {});
+
+export const useControlsState = () => useRequiredContext(ControlsContext, "Controls");
+export const useControlsDispatch = () => {
+ const dispatch = useRequiredContext(ControlsDispatchContext, "ControlsDispatch");
+ return useMemo(
+ () => ({
+ toggleDiff: (visible?: boolean) => dispatch({ type: "toggleDiff", payload: visible }),
+ toggleFocus: (visible?: boolean) => dispatch({ type: "toggleFocus", payload: visible }),
+ toggleSettings: (visible?: boolean) => dispatch({ type: "toggleSettings", payload: visible }),
+ toggleWarnings: (visible?: boolean) => dispatch({ type: "toggleWarnings", payload: visible }),
+ toggleBaselineImage: (visible?: boolean) =>
+ dispatch({ type: "toggleBaselineImage", payload: visible }),
+ }),
+ [dispatch]
+ );
+};
+
+export const ControlsProvider = ({
+ children,
+ initialState = initialControls,
+}: {
+ children: React.ReactNode;
+ initialState?: State;
+}) => {
+ const [state, dispatch] = useReducer(controlsReducer, initialState);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/screens/VisualTests/RenderSettings.stories.tsx b/src/screens/VisualTests/RenderSettings.stories.tsx
index cb94e911..0b765e9a 100644
--- a/src/screens/VisualTests/RenderSettings.stories.tsx
+++ b/src/screens/VisualTests/RenderSettings.stories.tsx
@@ -3,13 +3,14 @@ import { graphql, HttpResponse } from "msw";
import { ProjectQueryQuery } from "../../gql/graphql";
import { panelModes } from "../../modes";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
+import { storyWrapper } from "../../utils/storyWrapper";
import { withFigmaDesign } from "../../utils/withFigmaDesign";
import { RenderSettings } from "./RenderSettings";
const meta = {
component: RenderSettings,
- decorators: [storyWrapper],
+ decorators: [storyWrapper(GraphQLClientProvider)],
parameters: {
chromatic: {
modes: panelModes,
diff --git a/src/screens/VisualTests/ReviewTestContext.tsx b/src/screens/VisualTests/ReviewTestContext.tsx
new file mode 100644
index 00000000..49910e9d
--- /dev/null
+++ b/src/screens/VisualTests/ReviewTestContext.tsx
@@ -0,0 +1,29 @@
+import React, { createContext } from "react";
+
+import { ReviewTestBatch } from "../../gql/graphql";
+import { useRequiredContext } from "../../utils/useRequiredContext";
+
+const initialState = {
+ isReviewing: false,
+ userCanReview: false,
+ buildIsReviewable: false,
+ acceptTest: (testId: string, batch: ReviewTestBatch = ReviewTestBatch.Spec) => Promise.resolve(),
+ unacceptTest: (testId: string, batch: ReviewTestBatch = ReviewTestBatch.Spec) =>
+ Promise.resolve(),
+};
+
+type State = typeof initialState;
+
+export const ReviewTestContext = createContext(initialState);
+
+export const useReviewTestState = () => useRequiredContext(ReviewTestContext, "ReviewTest");
+
+export const ReviewTestProvider = ({
+ children,
+ watchState = initialState,
+}: {
+ children: React.ReactNode;
+ watchState?: State;
+}) => {
+ return {children};
+};
diff --git a/src/screens/VisualTests/SnapshotComparison.stories.tsx b/src/screens/VisualTests/SnapshotComparison.stories.tsx
index 60111994..9c6f4b7f 100644
--- a/src/screens/VisualTests/SnapshotComparison.stories.tsx
+++ b/src/screens/VisualTests/SnapshotComparison.stories.tsx
@@ -1,58 +1,71 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
-import { screen, userEvent, within } from "@storybook/testing-library";
+import { findByRole, fireEvent, screen, userEvent, within } from "@storybook/testing-library";
+import type { StoryContext } from "@storybook/types";
import React, { ComponentProps } from "react";
import {
Browser,
ComparisonResult,
- StoryTestFieldsFragment,
+ SelectedBuildFieldsFragment,
TestResult,
TestStatus,
} from "../../gql/graphql";
import { panelModes } from "../../modes";
import { playAll } from "../../utils/playAll";
import { makeComparison, makeTest, makeTests } from "../../utils/storyData";
-import { interactionFailureTests, pendingBuild } from "./mocks";
+import { storyWrapper } from "../../utils/storyWrapper";
+import { BuildProvider } from "./BuildContext";
+import { ControlsProvider } from "./ControlsContext";
+import { interactionFailureTests, pendingBuild, pendingTests, withTests } from "./mocks";
+import { ReviewTestProvider } from "./ReviewTestContext";
import { SnapshotComparison } from "./SnapshotComparison";
+const build = { ...pendingBuild, startedAt: new Date() };
+
+const buildInfo = (selectedBuild?: SelectedBuildFieldsFragment) => ({
+ hasData: true,
+ hasProject: true,
+ hasSelectedBuild: !!selectedBuild,
+ lastBuildOnBranch: undefined,
+ lastBuildOnBranchIsNewer: false,
+ lastBuildOnBranchIsReady: false,
+ lastBuildOnBranchIsSelectable: false,
+ selectedBuild,
+ selectedBuildMatchesGit: true,
+ rerunQuery: () => {},
+ queryError: undefined,
+ userCanReview: true,
+});
+
const meta = {
component: SnapshotComparison,
+ decorators: [
+ storyWrapper(ReviewTestProvider, (ctx) => ({
+ watchState: {
+ isReviewing: false,
+ userCanReview: true,
+ buildIsReviewable: true,
+ acceptTest: action("acceptTest"),
+ unacceptTest: action("unacceptTest"),
+ ...ctx.parameters.reviewTest,
+ },
+ })),
+ storyWrapper(BuildProvider, (ctx) => ({ watchState: buildInfo(ctx.parameters.selectedBuild) })),
+ storyWrapper(ControlsProvider),
+ ],
args: {
storyId: "button--primary",
- tests: makeTests({
- browsers: [Browser.Chrome, Browser.Safari],
- viewports: [
- {
- status: TestStatus.Pending,
- viewport: 480,
- comparisonResults: [ComparisonResult.Changed, ComparisonResult.Equal],
- },
- { status: TestStatus.Passed, viewport: 800 },
- { status: TestStatus.Passed, viewport: 1200 },
- ],
- }),
- startedAt: new Date(),
- userCanReview: true,
isStarting: false,
isBuildFailed: false,
- isReviewable: true,
- isReviewing: false,
- onAccept: action("onAccept"),
- onUnaccept: action("onUnaccept"),
- baselineImageVisible: false,
shouldSwitchToLastBuildOnBranch: false,
- selectedBuild: pendingBuild,
- setSettingsVisible: action("setSettingsVisible"),
- settingsVisible: false,
- setWarningsVisible: action("setWarningsVisible"),
- warningsVisible: false,
setAccessToken: action("setAccessToken"),
},
parameters: {
chromatic: {
modes: panelModes,
},
+ selectedBuild: withTests(build, pendingTests),
},
} satisfies Meta;
@@ -60,19 +73,22 @@ export default meta;
type Story = StoryObj;
export const InProgress = {
- args: {
- tests: makeTests({
- browsers: [Browser.Chrome, Browser.Safari],
- viewports: [
- {
- status: TestStatus.Pending,
- viewport: 480,
- comparisonResults: [ComparisonResult.Changed, ComparisonResult.Equal],
- },
- { status: TestStatus.Passed, viewport: 800 },
- { status: TestStatus.InProgress, viewport: 1200 },
- ],
- }),
+ parameters: {
+ selectedBuild: withTests(
+ build,
+ makeTests({
+ browsers: [Browser.Chrome, Browser.Safari],
+ viewports: [
+ {
+ status: TestStatus.Pending,
+ viewport: 480,
+ comparisonResults: [ComparisonResult.Changed, ComparisonResult.Equal],
+ },
+ { status: TestStatus.Passed, viewport: 800 },
+ { status: TestStatus.InProgress, viewport: 1200 },
+ ],
+ })
+ ),
},
} satisfies Story;
@@ -84,49 +100,55 @@ export const Default = {} satisfies Story;
* test, which does not have a visual diff.
*/
export const FirstPassed: Story = {
- args: {
- tests: makeTests({
- browsers: [Browser.Chrome, Browser.Safari],
- viewports: [
- { status: TestStatus.Passed, viewport: 800 },
- {
- status: TestStatus.Pending,
- viewport: 1200,
- comparisonResults: [ComparisonResult.Equal, ComparisonResult.Changed],
- },
- ],
- }),
+ parameters: {
+ selectedBuild: withTests(
+ build,
+ makeTests({
+ browsers: [Browser.Chrome, Browser.Safari],
+ viewports: [
+ { status: TestStatus.Passed, viewport: 800 },
+ {
+ status: TestStatus.Pending,
+ viewport: 1200,
+ comparisonResults: [ComparisonResult.Equal, ComparisonResult.Changed],
+ },
+ ],
+ })
+ ),
},
} satisfies Story;
export const StoryAdded: Story = {
- args: {
- tests: makeTests({
- browsers: [Browser.Chrome, Browser.Safari],
- viewports: [
- {
- status: TestStatus.Pending,
- result: TestResult.Added,
- viewport: 800,
- comparisons: [
- makeComparison({ result: ComparisonResult.Added, baseCapture: null }),
- makeComparison({ result: ComparisonResult.Added, baseCapture: null }),
- ],
- },
- ],
- }),
+ parameters: {
+ selectedBuild: withTests(
+ build,
+ makeTests({
+ browsers: [Browser.Chrome, Browser.Safari],
+ viewports: [
+ {
+ status: TestStatus.Pending,
+ result: TestResult.Added,
+ viewport: 800,
+ comparisons: [
+ makeComparison({ result: ComparisonResult.Added, baseCapture: null }),
+ makeComparison({ result: ComparisonResult.Added, baseCapture: null }),
+ ],
+ },
+ ],
+ })
+ ),
},
};
export const ShowingBaseline: Story = {
- args: {
- baselineImageVisible: true,
- },
+ play: playAll(async ({ canvasElement }) => {
+ fireEvent.click(await findByRole(canvasElement, "button", { name: "Switch snapshot" }));
+ }),
} satisfies Story;
export const NoBaseline: Story = {
- args: {
- tests: [
+ parameters: {
+ selectedBuild: withTests(build, [
makeTest({
status: TestStatus.Pending,
comparisons: [
@@ -136,32 +158,38 @@ export const NoBaseline: Story = {
},
],
}),
- ],
+ ]),
},
};
export const SwitchingMode = {
- args: {
- tests: makeTests({
- browsers: [Browser.Chrome, Browser.Safari],
- viewports: [
- { status: TestStatus.Passed, viewport: 320 },
- { status: TestStatus.Passed, viewport: 600 },
- { status: TestStatus.Passed, viewport: 1200 },
- ],
- }).map((test) => ({
- ...test,
- comparisons: test.comparisons.map((comparison) => ({
- ...comparison,
- headCapture: {
- ...comparison.headCapture,
- captureImage: {
- imageUrl: `/ProjectItem-${comparison.browser.name}-${parseInt(test.mode.name, 10)}.png`,
- imageWidth: parseInt(test.mode.name, 10),
+ parameters: {
+ selectedBuild: withTests(
+ build,
+ makeTests({
+ browsers: [Browser.Chrome, Browser.Safari],
+ viewports: [
+ { status: TestStatus.Passed, viewport: 320 },
+ { status: TestStatus.Passed, viewport: 600 },
+ { status: TestStatus.Passed, viewport: 1200 },
+ ],
+ }).map((test) => ({
+ ...test,
+ comparisons: test.comparisons.map((comparison) => ({
+ ...comparison,
+ headCapture: {
+ ...comparison.headCapture,
+ captureImage: {
+ imageUrl: `/ProjectItem-${comparison.browser.name}-${parseInt(
+ test.mode.name,
+ 10
+ )}.png`,
+ imageWidth: parseInt(test.mode.name, 10),
+ },
},
- },
- })),
- })),
+ })),
+ }))
+ ),
},
play: playAll(async ({ canvasElement, canvasIndex }) => {
const canvas = within(canvasElement);
@@ -173,7 +201,7 @@ export const SwitchingMode = {
} satisfies Story;
export const SwitchingBrowser = {
- args: SwitchingMode.args,
+ parameters: SwitchingMode.parameters,
play: playAll(async ({ canvasElement, canvasIndex }) => {
const canvas = within(canvasElement);
const menu = await canvas.findByRole("button", { name: "Chrome" });
@@ -184,16 +212,23 @@ export const SwitchingBrowser = {
} satisfies Story;
export const SwitchingTests = {
- args: SwitchingMode.args,
- render: function RenderSwitchingTests({ ...props }: ComponentProps) {
- const [tests, setTests] = React.useState();
- if (!tests) setTimeout(() => setTests([makeTest({})]), 0);
- return ;
+ parameters: SwitchingMode.parameters,
+ render: function RenderSwitchingTests(
+ { ...props }: ComponentProps,
+ { parameters }: StoryContext
+ ) {
+ const [activeBuild, setBuild] = React.useState();
+ if (!activeBuild) setTimeout(() => setBuild(withTests(build, [makeTest({})])), 0);
+ return (
+
+
+
+ );
},
} satisfies Story;
export const InteractionFailure = {
- args: {
- tests: interactionFailureTests,
+ parameters: {
+ selectedBuild: withTests(build, interactionFailureTests),
},
};
diff --git a/src/screens/VisualTests/SnapshotComparison.tsx b/src/screens/VisualTests/SnapshotComparison.tsx
index 35d26b3e..5f725a2c 100644
--- a/src/screens/VisualTests/SnapshotComparison.tsx
+++ b/src/screens/VisualTests/SnapshotComparison.tsx
@@ -1,21 +1,15 @@
import { Loader } from "@storybook/components";
import { Link } from "@storybook/design-system";
import { styled } from "@storybook/theming";
-import React, { useEffect, useState } from "react";
+import React from "react";
import { Text } from "../../components/layout";
import { SnapshotImage } from "../../components/SnapshotImage";
-import {
- ComparisonResult,
- ReviewTestBatch,
- SelectedBuildFieldsFragment,
- StoryTestFieldsFragment,
- TestResult,
- TestStatus,
-} from "../../gql/graphql";
+import { ComparisonResult, TestResult, TestStatus } from "../../gql/graphql";
import { summarizeTests } from "../../utils/summarizeTests";
-import { useTests } from "../../utils/useTests";
+import { useSelectedBuildState, useSelectedStoryState } from "./BuildContext";
import { BuildResultsFooter } from "./BuildResultsFooter";
+import { useControlsDispatch, useControlsState } from "./ControlsContext";
import { SnapshotControls } from "./SnapshotControls";
import { StoryInfo } from "./StoryInfo";
@@ -109,60 +103,37 @@ const WarningText = styled(Text)(({ theme }) => ({
}));
interface SnapshotSectionProps {
- tests?: StoryTestFieldsFragment[];
- startedAt: Date;
isStarting: boolean;
startDevBuild: () => void;
isBuildFailed: boolean;
shouldSwitchToLastBuildOnBranch: boolean;
switchToLastBuildOnBranch?: () => void;
- userCanReview: boolean;
- isReviewable: boolean;
- isReviewing: boolean;
- baselineImageVisible: boolean;
- toggleBaselineImage: () => void;
- onAccept: (testId: StoryTestFieldsFragment["id"], batch?: ReviewTestBatch) => void;
- onUnaccept: (testId: StoryTestFieldsFragment["id"]) => void;
setAccessToken: (accessToken: string | null) => void;
- selectedBuild: SelectedBuildFieldsFragment;
- setSettingsVisible: (visible: boolean) => void;
- setWarningsVisible: (visible: boolean) => void;
- settingsVisible: boolean;
- warningsVisible: boolean;
hidden?: boolean;
storyId: string;
}
export const SnapshotComparison = ({
- tests = [],
- startedAt,
isStarting,
startDevBuild,
isBuildFailed,
shouldSwitchToLastBuildOnBranch,
switchToLastBuildOnBranch,
- userCanReview,
- isReviewable,
- isReviewing,
- onAccept,
- onUnaccept,
- baselineImageVisible,
- toggleBaselineImage,
setAccessToken,
- selectedBuild,
- setSettingsVisible,
- setWarningsVisible,
- settingsVisible,
- warningsVisible,
hidden,
storyId,
}: SnapshotSectionProps) => {
- const [diffVisible, setDiffVisible] = useState(true);
- const [focusVisible] = useState(false);
- const testControls = useTests(tests);
+ const { baselineImageVisible, diffVisible, focusVisible } = useControlsState();
+ const { toggleBaselineImage, toggleSettings, toggleWarnings } = useControlsDispatch();
+
+ const selectedBuild = useSelectedBuildState();
+ const startedAt: Date = "startedAt" in selectedBuild && selectedBuild.startedAt;
+
+ const selectedStory = useSelectedStoryState();
+ const { tests } = selectedStory;
const prevStoryIdRef = React.useRef(storyId);
- const prevSelectedComparisonIdRef = React.useRef(testControls.selectedComparison?.id);
+ const prevSelectedComparisonIdRef = React.useRef(selectedStory.selectedComparison?.id);
const prevSelectedBuildIdRef = React.useRef(selectedBuild.id);
React.useEffect(() => {
@@ -170,24 +141,23 @@ export const SnapshotComparison = ({
// This is most important for the baseline image toggle because baseline can not exist for a different story.
if (
prevStoryIdRef.current !== storyId ||
- prevSelectedComparisonIdRef.current !== testControls.selectedComparison?.id ||
+ prevSelectedComparisonIdRef.current !== selectedStory.selectedComparison?.id ||
prevSelectedBuildIdRef.current !== selectedBuild.id
) {
- if (baselineImageVisible) toggleBaselineImage();
- setSettingsVisible(false);
- setWarningsVisible(false);
+ toggleBaselineImage(false);
+ toggleSettings(false);
+ toggleWarnings(false);
}
- prevSelectedComparisonIdRef.current = testControls.selectedComparison?.id;
+ prevSelectedComparisonIdRef.current = selectedStory.selectedComparison?.id;
prevStoryIdRef.current = storyId;
prevSelectedBuildIdRef.current = selectedBuild.id;
}, [
- baselineImageVisible,
selectedBuild.id,
- setSettingsVisible,
- setWarningsVisible,
storyId,
- testControls,
+ selectedStory,
toggleBaselineImage,
+ toggleSettings,
+ toggleWarnings,
]);
const storyInfo = (
@@ -210,7 +180,7 @@ export const SnapshotComparison = ({
const testSummary = summarizeTests(tests);
const { isInProgress } = testSummary;
- const { selectedTest, selectedComparison } = testControls;
+ const { selectedTest, selectedComparison } = selectedStory;
// isNewStory is when the story itself is added and all tests should also be added
const isNewStory = tests.every(
@@ -240,13 +210,7 @@ export const SnapshotComparison = ({
{storyInfo}
-
-
+
@@ -309,17 +273,7 @@ export const SnapshotComparison = ({
)}
-
+
);
diff --git a/src/screens/VisualTests/SnapshotControls.stories.tsx b/src/screens/VisualTests/SnapshotControls.stories.tsx
index 418ba8a2..9def328a 100644
--- a/src/screens/VisualTests/SnapshotControls.stories.tsx
+++ b/src/screens/VisualTests/SnapshotControls.stories.tsx
@@ -1,14 +1,16 @@
-import { action } from "@storybook/addon-actions";
import { expect } from "@storybook/jest";
import type { Meta, StoryObj } from "@storybook/react";
import { screen, userEvent, within } from "@storybook/testing-library";
-import React from "react";
import { Browser, ComparisonResult, TestStatus } from "../../gql/graphql";
import { panelModes } from "../../modes";
+import { action } from "../../utils/action";
import { playAll } from "../../utils/playAll";
import { makeTest, makeTests } from "../../utils/storyData";
+import { storyWrapper } from "../../utils/storyWrapper";
import { summarizeTests } from "../../utils/summarizeTests";
+import { ControlsProvider } from "./ControlsContext";
+import { ReviewTestProvider } from "./ReviewTestContext";
import { Grid } from "./SnapshotComparison";
import { SnapshotControls } from "./SnapshotControls";
@@ -20,6 +22,11 @@ const withTests = (tests: ReturnType) => ({
const meta = {
component: SnapshotControls,
+ decorators: [
+ storyWrapper(ReviewTestProvider, (ctx) => ({ watchState: ctx.parameters.reviewTest })),
+ storyWrapper(ControlsProvider, () => ({ initialState: { diffVisible: true } })),
+ storyWrapper(Grid),
+ ],
args: {
...withTests(
makeTests({
@@ -37,25 +44,18 @@ const meta = {
),
onSelectMode: action("onSelectMode"),
onSelectBrowser: action("onSelectBrowser"),
- userCanReview: true,
- isReviewable: true,
- isReviewing: false,
- onAccept: action("onAccept"),
- onUnaccept: action("onUnaccept"),
- diffVisible: true,
- setDiffVisible: action("setDiffVisible"),
},
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
parameters: {
chromatic: {
modes: panelModes,
},
+ reviewTest: {
+ isReviewing: false,
+ userCanReview: true,
+ buildIsReviewable: true,
+ acceptTest: action("acceptTest"),
+ unacceptTest: action("unacceptTest"),
+ },
},
} satisfies Meta;
@@ -82,9 +82,12 @@ export const WithMultipleTestsInProgress = {
} satisfies Story;
export const WithSingleTestAccepting = {
- args: {
- ...WithSingleTest.args,
- isReviewing: true,
+ args: WithSingleTest.args,
+ parameters: {
+ reviewTest: {
+ ...meta.parameters.reviewTest,
+ isReviewing: true,
+ },
},
} satisfies Story;
@@ -93,9 +96,12 @@ export const WithSingleTestAccepted = {
} satisfies Story;
export const WithSingleTestUnreviewable = {
- args: {
- ...WithSingleTest.args,
- isReviewable: false,
+ args: WithSingleTest.args,
+ parameters: {
+ reviewTest: {
+ ...meta.parameters.reviewTest,
+ buildIsReviewable: false,
+ },
},
} satisfies Story;
@@ -140,9 +146,12 @@ export const BatchAcceptOptions = {
} satisfies Story;
export const BatchAcceptedBuild = {
- play: playAll(BatchAcceptOptions, async ({ args, canvasIndex }) => {
+ play: playAll(BatchAcceptOptions, async ({ args, canvasIndex, parameters }) => {
const items = await screen.findAllByText("Accept entire build");
await userEvent.click(items[canvasIndex]);
- await expect(args.onAccept).toHaveBeenCalledWith(args.selectedTest.id, "BUILD");
+ await expect(parameters.reviewTest.acceptTest).toHaveBeenCalledWith(
+ args.selectedTest.id,
+ "BUILD"
+ );
}),
} satisfies Story;
diff --git a/src/screens/VisualTests/SnapshotControls.tsx b/src/screens/VisualTests/SnapshotControls.tsx
index be0e58f2..a3b51b48 100644
--- a/src/screens/VisualTests/SnapshotControls.tsx
+++ b/src/screens/VisualTests/SnapshotControls.tsx
@@ -16,6 +16,8 @@ import {
} from "../../gql/graphql";
import { summarizeTests } from "../../utils/summarizeTests";
import { useTests } from "../../utils/useTests";
+import { useControlsDispatch, useControlsState } from "./ControlsContext";
+import { useReviewTestState } from "./ReviewTestContext";
const Controls = styled.div({
gridArea: "controls",
@@ -56,13 +58,6 @@ interface SnapshotSectionProps {
browserResults: TestSummary["browserResults"];
onSelectMode: TestControls["onSelectMode"];
onSelectBrowser: TestControls["onSelectBrowser"];
- userCanReview: boolean;
- isReviewable: boolean;
- isReviewing: boolean;
- onAccept: (testId: StoryTestFieldsFragment["id"], batch?: ReviewTestBatch) => void;
- onUnaccept: (testId: StoryTestFieldsFragment["id"]) => void;
- diffVisible: boolean;
- setDiffVisible: (visible: boolean) => void;
}
export const SnapshotControls = ({
@@ -70,19 +65,18 @@ export const SnapshotControls = ({
changeCount,
selectedTest,
selectedComparison,
+ isInProgress,
modeResults,
browserResults,
onSelectMode,
onSelectBrowser,
- userCanReview,
- isInProgress,
- isReviewable,
- isReviewing,
- onAccept,
- onUnaccept,
- diffVisible,
- setDiffVisible,
}: SnapshotSectionProps) => {
+ const { diffVisible } = useControlsState();
+ const { toggleDiff } = useControlsDispatch();
+
+ const { isReviewing, buildIsReviewable, userCanReview, acceptTest, unacceptTest } =
+ useReviewTestState();
+
if (isInProgress)
return (
@@ -124,7 +118,7 @@ export const SnapshotControls = ({
setDiffVisible(!diffVisible)}
+ onClick={() => toggleDiff(!diffVisible)}
>
@@ -134,7 +128,7 @@ export const SnapshotControls = ({
{(isAcceptable || isUnacceptable) && (
- {userCanReview && isReviewable && isAcceptable && (
+ {userCanReview && buildIsReviewable && isAcceptable && (
<>
}
@@ -144,7 +138,7 @@ export const SnapshotControls = ({
onAccept(selectedTest.id)}
+ onClick={() => acceptTest(selectedTest.id)}
>
Accept
@@ -156,7 +150,7 @@ export const SnapshotControls = ({
id: "acceptStory",
title: "Accept story",
center: "Accept all unreviewed changes to this story",
- onClick: () => onAccept(selectedTest.id, ReviewTestBatch.Spec),
+ onClick: () => acceptTest(selectedTest.id, ReviewTestBatch.Spec),
disabled: isReviewing,
loading: isReviewing,
},
@@ -164,7 +158,7 @@ export const SnapshotControls = ({
id: "acceptComponent",
title: "Accept component",
center: "Accept all unreviewed changes for this component",
- onClick: () => onAccept(selectedTest.id, ReviewTestBatch.Component),
+ onClick: () => acceptTest(selectedTest.id, ReviewTestBatch.Component),
disabled: isReviewing,
loading: isReviewing,
},
@@ -172,7 +166,7 @@ export const SnapshotControls = ({
id: "acceptBuild",
title: "Accept entire build",
center: "Accept all unreviewed changes for every story in the Storybook",
- onClick: () => onAccept(selectedTest.id, ReviewTestBatch.Build),
+ onClick: () => acceptTest(selectedTest.id, ReviewTestBatch.Build),
disabled: isReviewing,
loading: isReviewing,
},
@@ -191,20 +185,20 @@ export const SnapshotControls = ({
>
)}
- {userCanReview && isReviewable && isUnacceptable && (
+ {userCanReview && buildIsReviewable && isUnacceptable && (
}
trigger="hover"
hasChrome={false}
>
- onUnaccept(selectedTest.id)}>
+ unacceptTest(selectedTest.id)}>
Unaccept
)}
- {!(userCanReview && isReviewable) && (
+ {!(userCanReview && buildIsReviewable) && (
}
trigger="hover"
diff --git a/src/screens/VisualTests/VisualTests.stories.tsx b/src/screens/VisualTests/VisualTests.stories.tsx
index 0ccf6e21..6a4f0460 100644
--- a/src/screens/VisualTests/VisualTests.stories.tsx
+++ b/src/screens/VisualTests/VisualTests.stories.tsx
@@ -5,8 +5,8 @@ import { action } from "@storybook/addon-actions";
import { expect } from "@storybook/jest";
import type { Meta, StoryObj } from "@storybook/react";
import {
+ findByLabelText,
findByRole,
- findByTestId,
fireEvent,
screen,
userEvent,
@@ -28,10 +28,12 @@ import {
withGraphQLMutationParameters,
withGraphQLQueryParameters,
} from "../../utils/gqlStoryHelpers";
-import { storyWrapper } from "../../utils/graphQLClient";
+import { GraphQLClientProvider } from "../../utils/graphQLClient";
import { playAll } from "../../utils/playAll";
import { makeComparison, makeTest, makeTests } from "../../utils/storyData";
+import { storyWrapper } from "../../utils/storyWrapper";
import { withFigmaDesign } from "../../utils/withFigmaDesign";
+import { ControlsProvider } from "./ControlsContext";
import { QueryBuild } from "./graphql";
import {
acceptedBuild,
@@ -109,7 +111,7 @@ type StoryArgs = Parameters[0] & {
const meta = {
title: "screens/VisualTests/VisualTests",
component: VisualTestsWithoutSelectedBuildId,
- decorators: [storyWrapper],
+ decorators: [storyWrapper(ControlsProvider), storyWrapper(GraphQLClientProvider)],
parameters: { chromatic: { modes: panelModes } },
argTypes: {
addNotification: { type: "function", target: "manager-api" },
@@ -751,7 +753,7 @@ export const ToggleSnapshot: Story = {
),
},
play: playAll(async ({ canvasElement }) => {
- const button = await findByTestId(canvasElement, "button-toggle-snapshot");
+ const button = await findByLabelText(canvasElement, "Switch snapshot");
await fireEvent.click(button);
}),
} satisfies Story;
diff --git a/src/screens/VisualTests/VisualTests.tsx b/src/screens/VisualTests/VisualTests.tsx
index 3b5fa126..9ec78629 100644
--- a/src/screens/VisualTests/VisualTests.tsx
+++ b/src/screens/VisualTests/VisualTests.tsx
@@ -1,31 +1,21 @@
import { useStorybookApi } from "@storybook/manager-api";
import type { API_StatusState } from "@storybook/types";
import React, { useCallback, useEffect, useState } from "react";
-import { useMutation, useQuery } from "urql";
+import { useMutation } from "urql";
import { getFragment } from "../../gql";
-import {
- ReviewTestBatch,
- ReviewTestInputStatus,
- StoryTestFieldsFragment,
- TestStatus,
-} from "../../gql/graphql";
+import { ReviewTestBatch, ReviewTestInputStatus } from "../../gql/graphql";
import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "../../types";
-import { statusMap, testsToStatusUpdate } from "../../utils/testsToStatusUpdate";
+import { testsToStatusUpdate } from "../../utils/testsToStatusUpdate";
import { SelectedBuildInfo, updateSelectedBuildInfo } from "../../utils/updateSelectedBuildInfo";
+import { BuildProvider, useBuild } from "./BuildContext";
import { BuildResults } from "./BuildResults";
-import {
- FragmentLastBuildOnBranchBuildFields,
- FragmentLastBuildOnBranchTestFields,
- FragmentSelectedBuildFields,
- FragmentStatusTestFields,
- MutationReviewTest,
- QueryBuild,
-} from "./graphql";
+import { FragmentStatusTestFields, MutationReviewTest } from "./graphql";
import { NoBuild } from "./NoBuild";
+import { ReviewTestProvider } from "./ReviewTestContext";
const createEmptyStoryStatusUpdate = (state: API_StatusState) => {
- return Object.fromEntries(Object.entries(state).map(([id, update]) => [id, null]));
+ return Object.fromEntries(Object.entries(state).map(([id]) => [id, null]));
};
interface VisualTestsProps {
@@ -45,6 +35,55 @@ interface VisualTestsProps {
storyId: string;
}
+interface ReviewTestInput {
+ testId: string;
+ status: ReviewTestInputStatus.Accepted | ReviewTestInputStatus.Pending;
+ batch?: ReviewTestBatch;
+}
+
+const useReview = ({
+ buildIsReviewable,
+ userCanReview,
+ onReviewSuccess,
+ onReviewError,
+}: {
+ buildIsReviewable: boolean;
+ userCanReview: boolean;
+ onReviewSuccess?: (input: ReviewTestInput) => void;
+ onReviewError?: (err: any, input: ReviewTestInput) => void;
+}) => {
+ const [{ fetching: isReviewing }, runMutation] = useMutation(MutationReviewTest);
+
+ const reviewTest = useCallback(
+ async (input: ReviewTestInput) => {
+ try {
+ if (!buildIsReviewable) throw new Error("Build is not reviewable");
+ if (!userCanReview) throw new Error("No permission to review tests");
+ const { error } = await runMutation({ input });
+ if (error) throw error;
+ onReviewSuccess?.(input);
+ } catch (err) {
+ onReviewError?.(err, input);
+ }
+ },
+ [onReviewSuccess, onReviewError, runMutation, buildIsReviewable, userCanReview]
+ );
+
+ const acceptTest = useCallback(
+ (testId: string, batch: ReviewTestBatch = ReviewTestBatch.Spec) =>
+ reviewTest({ status: ReviewTestInputStatus.Accepted, testId, batch }),
+ [reviewTest]
+ );
+
+ const unacceptTest = useCallback(
+ (testId: string, batch: ReviewTestBatch = ReviewTestBatch.Spec) =>
+ reviewTest({ status: ReviewTestInputStatus.Pending, testId, batch }),
+ [reviewTest]
+ );
+
+ return { isReviewing, acceptTest, unacceptTest, buildIsReviewable, userCanReview };
+};
+
export const VisualTestsWithoutSelectedBuildId = ({
selectedBuildInfo,
setSelectedBuildInfo,
@@ -60,169 +99,99 @@ export const VisualTestsWithoutSelectedBuildId = ({
}: VisualTestsProps) => {
const { addNotification } = useStorybookApi();
- const [{ data, error: queryError, operation }, rerunQuery] = useQuery({
- query: QueryBuild,
- variables: {
- projectId,
- storyId,
- testStatuses: Object.keys(statusMap) as any as TestStatus[],
- branch: gitInfo.branch || "",
- ...(gitInfo.slug ? { repositoryOwnerName: gitInfo.slug.split("/", 1)[0] } : {}),
- gitUserEmailHash: gitInfo.userEmailHash,
- selectedBuildId: selectedBuildInfo?.buildId || "",
- hasSelectedBuildId: !!selectedBuildInfo,
- },
- });
-
- // When you change story, for a period the query will return the previous set of data, and indicate
- // that with the operation being for the previous query.
- const storyDataIsStale = operation && storyId && operation.variables.storyId !== storyId;
-
- // Poll for updates
- useEffect(() => {
- const interval = setInterval(rerunQuery, 5000);
- return () => clearInterval(interval);
- }, [rerunQuery]);
-
- const { userCanReview = false } = data?.viewer?.projectMembership || {};
-
- const [{ fetching: isReviewing }, reviewTest] = useMutation(MutationReviewTest);
-
- const onReview = useCallback(
- async (
- status: ReviewTestInputStatus.Accepted | ReviewTestInputStatus.Pending,
- testId: string,
- batch?: ReviewTestBatch
- ) => {
- try {
- const { error: reviewError } = await reviewTest({
- input: { testId, status, batch },
+ const buildInfo = useBuild({ projectId, storyId, gitInfo, selectedBuildInfo });
+
+ const {
+ hasData,
+ hasProject,
+ hasSelectedBuild,
+ lastBuildOnBranch,
+ lastBuildOnBranchIsReady,
+ lastBuildOnBranchIsSelectable,
+ selectedBuild,
+ selectedBuildMatchesGit,
+ queryError,
+ rerunQuery,
+ userCanReview,
+ } = buildInfo;
+
+ const reviewState = useReview({
+ buildIsReviewable: !!selectedBuild && selectedBuild.id === lastBuildOnBranch?.id,
+ userCanReview,
+ onReviewSuccess: rerunQuery,
+ onReviewError: (err, update) => {
+ if (err instanceof Error) {
+ addNotification({
+ id: "chromatic/errorAccepting",
+ // @ts-expect-error we need a better API for not passing a link
+ link: undefined,
+ content: {
+ headline: `Failed to ${
+ update.status === ReviewTestInputStatus.Accepted ? "accept" : "unaccept"
+ } changes`,
+ subHeadline: err.message,
+ },
+ icon: {
+ name: "cross",
+ color: "red",
+ },
});
-
- if (reviewError) {
- throw reviewError;
- }
- rerunQuery();
- } catch (err) {
- if (err instanceof Error) {
- addNotification({
- id: "chromatic/errorAccepting",
- // @ts-expect-error we need a better API for not passing a link
- link: undefined,
- content: {
- headline: `Failed to ${
- status === ReviewTestInputStatus.Accepted ? "accept" : "unaccept"
- } changes`,
- subHeadline: err.message,
- },
- icon: {
- name: "cross",
- color: "red",
- },
- });
- }
}
},
- [addNotification, rerunQuery, reviewTest]
- );
-
- const onAccept = useCallback(
- async (testId: StoryTestFieldsFragment["id"], batch?: ReviewTestBatch) =>
- onReview(ReviewTestInputStatus.Accepted, testId, batch),
- [onReview]
- );
-
- const onUnaccept = useCallback(
- async (testId: string) => onReview(ReviewTestInputStatus.Pending, testId),
- [onReview]
- );
-
- const lastBuildOnBranch = getFragment(
- FragmentLastBuildOnBranchBuildFields,
- data?.project?.lastBuildOnBranch
- );
-
- const lastBuildOnBranchStoryTests = [
- ...getFragment(
- FragmentLastBuildOnBranchTestFields,
- lastBuildOnBranch && "testsForStory" in lastBuildOnBranch && lastBuildOnBranch.testsForStory
- ? lastBuildOnBranch.testsForStory.nodes
- : []
- ),
- ];
- const lastBuildOnBranchCompletedStory =
- !!lastBuildOnBranch &&
- lastBuildOnBranchStoryTests.every(({ status }) => status !== TestStatus.InProgress);
-
- // Before we set the storyInfo, we use the lastBuildOnBranch for story data if it's ready
- const selectedBuild = getFragment(
- FragmentSelectedBuildFields,
- data?.selectedBuild ??
- (lastBuildOnBranchCompletedStory ? data?.project?.lastBuildOnBranch : undefined)
- );
+ });
- const selectedBuildHasCorrectBranch = selectedBuild?.branch === gitInfo.branch;
// Currently only used by the sidebar button to show a blue dot ("build outdated")
- const isOutdated =
- !selectedBuildHasCorrectBranch ||
- selectedBuild?.commit !== gitInfo.commit ||
- selectedBuild?.uncommittedHash !== gitInfo.uncommittedHash;
- useEffect(() => setOutdated(isOutdated), [isOutdated, setOutdated]);
+ useEffect(() => setOutdated(!selectedBuildMatchesGit), [selectedBuildMatchesGit, setOutdated]);
- // If the next build is *newer* than the current commit, we don't want to switch to the build
- const lastBuildOnBranchNewer =
- lastBuildOnBranch && lastBuildOnBranch.committedAt > gitInfo.committedAt;
- const canSwitchToLastBuildOnBranch = !!lastBuildOnBranch && !lastBuildOnBranchNewer;
-
- // We always set status to the next build's status, as when we change to a new story we'll see
- // the next builds
+ // We always set status to the next build's status, as when we change to a new story we'll see the
+ // next builds. The status update is calculated outside useEffect so it only reruns when changed.
const testsForStatus =
lastBuildOnBranch &&
"testsForStatus" in lastBuildOnBranch &&
- lastBuildOnBranch.testsForStatus &&
+ lastBuildOnBranch.testsForStatus?.nodes &&
getFragment(FragmentStatusTestFields, lastBuildOnBranch.testsForStatus.nodes);
-
- const buildStatusUpdate =
- canSwitchToLastBuildOnBranch && testsForStatus ? testsToStatusUpdate(testsForStatus) : {};
-
+ const statusUpdate = lastBuildOnBranchIsSelectable && testsToStatusUpdate(testsForStatus || []);
useEffect(() => {
// @ts-expect-error The return type of this function is wrong in the API, it should allow `null` values
updateBuildStatus((state) => ({
...createEmptyStoryStatusUpdate(state),
- ...buildStatusUpdate,
+ ...statusUpdate,
}));
- // We use the stringified version of buildStatusUpdate to do a deep diff
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [JSON.stringify(buildStatusUpdate), updateBuildStatus]);
+ }, [JSON.stringify(statusUpdate), updateBuildStatus]);
- const shouldSwitchToLastBuildOnBranch =
- canSwitchToLastBuildOnBranch && lastBuildOnBranchCompletedStory;
-
- // Ensure we are holding the right story build
+ // Auto-select the last build on branch if it's selectable and ready
useEffect(() => {
setSelectedBuildInfo((oldSelectedBuildInfo) =>
updateSelectedBuildInfo(oldSelectedBuildInfo, {
- shouldSwitchToLastBuildOnBranch,
+ shouldSwitchToLastBuildOnBranch: lastBuildOnBranchIsSelectable && lastBuildOnBranchIsReady,
lastBuildOnBranchId: lastBuildOnBranch?.id,
storyId,
})
);
- }, [shouldSwitchToLastBuildOnBranch, lastBuildOnBranch?.id, storyId, setSelectedBuildInfo]);
+ }, [
+ lastBuildOnBranchIsSelectable,
+ lastBuildOnBranchIsReady,
+ lastBuildOnBranch?.id,
+ setSelectedBuildInfo,
+ storyId,
+ ]);
const switchToLastBuildOnBranch = useCallback(
() =>
- canSwitchToLastBuildOnBranch &&
+ lastBuildOnBranch?.id &&
+ lastBuildOnBranchIsSelectable &&
setSelectedBuildInfo({ buildId: lastBuildOnBranch.id, storyId }),
- [setSelectedBuildInfo, canSwitchToLastBuildOnBranch, lastBuildOnBranch?.id, storyId]
+ [setSelectedBuildInfo, lastBuildOnBranchIsSelectable, lastBuildOnBranch?.id, storyId]
);
- return !selectedBuildHasCorrectBranch || !selectedBuild || storyDataIsStale || queryError ? (
+ return !selectedBuild || !hasSelectedBuild || !hasData || queryError ? (
) : (
-
+
+
+
+
+
);
};
diff --git a/src/utils/action.ts b/src/utils/action.ts
new file mode 100644
index 00000000..1f7458b9
--- /dev/null
+++ b/src/utils/action.ts
@@ -0,0 +1,9 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import { action as act, type ActionOptions, type HandlerFunction } from "@storybook/addon-actions";
+import { jest } from "@storybook/jest";
+
+export const action = (name: string, options?: ActionOptions): HandlerFunction => {
+ const spy = jest.fn(act(name, options));
+ Object.defineProperty(spy, "name", { value: name, writable: false });
+ return spy;
+};
diff --git a/src/utils/graphQLClient.tsx b/src/utils/graphQLClient.tsx
index 7e51cff4..33248178 100644
--- a/src/utils/graphQLClient.tsx
+++ b/src/utils/graphQLClient.tsx
@@ -106,8 +106,6 @@ export const client = new Client({
fetchOptions: getFetchOptions(), // Auth header (token) is handled by authExchange
});
-export const storyWrapper = (Story: any) => (
-
-
-
-);
+export const GraphQLClientProvider = ({ children }: { children: React.ReactNode }) => {
+ return {children};
+};
diff --git a/src/utils/storyWrapper.tsx b/src/utils/storyWrapper.tsx
new file mode 100644
index 00000000..ecd33449
--- /dev/null
+++ b/src/utils/storyWrapper.tsx
@@ -0,0 +1,14 @@
+import type { StoryContext } from "@storybook/types";
+import React from "react";
+
+export const storyWrapper = (
+ Wrapper: React.FunctionComponent,
+ getProps?: (context: StoryContext) => any
+) =>
+ function StoryWrapper(Story: React.FunctionComponent, context: StoryContext) {
+ return (
+
+
+
+ );
+ };
diff --git a/src/utils/useRequiredContext.ts b/src/utils/useRequiredContext.ts
new file mode 100644
index 00000000..4d202609
--- /dev/null
+++ b/src/utils/useRequiredContext.ts
@@ -0,0 +1,7 @@
+import { useContext } from "react";
+
+export const useRequiredContext = (context: React.Context, name: string) => {
+ const value = useContext(context);
+ if (value === null || value === undefined) throw new Error(`Missing context value for ${name}`);
+ return value as NonNullable;
+};
diff --git a/src/utils/useTests.ts b/src/utils/useTests.ts
index 81bcdb10..43776397 100644
--- a/src/utils/useTests.ts
+++ b/src/utils/useTests.ts
@@ -11,7 +11,7 @@ type ModeData = Pick;
*/
export function useTests(tests: StoryTestFieldsFragment[]) {
const [selectedBrowserId, onSelectBrowserId] = useState(
- tests[0]?.comparisons[0].browser.id
+ tests[0]?.comparisons[0]?.browser.id
);
const [selectedModeName, onSelectModeName] = useState(tests[0]?.mode.name);