diff --git a/src/components/PipelineRun/RunNotesEditor.tsx b/src/components/PipelineRun/RunNotesEditor.tsx index c16165c30e..6cac7e79b1 100644 --- a/src/components/PipelineRun/RunNotesEditor.tsx +++ b/src/components/PipelineRun/RunNotesEditor.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Textarea } from "@/components/ui/textarea"; import { Paragraph } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; import { useBackend } from "@/providers/BackendProvider"; import { updateRunAnnotation } from "@/services/pipelineRunService"; import { runAnnotationsQueryOptions } from "@/services/runAnnotations"; @@ -14,10 +15,16 @@ import { interface RunNotesEditorProps { runId: string; readOnly?: boolean; + teamId?: string; } -export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => { +export const RunNotesEditor = ({ + runId, + readOnly, + teamId, +}: RunNotesEditorProps) => { const { backendUrl } = useBackend(); + const notify = useToastNotification(); const { data: annotations, @@ -29,13 +36,21 @@ export const RunNotesEditor = ({ runId, readOnly }: RunNotesEditorProps) => { const { mutate: saveRunNotes, isPending } = useMutation({ mutationFn: (runId: string) => - updateRunAnnotation(runId, backendUrl, { - key: PIPELINE_RUN_NOTES_ANNOTATION, - value: value, - }), + updateRunAnnotation( + runId, + backendUrl, + { + key: PIPELINE_RUN_NOTES_ANNOTATION, + value: value, + }, + teamId, + ), onSuccess: () => { refetch(); }, + onError: (error) => { + notify(error.message, "error"); + }, }); const notes = diff --git a/src/components/PipelineRun/RunToolbar.test.tsx b/src/components/PipelineRun/RunToolbar.test.tsx index dbfc991bf3..bbf647b2fa 100644 --- a/src/components/PipelineRun/RunToolbar.test.tsx +++ b/src/components/PipelineRun/RunToolbar.test.tsx @@ -42,6 +42,10 @@ vi.mock("@/hooks/useUserDetails", () => ({ })), })); +vi.mock("@/services/teams/useRunTeam", () => ({ + useRunTeam: () => ({ status: "none" }), +})); + describe("", () => { const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/components/PipelineRun/RunToolbar.tsx b/src/components/PipelineRun/RunToolbar.tsx index b2d21154da..ef22656b07 100644 --- a/src/components/PipelineRun/RunToolbar.tsx +++ b/src/components/PipelineRun/RunToolbar.tsx @@ -88,11 +88,7 @@ export const RunToolbar = () => { )} {isComplete && ( - + )} ); diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx index 9ae7920988..ddbb9f3c33 100644 --- a/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx +++ b/src/components/PipelineRun/components/CancelPipelineRunButton.test.tsx @@ -145,6 +145,7 @@ describe("", () => { expect(cancelPipelineRun).toHaveBeenCalledWith( "test-run-123", "http://localhost:8000", + undefined, ); expect(mockNotify).toHaveBeenCalledWith( "Pipeline run test-run-123 cancelled", @@ -154,6 +155,27 @@ describe("", () => { expect(button).toHaveTextContent("Stop"); }); + test("cancels a team's run as the team", async () => { + cancelPipelineRun.mockResolvedValue(); + renderWithQueryClient( + , + ); + + await act(() => + fireEvent.click(screen.getByTestId("cancel-pipeline-run-button")), + ); + await act(() => fireEvent.click(screen.getByText("Continue"))); + + expect(cancelPipelineRun).toHaveBeenCalledWith( + "test-run-123", + "http://localhost:8000", + "ml-team@example.com", + ); + }); + test("handles cancellation error", async () => { // arrange const errorMessage = "Network error"; @@ -168,7 +190,7 @@ describe("", () => { // assert expect(mockNotify).toHaveBeenCalledWith( - `Error cancelling run: Error: ${errorMessage}`, + `Error cancelling run: ${errorMessage}`, "error", ); expect(button).toBeVisible(); diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx index f4618381b6..4011dd6e8b 100644 --- a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx +++ b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx @@ -11,6 +11,7 @@ import { cancelPipelineRun } from "@/services/pipelineRunService"; type CancelPipelineRunButtonProps = { runId: string | null | undefined; + teamId?: string; showLabel?: boolean; displayLabel?: string; showTooltip?: boolean; @@ -21,6 +22,7 @@ type CancelPipelineRunButtonProps = { export const CancelPipelineRunButton = ({ runId, + teamId, showLabel, displayLabel, showTooltip = true, @@ -36,12 +38,12 @@ export const CancelPipelineRunButton = ({ isPending, isSuccess, } = useMutation({ - mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl), + mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl, teamId), onSuccess: () => { notify(`Pipeline run ${runId} cancelled`, "success"); }, onError: (error) => { - notify(`Error cancelling run: ${error}`, "error"); + notify(`Error cancelling run: ${error.message}`, "error"); }, }); diff --git a/src/components/PipelineRun/components/RerunBlockedReason.tsx b/src/components/PipelineRun/components/RerunBlockedReason.tsx new file mode 100644 index 0000000000..5ee7ae3d95 --- /dev/null +++ b/src/components/PipelineRun/components/RerunBlockedReason.tsx @@ -0,0 +1,15 @@ +import { TeamName } from "@/components/shared/Teams/TeamName"; +import type { RerunBlock } from "@/routes/v2/pages/RunView/hooks/useRerunPipelineRun"; + +export function RerunBlockedReason({ block }: { block: RerunBlock }) { + if (block.kind === "team-unchecked") { + return <>{"Couldn't check this run's workspace, so it can't be rerun."}; + } + return ( + <> + {"This run went as the "} + + {" workspace. Only someone who can act as the workspace can rerun it."} + + ); +} diff --git a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx index b6ff67d162..9ab5eb2700 100644 --- a/src/components/PipelineRun/components/RerunPipelineButton.test.tsx +++ b/src/components/PipelineRun/components/RerunPipelineButton.test.tsx @@ -7,10 +7,17 @@ import { screen, waitFor, } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type RunIdentity, + runIdentity, +} from "@/components/shared/SecretsManagement/runIdentity"; +import { useRunIdentity } from "@/components/shared/SecretsManagement/useRunIdentity"; import { BackendProvider } from "@/providers/BackendProvider"; +import { useRunTeam } from "@/services/teams/useRunTeam"; import { RerunPipelineButton } from "./RerunPipelineButton"; @@ -74,6 +81,44 @@ vi.mock("@/providers/ExecutionDataProvider", () => ({ useExecutionDataOptional: mockUseExecutionDataOptional, })); +vi.mock("@/services/teams/useRunTeam", () => ({ + useRunTeam: vi.fn(), +})); + +vi.mock("@/hooks/useUserDetails", () => ({ + useUserDetails: () => ({ data: { id: "me@example.com", permissions: [] } }), +})); + +vi.mock( + "@/components/shared/SecretsManagement/useRunIdentity", + async (importOriginal) => ({ + ...(await importOriginal()), + useRunIdentity: vi.fn(), + }), +); + +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +vi.stubGlobal("ResizeObserver", ResizeObserverMock); + +const ML_TEAM = { id: "ml-team@example.com", label: "ML Research" }; +const ME = "me@example.com"; + +const named = (...names: string[]) => names.map((name) => ({ name })); + +function ranBy(createdBy: string) { + mockUseExecutionDataOptional.mockReturnValue({ + metadata: { id: "run-1", created_by: createdBy }, + }); +} + +function withIdentity(identity: RunIdentity) { + vi.mocked(useRunIdentity).mockReturnValue({ ...identity, isChecking: false }); +} + const testOrigin = import.meta.env.VITE_BASE_URL || "http://localhost:3000"; Object.defineProperty(window, "location", { @@ -116,6 +161,8 @@ describe("", () => { mockGetToken.mockReturnValue("mock-token"); mockAwaitAuthorization.mockClear(); mockUseExecutionDataOptional.mockReturnValue(undefined); + vi.mocked(useRunTeam).mockReturnValue({ status: "none" }); + withIdentity(runIdentity({ team: undefined, referenced: [] })); }); afterEach(async () => { @@ -419,4 +466,248 @@ describe("", () => { ); }); }); + + describe("for a run filed under a team's project", () => { + const rerunNow = async () => { + await act(async () => { + renderWithProviders( + , + ); + }); + await act(async () => { + fireEvent.click(screen.getByTestId("rerun-pipeline-button")); + }); + }; + + beforeEach(() => { + mockSubmitPipelineRun.mockImplementation(async (_, __, { onSuccess }) => { + onSuccess({ id: 123 }); + }); + }); + + const ACTIVE = { + status: "active", + team: { ...ML_TEAM, canWrite: true }, + } as const; + + test("reruns as you when its secrets don't need the team", async () => { + vi.mocked(useRunTeam).mockReturnValue(ACTIVE); + withIdentity( + runIdentity({ + team: ML_TEAM, + referenced: ["TEST_PERSONAL"], + yourSecrets: named("TEST_PERSONAL"), + teamSecrets: [], + }), + ); + + await rerunNow(); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ teamId: undefined }), + ); + }); + }); + + test("reruns as the team when only the team has its secrets", async () => { + vi.mocked(useRunTeam).mockReturnValue(ACTIVE); + withIdentity( + runIdentity({ + team: ML_TEAM, + referenced: ["Test"], + yourSecrets: [], + teamSecrets: named("Test"), + }), + ); + + await rerunNow(); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ teamId: ML_TEAM.id }), + ); + }); + }); + + test("lets you choose who reruns it when its secrets are split", async () => { + vi.mocked(useRunTeam).mockReturnValue(ACTIVE); + withIdentity( + runIdentity({ + team: ML_TEAM, + referenced: ["TEST_PERSONAL", "Test"], + yourSecrets: named("TEST_PERSONAL"), + teamSecrets: named("Test"), + }), + ); + + await rerunNow(); + + expect(screen.getByTestId("info-box-title")).toHaveTextContent( + "Secrets split between you and the workspace", + ); + expect(mockSubmitPipelineRun).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click( + screen.getByRole("radio", { name: "Workspace: ML Research" }), + ); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + }); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ teamId: ML_TEAM.id }), + ); + }); + }); + + test("reruns the team's run as the team", async () => { + vi.mocked(useRunTeam).mockReturnValue(ACTIVE); + ranBy(ML_TEAM.id); + + await rerunNow(); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ teamId: ML_TEAM.id }), + ); + }); + }); + + test.each([ + ["you're not in its workspace", { status: "not-member", team: ML_TEAM }], + [ + "you can't act as its workspace", + { status: "unavailable", team: ML_TEAM }, + ], + ] as const)("won't rerun the team's run when %s", async (_, resolution) => { + vi.mocked(useRunTeam).mockReturnValue(resolution); + ranBy(ML_TEAM.id); + const user = userEvent.setup(); + + await act(async () => { + renderWithProviders( + , + ); + }); + + const button = screen.getByTestId("rerun-pipeline-button"); + expect(button).toBeDisabled(); + + await user.hover(button.parentElement!); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "This run went as the ML Research workspace. Only someone who can act as the workspace can rerun it.", + ); + }); + + test("reruns your own run as you when you're not in its team", async () => { + vi.mocked(useRunTeam).mockReturnValue({ + status: "not-member", + team: ML_TEAM, + }); + ranBy(ME); + + await rerunNow(); + + expect(screen.queryByRole("alertdialog")).toBeNull(); + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ teamId: undefined }), + ); + }); + }); + + test("won't rerun someone else's run when its team can't be checked", async () => { + vi.mocked(useRunTeam).mockReturnValue({ + status: "error", + error: new Error("Lookup failed"), + }); + ranBy("colleague@example.com"); + + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled(); + }); + + test("reruns your own run when its team can't be checked", async () => { + vi.mocked(useRunTeam).mockReturnValue({ + status: "error", + error: new Error("Lookup failed"), + }); + ranBy(ME); + + await rerunNow(); + + await waitFor(() => { + expect(mockSubmitPipelineRun).toHaveBeenCalledWith( + componentSpec, + expect.any(String), + expect.objectContaining({ teamId: undefined }), + ); + }); + }); + + test("asks before rerunning without the secrets the run uses", async () => { + vi.mocked(useRunTeam).mockReturnValue(ACTIVE); + withIdentity( + runIdentity({ + team: ML_TEAM, + referenced: ["API_KEY"], + yourSecrets: [], + teamSecrets: [], + }), + ); + + await rerunNow(); + + expect(screen.getByTestId("info-box-warning")).toHaveTextContent( + "API_KEY isn't in your secrets, so the run will fail when it starts.", + ); + expect(mockSubmitPipelineRun).not.toHaveBeenCalled(); + }); + + test("waits for the run's team before rerunning", async () => { + vi.mocked(useRunTeam).mockReturnValue({ status: "resolving" }); + + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled(); + }); + + test("won't rerun a run whose projects are on different teams", async () => { + vi.mocked(useRunTeam).mockReturnValue({ + status: "conflict", + teams: [ML_TEAM, { id: "data@example.com", label: "Data" }], + }); + + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.getByTestId("rerun-pipeline-button")).toBeDisabled(); + }); + }); }); diff --git a/src/components/PipelineRun/components/RerunPipelineButton.tsx b/src/components/PipelineRun/components/RerunPipelineButton.tsx index 0076ab15e3..f26d6b8b6a 100644 --- a/src/components/PipelineRun/components/RerunPipelineButton.tsx +++ b/src/components/PipelineRun/components/RerunPipelineButton.tsx @@ -1,28 +1,15 @@ -import { useMutation } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { type ComponentPropsWithoutRef, useCallback } from "react"; +import type { ComponentPropsWithoutRef } from "react"; -import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; -import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage"; -import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; -import { buildTaskSpecShape } from "@/components/shared/PipelineRunNameTemplate/types"; import { Icon } from "@/components/ui/icon"; -import { useRerunProjectIds } from "@/hooks/useRerunProjectIds"; -import useToastNotification from "@/hooks/useToastNotification"; -import { useBackend } from "@/providers/BackendProvider"; -import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; -import { useRunSubmissionAnnotations } from "@/providers/RunSubmissionScopeProvider"; -import { getDefaultRunPath } from "@/routes/runRoutes"; -import type { PipelineRun } from "@/types/pipelineRun"; -import { extractCanonicalName } from "@/utils/canonicalPipelineName"; -import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec"; -import { projectRunAnnotations } from "@/utils/projectRunAnnotation"; -import { submitPipelineRun } from "@/utils/submitPipeline"; +import { useRerunPipelineRun } from "@/routes/v2/pages/RunView/hooks/useRerunPipelineRun"; +import type { ComponentSpec } from "@/utils/componentSpec"; + +import { RerunBlockedReason } from "./RerunBlockedReason"; +import { RerunReviewDialog } from "./RerunReviewDialog"; type RerunPipelineButtonProps = { componentSpec: ComponentSpec; - runId?: string | null; showLabel?: boolean; displayLabel?: string; showTooltip?: boolean; @@ -33,88 +20,34 @@ type RerunPipelineButtonProps = { export const RerunPipelineButton = ({ componentSpec, - runId, showLabel, displayLabel, showTooltip = true, ...rest }: RerunPipelineButtonProps) => { - const { backendUrl } = useBackend(); - const navigate = useNavigate(); - const notify = useToastNotification(); - const executionData = useExecutionDataOptional(); - const runAnnotations = useRunSubmissionAnnotations(); - const rerunProjectIds = useRerunProjectIds(); - - const { awaitAuthorization, isAuthorized } = useAwaitAuthorization(); - const { getToken } = useAuthLocalStorage(); + const { rerun, isRerunning, canRerun, blocked, review } = + useRerunPipelineRun(componentSpec); - const onSuccess = useCallback((response: PipelineRun) => { - navigate({ to: getDefaultRunPath(response.id) }); - }, []); - - const onError = useCallback( - (error: Error | string) => { - const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`; - notify(message, "error"); - }, - [notify], + const tooltip = blocked ? ( + + ) : ( + showTooltip && "Rerun pipeline" ); - const getAuthToken = useCallback(async (): Promise => { - const authorizationRequired = isAuthorizationRequired(); - - if (authorizationRequired && !isAuthorized) { - const token = await awaitAuthorization(); - if (token) { - return token; - } - } - - return getToken(); - }, [awaitAuthorization, getToken, isAuthorized]); - - const { mutate, isPending } = useMutation({ - mutationFn: async () => { - const authorizationToken = await getAuthToken(); - const projectIds = await rerunProjectIds(runId); - - return new Promise((resolve, reject) => { - submitPipelineRun(componentSpec, backendUrl, { - canonicalName: extractCanonicalName( - buildTaskSpecShape( - executionData?.rootDetails?.task_spec, - componentSpec, - ), - ), - // Generated API definitions slightly differs from the componentSpec - taskArguments: executionData?.rootDetails?.task_spec - .arguments as Record, - authorizationToken, - runAnnotations: { - ...runAnnotations, - ...projectRunAnnotations(projectIds), - }, - onSuccess: resolve, - onError: reject, - }); - }); - }, - onSuccess, - onError, - }); - return ( - mutate()} - tooltip={showTooltip ? "Rerun pipeline" : undefined} - disabled={isPending} - data-testid="rerun-pipeline-button" - {...rest} - > - - {displayLabel ?? (showLabel ? "Rerun" : null)} - + <> + + + {displayLabel ?? (showLabel ? "Rerun" : null)} + + + ); }; diff --git a/src/components/PipelineRun/components/RerunReviewDialog.tsx b/src/components/PipelineRun/components/RerunReviewDialog.tsx new file mode 100644 index 0000000000..5e56334b2b --- /dev/null +++ b/src/components/PipelineRun/components/RerunReviewDialog.tsx @@ -0,0 +1,27 @@ +import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog"; +import { RunIdentitySummary } from "@/components/shared/Submitters/Tangle/components/RunIdentitySummary"; +import type { RerunReview } from "@/routes/v2/pages/RunView/hooks/useRerunPipelineRun"; + +interface RerunReviewDialogProps { + review: RerunReview; +} + +export function RerunReviewDialog({ review }: RerunReviewDialogProps) { + return ( + + } + onConfirm={review.confirm} + onCancel={review.dismiss} + /> + ); +} diff --git a/src/routes/v2/pages/RunView/components/RunActionsBar.tsx b/src/routes/v2/pages/RunView/components/RunActionsBar.tsx index 93527dcef3..2654b49d52 100644 --- a/src/routes/v2/pages/RunView/components/RunActionsBar.tsx +++ b/src/routes/v2/pages/RunView/components/RunActionsBar.tsx @@ -1,3 +1,5 @@ +import { RerunBlockedReason } from "@/components/PipelineRun/components/RerunBlockedReason"; +import { RerunReviewDialog } from "@/components/PipelineRun/components/RerunReviewDialog"; import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import ConfirmationDialog from "@/components/shared/Dialogs/ConfirmationDialog"; import { StatusBar } from "@/components/shared/Status"; @@ -35,18 +37,20 @@ export function RunActionsBar({ const actions = useRunViewActions(); const componentSpec = actions.ready ? actions.componentSpec : undefined; const runId = actions.ready ? actions.runId : undefined; + const cancelAsTeamId = actions.ready ? actions.management.teamId : undefined; const pipelineName = actions.ready ? actions.pipelineName : undefined; const { yamlViewerOpen, openYamlViewer, closeYamlViewer } = useYamlViewer(); const { clone, isCloning } = useClonePipelineRun(componentSpec, runId); - const { rerun, isRerunning } = useRerunPipelineRun(componentSpec); + const { rerun, isRerunning, canRerun, blocked, review } = + useRerunPipelineRun(componentSpec); const { cancelDialogOpen, isCancelling, requestCancel, confirmCancel, dismissCancel, - } = useCancelPipelineRun(runId); + } = useCancelPipelineRun(runId, cancelAsTeamId); const { inspect } = useInspectPipeline(pipelineName); const { exportYaml } = useExportPipelineYaml(componentSpec, pipelineName); @@ -72,9 +76,8 @@ export function RunActionsBar({ ); } - const { canAccessEditorSpec, isRunCreator, isInProgress, isComplete } = - actions; - const showCancel = isInProgress && isRunCreator; + const { canAccessEditorSpec, management, isInProgress, isComplete } = actions; + const showCancel = isInProgress && management.canManage; const showSeparator = showCancel || isComplete; return ( @@ -150,11 +153,18 @@ export function RunActionsBar({ {isComplete && ( - Rerun Pipeline + + Rerun Pipeline + {blocked && ( + + + + )} + )} @@ -170,6 +180,8 @@ export function RunActionsBar({ onCancel={dismissCancel} /> + + {yamlViewerOpen && ( | undefined; +let runCreatedBy = mockMetadata.created_by; + +const ML_TEAM = { id: "ml-team@example.com", label: "ML Research" }; vi.mock("@/routes/v2/shared/providers/SpecContext", () => ({ useSpec: () => mockSpec, @@ -32,7 +43,11 @@ vi.mock("@/providers/ExecutionDataProvider", () => ({ useExecutionData: () => ({ rootDetails: { task_spec: { arguments: {} } }, rootState: { child_execution_status_stats: {} }, - metadata: { ...mockMetadata, execution_status_stats: runStats }, + metadata: { + ...mockMetadata, + created_by: runCreatedBy, + execution_status_stats: runStats, + }, isLoading: false, error: undefined, }), @@ -52,7 +67,11 @@ vi.mock("@/hooks/useUserDetails", () => ({ vi.mock("@/services/pipelineRunService", () => ({ fetchRunAnnotations: (...args: unknown[]) => fetchRunAnnotations(...args), - updateRunAnnotation: vi.fn(), + updateRunAnnotation: (...args: unknown[]) => updateRunAnnotation(...args), +})); + +vi.mock("@/services/teams/useRunTeam", () => ({ + useRunTeam: vi.fn(), })); vi.mock("@/components/shared/Execution/PipelineIO", () => ({ @@ -99,10 +118,12 @@ const openDetailsSection = async () => { beforeEach(() => { queryClient.clear(); vi.clearAllMocks(); + vi.mocked(useRunTeam).mockReturnValue({ status: "none" }); }); afterEach(() => { cleanup(); + runCreatedBy = mockMetadata.created_by; }); describe("", () => { @@ -138,6 +159,56 @@ describe("", () => { expect(screen.queryByText("Run Annotations")).not.toBeInTheDocument(); }); + describe("run notes", () => { + beforeEach(() => { + fetchRunAnnotations.mockResolvedValue({ notes: "First try" }); + updateRunAnnotation.mockResolvedValue(undefined); + }); + + it("lets you edit the notes of a run you created", async () => { + renderPanel(); + await openDetailsSection(); + + expect(await screen.findByDisplayValue("First try")).toBeInTheDocument(); + }); + + it("saves a team's run notes as the team", async () => { + runCreatedBy = ML_TEAM.id; + vi.mocked(useRunTeam).mockReturnValue({ + status: "active", + team: { ...ML_TEAM, canWrite: true }, + }); + renderPanel(); + await openDetailsSection(); + + const notes = await screen.findByDisplayValue("First try"); + fireEvent.change(notes, { target: { value: "Second try" } }); + fireEvent.blur(notes); + + await waitFor(() => + expect(updateRunAnnotation).toHaveBeenCalledWith( + "run-1", + "http://backend", + { key: "notes", value: "Second try" }, + ML_TEAM.id, + ), + ); + }); + + it("shows a team's run notes read-only outside the team", async () => { + runCreatedBy = ML_TEAM.id; + vi.mocked(useRunTeam).mockReturnValue({ + status: "not-member", + team: ML_TEAM, + }); + renderPanel(); + await openDetailsSection(); + + expect(await screen.findByText("First try")).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + }); + describe("with Tangent", () => { beforeEach(() => { tangentEnabled = true; diff --git a/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx b/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx index 63ecfa7acf..023c51d51a 100644 --- a/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx +++ b/src/routes/v2/pages/RunView/components/RunDetailsContent.tsx @@ -37,6 +37,8 @@ import { useOpenInTangent } from "@/routes/v2/pages/RunView/hooks/useOpenInTange import { PipelineDetailsCollapsibleSection } from "@/routes/v2/shared/components/PipelineDetailsCollapsibleSection"; import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; import { runAnnotationsQueryOptions } from "@/services/runAnnotations"; +import { runManagement } from "@/services/teams/runManagement"; +import { useRunTeam } from "@/services/teams/useRunTeam"; import { getAnnotationValue, isSystemRunAnnotation, @@ -362,8 +364,12 @@ function NotesSection({ metadata, currentUserId, }: NotesSectionProps) { - const isRunCreator = - !!currentUserId && metadata?.created_by === currentUserId; + const runTeam = useRunTeam(metadata?.id); + const management = runManagement( + metadata?.created_by, + currentUserId, + runTeam, + ); return ( @@ -376,7 +382,11 @@ function NotesSection({ {!!metadata?.id && ( Run Notes - + )} diff --git a/src/routes/v2/pages/RunView/components/RunToolsContent.tsx b/src/routes/v2/pages/RunView/components/RunToolsContent.tsx index 9536098a20..69bbff2786 100644 --- a/src/routes/v2/pages/RunView/components/RunToolsContent.tsx +++ b/src/routes/v2/pages/RunView/components/RunToolsContent.tsx @@ -27,7 +27,7 @@ export const RunToolsContent = observer(function RunToolsContent({ componentSpec, runId, canAccessEditorSpec, - isRunCreator, + management, isInProgress, isComplete, pipelineName, @@ -84,9 +84,10 @@ export const RunToolsContent = observer(function RunToolsContent({ {...tracking("v2.run_view.tools.clone_pipeline")} /> - {isInProgress && isRunCreator && ( + {isInProgress && management.canManage && ( - Rerun Pipeline + + Rerun Pipeline + {blocked && ( + + + + )} + )} @@ -158,6 +170,8 @@ export function RunMenu() { onCancel={dismissCancel} /> + + {yamlViewerOpen && ( cancelPipelineRun(runId, backendUrl), + mutationFn: (runId: string) => cancelPipelineRun(runId, backendUrl, teamId), onSuccess: () => { notify("Pipeline run cancelled", "success"); }, onError: (error) => { - notify(`Error cancelling run: ${error}`, "error"); + notify(`Error cancelling run: ${error.message}`, "error"); }, }); diff --git a/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts b/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts index 0cf8cd1303..212aacea92 100644 --- a/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts +++ b/src/routes/v2/pages/RunView/hooks/useRerunPipelineRun.ts @@ -1,16 +1,31 @@ import { useMutation } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers"; import { useAuthLocalStorage } from "@/components/shared/Authentication/useAuthLocalStorage"; import { useAwaitAuthorization } from "@/components/shared/Authentication/useAwaitAuthorization"; import { buildTaskSpecShape } from "@/components/shared/PipelineRunNameTemplate/types"; +import { + needsReview, + type RunAs, + runAsFor, + type RunIdentity, + teamIdFor, +} from "@/components/shared/SecretsManagement/runIdentity"; +import { + useRunAsChoice, + useRunIdentity, +} from "@/components/shared/SecretsManagement/useRunIdentity"; import { useRerunProjectIds } from "@/hooks/useRerunProjectIds"; import useToastNotification from "@/hooks/useToastNotification"; +import { useUserDetails } from "@/hooks/useUserDetails"; import { useBackend } from "@/providers/BackendProvider"; -import { useExecutionData } from "@/providers/ExecutionDataProvider"; +import { useExecutionDataOptional } from "@/providers/ExecutionDataProvider"; import { useRunSubmissionAnnotations } from "@/providers/RunSubmissionScopeProvider"; -import { APP_ROUTES } from "@/routes/router"; +import { getDefaultRunPath } from "@/routes/runRoutes"; +import type { TeamResolution, TeamSummary } from "@/services/teams/types"; +import { useRunTeam } from "@/services/teams/useRunTeam"; import type { PipelineRun } from "@/types/pipelineRun"; import { extractCanonicalName } from "@/utils/canonicalPipelineName"; import type { ArgumentType, ComponentSpec } from "@/utils/componentSpec"; @@ -21,6 +36,48 @@ interface RerunVariables { componentSpec: ComponentSpec; canonicalName?: string; taskArguments?: Record; + teamId?: string; +} + +export interface RerunReview { + isOpen: boolean; + team: TeamResolution; + identity: RunIdentity; + runAs: RunAs; + chooseRunAs: (runAs: RunAs) => void; + confirm: () => void; + dismiss: () => void; +} + +export type RerunBlock = + { kind: "team-only"; team: TeamSummary } | { kind: "team-unchecked" }; + +function teamThatRan( + createdBy: string | null | undefined, + runTeam: TeamResolution, +): TeamSummary | undefined { + if ( + runTeam.status !== "active" && + runTeam.status !== "not-member" && + runTeam.status !== "unavailable" + ) { + return undefined; + } + return createdBy === runTeam.team.id ? runTeam.team : undefined; +} + +function rerunBlock( + ranAs: TeamSummary | undefined, + runTeam: TeamResolution, + isYours: boolean, +): RerunBlock | undefined { + if (ranAs && runTeam.status !== "active") { + return { kind: "team-only", team: ranAs }; + } + if (runTeam.status === "error" && !isYours) { + return { kind: "team-unchecked" }; + } + return undefined; } export function useRerunPipelineRun(componentSpec?: ComponentSpec) { @@ -29,9 +86,30 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) { const { backendUrl } = useBackend(); const { awaitAuthorization, isAuthorized } = useAwaitAuthorization(); const { getToken } = useAuthLocalStorage(); - const { rootDetails, metadata } = useExecutionData(); + const executionData = useExecutionDataOptional(); const runAnnotations = useRunSubmissionAnnotations(); const rerunProjectIds = useRerunProjectIds(); + const [isReviewOpen, setIsReviewOpen] = useState(false); + + const runId = executionData?.metadata?.id; + const taskSpec = executionData?.rootDetails?.task_spec; + const taskArguments = taskSpec?.arguments as + Record | undefined; + + const createdBy = executionData?.metadata?.created_by; + const { data: currentUser } = useUserDetails(); + + const runTeam = useRunTeam(runId); + const identity = useRunIdentity(runTeam, [componentSpec, taskArguments]); + const { runAs: reviewedRunAs, choose: chooseRunAs } = + useRunAsChoice(identity); + + const ranAsTeam = teamThatRan(createdBy, runTeam); + const blocked = rerunBlock( + ranAsTeam, + runTeam, + !!currentUser?.id && createdBy === currentUser.id, + ); const getAuthToken = async (): Promise => { if (isAuthorizationRequired() && !isAuthorized) { @@ -48,14 +126,16 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) { componentSpec, canonicalName, taskArguments, + teamId, }: RerunVariables) => { const authorizationToken = await getAuthToken(); - const projectIds = await rerunProjectIds(metadata?.id); + const projectIds = await rerunProjectIds(runId); return new Promise((resolve, reject) => { submitPipelineRun(componentSpec, backendUrl, { canonicalName, taskArguments, authorizationToken, + teamId, runAnnotations: { ...runAnnotations, ...projectRunAnnotations(projectIds), @@ -66,7 +146,7 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) { }); }, onSuccess: (response) => { - navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` }); + navigate({ to: getDefaultRunPath(response.id) }); }, onError: (error) => { const message = `Failed to submit pipeline. ${error instanceof Error ? error.message : String(error)}`; @@ -74,20 +154,50 @@ export function useRerunPipelineRun(componentSpec?: ComponentSpec) { }, }); - const rerun = () => { + const submitRerun = (teamId: string | undefined) => { if (!componentSpec) return; - const canonicalName = extractCanonicalName( - buildTaskSpecShape(rootDetails?.task_spec, componentSpec), - ); - rerunPipeline({ componentSpec, - canonicalName, - taskArguments: rootDetails?.task_spec.arguments as - Record | undefined, + canonicalName: extractCanonicalName( + buildTaskSpecShape(taskSpec, componentSpec), + ), + taskArguments, + teamId, }); }; - return { rerun, isRerunning }; + const rerun = () => { + if (ranAsTeam) { + submitRerun(ranAsTeam.id); + return; + } + if (needsReview(identity)) { + chooseRunAs(undefined); + setIsReviewOpen(true); + return; + } + submitRerun(teamIdFor(identity, runAsFor(identity))); + }; + + const canRerun = + !blocked && + runTeam.status !== "resolving" && + runTeam.status !== "conflict" && + !identity.isChecking; + + const review: RerunReview = { + isOpen: isReviewOpen, + team: runTeam, + identity, + runAs: reviewedRunAs, + chooseRunAs, + confirm: () => { + setIsReviewOpen(false); + submitRerun(teamIdFor(identity, reviewedRunAs)); + }, + dismiss: () => setIsReviewOpen(false), + }; + + return { rerun, isRerunning, canRerun, blocked, review }; } diff --git a/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts b/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts index 7ac2be45d3..e41f040ea3 100644 --- a/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts +++ b/src/routes/v2/pages/RunView/hooks/useRunViewActions.ts @@ -9,6 +9,11 @@ import { useUserDetails } from "@/hooks/useUserDetails"; import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { useExecutionData } from "@/providers/ExecutionDataProvider"; import { getDefaultEditorPath } from "@/routes/editorRoutes"; +import { + type RunManagement, + runManagement, +} from "@/services/teams/runManagement"; +import { useRunTeam } from "@/services/teams/useRunTeam"; import { extractCanonicalName } from "@/utils/canonicalPipelineName"; import type { ComponentSpec } from "@/utils/componentSpec"; import { @@ -22,7 +27,7 @@ interface RunViewActionsReady { componentSpec: ComponentSpec; runId: string | null | undefined; canAccessEditorSpec: boolean; - isRunCreator: boolean | "" | undefined; + management: RunManagement; isInProgress: boolean; isComplete: boolean; pipelineName: string | undefined; @@ -40,12 +45,9 @@ function resolveActions( runId: string | null | undefined, metadata: PipelineRunResponse | undefined, details: GetExecutionInfoResponse | undefined, - currentUserId: string | undefined, + management: RunManagement, canAccessEditorSpec: boolean, ): RunViewActionsReady { - const isRunCreator = - !!currentUserId && metadata?.created_by === currentUserId; - const executionStatusStats = metadata?.execution_status_stats ?? flattenExecutionStatusStats(state.child_execution_status_stats); @@ -63,7 +65,7 @@ function resolveActions( componentSpec, runId, canAccessEditorSpec, - isRunCreator, + management, isInProgress, isComplete, pipelineName, @@ -79,6 +81,7 @@ export function useRunViewActions(): RunViewActions { rootDetails: details, } = useExecutionData(); const { data: currentUserDetails } = useUserDetails(); + const runTeam = useRunTeam(metadata?.id); const editorRoute = componentSpec?.name ? getDefaultEditorPath(componentSpec.name) @@ -99,7 +102,7 @@ export function useRunViewActions(): RunViewActions { runId, metadata, details, - currentUserDetails?.id, + runManagement(metadata?.created_by, currentUserDetails?.id, runTeam), canAccessEditorSpec, ); } diff --git a/src/services/pipelineRunService.test.ts b/src/services/pipelineRunService.test.ts index 5fc666bbc8..77601a90b4 100644 --- a/src/services/pipelineRunService.test.ts +++ b/src/services/pipelineRunService.test.ts @@ -95,6 +95,41 @@ describe("createPipelineRun", () => { }); }); +describe("changing a run", () => { + let mockFetch: Mock; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a cancel with the reason the backend gave", async () => { + mockFetch.mockResolvedValue( + jsonResponse(403, { detail: "Only the run's creator can cancel it." }), + ); + + await expect(cancelPipelineRun("42", BACKEND_URL)).rejects.toThrow( + "Failed to cancel the run (403): Only the run's creator can cancel it.", + ); + }); + + it("rejects an annotation with the reason the backend gave", async () => { + mockFetch.mockResolvedValue( + jsonResponse(403, { detail: "Only the run's creator can annotate it." }), + ); + + await expect( + updateRunAnnotation("42", BACKEND_URL, { key: "notes", value: "Hi" }), + ).rejects.toThrow( + "Failed to save the run's notes (403): Only the run's creator can annotate it.", + ); + }); +}); + describe("acting as a team", () => { const TEAM_ID = "ml-team@project.iam.gserviceaccount.com"; const HEADER_NAME = "x-tangle-impersonate-team-id"; diff --git a/src/services/pipelineRunService.ts b/src/services/pipelineRunService.ts index e387beffa6..45e16b732b 100644 --- a/src/services/pipelineRunService.ts +++ b/src/services/pipelineRunService.ts @@ -208,13 +208,19 @@ export const cancelPipelineRun = async ( backendUrl: string, teamId?: string, ) => { - await fetchWithErrorHandling( + const response = await fetch( `${backendUrl}/api/pipeline_runs/${runId}/cancel`, { method: "POST", headers: teamRequestHeaders(teamId), }, ); + + if (!response.ok) { + throw new Error( + await requestFailureMessage(response, "Failed to cancel the run"), + ); + } }; export const fetchRunAnnotations = async ( @@ -239,8 +245,17 @@ export const updateRunAnnotation = async ( ); url.searchParams.append("value", annotation.value); - await fetchWithErrorHandling(url.toString(), { + const response = await fetch(url.toString(), { method: "PUT", headers: teamRequestHeaders(teamId), }); + + if (!response.ok) { + throw new Error( + await requestFailureMessage( + response, + `Failed to save the run's ${annotation.key}`, + ), + ); + } }; diff --git a/src/services/teams/runManagement.test.ts b/src/services/teams/runManagement.test.ts new file mode 100644 index 0000000000..0d79883168 --- /dev/null +++ b/src/services/teams/runManagement.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { runManagement } from "./runManagement"; +import type { TeamResolution } from "./types"; + +const TEAM = { id: "ml-team@example.com", label: "ML Research" }; +const activeTeam = (canWrite: boolean): TeamResolution => ({ + status: "active", + team: { ...TEAM, canWrite }, +}); + +describe("runManagement", () => { + it("lets you manage a run you created, as you", () => { + expect(runManagement("morgan", "morgan", { status: "none" })).toEqual({ + canManage: true, + teamId: undefined, + }); + }); + + it("lets a member manage a run the team created, as the team", () => { + expect(runManagement(TEAM.id, "morgan", activeTeam(true))).toEqual({ + canManage: true, + teamId: TEAM.id, + }); + }); + + it("keeps a run the team created from a member who can't write", () => { + expect(runManagement(TEAM.id, "morgan", activeTeam(false))).toEqual({ + canManage: false, + teamId: TEAM.id, + }); + }); + + it("keeps a run the team created from someone outside the team", () => { + expect( + runManagement(TEAM.id, "morgan", { status: "not-member", team: TEAM }), + ).toEqual({ canManage: false, teamId: undefined }); + }); + + it("manages a run you created in a team's project as you", () => { + expect(runManagement("morgan", "morgan", activeTeam(true))).toEqual({ + canManage: true, + teamId: undefined, + }); + }); + + it("keeps someone else's run from you", () => { + expect(runManagement("riley", "morgan", activeTeam(true))).toEqual({ + canManage: false, + teamId: undefined, + }); + }); + + it("keeps a run from you until you're known", () => { + expect(runManagement(undefined, undefined, { status: "none" })).toEqual({ + canManage: false, + teamId: undefined, + }); + }); +}); diff --git a/src/services/teams/runManagement.ts b/src/services/teams/runManagement.ts new file mode 100644 index 0000000000..86d4fff01a --- /dev/null +++ b/src/services/teams/runManagement.ts @@ -0,0 +1,20 @@ +import type { TeamResolution } from "./types"; + +export interface RunManagement { + canManage: boolean; + teamId: string | undefined; +} + +export function runManagement( + createdBy: string | null | undefined, + currentUserId: string | undefined, + team: TeamResolution, +): RunManagement { + if (team.status === "active" && createdBy === team.team.id) { + return { canManage: team.team.canWrite, teamId: team.team.id }; + } + return { + canManage: !!currentUserId && createdBy === currentUserId, + teamId: undefined, + }; +} diff --git a/src/services/teams/useRunTeam.test.tsx b/src/services/teams/useRunTeam.test.tsx new file mode 100644 index 0000000000..3b9f03c090 --- /dev/null +++ b/src/services/teams/useRunTeam.test.tsx @@ -0,0 +1,91 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useProjectsEnabled } from "@/components/shared/Settings/useFlags"; +import { fetchRunAnnotations } from "@/services/pipelineRunService"; +import { projectRunAnnotations } from "@/utils/projectRunAnnotation"; + +import { useRunTeam } from "./useRunTeam"; +import { useTeamForSubmission } from "./useTeamForSubmission"; + +vi.mock("./useTeamForSubmission", () => ({ + useTeamForSubmission: vi.fn(() => ({ status: "none" })), +})); + +vi.mock("@/services/pipelineRunService", () => ({ + fetchRunAnnotations: vi.fn(), +})); + +vi.mock("@/components/shared/Settings/useFlags", () => ({ + useProjectsEnabled: vi.fn(), +})); + +vi.mock("@/providers/BackendProvider", () => ({ + useBackend: () => ({ backendUrl: "https://backend.test" }), +})); + +function resolveRunTeam(runId: string | undefined) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderHook(() => useRunTeam(runId), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +describe("useRunTeam", () => { + beforeEach(() => { + vi.mocked(useProjectsEnabled).mockReturnValue(true); + }); + + afterEach(() => vi.clearAllMocks()); + + it("resolves the team of the projects the run is filed under", async () => { + vi.mocked(fetchRunAnnotations).mockResolvedValue( + projectRunAnnotations(["research-project"]), + ); + + const { result } = resolveRunTeam("42"); + + expect(result.current).toEqual({ status: "resolving" }); + await waitFor(() => expect(result.current).toEqual({ status: "none" })); + expect(useTeamForSubmission).toHaveBeenLastCalledWith(["research-project"]); + expect(fetchRunAnnotations).toHaveBeenCalledWith( + "42", + "https://backend.test", + ); + }); + + it("says the team couldn't be checked when the run's projects can't be read", async () => { + vi.mocked(fetchRunAnnotations).mockRejectedValue(new Error("backend down")); + + const { result } = resolveRunTeam("42"); + + await waitFor(() => + expect(result.current).toEqual({ + status: "error", + error: new Error("backend down"), + }), + ); + }); + + it("reads nothing without projects", () => { + vi.mocked(useProjectsEnabled).mockReturnValue(false); + + const { result } = resolveRunTeam("42"); + + expect(result.current).toEqual({ status: "none" }); + expect(fetchRunAnnotations).not.toHaveBeenCalled(); + }); + + it("reads nothing before the run is known", () => { + const { result } = resolveRunTeam(undefined); + + expect(result.current).toEqual({ status: "none" }); + expect(fetchRunAnnotations).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/teams/useRunTeam.ts b/src/services/teams/useRunTeam.ts new file mode 100644 index 0000000000..7b14fe6780 --- /dev/null +++ b/src/services/teams/useRunTeam.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useProjectsEnabled } from "@/components/shared/Settings/useFlags"; +import { useBackend } from "@/providers/BackendProvider"; +import { runAnnotationsQueryOptions } from "@/services/runAnnotations"; +import { projectIdsFromAnnotations } from "@/utils/projectRunAnnotation"; + +import type { TeamResolution } from "./types"; +import { useTeamForSubmission } from "./useTeamForSubmission"; + +export function useRunTeam( + runId: string | number | null | undefined, +): TeamResolution { + const { backendUrl } = useBackend(); + const projectsEnabled = useProjectsEnabled(); + const annotations = useQuery({ + ...runAnnotationsQueryOptions(runId ?? undefined, backendUrl), + enabled: projectsEnabled && runId != null && String(runId) !== "", + }); + const team = useTeamForSubmission( + projectIdsFromAnnotations(annotations.data), + ); + + if (annotations.isLoading) return { status: "resolving" }; + if (annotations.isError) { + return { status: "error", error: annotations.error }; + } + return team; +} diff --git a/src/services/teams/useTeamForRunAnnotations.test.ts b/src/services/teams/useTeamForRunAnnotations.test.ts deleted file mode 100644 index 9253d1c544..0000000000 --- a/src/services/teams/useTeamForRunAnnotations.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("./useTeamForProjects", () => ({ - useTeamForProjects: vi.fn(() => ({ status: "none" })), -})); - -import { projectRunAnnotations } from "@/utils/projectRunAnnotation"; - -import { useTeamForProjects } from "./useTeamForProjects"; -import { useTeamForRunAnnotations } from "./useTeamForRunAnnotations"; - -describe("useTeamForRunAnnotations", () => { - it("resolves the team of the projects a run is filed under", () => { - const annotations = { - ...projectRunAnnotations(["research-project", "data-project"]), - source: "web-app", - }; - - renderHook(() => useTeamForRunAnnotations(annotations)); - - expect(useTeamForProjects).toHaveBeenCalledWith([ - "research-project", - "data-project", - ]); - }); - - it("resolves no projects for a run filed under none", () => { - renderHook(() => useTeamForRunAnnotations(null)); - - expect(useTeamForProjects).toHaveBeenLastCalledWith([]); - }); -}); diff --git a/src/services/teams/useTeamForRunAnnotations.ts b/src/services/teams/useTeamForRunAnnotations.ts deleted file mode 100644 index e2062bbc19..0000000000 --- a/src/services/teams/useTeamForRunAnnotations.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { projectIdsFromAnnotations } from "@/utils/projectRunAnnotation"; - -import type { TeamResolution } from "./types"; -import { useTeamForProjects } from "./useTeamForProjects"; - -export function useTeamForRunAnnotations( - annotations: Record | null | undefined, -): TeamResolution { - return useTeamForProjects(projectIdsFromAnnotations(annotations)); -}