Skip to content

Commit a67b265

Browse files
feat: implement use toast mutation for workspaces (#184)
* feat: useKbdShortcuts hook & example implementation * chore: tidy up remnant * feat: useToastMutation hook * chore: remove junk comment * feat: implement `useToastMutation` for workspaces
1 parent 9fe55a5 commit a67b265

18 files changed

+149
-79
lines changed

src/features/workspace/components/__tests__/archive-workspace.test.tsx

+5-12
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
44
import { screen, waitFor } from "@testing-library/react";
55

66
const mockNavigate = vi.fn();
7-
const mockToast = vi.fn();
7+
88
vi.mock("react-router-dom", async () => {
99
const original =
1010
await vi.importActual<typeof import("react-router-dom")>(
@@ -16,21 +16,14 @@ vi.mock("react-router-dom", async () => {
1616
};
1717
});
1818

19-
vi.mock("@stacklok/ui-kit", async () => {
20-
const original =
21-
await vi.importActual<typeof import("@stacklok/ui-kit")>(
22-
"@stacklok/ui-kit",
23-
);
24-
return {
25-
...original,
26-
toast: { error: () => mockToast },
27-
};
28-
});
29-
3019
test("archive workspace", async () => {
3120
render(<ArchiveWorkspace isArchived={false} workspaceName="foo" />);
3221

3322
await userEvent.click(screen.getByRole("button", { name: /archive/i }));
3423
await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1));
3524
expect(mockNavigate).toHaveBeenCalledWith("/workspaces");
25+
26+
await waitFor(() => {
27+
expect(screen.getByText(/archived "(.*)" workspace/i)).toBeVisible();
28+
});
3629
});

src/features/workspace/components/__tests__/workspace-creation.test.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ test("create workspace", async () => {
2323
await userEvent.type(screen.getByRole("textbox"), "workspaceA");
2424
await userEvent.click(screen.getByRole("button", { name: /create/i }));
2525
await waitFor(() => expect(mockNavigate).toBeCalled());
26+
27+
await waitFor(() => {
28+
expect(screen.getByText(/created "(.*)" workspace/i)).toBeVisible();
29+
});
2630
});
2731

2832
test("create workspace with enter button", async () => {
@@ -32,4 +36,8 @@ test("create workspace with enter button", async () => {
3236

3337
await userEvent.type(screen.getByRole("textbox"), "workspaceA{enter}");
3438
await waitFor(() => expect(mockNavigate).toBeCalled());
39+
40+
await waitFor(() => {
41+
expect(screen.getByText(/created "(.*)" workspace/i)).toBeVisible();
42+
});
3543
});

src/features/workspace/components/workspace-creation.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCreateWorkspace } from "@/features/workspace/hooks/use-create-workspace";
1+
import { useMutationCreateWorkspace } from "@/features/workspace/hooks/use-mutation-create-workspace";
22
import {
33
Button,
44
Card,
@@ -16,12 +16,12 @@ import { useNavigate } from "react-router-dom";
1616
export function WorkspaceCreation() {
1717
const navigate = useNavigate();
1818
const [workspaceName, setWorkspaceName] = useState("");
19-
const { mutate, isPending, error } = useCreateWorkspace();
19+
const { mutateAsync, isPending, error } = useMutationCreateWorkspace();
2020
const errorMsg = error?.detail ? `${error?.detail}` : "";
2121

2222
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
2323
e.preventDefault();
24-
mutate(
24+
mutateAsync(
2525
{
2626
body: { name: workspaceName },
2727
},
@@ -36,6 +36,7 @@ export function WorkspaceCreation() {
3636
<Card>
3737
<CardBody className="w-full">
3838
<TextField
39+
autoFocus
3940
aria-label="Workspace name"
4041
name="Workspace name"
4142
validationBehavior="aria"

src/features/workspace/components/workspace-name.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
TextField,
1010
} from "@stacklok/ui-kit";
1111
import { twMerge } from "tailwind-merge";
12-
import { useCreateWorkspace } from "../hooks/use-create-workspace";
12+
import { useMutationCreateWorkspace } from "../hooks/use-mutation-create-workspace";
1313
import { FormEvent, useState } from "react";
1414
import { useNavigate } from "react-router-dom";
1515

@@ -24,12 +24,12 @@ export function WorkspaceName({
2424
}) {
2525
const navigate = useNavigate();
2626
const [name, setName] = useState(workspaceName);
27-
const { mutate, isPending, error } = useCreateWorkspace();
27+
const { mutateAsync, isPending, error } = useMutationCreateWorkspace();
2828
const errorMsg = error?.detail ? `${error?.detail}` : "";
2929

3030
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
3131
e.preventDefault();
32-
mutate(
32+
mutateAsync(
3333
{ body: { name: workspaceName, rename_to: name } },
3434
{
3535
onSuccess: () => navigate(`/workspace/${name}`),
@@ -63,6 +63,7 @@ export function WorkspaceName({
6363
isDisabled={isArchived || name === ""}
6464
isPending={isPending}
6565
type="submit"
66+
variant="secondary"
6667
>
6768
Save
6869
</Button>

src/features/workspace/components/workspaces-selection.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ import {
1313
import { useQueryClient } from "@tanstack/react-query";
1414
import { ChevronDown, Search, Settings } from "lucide-react";
1515
import { useState } from "react";
16-
import { useActivateWorkspace } from "../hooks/use-activate-workspace";
16+
import { useMutationActivateWorkspace } from "../hooks/use-mutation-activate-workspace";
1717
import clsx from "clsx";
1818
import { useActiveWorkspaceName } from "../hooks/use-active-workspace-name";
1919

2020
export function WorkspacesSelection() {
2121
const queryClient = useQueryClient();
2222

2323
const { data: workspacesResponse } = useListWorkspaces();
24-
const { mutateAsync: activateWorkspace } = useActivateWorkspace();
24+
const { mutateAsync: activateWorkspace } = useMutationActivateWorkspace();
2525

2626
const { data: activeWorkspaceName } = useActiveWorkspaceName();
2727

src/features/workspace/hooks/use-activate-workspace.ts

-8
This file was deleted.

src/features/workspace/hooks/use-archive-workspace-button.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import { Button } from "@stacklok/ui-kit";
22
import { ComponentProps } from "react";
3-
import { useArchiveWorkspace } from "@/features/workspace-system-prompt/hooks/use-archive-workspace";
3+
import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace";
4+
import { useNavigate } from "react-router-dom";
45

56
export function useArchiveWorkspaceButton({
67
workspaceName,
78
}: {
89
workspaceName: string;
910
}): ComponentProps<typeof Button> {
10-
const { mutate, isPending } = useArchiveWorkspace();
11+
const { mutateAsync, isPending } = useMutationArchiveWorkspace();
12+
const navigate = useNavigate();
1113

1214
return {
1315
isPending,
1416
isDisabled: isPending,
15-
onPress: () => mutate({ path: { workspace_name: workspaceName } }),
17+
onPress: () =>
18+
mutateAsync(
19+
{ path: { workspace_name: workspaceName } },
20+
{
21+
onSuccess: () => navigate("/workspaces"),
22+
},
23+
),
1624
isDestructive: true,
1725
children: "Archive",
1826
};

src/features/workspace/hooks/use-create-workspace.ts

-8
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {
2+
v1ListArchivedWorkspacesQueryKey,
3+
v1ListWorkspacesOptions,
4+
} from "@/api/generated/@tanstack/react-query.gen";
5+
import { useQueryClient } from "@tanstack/react-query";
6+
import { useCallback } from "react";
7+
8+
export function useInvalidateWorkspaceQueries() {
9+
const queryClient = useQueryClient();
10+
11+
const invalidate = useCallback(() => {
12+
queryClient.invalidateQueries({
13+
queryKey: v1ListWorkspacesOptions(),
14+
refetchType: "all",
15+
});
16+
queryClient.invalidateQueries({
17+
queryKey: v1ListArchivedWorkspacesQueryKey(),
18+
refetchType: "all",
19+
});
20+
}, [queryClient]);
21+
22+
return invalidate;
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { v1ActivateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useToastMutation as useToastMutation } from "@/hooks/use-toast-mutation";
3+
import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries";
4+
5+
export function useMutationActivateWorkspace() {
6+
const invalidate = useInvalidateWorkspaceQueries();
7+
8+
return useToastMutation({
9+
...v1ActivateWorkspaceMutation(),
10+
onSuccess: () => invalidate(),
11+
successMsg: (variables) => `Activated "${variables.body.name}" workspace`,
12+
});
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { v1DeleteWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useToastMutation } from "@/hooks/use-toast-mutation";
3+
import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries";
4+
5+
export function useMutationArchiveWorkspace() {
6+
const invalidate = useInvalidateWorkspaceQueries();
7+
8+
return useToastMutation({
9+
...v1DeleteWorkspaceMutation(),
10+
onSuccess: () => invalidate(),
11+
successMsg: (variables) =>
12+
`Archived "${variables.path.workspace_name}" workspace`,
13+
});
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { v1CreateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries";
3+
import { useToastMutation } from "@/hooks/use-toast-mutation";
4+
5+
export function useMutationCreateWorkspace() {
6+
const invalidate = useInvalidateWorkspaceQueries();
7+
8+
return useToastMutation({
9+
...v1CreateWorkspaceMutation(),
10+
onSuccess: () => invalidate(),
11+
successMsg: (variables) => `Created "${variables.body.name}" workspace`,
12+
});
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { v1HardDeleteWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useToastMutation } from "@/hooks/use-toast-mutation";
3+
import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries";
4+
5+
export function useMutationHardDeleteWorkspace() {
6+
const invalidate = useInvalidateWorkspaceQueries();
7+
8+
return useToastMutation({
9+
...v1HardDeleteWorkspaceMutation(),
10+
onSuccess: () => invalidate(),
11+
successMsg: (variables) =>
12+
`Permanently deleted "${variables.path.name}" workspace`,
13+
});
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { v1RecoverWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useToastMutation } from "@/hooks/use-toast-mutation";
3+
import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries";
4+
5+
export function useMutationRestoreWorkspace() {
6+
const invalidate = useInvalidateWorkspaceQueries();
7+
8+
return useToastMutation({
9+
...v1RecoverWorkspaceMutation(),
10+
onSuccess: () => invalidate(),
11+
successMsg: (variables) =>
12+
`Restored "${variables.path.workspace_name}" workspace`,
13+
});
14+
}
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { Button } from "@stacklok/ui-kit";
22
import { ComponentProps } from "react";
3-
import { useRestoreWorkspace } from "./use-restore-workspace";
3+
import { useMutationRestoreWorkspace } from "./use-mutation-restore-workspace";
44

55
export function useRestoreWorkspaceButton({
66
workspaceName,
77
}: {
88
workspaceName: string;
99
}): ComponentProps<typeof Button> {
10-
const { mutate, isPending } = useRestoreWorkspace();
10+
const { mutateAsync, isPending } = useMutationRestoreWorkspace();
1111

1212
return {
1313
isPending,
1414
isDisabled: isPending,
15-
onPress: () => mutate({ path: { workspace_name: workspaceName } }),
15+
onPress: () => mutateAsync({ path: { workspace_name: workspaceName } }),
1616
children: "Restore",
1717
};
1818
}

src/features/workspace/hooks/use-restore-workspace.ts

-28
This file was deleted.

src/hooks/use-toast-mutation.ts

+19-8
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,40 @@ export function useToastMutation<
1111
TError = DefaultError,
1212
TVariables = void,
1313
TContext = unknown,
14-
>(options: UseMutationOptions<TData, TError, TVariables, TContext>) {
14+
>({
15+
successMsg,
16+
errorMsg,
17+
loadingMsg,
18+
...options
19+
}: UseMutationOptions<TData, TError, TVariables, TContext> & {
20+
successMsg?: ((variables: TVariables) => string) | string;
21+
loadingMsg?: string;
22+
errorMsg?: string;
23+
}) {
1524
const {
1625
mutateAsync: originalMutateAsync,
17-
// NOTE: We are not allowing direct use of the `mutate` (sync) function.
26+
// NOTE: We are not allowing direct use of the `mutate` (sync) function
1827
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1928
mutate: _,
2029
...rest
2130
} = useMutation(options);
2231

2332
const mutateAsync = useCallback(
24-
<TError extends { detail: string | undefined }>(
33+
async <TError extends { detail: string | undefined }>(
2534
variables: Parameters<typeof originalMutateAsync>[0],
26-
options: Parameters<typeof originalMutateAsync>[1],
27-
{ successMsg }: { successMsg: string },
35+
options: Parameters<typeof originalMutateAsync>[1] = {},
2836
) => {
2937
const promise = originalMutateAsync(variables, options);
3038

3139
toast.promise(promise, {
32-
success: successMsg,
33-
error: (e: TError) => (e.detail ? e.detail : "An error occurred"),
40+
success:
41+
typeof successMsg === "function" ? successMsg(variables) : successMsg,
42+
loading: loadingMsg ?? "Loading...",
43+
error: (e: TError) =>
44+
errorMsg ?? (e.detail ? e.detail : "An error occurred"),
3445
});
3546
},
36-
[originalMutateAsync],
47+
[errorMsg, loadingMsg, originalMutateAsync, successMsg],
3748
);
3849

3950
return { mutateAsync, ...rest };

src/lib/test-utils.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SidebarProvider } from "@/components/ui/sidebar";
2-
import { DarkModeProvider } from "@stacklok/ui-kit";
2+
import { DarkModeProvider, Toaster } from "@stacklok/ui-kit";
33
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
44
import { RenderOptions, render } from "@testing-library/react";
55
import React, { ReactNode } from "react";
@@ -45,6 +45,7 @@ const renderWithProviders = (
4545
render(
4646
<TestQueryClientProvider>
4747
<DarkModeProvider>
48+
<Toaster />
4849
<MemoryRouter {...options?.routeConfig}>
4950
<Routes>
5051
<Route

0 commit comments

Comments
 (0)