diff --git a/src/features/workspace/components/__tests__/archive-workspace.test.tsx b/src/features/workspace/components/__tests__/archive-workspace.test.tsx index 88a2a3a1..43b53b07 100644 --- a/src/features/workspace/components/__tests__/archive-workspace.test.tsx +++ b/src/features/workspace/components/__tests__/archive-workspace.test.tsx @@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event"; import { screen, waitFor } from "@testing-library/react"; const mockNavigate = vi.fn(); -const mockToast = vi.fn(); + vi.mock("react-router-dom", async () => { const original = await vi.importActual( @@ -16,21 +16,14 @@ vi.mock("react-router-dom", async () => { }; }); -vi.mock("@stacklok/ui-kit", async () => { - const original = - await vi.importActual( - "@stacklok/ui-kit", - ); - return { - ...original, - toast: { error: () => mockToast }, - }; -}); - test("archive workspace", async () => { render(); await userEvent.click(screen.getByRole("button", { name: /archive/i })); await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1)); expect(mockNavigate).toHaveBeenCalledWith("/workspaces"); + + await waitFor(() => { + expect(screen.getByText(/archived "(.*)" workspace/i)).toBeVisible(); + }); }); diff --git a/src/features/workspace/components/__tests__/workspace-creation.test.tsx b/src/features/workspace/components/__tests__/workspace-creation.test.tsx index c007e276..f9ea7d3f 100644 --- a/src/features/workspace/components/__tests__/workspace-creation.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-creation.test.tsx @@ -23,6 +23,10 @@ test("create workspace", async () => { await userEvent.type(screen.getByRole("textbox"), "workspaceA"); await userEvent.click(screen.getByRole("button", { name: /create/i })); await waitFor(() => expect(mockNavigate).toBeCalled()); + + await waitFor(() => { + expect(screen.getByText(/created "(.*)" workspace/i)).toBeVisible(); + }); }); test("create workspace with enter button", async () => { @@ -32,4 +36,8 @@ test("create workspace with enter button", async () => { await userEvent.type(screen.getByRole("textbox"), "workspaceA{enter}"); await waitFor(() => expect(mockNavigate).toBeCalled()); + + await waitFor(() => { + expect(screen.getByText(/created "(.*)" workspace/i)).toBeVisible(); + }); }); diff --git a/src/features/workspace/components/workspace-creation.tsx b/src/features/workspace/components/workspace-creation.tsx index 35e23116..68571355 100644 --- a/src/features/workspace/components/workspace-creation.tsx +++ b/src/features/workspace/components/workspace-creation.tsx @@ -1,4 +1,4 @@ -import { useCreateWorkspace } from "@/features/workspace/hooks/use-create-workspace"; +import { useMutationCreateWorkspace } from "@/features/workspace/hooks/use-mutation-create-workspace"; import { Button, Card, @@ -16,12 +16,12 @@ import { useNavigate } from "react-router-dom"; export function WorkspaceCreation() { const navigate = useNavigate(); const [workspaceName, setWorkspaceName] = useState(""); - const { mutate, isPending, error } = useCreateWorkspace(); + const { mutateAsync, isPending, error } = useMutationCreateWorkspace(); const errorMsg = error?.detail ? `${error?.detail}` : ""; const handleSubmit = (e: FormEvent) => { e.preventDefault(); - mutate( + mutateAsync( { body: { name: workspaceName }, }, @@ -36,6 +36,7 @@ export function WorkspaceCreation() { ) => { e.preventDefault(); - mutate( + mutateAsync( { body: { name: workspaceName, rename_to: name } }, { onSuccess: () => navigate(`/workspace/${name}`), @@ -63,6 +63,7 @@ export function WorkspaceName({ isDisabled={isArchived || name === ""} isPending={isPending} type="submit" + variant="secondary" > Save diff --git a/src/features/workspace/components/workspaces-selection.tsx b/src/features/workspace/components/workspaces-selection.tsx index 87965add..22239d76 100644 --- a/src/features/workspace/components/workspaces-selection.tsx +++ b/src/features/workspace/components/workspaces-selection.tsx @@ -13,7 +13,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Search, Settings } from "lucide-react"; import { useState } from "react"; -import { useActivateWorkspace } from "../hooks/use-activate-workspace"; +import { useMutationActivateWorkspace } from "../hooks/use-mutation-activate-workspace"; import clsx from "clsx"; import { useActiveWorkspaceName } from "../hooks/use-active-workspace-name"; @@ -21,7 +21,7 @@ export function WorkspacesSelection() { const queryClient = useQueryClient(); const { data: workspacesResponse } = useListWorkspaces(); - const { mutateAsync: activateWorkspace } = useActivateWorkspace(); + const { mutateAsync: activateWorkspace } = useMutationActivateWorkspace(); const { data: activeWorkspaceName } = useActiveWorkspaceName(); diff --git a/src/features/workspace/hooks/use-activate-workspace.ts b/src/features/workspace/hooks/use-activate-workspace.ts deleted file mode 100644 index fa332849..00000000 --- a/src/features/workspace/hooks/use-activate-workspace.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { v1ActivateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; -import { useMutation } from "@tanstack/react-query"; - -export function useActivateWorkspace() { - return useMutation({ - ...v1ActivateWorkspaceMutation(), - }); -} diff --git a/src/features/workspace/hooks/use-archive-workspace-button.tsx b/src/features/workspace/hooks/use-archive-workspace-button.tsx index d7139eb6..d2746510 100644 --- a/src/features/workspace/hooks/use-archive-workspace-button.tsx +++ b/src/features/workspace/hooks/use-archive-workspace-button.tsx @@ -1,18 +1,26 @@ import { Button } from "@stacklok/ui-kit"; import { ComponentProps } from "react"; -import { useArchiveWorkspace } from "@/features/workspace-system-prompt/hooks/use-archive-workspace"; +import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace"; +import { useNavigate } from "react-router-dom"; export function useArchiveWorkspaceButton({ workspaceName, }: { workspaceName: string; }): ComponentProps { - const { mutate, isPending } = useArchiveWorkspace(); + const { mutateAsync, isPending } = useMutationArchiveWorkspace(); + const navigate = useNavigate(); return { isPending, isDisabled: isPending, - onPress: () => mutate({ path: { workspace_name: workspaceName } }), + onPress: () => + mutateAsync( + { path: { workspace_name: workspaceName } }, + { + onSuccess: () => navigate("/workspaces"), + }, + ), isDestructive: true, children: "Archive", }; diff --git a/src/features/workspace/hooks/use-create-workspace.ts b/src/features/workspace/hooks/use-create-workspace.ts deleted file mode 100644 index adaedf3d..00000000 --- a/src/features/workspace/hooks/use-create-workspace.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { v1CreateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; - -export function useCreateWorkspace() { - return useMutation({ - ...v1CreateWorkspaceMutation(), - }); -} diff --git a/src/features/workspace/hooks/use-invalidate-workspace-queries.ts b/src/features/workspace/hooks/use-invalidate-workspace-queries.ts new file mode 100644 index 00000000..1985e28f --- /dev/null +++ b/src/features/workspace/hooks/use-invalidate-workspace-queries.ts @@ -0,0 +1,23 @@ +import { + v1ListArchivedWorkspacesQueryKey, + v1ListWorkspacesOptions, +} from "@/api/generated/@tanstack/react-query.gen"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +export function useInvalidateWorkspaceQueries() { + const queryClient = useQueryClient(); + + const invalidate = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: v1ListWorkspacesOptions(), + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: v1ListArchivedWorkspacesQueryKey(), + refetchType: "all", + }); + }, [queryClient]); + + return invalidate; +} diff --git a/src/features/workspace/hooks/use-mutation-activate-workspace.ts b/src/features/workspace/hooks/use-mutation-activate-workspace.ts new file mode 100644 index 00000000..3979d188 --- /dev/null +++ b/src/features/workspace/hooks/use-mutation-activate-workspace.ts @@ -0,0 +1,13 @@ +import { v1ActivateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { useToastMutation as useToastMutation } from "@/hooks/use-toast-mutation"; +import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; + +export function useMutationActivateWorkspace() { + const invalidate = useInvalidateWorkspaceQueries(); + + return useToastMutation({ + ...v1ActivateWorkspaceMutation(), + onSuccess: () => invalidate(), + successMsg: (variables) => `Activated "${variables.body.name}" workspace`, + }); +} diff --git a/src/features/workspace/hooks/use-mutation-archive-workspace.ts b/src/features/workspace/hooks/use-mutation-archive-workspace.ts new file mode 100644 index 00000000..cd12a702 --- /dev/null +++ b/src/features/workspace/hooks/use-mutation-archive-workspace.ts @@ -0,0 +1,14 @@ +import { v1DeleteWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; + +export function useMutationArchiveWorkspace() { + const invalidate = useInvalidateWorkspaceQueries(); + + return useToastMutation({ + ...v1DeleteWorkspaceMutation(), + onSuccess: () => invalidate(), + successMsg: (variables) => + `Archived "${variables.path.workspace_name}" workspace`, + }); +} diff --git a/src/features/workspace/hooks/use-mutation-create-workspace.ts b/src/features/workspace/hooks/use-mutation-create-workspace.ts new file mode 100644 index 00000000..955e2f71 --- /dev/null +++ b/src/features/workspace/hooks/use-mutation-create-workspace.ts @@ -0,0 +1,13 @@ +import { v1CreateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; + +export function useMutationCreateWorkspace() { + const invalidate = useInvalidateWorkspaceQueries(); + + return useToastMutation({ + ...v1CreateWorkspaceMutation(), + onSuccess: () => invalidate(), + successMsg: (variables) => `Created "${variables.body.name}" workspace`, + }); +} diff --git a/src/features/workspace/hooks/use-mutation-hard-delete-workspace.ts b/src/features/workspace/hooks/use-mutation-hard-delete-workspace.ts new file mode 100644 index 00000000..9456788b --- /dev/null +++ b/src/features/workspace/hooks/use-mutation-hard-delete-workspace.ts @@ -0,0 +1,14 @@ +import { v1HardDeleteWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; + +export function useMutationHardDeleteWorkspace() { + const invalidate = useInvalidateWorkspaceQueries(); + + return useToastMutation({ + ...v1HardDeleteWorkspaceMutation(), + onSuccess: () => invalidate(), + successMsg: (variables) => + `Permanently deleted "${variables.path.name}" workspace`, + }); +} diff --git a/src/features/workspace/hooks/use-mutation-restore-workspace.ts b/src/features/workspace/hooks/use-mutation-restore-workspace.ts new file mode 100644 index 00000000..913b7c01 --- /dev/null +++ b/src/features/workspace/hooks/use-mutation-restore-workspace.ts @@ -0,0 +1,14 @@ +import { v1RecoverWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; + +export function useMutationRestoreWorkspace() { + const invalidate = useInvalidateWorkspaceQueries(); + + return useToastMutation({ + ...v1RecoverWorkspaceMutation(), + onSuccess: () => invalidate(), + successMsg: (variables) => + `Restored "${variables.path.workspace_name}" workspace`, + }); +} diff --git a/src/features/workspace/hooks/use-restore-workspace-button.tsx b/src/features/workspace/hooks/use-restore-workspace-button.tsx index ce619b34..7ecfa2b4 100644 --- a/src/features/workspace/hooks/use-restore-workspace-button.tsx +++ b/src/features/workspace/hooks/use-restore-workspace-button.tsx @@ -1,18 +1,18 @@ import { Button } from "@stacklok/ui-kit"; import { ComponentProps } from "react"; -import { useRestoreWorkspace } from "./use-restore-workspace"; +import { useMutationRestoreWorkspace } from "./use-mutation-restore-workspace"; export function useRestoreWorkspaceButton({ workspaceName, }: { workspaceName: string; }): ComponentProps { - const { mutate, isPending } = useRestoreWorkspace(); + const { mutateAsync, isPending } = useMutationRestoreWorkspace(); return { isPending, isDisabled: isPending, - onPress: () => mutate({ path: { workspace_name: workspaceName } }), + onPress: () => mutateAsync({ path: { workspace_name: workspaceName } }), children: "Restore", }; } diff --git a/src/features/workspace/hooks/use-restore-workspace.ts b/src/features/workspace/hooks/use-restore-workspace.ts deleted file mode 100644 index b2f388a1..00000000 --- a/src/features/workspace/hooks/use-restore-workspace.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - v1ListArchivedWorkspacesQueryKey, - v1ListWorkspacesOptions, - v1RecoverWorkspaceMutation, -} from "@/api/generated/@tanstack/react-query.gen"; -import { toast } from "@stacklok/ui-kit"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -export function useRestoreWorkspace() { - const queryClient = useQueryClient(); - - return useMutation({ - ...v1RecoverWorkspaceMutation(), - onError: (err) => { - toast.error(err.detail ? `${err.detail}` : "Failed to restore workspace"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: v1ListArchivedWorkspacesQueryKey(), - refetchType: "all", - }); - queryClient.invalidateQueries({ - queryKey: v1ListWorkspacesOptions(), - refetchType: "all", - }); - }, - }); -} diff --git a/src/hooks/use-toast-mutation.ts b/src/hooks/use-toast-mutation.ts index f3ddc1a4..a087de57 100644 --- a/src/hooks/use-toast-mutation.ts +++ b/src/hooks/use-toast-mutation.ts @@ -11,29 +11,40 @@ export function useToastMutation< TError = DefaultError, TVariables = void, TContext = unknown, ->(options: UseMutationOptions) { +>({ + successMsg, + errorMsg, + loadingMsg, + ...options +}: UseMutationOptions & { + successMsg?: ((variables: TVariables) => string) | string; + loadingMsg?: string; + errorMsg?: string; +}) { const { mutateAsync: originalMutateAsync, - // NOTE: We are not allowing direct use of the `mutate` (sync) function. + // NOTE: We are not allowing direct use of the `mutate` (sync) function // eslint-disable-next-line @typescript-eslint/no-unused-vars mutate: _, ...rest } = useMutation(options); const mutateAsync = useCallback( - ( + async ( variables: Parameters[0], - options: Parameters[1], - { successMsg }: { successMsg: string }, + options: Parameters[1] = {}, ) => { const promise = originalMutateAsync(variables, options); toast.promise(promise, { - success: successMsg, - error: (e: TError) => (e.detail ? e.detail : "An error occurred"), + success: + typeof successMsg === "function" ? successMsg(variables) : successMsg, + loading: loadingMsg ?? "Loading...", + error: (e: TError) => + errorMsg ?? (e.detail ? e.detail : "An error occurred"), }); }, - [originalMutateAsync], + [errorMsg, loadingMsg, originalMutateAsync, successMsg], ); return { mutateAsync, ...rest }; diff --git a/src/lib/test-utils.tsx b/src/lib/test-utils.tsx index 6f9cf6c5..d58c2430 100644 --- a/src/lib/test-utils.tsx +++ b/src/lib/test-utils.tsx @@ -1,5 +1,5 @@ import { SidebarProvider } from "@/components/ui/sidebar"; -import { DarkModeProvider } from "@stacklok/ui-kit"; +import { DarkModeProvider, Toaster } from "@stacklok/ui-kit"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RenderOptions, render } from "@testing-library/react"; import React, { ReactNode } from "react"; @@ -45,6 +45,7 @@ const renderWithProviders = ( render( +