From f2cf621f7f486323aabb14b9a9d83325d21e69a9 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 10:54:36 +0000 Subject: [PATCH 01/15] feat: useKbdShortcuts hook & example implementation --- src/hooks/use-kbd-shortcuts.ts | 31 +++++++++++++++++++++++++++++++ src/lib/hrefs.ts | 5 +++++ src/routes/route-workspaces.tsx | 9 ++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/hooks/use-kbd-shortcuts.ts create mode 100644 src/lib/hrefs.ts diff --git a/src/hooks/use-kbd-shortcuts.ts b/src/hooks/use-kbd-shortcuts.ts new file mode 100644 index 00000000..b493fd7a --- /dev/null +++ b/src/hooks/use-kbd-shortcuts.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +export function useKbdShortcuts(map: [string, () => void][]) { + return useEffect(() => { + // Attach a listener to the document to listen for the "/" key + const documentListener = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + for (const [key, callback] of map) { + if (e.key.toLowerCase() === key.toLowerCase()) { + e.preventDefault(); + e.stopPropagation(); + callback(); + } + } + }; + + document.addEventListener("keydown", documentListener); + + return () => { + document.removeEventListener("keydown", documentListener); + }; + }, [map]); +} diff --git a/src/lib/hrefs.ts b/src/lib/hrefs.ts new file mode 100644 index 00000000..5c124f3f --- /dev/null +++ b/src/lib/hrefs.ts @@ -0,0 +1,5 @@ +export const hrefs = { + workspaces: { + create: "/workspace/create", + }, +}; diff --git a/src/routes/route-workspaces.tsx b/src/routes/route-workspaces.tsx index 22b2dda1..b089078a 100644 --- a/src/routes/route-workspaces.tsx +++ b/src/routes/route-workspaces.tsx @@ -19,6 +19,9 @@ import { useArchivedWorkspaces } from "@/features/workspace/hooks/use-archived-w import { Workspace } from "@/api/generated"; import SvgFlipBackward from "@/components/icons/FlipBackward"; import { useRestoreWorkspaceButton } from "@/features/workspace/hooks/use-restore-workspace-button"; +import { useKbdShortcuts } from "@/hooks/use-kbd-shortcuts"; +import { useNavigate } from "react-router-dom"; +import { hrefs } from "@/lib/hrefs"; function CellName({ name, @@ -89,6 +92,10 @@ export function RouteWorkspaces() { })) ?? []), ]; + const navigate = useNavigate(); + + useKbdShortcuts([["c", () => navigate(hrefs.workspaces.create)]]); + return ( <> @@ -97,7 +104,7 @@ export function RouteWorkspaces() { - + Create Workspace From 997393962e5eee68c7e1468d3f26e85a2f9f6e62 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 10:56:11 +0000 Subject: [PATCH 02/15] chore: tidy up remnant --- src/hooks/use-kbd-shortcuts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/use-kbd-shortcuts.ts b/src/hooks/use-kbd-shortcuts.ts index b493fd7a..02a65b3e 100644 --- a/src/hooks/use-kbd-shortcuts.ts +++ b/src/hooks/use-kbd-shortcuts.ts @@ -2,7 +2,6 @@ import { useEffect } from "react"; export function useKbdShortcuts(map: [string, () => void][]) { return useEffect(() => { - // Attach a listener to the document to listen for the "/" key const documentListener = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if ( From 13249e9c24a77da1fa1c3895a3a61441fdff2727 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 15:25:44 +0000 Subject: [PATCH 03/15] feat: useToastMutation hook --- src/hooks/use-toast-mutation.ts | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/hooks/use-toast-mutation.ts diff --git a/src/hooks/use-toast-mutation.ts b/src/hooks/use-toast-mutation.ts new file mode 100644 index 00000000..5fba63bc --- /dev/null +++ b/src/hooks/use-toast-mutation.ts @@ -0,0 +1,42 @@ +import { toast } from "@stacklok/ui-kit"; +import { + DefaultError, + useMutation, + UseMutationOptions, +} from "@tanstack/react-query"; +import { useCallback } from "react"; + +export function useToastMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TContext = unknown, +>(options: UseMutationOptions) { + const { + mutateAsync: originalMutateAsync, + // 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); + + // NOTE: That we are not allowing the user to pass in customization options + // (the second arg to mutate) + const mutateAsync = useCallback( + ( + variables: Parameters[0], + options: Parameters[1], + { successMsg }: { successMsg: string }, + ) => { + const promise = originalMutateAsync(variables, options); + + toast.promise(promise, { + success: successMsg, + error: (e: TError) => (e.detail ? e.detail : "An error occurred"), + }); + }, + [originalMutateAsync], + ); + + return { mutateAsync, ...rest }; +} From 343c342d722d1e8865508ed0a9f095cc41cee67c Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 15:28:14 +0000 Subject: [PATCH 04/15] chore: remove junk comment --- src/hooks/use-toast-mutation.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/hooks/use-toast-mutation.ts b/src/hooks/use-toast-mutation.ts index 5fba63bc..f3ddc1a4 100644 --- a/src/hooks/use-toast-mutation.ts +++ b/src/hooks/use-toast-mutation.ts @@ -20,8 +20,6 @@ export function useToastMutation< ...rest } = useMutation(options); - // NOTE: That we are not allowing the user to pass in customization options - // (the second arg to mutate) const mutateAsync = useCallback( ( variables: Parameters[0], From 3796ddd044577260130e6e3ad66f42b463e163f0 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 16:06:06 +0000 Subject: [PATCH 05/15] feat: implement `useToastMutation` for workspaces --- .../__tests__/archive-workspace.test.tsx | 17 ++++------- .../__tests__/workspace-creation.test.tsx | 8 ++++++ .../components/workspace-creation.tsx | 7 +++-- .../workspace/components/workspace-name.tsx | 7 +++-- .../components/workspaces-selection.tsx | 4 +-- .../workspace/hooks/use-activate-workspace.ts | 8 ------ .../hooks/use-archive-workspace-button.tsx | 14 ++++++++-- .../workspace/hooks/use-create-workspace.ts | 8 ------ .../hooks/use-invalidate-workspace-queries.ts | 23 +++++++++++++++ .../hooks/use-mutation-activate-workspace.ts | 13 +++++++++ .../hooks/use-mutation-archive-workspace.ts | 14 ++++++++++ .../hooks/use-mutation-create-workspace.ts | 13 +++++++++ .../use-mutation-hard-delete-workspace.ts | 14 ++++++++++ .../hooks/use-mutation-restore-workspace.ts | 14 ++++++++++ .../hooks/use-restore-workspace-button.tsx | 6 ++-- .../workspace/hooks/use-restore-workspace.ts | 28 ------------------- src/hooks/use-toast-mutation.ts | 25 ++++++++++++----- src/lib/test-utils.tsx | 3 +- 18 files changed, 148 insertions(+), 78 deletions(-) delete mode 100644 src/features/workspace/hooks/use-activate-workspace.ts delete mode 100644 src/features/workspace/hooks/use-create-workspace.ts create mode 100644 src/features/workspace/hooks/use-invalidate-workspace-queries.ts create mode 100644 src/features/workspace/hooks/use-mutation-activate-workspace.ts create mode 100644 src/features/workspace/hooks/use-mutation-archive-workspace.ts create mode 100644 src/features/workspace/hooks/use-mutation-create-workspace.ts create mode 100644 src/features/workspace/hooks/use-mutation-hard-delete-workspace.ts create mode 100644 src/features/workspace/hooks/use-mutation-restore-workspace.ts delete mode 100644 src/features/workspace/hooks/use-restore-workspace.ts 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..a3ca8387 100644 --- a/src/hooks/use-toast-mutation.ts +++ b/src/hooks/use-toast-mutation.ts @@ -11,7 +11,16 @@ 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. @@ -21,19 +30,21 @@ export function useToastMutation< } = 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( + Date: Thu, 23 Jan 2025 16:18:58 +0000 Subject: [PATCH 06/15] refactor: `useQueries` to fetch workspaces data --- .../hooks/use-query-list-all-workspaces.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/features/workspace/hooks/use-query-list-all-workspaces.ts diff --git a/src/features/workspace/hooks/use-query-list-all-workspaces.ts b/src/features/workspace/hooks/use-query-list-all-workspaces.ts new file mode 100644 index 00000000..37a50551 --- /dev/null +++ b/src/features/workspace/hooks/use-query-list-all-workspaces.ts @@ -0,0 +1,67 @@ +import { + DefinedUseQueryResult, + QueryObserverLoadingErrorResult, + QueryObserverLoadingResult, + QueryObserverPendingResult, + QueryObserverRefetchErrorResult, + useQueries, +} from "@tanstack/react-query"; +import { + v1ListArchivedWorkspacesOptions, + v1ListWorkspacesOptions, +} from "@/api/generated/@tanstack/react-query.gen"; +import { + V1ListArchivedWorkspacesResponse, + V1ListWorkspacesResponse, +} from "@/api/generated"; + +type QueryResult = + | DefinedUseQueryResult + | QueryObserverLoadingErrorResult + | QueryObserverLoadingResult + | QueryObserverPendingResult + | QueryObserverRefetchErrorResult; + +type UseQueryDataReturn = [ + QueryResult, + QueryResult, +]; + +const combine = (results: UseQueryDataReturn) => { + const [workspaces, archivedWorkspaces] = results; + + const active = workspaces.data?.workspaces + ? workspaces.data?.workspaces.map( + (i) => ({ ...i, id: `workspace-${i.name}`, isArchived: false }), + [], + ) + : []; + + const archived = archivedWorkspaces.data?.workspaces + ? archivedWorkspaces.data?.workspaces.map( + (i) => ({ ...i, id: `archived-workspace-${i.name}`, isArchived: true }), + [], + ) + : []; + + return { + data: [...active, ...archived], + isPending: results.some((r) => r.isPending), + isFetching: results.some((r) => r.isFetching), + isRefetching: results.some((r) => r.isRefetching), + }; +}; + +export const useListAllWorkspaces = () => { + return useQueries({ + combine, + queries: [ + { + ...v1ListWorkspacesOptions(), + }, + { + ...v1ListArchivedWorkspacesOptions(), + }, + ], + }); +}; From 66a915755cfe8c4d094b124c126b257a4875db02 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 17:02:18 +0000 Subject: [PATCH 07/15] feat: implement "hard delete" from workspaces table --- src/context/confirm-context.tsx | 86 +++++++++ .../components/system-prompt-editor.tsx | 1 + .../hooks/use-archive-workspace.ts | 15 -- .../workspace/components/table-workspaces.tsx | 173 ++++++++++++++++++ .../use-confirm-hard-delete-workspace.tsx | 35 ++++ .../hooks/use-invalidate-workspace-queries.ts | 6 +- .../hooks/use-mutation-activate-workspace.ts | 6 +- .../hooks/use-mutation-archive-workspace.ts | 57 +++++- .../hooks/use-mutation-create-workspace.ts | 4 +- .../hooks/use-mutation-restore-workspace.ts | 51 +++++- src/hooks/use-confirm.tsx | 29 +++ src/lib/test-utils.tsx | 21 ++- src/main.tsx | 7 +- .../__tests__/route-workspaces.test.tsx | 15 +- src/routes/route-workspaces.tsx | 117 +----------- 15 files changed, 459 insertions(+), 164 deletions(-) create mode 100644 src/context/confirm-context.tsx delete mode 100644 src/features/workspace-system-prompt/hooks/use-archive-workspace.ts create mode 100644 src/features/workspace/components/table-workspaces.tsx create mode 100644 src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx create mode 100644 src/hooks/use-confirm.tsx diff --git a/src/context/confirm-context.tsx b/src/context/confirm-context.tsx new file mode 100644 index 00000000..ba7fa19c --- /dev/null +++ b/src/context/confirm-context.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogModal, + DialogModalOverlay, + DialogTitle, +} from "@stacklok/ui-kit"; +import type { ReactNode } from "react"; +import { createContext, useState } from "react"; + +type Buttons = { + yes: ReactNode; + no: ReactNode; +}; + +type Config = { + buttons: Buttons; + title?: ReactNode; + isDestructive?: boolean; +}; + +type Question = { + message: ReactNode; + config: Config; + resolve: (value: boolean) => void; +}; + +type ConfirmContextType = { + confirm: (message: ReactNode, config: Config) => Promise; +}; + +export const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [activeQuestion, setActiveQuestion] = useState(null); + const [isOpen, setIsOpen] = useState(false); + + const handleAnswer = (answer: boolean) => { + if (activeQuestion === null) return; + activeQuestion.resolve(answer); + setIsOpen(false); + }; + + const confirm = (message: ReactNode, config: Config) => { + return new Promise((resolve) => { + setActiveQuestion({ message, config, resolve }); + setIsOpen(true); + }); + }; + + return ( + + {children} + + + + + + {activeQuestion?.config.title} + + {activeQuestion?.message} + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/features/workspace-system-prompt/components/system-prompt-editor.tsx b/src/features/workspace-system-prompt/components/system-prompt-editor.tsx index 4071bfb2..b3556f37 100644 --- a/src/features/workspace-system-prompt/components/system-prompt-editor.tsx +++ b/src/features/workspace-system-prompt/components/system-prompt-editor.tsx @@ -218,6 +218,7 @@ export function SystemPromptEditor({ isPending={isMutationPending} isDisabled={Boolean(isArchived ?? isGetPromptPending ?? saved)} onPress={() => handleSubmit(value)} + variant="secondary" > {saved ? ( <> diff --git a/src/features/workspace-system-prompt/hooks/use-archive-workspace.ts b/src/features/workspace-system-prompt/hooks/use-archive-workspace.ts deleted file mode 100644 index 21e03749..00000000 --- a/src/features/workspace-system-prompt/hooks/use-archive-workspace.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { v1DeleteWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; -import { toast } from "@stacklok/ui-kit"; -import { useMutation } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; - -export function useArchiveWorkspace() { - const navigate = useNavigate(); - return useMutation({ - ...v1DeleteWorkspaceMutation(), - onSuccess: () => navigate("/workspaces"), - onError: (err) => { - toast.error(err.detail ? `${err.detail}` : "Failed to archive workspace"); - }, - }); -} diff --git a/src/features/workspace/components/table-workspaces.tsx b/src/features/workspace/components/table-workspaces.tsx new file mode 100644 index 00000000..9f9e8306 --- /dev/null +++ b/src/features/workspace/components/table-workspaces.tsx @@ -0,0 +1,173 @@ +import { Workspace } from "@/api/generated"; +import { + Badge, + Button, + Cell, + Column, + Menu, + MenuTrigger, + OptionsSchema, + Popover, + Row, + Table, + TableBody, + TableHeader, +} from "@stacklok/ui-kit"; + +import { Undo2, X, SlidersHorizontal, Ellipsis, Check } from "lucide-react"; +import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace"; +import { useMutationRestoreWorkspace } from "../hooks/use-mutation-restore-workspace"; +import { useMutationHardDeleteWorkspace } from "../hooks/use-mutation-hard-delete-workspace"; +import { useListAllWorkspaces } from "../hooks/use-query-list-all-workspaces"; +import { useMutationActivateWorkspace } from "../hooks/use-mutation-activate-workspace"; +import { useActiveWorkspaceName } from "../hooks/use-active-workspace-name"; +import { useConfirmHardDeleteWorkspace } from "../hooks/use-confirm-hard-delete-workspace"; + +const getWorkspaceActions = ({ + archiveWorkspace, + workspace, + activateWorkspace, + activeWorkspaceName, +}: { + workspace: Workspace & { + isArchived?: boolean; + }; + archiveWorkspace: ReturnType< + typeof useMutationArchiveWorkspace + >["mutateAsync"]; + activateWorkspace: ReturnType< + typeof useMutationActivateWorkspace + >["mutateAsync"]; + activeWorkspaceName: string | null | undefined; +}): OptionsSchema<"menu">[] => [ + { + textValue: "Activate", + icon: , + id: "activate", + isDisabled: workspace.name === activeWorkspaceName, + onAction: () => activateWorkspace({ body: { name: workspace.name } }), + }, + { + textValue: "Edit", + icon: , + id: "edit", + href: `/workspace/${workspace.name}`, + }, + { + textValue: "Archive", + icon: , + id: "archive", + isDisabled: + workspace.name === activeWorkspaceName || workspace.name === "default", + onAction: () => + archiveWorkspace({ path: { workspace_name: workspace.name } }), + }, +]; + +const getArchivedWorkspaceActions = ({ + workspace, + restoreWorkspace, + hardDeleteWorkspace, +}: { + workspace: Workspace & { + isArchived?: boolean; + }; + restoreWorkspace: ReturnType< + typeof useMutationArchiveWorkspace + >["mutateAsync"]; + hardDeleteWorkspace: ReturnType< + typeof useMutationHardDeleteWorkspace + >["mutateAsync"]; +}): OptionsSchema<"menu">[] => [ + { + textValue: "Restore", + icon: , + id: "restore", + onAction: () => + restoreWorkspace({ path: { workspace_name: workspace.name } }), + }, + { + textValue: "Permanently delete", + isDestructive: true, + icon: , + id: "permanently-delete", + onAction: () => + hardDeleteWorkspace({ path: { workspace_name: workspace.name } }), + }, +]; + +function CellName({ + name, + isArchived = false, +}: { + name: string; + isArchived?: boolean; +}) { + if (isArchived) + return ( + + {name} +    + + Archived + + + ); + + return {name}; +} + +export function TableWorkspaces() { + const { data: workspaces } = useListAllWorkspaces(); + const { data: activeWorkspaceName } = useActiveWorkspaceName(); + + const { mutateAsync: archiveWorkspace } = useMutationArchiveWorkspace(); + const { mutateAsync: restoreWorkspace } = useMutationRestoreWorkspace(); + const { mutateAsync: activateWorkspace } = useMutationActivateWorkspace(); + const hardDeleteWorkspace = useConfirmHardDeleteWorkspace(); + + return ( + + + + + Name + + + + + + {workspaces.map((workspace) => ( + + + + + + + + + + + + ))} + +
+ ); +} diff --git a/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx b/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx new file mode 100644 index 00000000..c536248e --- /dev/null +++ b/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx @@ -0,0 +1,35 @@ +import { useConfirm } from "@/hooks/use-confirm"; +import { useCallback } from "react"; +import { useMutationHardDeleteWorkspace } from "./use-mutation-hard-delete-workspace"; + +export function useConfirmHardDeleteWorkspace() { + const { mutateAsync: hardDeleteWorkspace } = useMutationHardDeleteWorkspace(); + + const { confirm } = useConfirm(); + + return useCallback( + async (...params: Parameters) => { + const answer = await confirm( + <> +

Are you sure you want to delete this workspace?

+

You will lose any custom instructions, or other configuration.

+

+ This action cannot be undone. +

+ , + { + buttons: { + yes: "Remove", + no: "Cancel", + }, + title: "Permanently delete workspace", + isDestructive: true, + }, + ); + if (answer) { + void hardDeleteWorkspace(...params); + } + }, + [confirm, hardDeleteWorkspace], + ); +} diff --git a/src/features/workspace/hooks/use-invalidate-workspace-queries.ts b/src/features/workspace/hooks/use-invalidate-workspace-queries.ts index 1985e28f..3bb23afa 100644 --- a/src/features/workspace/hooks/use-invalidate-workspace-queries.ts +++ b/src/features/workspace/hooks/use-invalidate-workspace-queries.ts @@ -8,12 +8,12 @@ import { useCallback } from "react"; export function useInvalidateWorkspaceQueries() { const queryClient = useQueryClient(); - const invalidate = useCallback(() => { - queryClient.invalidateQueries({ + const invalidate = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: v1ListWorkspacesOptions(), refetchType: "all", }); - queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: v1ListArchivedWorkspacesQueryKey(), refetchType: "all", }); diff --git a/src/features/workspace/hooks/use-mutation-activate-workspace.ts b/src/features/workspace/hooks/use-mutation-activate-workspace.ts index 3979d188..0e7ac68a 100644 --- a/src/features/workspace/hooks/use-mutation-activate-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-activate-workspace.ts @@ -1,13 +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"; +import { useQueryClient } from "@tanstack/react-query"; export function useMutationActivateWorkspace() { - const invalidate = useInvalidateWorkspaceQueries(); + const queryClient = useQueryClient(); return useToastMutation({ ...v1ActivateWorkspaceMutation(), - onSuccess: () => invalidate(), + onSuccess: () => queryClient.invalidateQueries({ refetchType: "all" }), // Global setting, refetch **everything** 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 index cd12a702..3a972dad 100644 --- a/src/features/workspace/hooks/use-mutation-archive-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-archive-workspace.ts @@ -1,13 +1,66 @@ -import { v1DeleteWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { + v1DeleteWorkspaceMutation, + v1ListArchivedWorkspacesQueryKey, + v1ListWorkspacesQueryKey, +} from "@/api/generated/@tanstack/react-query.gen"; import { useToastMutation } from "@/hooks/use-toast-mutation"; import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; +import { useQueryClient } from "@tanstack/react-query"; +import { + V1ListArchivedWorkspacesResponse, + V1ListWorkspacesResponse, +} from "@/api/generated"; +import { useActiveWorkspaceName } from "./use-active-workspace-name"; export function useMutationArchiveWorkspace() { + const queryClient = useQueryClient(); const invalidate = useInvalidateWorkspaceQueries(); + const { data: activeWorkspaceName } = useActiveWorkspaceName(); return useToastMutation({ ...v1DeleteWorkspaceMutation(), - onSuccess: () => invalidate(), + onMutate: async (variables) => { + // These conditions would cause the archive operation to error + if (variables.path.workspace_name === "default") return; + if (variables.path.workspace_name === activeWorkspaceName) return; + + // Cancel any outgoing refetches + // Prevents the refetch from overwriting the optimistic update + await queryClient.cancelQueries({ + queryKey: v1ListWorkspacesQueryKey(), + }); + await queryClient.cancelQueries({ + queryKey: v1ListArchivedWorkspacesQueryKey(), + }); + + // Optimistically remove the archived workspace from the list + queryClient.setQueryData( + v1ListWorkspacesQueryKey(), + (old: V1ListWorkspacesResponse) => ({ + workspaces: [...old.workspaces].filter( + (o) => o.name !== variables.path.workspace_name, + ), + }), + ); + // Optimistically add the archived workspace to the archived list + queryClient.setQueryData( + v1ListArchivedWorkspacesQueryKey(), + (old: V1ListArchivedWorkspacesResponse) => ({ + workspaces: [ + ...old.workspaces, + { name: variables.path.workspace_name }, + ], + }), + ); + + return {}; + }, + onSettled: async () => { + await invalidate(); + }, + onError: async () => { + await 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 index 955e2f71..f1f698d6 100644 --- a/src/features/workspace/hooks/use-mutation-create-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-create-workspace.ts @@ -7,7 +7,9 @@ export function useMutationCreateWorkspace() { return useToastMutation({ ...v1CreateWorkspaceMutation(), - onSuccess: () => invalidate(), + onSuccess: async () => { + await invalidate(); + }, successMsg: (variables) => `Created "${variables.body.name}" workspace`, }); } diff --git a/src/features/workspace/hooks/use-mutation-restore-workspace.ts b/src/features/workspace/hooks/use-mutation-restore-workspace.ts index 913b7c01..0e5a8e57 100644 --- a/src/features/workspace/hooks/use-mutation-restore-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-restore-workspace.ts @@ -1,13 +1,60 @@ -import { v1RecoverWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen"; +import { + v1ListArchivedWorkspacesQueryKey, + v1ListWorkspacesQueryKey, + v1RecoverWorkspaceMutation, +} from "@/api/generated/@tanstack/react-query.gen"; import { useToastMutation } from "@/hooks/use-toast-mutation"; import { useInvalidateWorkspaceQueries } from "./use-invalidate-workspace-queries"; +import { + V1ListWorkspacesResponse, + V1ListArchivedWorkspacesResponse, +} from "@/api/generated"; +import { useQueryClient } from "@tanstack/react-query"; export function useMutationRestoreWorkspace() { const invalidate = useInvalidateWorkspaceQueries(); + const queryClient = useQueryClient(); return useToastMutation({ ...v1RecoverWorkspaceMutation(), - onSuccess: () => invalidate(), + onMutate: async (variables) => { + // Cancel any outgoing refetches + // Prevents the refetch from overwriting the optimistic update + await queryClient.cancelQueries({ + queryKey: v1ListWorkspacesQueryKey(), + }); + await queryClient.cancelQueries({ + queryKey: v1ListArchivedWorkspacesQueryKey(), + }); + + // Optimistically remove the workspace from the archived list + queryClient.setQueryData( + v1ListArchivedWorkspacesQueryKey(), + (old: V1ListWorkspacesResponse) => ({ + workspaces: [...old.workspaces].filter( + (o) => o.name !== variables.path.workspace_name, + ), + }), + ); + // Optimistically add the workspace to the non-archived list + queryClient.setQueryData( + v1ListWorkspacesQueryKey(), + (old: V1ListArchivedWorkspacesResponse) => ({ + workspaces: [ + ...old.workspaces, + { name: variables.path.workspace_name }, + ], + }), + ); + + return {}; + }, + onSettled: async () => { + await invalidate(); + }, + onError: async () => { + await invalidate(); + }, successMsg: (variables) => `Restored "${variables.path.workspace_name}" workspace`, }); diff --git a/src/hooks/use-confirm.tsx b/src/hooks/use-confirm.tsx new file mode 100644 index 00000000..dc1305cc --- /dev/null +++ b/src/hooks/use-confirm.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ConfirmContext } from "@/context/confirm-context"; +import type { ReactNode } from "react"; +import { useContext } from "react"; + +type Buttons = { + yes: ReactNode; + no: ReactNode; +}; + +type Config = { + buttons: Buttons; + title?: ReactNode; + isDestructive?: boolean; +}; + +export type ConfirmFunction = ( + message: ReactNode, + config: Config, +) => Promise; + +export const useConfirm = () => { + const context = useContext(ConfirmContext); + if (!context) { + throw new Error("useConfirmContext must be used within a ConfirmProvider"); + } + return context; +}; diff --git a/src/lib/test-utils.tsx b/src/lib/test-utils.tsx index d58c2430..cfbffbfe 100644 --- a/src/lib/test-utils.tsx +++ b/src/lib/test-utils.tsx @@ -1,4 +1,5 @@ import { SidebarProvider } from "@/components/ui/sidebar"; +import { ConfirmProvider } from "@/context/confirm-context"; import { DarkModeProvider, Toaster } from "@stacklok/ui-kit"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RenderOptions, render } from "@testing-library/react"; @@ -45,15 +46,17 @@ const renderWithProviders = ( render( - - - - {children}} - /> - - + + + + + {children}} + /> + + + , ); diff --git a/src/main.tsx b/src/main.tsx index b3d25e3f..bd08502d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,7 @@ import { client } from "./api/generated/index.ts"; import { QueryClientProvider } from "./components/react-query-provider.tsx"; import { BrowserRouter } from "react-router-dom"; import { UiKitClientSideRoutingProvider } from "./lib/ui-kit-client-side-routing.tsx"; +import { ConfirmProvider } from "./context/confirm-context.tsx"; // Initialize the API client client.setConfig({ @@ -25,8 +26,10 @@ createRoot(document.getElementById("root")!).render( }> - - + + + + diff --git a/src/routes/__tests__/route-workspaces.test.tsx b/src/routes/__tests__/route-workspaces.test.tsx index 608f2f08..37b44cb6 100644 --- a/src/routes/__tests__/route-workspaces.test.tsx +++ b/src/routes/__tests__/route-workspaces.test.tsx @@ -25,9 +25,6 @@ describe("Workspaces page", () => { it("has a table with the correct columns", () => { expect(screen.getByRole("columnheader", { name: /name/i })).toBeVisible(); - expect( - screen.getByRole("columnheader", { name: /configuration/i }), - ).toBeVisible(); }); it("has a row for each workspace", async () => { @@ -43,12 +40,8 @@ describe("Workspaces page", () => { ).toBeVisible(); const firstRow = screen.getByRole("row", { name: /myworkspace/i }); - const firstButton = within(firstRow).getByRole("link", { - name: /settings/i, - }); - expect(firstButton).toBeVisible(); - expect(firstButton).toHaveAttribute("href", "/workspace/myworkspace"); + expect(firstRow).toHaveAttribute("data-href", "/workspace/myworkspace"); }); it("has archived workspace", async () => { @@ -59,11 +52,5 @@ describe("Workspaces page", () => { expect( screen.getByRole("rowheader", { name: /archived_workspace/i }), ).toBeVisible(); - - expect( - screen.getByRole("button", { - name: /restore configuration/i, - }), - ).toBeVisible(); }); }); diff --git a/src/routes/route-workspaces.tsx b/src/routes/route-workspaces.tsx index b089078a..d7eb634f 100644 --- a/src/routes/route-workspaces.tsx +++ b/src/routes/route-workspaces.tsx @@ -1,97 +1,13 @@ import { WorkspaceHeading } from "@/features/workspace/components/workspace-heading"; -import { useListWorkspaces } from "@/features/workspace/hooks/use-list-workspaces"; import { BreadcrumbHome } from "@/components/BreadcrumbHome"; -import { - Badge, - Breadcrumb, - Breadcrumbs, - Button, - Cell, - Column, - LinkButton, - Row, - Table, - TableBody, - TableHeader, -} from "@stacklok/ui-kit"; -import { Settings, SquarePlus } from "lucide-react"; -import { useArchivedWorkspaces } from "@/features/workspace/hooks/use-archived-workspaces"; -import { Workspace } from "@/api/generated"; -import SvgFlipBackward from "@/components/icons/FlipBackward"; -import { useRestoreWorkspaceButton } from "@/features/workspace/hooks/use-restore-workspace-button"; +import { Breadcrumb, Breadcrumbs, LinkButton } from "@stacklok/ui-kit"; +import { SquarePlus } from "lucide-react"; +import { TableWorkspaces } from "@/features/workspace/components/table-workspaces"; import { useKbdShortcuts } from "@/hooks/use-kbd-shortcuts"; import { useNavigate } from "react-router-dom"; import { hrefs } from "@/lib/hrefs"; -function CellName({ - name, - isArchived = false, -}: { - name: string; - isArchived?: boolean; -}) { - if (isArchived) - return ( - - {name} -    - - Archived - - - ); - - return {name}; -} - -function CellConfiguration({ - name, - isArchived = false, -}: { - name: string; - isArchived?: boolean; -}) { - const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName: name }); - - if (isArchived) { - return ( - - - - ); - } - - return ( - - - - Settings - - - ); -} - export function RouteWorkspaces() { - const { data: availableWorkspaces } = useListWorkspaces(); - const { data: archivedWorkspaces } = useArchivedWorkspaces(); - const workspaces: (Workspace & { isArchived?: boolean })[] = [ - ...(availableWorkspaces?.workspaces ?? []), - ...(archivedWorkspaces?.workspaces.map((item) => ({ - ...item, - isArchived: true, - })) ?? []), - ]; - const navigate = useNavigate(); useKbdShortcuts([["c", () => navigate(hrefs.workspaces.create)]]); @@ -109,32 +25,7 @@ export function RouteWorkspaces() { - - - - - Name - - - Configuration - - - - - {workspaces.map((workspace) => ( - - - - - ))} - -
+ ); } From dc4cd3cec04cec5cc4fbf34ffa67e44a42535849 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Thu, 23 Jan 2025 23:30:49 +0000 Subject: [PATCH 08/15] chore: tidy ups --- .../__tests__/archive-workspace.test.tsx | 15 ----- .../components/archive-workspace.tsx | 43 +++++++++++-- .../hooks/use-archive-workspace-button.tsx | 10 +-- .../hooks/use-mutation-archive-workspace.ts | 64 ++++++++++--------- .../use-mutation-hard-delete-workspace.ts | 2 +- src/lib/hrefs.ts | 1 + 6 files changed, 77 insertions(+), 58 deletions(-) diff --git a/src/features/workspace/components/__tests__/archive-workspace.test.tsx b/src/features/workspace/components/__tests__/archive-workspace.test.tsx index 43b53b07..f50fbe0f 100644 --- a/src/features/workspace/components/__tests__/archive-workspace.test.tsx +++ b/src/features/workspace/components/__tests__/archive-workspace.test.tsx @@ -3,25 +3,10 @@ import { ArchiveWorkspace } from "../archive-workspace"; import userEvent from "@testing-library/user-event"; import { screen, waitFor } from "@testing-library/react"; -const mockNavigate = vi.fn(); - -vi.mock("react-router-dom", async () => { - const original = - await vi.importActual( - "react-router-dom", - ); - return { - ...original, - useNavigate: () => mockNavigate, - }; -}); - 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/archive-workspace.tsx b/src/features/workspace/components/archive-workspace.tsx index 61ded26d..798b5b77 100644 --- a/src/features/workspace/components/archive-workspace.tsx +++ b/src/features/workspace/components/archive-workspace.tsx @@ -2,6 +2,40 @@ import { Card, CardBody, Button, Text } from "@stacklok/ui-kit"; import { twMerge } from "tailwind-merge"; import { useRestoreWorkspaceButton } from "../hooks/use-restore-workspace-button"; import { useArchiveWorkspaceButton } from "../hooks/use-archive-workspace-button"; +import { useConfirmHardDeleteWorkspace } from "../hooks/use-confirm-hard-delete-workspace"; +import { useNavigate } from "react-router-dom"; +import { hrefs } from "@/lib/hrefs"; + +const ButtonsUnarchived = ({ workspaceName }: { workspaceName: string }) => { + const archiveButtonProps = useArchiveWorkspaceButton({ workspaceName }); + + return + + ); +}; export function ArchiveWorkspace({ className, @@ -12,9 +46,6 @@ export function ArchiveWorkspace({ className?: string; isArchived: boolean | undefined; }) { - const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName }); - const archiveButtonProps = useArchiveWorkspaceButton({ workspaceName }); - return ( @@ -26,7 +57,11 @@ export function ArchiveWorkspace({ - ); diff --git a/src/features/workspace/components/table-actions-workspaces.tsx b/src/features/workspace/components/table-actions-workspaces.tsx new file mode 100644 index 00000000..2b69235c --- /dev/null +++ b/src/features/workspace/components/table-actions-workspaces.tsx @@ -0,0 +1,128 @@ +import { Workspace } from "@/api/generated"; +import { + Button, + Menu, + MenuTrigger, + OptionsSchema, + Popover, +} from "@stacklok/ui-kit"; + +import { Undo2, X, SlidersHorizontal, Check, Ellipsis } from "lucide-react"; +import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace"; +import { useMutationRestoreWorkspace } from "../hooks/use-mutation-restore-workspace"; +import { useMutationHardDeleteWorkspace } from "../hooks/use-mutation-hard-delete-workspace"; +import { useMutationActivateWorkspace } from "../hooks/use-mutation-activate-workspace"; +import { useConfirmHardDeleteWorkspace } from "../hooks/use-confirm-hard-delete-workspace"; +import { hrefs } from "@/lib/hrefs"; + +const getWorkspaceActions = ({ + archiveWorkspace, + workspace, + activateWorkspace, + activeWorkspaceName, +}: { + workspace: Workspace & { + isArchived?: boolean; + }; + archiveWorkspace: ReturnType< + typeof useMutationArchiveWorkspace + >["mutateAsync"]; + activateWorkspace: ReturnType< + typeof useMutationActivateWorkspace + >["mutateAsync"]; + activeWorkspaceName: string | null | undefined; +}): OptionsSchema<"menu">[] => [ + { + textValue: "Activate", + icon: , + id: "activate", + isDisabled: workspace.name === activeWorkspaceName, + onAction: () => activateWorkspace({ body: { name: workspace.name } }), + }, + { + textValue: "Edit", + icon: , + id: "edit", + href: hrefs.workspaces.edit(workspace.name), + }, + { + textValue: "Archive", + icon: , + id: "archive", + isDisabled: + workspace.name === activeWorkspaceName || workspace.name === "default", + onAction: () => + void archiveWorkspace({ path: { workspace_name: workspace.name } }), + }, +]; + +const getArchivedWorkspaceActions = ({ + workspace, + restoreWorkspace, + hardDeleteWorkspace, +}: { + workspace: Workspace & { + isArchived?: boolean; + }; + restoreWorkspace: ReturnType< + typeof useMutationArchiveWorkspace + >["mutateAsync"]; + hardDeleteWorkspace: ReturnType< + typeof useMutationHardDeleteWorkspace + >["mutateAsync"]; +}): OptionsSchema<"menu">[] => [ + { + textValue: "Restore", + icon: , + id: "restore", + onAction: () => + restoreWorkspace({ path: { workspace_name: workspace.name } }), + }, + { + textValue: "Permanently delete", + isDestructive: true, + icon: , + id: "permanently-delete", + onAction: () => + hardDeleteWorkspace({ path: { workspace_name: workspace.name } }), + }, +]; + +export function TableActionsWorkspaces({ + workspace, + activeWorkspaceName, +}: { + activeWorkspaceName: string | null | undefined; + workspace: Workspace & { isArchived: boolean }; +}) { + const { mutateAsync: archiveWorkspace } = useMutationArchiveWorkspace(); + const { mutateAsync: restoreWorkspace } = useMutationRestoreWorkspace(); + const { mutateAsync: activateWorkspace } = useMutationActivateWorkspace(); + const hardDeleteWorkspace = useConfirmHardDeleteWorkspace(); + + return ( + + + + + + + ); +} diff --git a/src/features/workspace/components/table-workspaces.tsx b/src/features/workspace/components/table-workspaces.tsx index 76e49602..46187e8c 100644 --- a/src/features/workspace/components/table-workspaces.tsx +++ b/src/features/workspace/components/table-workspaces.tsx @@ -1,100 +1,17 @@ -import { Workspace } from "@/api/generated"; import { Badge, - Button, Cell, Column, - Menu, - MenuTrigger, - OptionsSchema, - Popover, Row, Table, TableBody, TableHeader, } from "@stacklok/ui-kit"; -import { Undo2, X, SlidersHorizontal, Ellipsis, Check } from "lucide-react"; -import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace"; -import { useMutationRestoreWorkspace } from "../hooks/use-mutation-restore-workspace"; -import { useMutationHardDeleteWorkspace } from "../hooks/use-mutation-hard-delete-workspace"; import { useListAllWorkspaces } from "../hooks/use-query-list-all-workspaces"; -import { useMutationActivateWorkspace } from "../hooks/use-mutation-activate-workspace"; import { useActiveWorkspaceName } from "../hooks/use-active-workspace-name"; -import { useConfirmHardDeleteWorkspace } from "../hooks/use-confirm-hard-delete-workspace"; - -const getWorkspaceActions = ({ - archiveWorkspace, - workspace, - activateWorkspace, - activeWorkspaceName, -}: { - workspace: Workspace & { - isArchived?: boolean; - }; - archiveWorkspace: ReturnType< - typeof useMutationArchiveWorkspace - >["mutateAsync"]; - activateWorkspace: ReturnType< - typeof useMutationActivateWorkspace - >["mutateAsync"]; - activeWorkspaceName: string | null | undefined; -}): OptionsSchema<"menu">[] => [ - { - textValue: "Activate", - icon: , - id: "activate", - isDisabled: workspace.name === activeWorkspaceName, - onAction: () => activateWorkspace({ body: { name: workspace.name } }), - }, - { - textValue: "Edit", - icon: , - id: "edit", - href: `/workspace/${workspace.name}`, - }, - { - textValue: "Archive", - icon: , - id: "archive", - isDisabled: - workspace.name === activeWorkspaceName || workspace.name === "default", - onAction: () => - archiveWorkspace({ path: { workspace_name: workspace.name } }), - }, -]; - -const getArchivedWorkspaceActions = ({ - workspace, - restoreWorkspace, - hardDeleteWorkspace, -}: { - workspace: Workspace & { - isArchived?: boolean; - }; - restoreWorkspace: ReturnType< - typeof useMutationArchiveWorkspace - >["mutateAsync"]; - hardDeleteWorkspace: ReturnType< - typeof useMutationHardDeleteWorkspace - >["mutateAsync"]; -}): OptionsSchema<"menu">[] => [ - { - textValue: "Restore", - icon: , - id: "restore", - onAction: () => - restoreWorkspace({ path: { workspace_name: workspace.name } }), - }, - { - textValue: "Permanently delete", - isDestructive: true, - icon: , - id: "permanently-delete", - onAction: () => - hardDeleteWorkspace({ path: { workspace_name: workspace.name } }), - }, -]; +import { TableActionsWorkspaces } from "./table-actions-workspaces"; +import { hrefs } from "@/lib/hrefs"; function CellName({ name, @@ -134,11 +51,6 @@ export function TableWorkspaces() { const { data: workspaces } = useListAllWorkspaces(); const { data: activeWorkspaceName } = useActiveWorkspaceName(); - const { mutateAsync: archiveWorkspace } = useMutationArchiveWorkspace(); - const { mutateAsync: restoreWorkspace } = useMutationRestoreWorkspace(); - const { mutateAsync: activateWorkspace } = useMutationActivateWorkspace(); - const hardDeleteWorkspace = useConfirmHardDeleteWorkspace(); - return ( @@ -151,36 +63,17 @@ export function TableWorkspaces() { {workspaces.map((workspace) => ( - + - - - - - - + ))} diff --git a/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx b/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx index c536248e..120eec16 100644 --- a/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx +++ b/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx @@ -12,14 +12,14 @@ export function useConfirmHardDeleteWorkspace() { const answer = await confirm( <>

Are you sure you want to delete this workspace?

-

You will lose any custom instructions, or other configuration.

+ You will lose any custom instructions, or other configuration.{" "} This action cannot be undone.

, { buttons: { - yes: "Remove", + yes: "Delete", no: "Cancel", }, title: "Permanently delete workspace", @@ -27,7 +27,7 @@ export function useConfirmHardDeleteWorkspace() { }, ); if (answer) { - void hardDeleteWorkspace(...params); + return hardDeleteWorkspace(...params); } }, [confirm, hardDeleteWorkspace], diff --git a/src/features/workspace/hooks/use-mutation-archive-workspace.ts b/src/features/workspace/hooks/use-mutation-archive-workspace.ts index db3cff38..7dc0267e 100644 --- a/src/features/workspace/hooks/use-mutation-archive-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-archive-workspace.ts @@ -16,7 +16,6 @@ export function useMutationArchiveWorkspace() { const queryClient = useQueryClient(); const invalidate = useInvalidateWorkspaceQueries(); const { data: activeWorkspaceName } = useActiveWorkspaceName(); - console.debug("👉 activeWorkspaceName:", activeWorkspaceName); return useToastMutation({ ...v1DeleteWorkspaceMutation(), @@ -25,47 +24,63 @@ export function useMutationArchiveWorkspace() { if (variables.path.workspace_name === "default") return; if (variables.path.workspace_name === activeWorkspaceName) return; - try { - // Cancel any outgoing refetches - // Prevents the refetch from overwriting the optimistic update - await queryClient.cancelQueries({ - queryKey: v1ListWorkspacesQueryKey(), - }); - await queryClient.cancelQueries({ - queryKey: v1ListArchivedWorkspacesQueryKey(), - }); + // Cancel any outgoing refetches + // Prevents the refetch from overwriting the optimistic update + await queryClient.cancelQueries({ + queryKey: v1ListWorkspacesQueryKey(), + }); + await queryClient.cancelQueries({ + queryKey: v1ListArchivedWorkspacesQueryKey(), + }); - // Optimistically remove the archived workspace from the list - queryClient.setQueryData( - v1ListWorkspacesQueryKey(), - (old: V1ListWorkspacesResponse | null) => ({ - workspaces: old - ? [...old.workspaces].filter( - (o) => o.name !== variables.path.workspace_name, - ) - : [], - }), - ); - // Optimistically add the archived workspace to the archived list - queryClient.setQueryData( - v1ListArchivedWorkspacesQueryKey(), - (old: V1ListArchivedWorkspacesResponse | null) => ({ - workspaces: old - ? [...old.workspaces, { name: variables.path.workspace_name }] - : [], - }), - ); - } catch (e) { - console.log(e); - } - return {}; + // Snapshot the previous data + const prevWorkspaces = queryClient.getQueryData( + v1ListWorkspacesQueryKey(), + ); + const prevArchivedWorkspaces = queryClient.getQueryData( + v1ListArchivedWorkspacesQueryKey(), + ); + + if (!prevWorkspaces || !prevArchivedWorkspaces) return; + + // Optimistically update values in cache + await queryClient.setQueryData( + v1ListWorkspacesQueryKey(), + (old: V1ListWorkspacesResponse | null) => ({ + workspaces: old + ? [...old.workspaces].filter( + (o) => o.name !== variables.path.workspace_name, + ) + : [], + }), + ); + await queryClient.setQueryData( + v1ListArchivedWorkspacesQueryKey(), + (old: V1ListArchivedWorkspacesResponse | null) => ({ + workspaces: old + ? [...old.workspaces, { name: variables.path.workspace_name }] + : [], + }), + ); + + return { + prevWorkspaces, + prevArchivedWorkspaces, + }; }, onSettled: async () => { await invalidate(); }, - onError: async (e) => { - console.error(e); - await invalidate(); + // Rollback cache updates on error + onError: async (_a, _b, context) => { + queryClient.setQueryData( + v1ListWorkspacesQueryKey(), + context?.prevWorkspaces, + ); + queryClient.setQueryData( + v1ListArchivedWorkspacesQueryKey(), + context?.prevArchivedWorkspaces, + ); }, successMsg: (variables) => `Archived "${variables.path.workspace_name}" workspace`, diff --git a/src/features/workspace/hooks/use-mutation-restore-workspace.ts b/src/features/workspace/hooks/use-mutation-restore-workspace.ts index 0e5a8e57..f984a238 100644 --- a/src/features/workspace/hooks/use-mutation-restore-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-restore-workspace.ts @@ -27,7 +27,17 @@ export function useMutationRestoreWorkspace() { queryKey: v1ListArchivedWorkspacesQueryKey(), }); - // Optimistically remove the workspace from the archived list + // Snapshot the previous data + const prevWorkspaces = queryClient.getQueryData( + v1ListWorkspacesQueryKey(), + ); + const prevArchivedWorkspaces = queryClient.getQueryData( + v1ListArchivedWorkspacesQueryKey(), + ); + + if (!prevWorkspaces || !prevArchivedWorkspaces) return; + + // Optimistically update values in cache queryClient.setQueryData( v1ListArchivedWorkspacesQueryKey(), (old: V1ListWorkspacesResponse) => ({ @@ -47,13 +57,24 @@ export function useMutationRestoreWorkspace() { }), ); - return {}; + return { + prevWorkspaces, + prevArchivedWorkspaces, + }; }, onSettled: async () => { await invalidate(); }, - onError: async () => { - await invalidate(); + // Rollback cache updates on error + onError: async (_a, _b, context) => { + queryClient.setQueryData( + v1ListWorkspacesQueryKey(), + context?.prevWorkspaces, + ); + queryClient.setQueryData( + v1ListArchivedWorkspacesQueryKey(), + context?.prevArchivedWorkspaces, + ); }, successMsg: (variables) => `Restored "${variables.path.workspace_name}" workspace`, diff --git a/src/lib/hrefs.ts b/src/lib/hrefs.ts index 8ab56d0e..cb3b9407 100644 --- a/src/lib/hrefs.ts +++ b/src/lib/hrefs.ts @@ -2,5 +2,6 @@ export const hrefs = { workspaces: { all: "/workspaces", create: "/workspace/create", + edit: (name: string) => `/workspace/${name}`, }, }; diff --git a/src/lib/test-utils.tsx b/src/lib/test-utils.tsx index cfbffbfe..8dc22683 100644 --- a/src/lib/test-utils.tsx +++ b/src/lib/test-utils.tsx @@ -10,6 +10,7 @@ import { Route, Routes, } from "react-router-dom"; +import { UiKitClientSideRoutingProvider } from "./ui-kit-client-side-routing"; type RoutConfig = { routeConfig?: MemoryRouterProps; @@ -49,12 +50,14 @@ const renderWithProviders = ( - - {children}} - /> - + + + {children}} + /> + + diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index eb865736..58649d74 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -16,13 +16,15 @@ export const handlers = [ HttpResponse.json({ status: "healthy" }), ), http.get("*/api/v1/workspaces/active", () => - HttpResponse.json([ - { - name: "my-awesome-workspace", - is_active: true, - last_updated: new Date(Date.now()).toISOString(), - }, - ]), + HttpResponse.json({ + workspaces: [ + { + name: "my-awesome-workspace", + is_active: true, + last_updated: new Date(Date.now()).toISOString(), + }, + ], + }), ), http.get("*/api/v1/workspaces/:name/messages", () => { return HttpResponse.json(mockedPrompts); @@ -46,16 +48,27 @@ export const handlers = [ http.post("*/api/v1/workspaces", () => { return HttpResponse.json(mockedWorkspaces); }), - http.post("*/api/v1/workspaces/archive/:workspace_name/recover", () => { - HttpResponse.json({ status: 204 }); - }), - http.delete("*/api/v1/workspaces/:name", () => - HttpResponse.json({ status: 204 }), + http.post( + "*/api/v1/workspaces/active", + () => new HttpResponse(null, { status: 204 }), + ), + http.post( + "*/api/v1/workspaces/archive/:workspace_name/recover", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/v1/workspaces/:name", + () => new HttpResponse(null, { status: 204 }), + ), + http.delete( + "*/api/v1/workspaces/archive/:name", + () => new HttpResponse(null, { status: 204 }), ), http.get("*/api/v1/workspaces/:name/custom-instructions", () => { return HttpResponse.json({ prompt: "foo" }); }), - http.put("*/api/v1/workspaces/:name/custom-instructions", () => { - return HttpResponse.json({}, { status: 204 }); - }), + http.put( + "*/api/v1/workspaces/:name/custom-instructions", + () => new HttpResponse(null, { status: 204 }), + ), ]; diff --git a/vitest.setup.ts b/vitest.setup.ts index 7c7db4dd..e2d97f8d 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -65,10 +65,17 @@ afterEach(() => { }); afterAll(() => server.close()); +const SILENCED_MESSAGES = [ + "Not implemented: navigation (except hash changes)", // JSDom issue — can safely be ignored +]; + failOnConsole({ shouldFailOnDebug: false, shouldFailOnError: true, shouldFailOnInfo: false, shouldFailOnLog: false, shouldFailOnWarn: true, + silenceMessage: (message: string) => { + return SILENCED_MESSAGES.some((m) => message.includes(m)); + }, }); From ca05c01ca978b6fdeb3241b5fe565c7118e1ae3a Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 24 Jan 2025 11:25:06 +0000 Subject: [PATCH 12/15] chore: tidy up test --- .../components/__tests__/table-actions-workspaces.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx b/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx index 4caa65cd..c5488fd9 100644 --- a/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx +++ b/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx @@ -36,7 +36,6 @@ it("has correct actions for default workspace when not active", async () => { expect(activate).not.toHaveAttribute("aria-disabled", "true"); const edit = getByRole("menuitem", { name: /edit/i }); - expect(edit).not.toBeDisabled(); expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("default")); const archive = getByRole("menuitem", { name: /archive/i }); @@ -61,7 +60,6 @@ it("has correct actions for default workspace when active", async () => { expect(activate).toHaveAttribute("aria-disabled", "true"); const edit = getByRole("menuitem", { name: /edit/i }); - expect(edit).not.toBeDisabled(); expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("default")); const archive = getByRole("menuitem", { name: /archive/i }); @@ -86,7 +84,6 @@ it("has correct actions for normal workspace when not active", async () => { expect(activate).not.toHaveAttribute("aria-disabled", "true"); const edit = getByRole("menuitem", { name: /edit/i }); - expect(edit).not.toBeDisabled(); expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("foo-bar")); const archive = getByRole("menuitem", { name: /archive/i }); @@ -111,7 +108,6 @@ it("has correct actions for normal workspace when active", async () => { expect(activate).toHaveAttribute("aria-disabled", "true"); const edit = getByRole("menuitem", { name: /edit/i }); - expect(edit).not.toBeDisabled(); expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("foo-bar")); const archive = getByRole("menuitem", { name: /archive/i }); From 4a9db8bbc1ae818a24091586c607915dbe67e21a Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 24 Jan 2025 11:32:57 +0000 Subject: [PATCH 13/15] fix(workspace name): correct message on rename + test --- .../__tests__/workspace-name.test.tsx | 31 +++++++++++++++++++ .../hooks/use-mutation-create-workspace.ts | 5 ++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/features/workspace/components/__tests__/workspace-name.test.tsx diff --git a/src/features/workspace/components/__tests__/workspace-name.test.tsx b/src/features/workspace/components/__tests__/workspace-name.test.tsx new file mode 100644 index 00000000..2d1ba447 --- /dev/null +++ b/src/features/workspace/components/__tests__/workspace-name.test.tsx @@ -0,0 +1,31 @@ +import { test, expect } from "vitest"; +import { WorkspaceName } from "../workspace-name"; +import { render, waitFor } from "@/lib/test-utils"; +import userEvent from "@testing-library/user-event"; + +test("can rename workspace", async () => { + const { getByRole, getByText } = render( + , + ); + + const input = getByRole("textbox", { name: /workspace name/i }); + await userEvent.clear(input); + + await userEvent.type(input, "baz-qux"); + expect(input).toHaveValue("baz-qux"); + + await userEvent.click(getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(getByText(/renamed workspace to "baz-qux"/i)).toBeVisible(); + }); +}); + +test("can't rename archived workspace", async () => { + const { getByRole } = render( + , + ); + + expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled(); + expect(getByRole("button", { name: /save/i })).toBeDisabled(); +}); diff --git a/src/features/workspace/hooks/use-mutation-create-workspace.ts b/src/features/workspace/hooks/use-mutation-create-workspace.ts index f1f698d6..92e35c43 100644 --- a/src/features/workspace/hooks/use-mutation-create-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-create-workspace.ts @@ -10,6 +10,9 @@ export function useMutationCreateWorkspace() { onSuccess: async () => { await invalidate(); }, - successMsg: (variables) => `Created "${variables.body.name}" workspace`, + successMsg: (variables) => + variables.body.rename_to + ? `Renamed workspace to "${variables.body.rename_to}"` + : `Created "${variables.body.name}" workspace`, }); } From 8b5830988ebc6198e8d0693ee7febe30eb431238 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 24 Jan 2025 11:40:56 +0000 Subject: [PATCH 14/15] chore: move code around --- .../hooks/use-set-system-prompt.tsx | 11 ----- .../__tests__/system-prompt-editor.test.tsx | 3 +- .../components/system-prompt-editor.tsx | 40 +++++-------------- .../constants/monaco-theme.ts} | 0 ...tion-set-workspace-custom-instructions.tsx | 23 +++++++++++ ...uery-get-workspace-custom-instructions.ts} | 0 src/routes/route-workspace.tsx | 3 +- 7 files changed, 38 insertions(+), 42 deletions(-) delete mode 100644 src/features/workspace-system-prompt/hooks/use-set-system-prompt.tsx rename src/features/{workspace-system-prompt => workspace}/components/__tests__/system-prompt-editor.test.tsx (99%) rename src/features/{workspace-system-prompt => workspace}/components/system-prompt-editor.tsx (85%) rename src/features/{workspace-system-prompt/constants.ts => workspace/constants/monaco-theme.ts} (100%) create mode 100644 src/features/workspace/hooks/use-mutation-set-workspace-custom-instructions.tsx rename src/features/{workspace-system-prompt/hooks/use-get-system-prompt.ts => workspace/hooks/use-query-get-workspace-custom-instructions.ts} (100%) diff --git a/src/features/workspace-system-prompt/hooks/use-set-system-prompt.tsx b/src/features/workspace-system-prompt/hooks/use-set-system-prompt.tsx deleted file mode 100644 index 77905392..00000000 --- a/src/features/workspace-system-prompt/hooks/use-set-system-prompt.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { v1SetWorkspaceCustomInstructionsMutation } from "@/api/generated/@tanstack/react-query.gen"; -import { V1GetWorkspaceCustomInstructionsData } from "@/api/generated"; - -export function usePostSystemPrompt( - options: V1GetWorkspaceCustomInstructionsData, -) { - return useMutation({ - ...v1SetWorkspaceCustomInstructionsMutation(options), - }); -} diff --git a/src/features/workspace-system-prompt/components/__tests__/system-prompt-editor.test.tsx b/src/features/workspace/components/__tests__/system-prompt-editor.test.tsx similarity index 99% rename from src/features/workspace-system-prompt/components/__tests__/system-prompt-editor.test.tsx rename to src/features/workspace/components/__tests__/system-prompt-editor.test.tsx index eaa5acf5..5197a570 100644 --- a/src/features/workspace-system-prompt/components/__tests__/system-prompt-editor.test.tsx +++ b/src/features/workspace/components/__tests__/system-prompt-editor.test.tsx @@ -1,9 +1,10 @@ import { render, waitFor } from "@/lib/test-utils"; import { expect, test } from "vitest"; -import { SystemPromptEditor } from "../system-prompt-editor"; + import userEvent from "@testing-library/user-event"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; +import { SystemPromptEditor } from "../system-prompt-editor"; vi.mock("../../lib/post-system-prompt"); diff --git a/src/features/workspace-system-prompt/components/system-prompt-editor.tsx b/src/features/workspace/components/system-prompt-editor.tsx similarity index 85% rename from src/features/workspace-system-prompt/components/system-prompt-editor.tsx rename to src/features/workspace/components/system-prompt-editor.tsx index b3556f37..660ae554 100644 --- a/src/features/workspace-system-prompt/components/system-prompt-editor.tsx +++ b/src/features/workspace/components/system-prompt-editor.tsx @@ -17,21 +17,22 @@ import { useMemo, useState, } from "react"; -import { usePostSystemPrompt } from "../hooks/use-set-system-prompt"; -import { Check } from "lucide-react"; + import { twMerge } from "tailwind-merge"; import { V1GetWorkspaceCustomInstructionsData, V1GetWorkspaceCustomInstructionsResponse, V1SetWorkspaceCustomInstructionsData, } from "@/api/generated"; -import { useGetSystemPrompt } from "../hooks/use-get-system-prompt"; + import { QueryCacheNotifyEvent, QueryClient, useQueryClient, } from "@tanstack/react-query"; import { v1GetWorkspaceCustomInstructionsQueryKey } from "@/api/generated/@tanstack/react-query.gen"; +import { useGetSystemPrompt } from "../hooks/use-query-get-workspace-custom-instructions"; +import { useMutationSetWorkspaceCustomInstructions } from "../hooks/use-mutation-set-workspace-custom-instructions"; type DarkModeContextValue = { preference: "dark" | "light" | null; @@ -54,17 +55,6 @@ function inferDarkMode( return "light"; } -function useSavedStatus() { - const [saved, setSaved] = useState(false); - - useEffect(() => { - const id = setTimeout(() => setSaved(false), 2000); - return () => clearTimeout(id); - }, [saved]); - - return { saved, setSaved }; -} - function EditorLoadingUI() { return ( // arbitrary value to match the monaco editor height @@ -158,7 +148,8 @@ export function SystemPromptEditor({ const { data: systemPromptResponse, isPending: isGetPromptPending } = useGetSystemPrompt(options); - const { mutate, isPending: isMutationPending } = usePostSystemPrompt(options); + const { mutateAsync, isPending: isMutationPending } = + useMutationSetWorkspaceCustomInstructions(options); const { setValue, value } = usePromptValue({ initialValue: systemPromptResponse?.prompt ?? "", @@ -166,11 +157,9 @@ export function SystemPromptEditor({ queryClient, }); - const { saved, setSaved } = useSavedStatus(); - const handleSubmit = useCallback( (value: string) => { - mutate( + mutateAsync( { ...options, body: { prompt: value } }, { onSuccess: () => { @@ -178,18 +167,17 @@ export function SystemPromptEditor({ queryKey: v1GetWorkspaceCustomInstructionsQueryKey(options), refetchType: "all", }); - setSaved(true); }, }, ); }, - [mutate, options, queryClient, setSaved], + [mutateAsync, options, queryClient], ); return ( - Custom prompt + Custom instructions Pass custom instructions to your LLM to augment it's behavior, and save time & tokens. @@ -216,17 +204,11 @@ export function SystemPromptEditor({ diff --git a/src/features/workspace-system-prompt/constants.ts b/src/features/workspace/constants/monaco-theme.ts similarity index 100% rename from src/features/workspace-system-prompt/constants.ts rename to src/features/workspace/constants/monaco-theme.ts diff --git a/src/features/workspace/hooks/use-mutation-set-workspace-custom-instructions.tsx b/src/features/workspace/hooks/use-mutation-set-workspace-custom-instructions.tsx new file mode 100644 index 00000000..e1531c12 --- /dev/null +++ b/src/features/workspace/hooks/use-mutation-set-workspace-custom-instructions.tsx @@ -0,0 +1,23 @@ +import { + v1GetWorkspaceCustomInstructionsQueryKey, + v1SetWorkspaceCustomInstructionsMutation, +} from "@/api/generated/@tanstack/react-query.gen"; +import { V1GetWorkspaceCustomInstructionsData } from "@/api/generated"; +import { useToastMutation } from "@/hooks/use-toast-mutation"; +import { useQueryClient } from "@tanstack/react-query"; + +export function useMutationSetWorkspaceCustomInstructions( + options: V1GetWorkspaceCustomInstructionsData, +) { + const queryClient = useQueryClient(); + + return useToastMutation({ + ...v1SetWorkspaceCustomInstructionsMutation(options), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: v1GetWorkspaceCustomInstructionsQueryKey(options), + refetchType: "all", + }), + successMsg: "Successfully updated custom instructions", + }); +} diff --git a/src/features/workspace-system-prompt/hooks/use-get-system-prompt.ts b/src/features/workspace/hooks/use-query-get-workspace-custom-instructions.ts similarity index 100% rename from src/features/workspace-system-prompt/hooks/use-get-system-prompt.ts rename to src/features/workspace/hooks/use-query-get-workspace-custom-instructions.ts diff --git a/src/routes/route-workspace.tsx b/src/routes/route-workspace.tsx index 816e1684..9ad69f99 100644 --- a/src/routes/route-workspace.tsx +++ b/src/routes/route-workspace.tsx @@ -1,12 +1,13 @@ import { BreadcrumbHome } from "@/components/BreadcrumbHome"; import { ArchiveWorkspace } from "@/features/workspace/components/archive-workspace"; -import { SystemPromptEditor } from "@/features/workspace-system-prompt/components/system-prompt-editor"; + import { WorkspaceHeading } from "@/features/workspace/components/workspace-heading"; import { WorkspaceName } from "@/features/workspace/components/workspace-name"; import { Alert, Breadcrumb, Breadcrumbs } from "@stacklok/ui-kit"; import { useParams } from "react-router-dom"; import { useArchivedWorkspaces } from "@/features/workspace/hooks/use-archived-workspaces"; import { useRestoreWorkspaceButton } from "@/features/workspace/hooks/use-restore-workspace-button"; +import { SystemPromptEditor } from "@/features/workspace/components/system-prompt-editor"; function WorkspaceArchivedBanner({ name }: { name: string }) { const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName: name }); From a5765f5cb52ba0f7311fce5d98c38481e6f187d0 Mon Sep 17 00:00:00 2001 From: alex-mcgovern Date: Fri, 24 Jan 2025 11:45:31 +0000 Subject: [PATCH 15/15] fix(custom instructions): failing test & rename to match API changes --- ....tsx => workspace-custom-instructions.tsx} | 22 ++++++++----- ....tsx => workspace-custom-instructions.tsx} | 33 +++++++++++-------- ...query-get-workspace-custom-instructions.ts | 2 +- src/routes/route-workspace.tsx | 4 +-- 4 files changed, 37 insertions(+), 24 deletions(-) rename src/features/workspace/components/__tests__/{system-prompt-editor.test.tsx => workspace-custom-instructions.tsx} (77%) rename src/features/workspace/components/{system-prompt-editor.tsx => workspace-custom-instructions.tsx} (84%) diff --git a/src/features/workspace/components/__tests__/system-prompt-editor.test.tsx b/src/features/workspace/components/__tests__/workspace-custom-instructions.tsx similarity index 77% rename from src/features/workspace/components/__tests__/system-prompt-editor.test.tsx rename to src/features/workspace/components/__tests__/workspace-custom-instructions.tsx index 5197a570..cf63e5fb 100644 --- a/src/features/workspace/components/__tests__/system-prompt-editor.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-custom-instructions.tsx @@ -4,9 +4,7 @@ import { expect, test } from "vitest"; import userEvent from "@testing-library/user-event"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; -import { SystemPromptEditor } from "../system-prompt-editor"; - -vi.mock("../../lib/post-system-prompt"); +import { WorkspaceCustomInstructions } from "../workspace-custom-instructions"; vi.mock("@monaco-editor/react", () => { const FakeEditor = vi.fn((props) => { @@ -22,16 +20,18 @@ vi.mock("@monaco-editor/react", () => { }); const renderComponent = () => - render(); + render( + , + ); -test("can update system prompt", async () => { +test("can update custom instructions", async () => { server.use( http.get("*/api/v1/workspaces/:name/custom-instructions", () => { return HttpResponse.json({ prompt: "initial prompt from server" }); }), ); - const { getByRole } = renderComponent(); + const { getByRole, getByText } = renderComponent(); await waitFor(() => { expect(getByRole("textbox")).toBeVisible(); @@ -44,14 +44,20 @@ test("can update system prompt", async () => { await userEvent.type(input, "new prompt from test"); expect(input).toHaveTextContent("new prompt from test"); - await userEvent.click(getByRole("button", { name: /Save/i })); - server.use( http.get("*/api/v1/workspaces/:name/custom-instructions", () => { return HttpResponse.json({ prompt: "new prompt from test" }); }), ); + await userEvent.click(getByRole("button", { name: /Save/i })); + + await waitFor(() => { + expect( + getByText(/successfully updated custom instructions/i), + ).toBeVisible(); + }); + await waitFor(() => { expect(input).toHaveTextContent("new prompt from test"); }); diff --git a/src/features/workspace/components/system-prompt-editor.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx similarity index 84% rename from src/features/workspace/components/system-prompt-editor.tsx rename to src/features/workspace/components/workspace-custom-instructions.tsx index 660ae554..2d878842 100644 --- a/src/features/workspace/components/system-prompt-editor.tsx +++ b/src/features/workspace/components/workspace-custom-instructions.tsx @@ -31,7 +31,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { v1GetWorkspaceCustomInstructionsQueryKey } from "@/api/generated/@tanstack/react-query.gen"; -import { useGetSystemPrompt } from "../hooks/use-query-get-workspace-custom-instructions"; +import { useQueryGetWorkspaceCustomInstructions } from "../hooks/use-query-get-workspace-custom-instructions"; import { useMutationSetWorkspaceCustomInstructions } from "../hooks/use-mutation-set-workspace-custom-instructions"; type DarkModeContextValue = { @@ -65,7 +65,7 @@ function EditorLoadingUI() { ); } -function isGetSystemPromptQuery( +function isGetWorkspaceCustomInstructionsQuery( queryKey: unknown, options: V1GetWorkspaceCustomInstructionsData, ): boolean { @@ -76,7 +76,9 @@ function isGetSystemPromptQuery( ); } -function getPromptFromNotifyEvent(event: QueryCacheNotifyEvent): string | null { +function getCustomInstructionsFromEvent( + event: QueryCacheNotifyEvent, +): string | null { if ("action" in event === false || "data" in event.action === false) return null; return ( @@ -89,7 +91,7 @@ function getPromptFromNotifyEvent(event: QueryCacheNotifyEvent): string | null { ); } -function usePromptValue({ +function useCustomInstructionsValue({ initialValue, options, queryClient, @@ -107,9 +109,12 @@ function usePromptValue({ if ( event.type === "updated" && event.action.type === "success" && - isGetSystemPromptQuery(event.query.options.queryKey, options) + isGetWorkspaceCustomInstructionsQuery( + event.query.options.queryKey, + options, + ) ) { - const prompt: string | null = getPromptFromNotifyEvent(event); + const prompt: string | null = getCustomInstructionsFromEvent(event); if (prompt === value || prompt === null) return; setValue(prompt); @@ -124,7 +129,7 @@ function usePromptValue({ return { value, setValue }; } -export function SystemPromptEditor({ +export function WorkspaceCustomInstructions({ className, workspaceName, isArchived, @@ -146,13 +151,15 @@ export function SystemPromptEditor({ const queryClient = useQueryClient(); - const { data: systemPromptResponse, isPending: isGetPromptPending } = - useGetSystemPrompt(options); + const { + data: customInstructionsResponse, + isPending: isCustomInstructionsPending, + } = useQueryGetWorkspaceCustomInstructions(options); const { mutateAsync, isPending: isMutationPending } = useMutationSetWorkspaceCustomInstructions(options); - const { setValue, value } = usePromptValue({ - initialValue: systemPromptResponse?.prompt ?? "", + const { setValue, value } = useCustomInstructionsValue({ + initialValue: customInstructionsResponse?.prompt ?? "", options, queryClient, }); @@ -183,7 +190,7 @@ export function SystemPromptEditor({ save time & tokens.
- {isGetPromptPending ? ( + {isCustomInstructionsPending ? ( ) : (