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/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-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/components/__tests__/archive-workspace.test.tsx b/src/features/workspace/components/__tests__/archive-workspace.test.tsx index 43b53b07..a66c1417 100644 --- a/src/features/workspace/components/__tests__/archive-workspace.test.tsx +++ b/src/features/workspace/components/__tests__/archive-workspace.test.tsx @@ -1,29 +1,62 @@ import { render } from "@/lib/test-utils"; 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, - }; +import { waitFor } from "@testing-library/react"; + +test("has correct buttons when not archived", async () => { + const { getByRole } = render( + , + ); + + expect(getByRole("button", { name: /archive/i })).toBeVisible(); +}); + +test("has correct buttons when archived", async () => { + const { getByRole } = render( + , + ); + expect(getByRole("button", { name: /restore/i })).toBeVisible(); + expect(getByRole("button", { name: /permanently delete/i })).toBeVisible(); +}); + +test("can archive workspace", async () => { + const { getByText, getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /archive/i })); + + await waitFor(() => { + expect(getByText(/archived "foo-bar" workspace/i)).toBeVisible(); + }); }); -test("archive workspace", async () => { - render(); +test("can restore archived workspace", async () => { + const { getByText, getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /restore/i })); + + await waitFor(() => { + expect(getByText(/restored "foo-bar" workspace/i)).toBeVisible(); + }); +}); + +test("can permanently delete archived workspace", async () => { + const { getByText, getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /permanently delete/i })); + + await waitFor(() => { + expect(getByRole("dialog", { name: /permanently delete/i })).toBeVisible(); + }); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1)); - expect(mockNavigate).toHaveBeenCalledWith("/workspaces"); + await userEvent.click(getByRole("button", { name: /delete/i })); await waitFor(() => { - expect(screen.getByText(/archived "(.*)" workspace/i)).toBeVisible(); + expect(getByText(/permanently deleted "foo-bar" workspace/i)).toBeVisible(); }); }); diff --git a/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx b/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx new file mode 100644 index 00000000..c5488fd9 --- /dev/null +++ b/src/features/workspace/components/__tests__/table-actions-workspaces.test.tsx @@ -0,0 +1,301 @@ +import { hrefs } from "@/lib/hrefs"; + +import { waitFor } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { TableActionsWorkspaces } from "../table-actions-workspaces"; +import { render } from "@/lib/test-utils"; + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async () => { + const original = + await vi.importActual( + "react-router-dom", + ); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +it("has correct actions for default workspace when not active", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const activate = getByRole("menuitem", { name: /activate/i }); + expect(activate).not.toHaveAttribute("aria-disabled", "true"); + + const edit = getByRole("menuitem", { name: /edit/i }); + expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("default")); + + const archive = getByRole("menuitem", { name: /archive/i }); + expect(archive).toHaveAttribute("aria-disabled", "true"); +}); + +it("has correct actions for default workspace when active", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const activate = getByRole("menuitem", { name: /activate/i }); + expect(activate).toHaveAttribute("aria-disabled", "true"); + + const edit = getByRole("menuitem", { name: /edit/i }); + expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("default")); + + const archive = getByRole("menuitem", { name: /archive/i }); + expect(archive).toHaveAttribute("aria-disabled", "true"); +}); + +it("has correct actions for normal workspace when not active", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const activate = getByRole("menuitem", { name: /activate/i }); + expect(activate).not.toHaveAttribute("aria-disabled", "true"); + + const edit = getByRole("menuitem", { name: /edit/i }); + expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("foo-bar")); + + const archive = getByRole("menuitem", { name: /archive/i }); + expect(archive).not.toHaveAttribute("aria-disabled", "true"); +}); + +it("has correct actions for normal workspace when active", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const activate = getByRole("menuitem", { name: /activate/i }); + expect(activate).toHaveAttribute("aria-disabled", "true"); + + const edit = getByRole("menuitem", { name: /edit/i }); + expect(edit).toHaveAttribute("href", hrefs.workspaces.edit("foo-bar")); + + const archive = getByRole("menuitem", { name: /archive/i }); + expect(archive).toHaveAttribute("aria-disabled", "true"); +}); + +it("has correct actions for archived workspace", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const restore = getByRole("menuitem", { name: /restore/i }); + expect(restore).not.toHaveAttribute("aria-disabled", "true"); + + const hardDelete = getByRole("menuitem", { + name: /permanently delete/i, + }); + expect(hardDelete).not.toHaveAttribute("aria-disabled", "true"); +}); + +it("can activate default workspace", async () => { + const { getByRole, getByText } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const activate = getByRole("menuitem", { name: /activate/i }); + await userEvent.click(activate); + + await waitFor(() => { + expect(getByText(/activated "default" workspace/i)).toBeVisible(); + }); +}); + +it("can edit default workspace", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const edit = getByRole("menuitem", { name: /edit/i }); + await userEvent.click(edit); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + hrefs.workspaces.edit("default"), + undefined, + ); + }); +}); + +it("can activate normal workspace", async () => { + const { getByRole, getByText } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const activate = getByRole("menuitem", { name: /activate/i }); + await userEvent.click(activate); + + await waitFor(() => { + expect(getByText(/activated "foo-bar" workspace/i)).toBeVisible(); + }); +}); + +it("can edit normal workspace", async () => { + const { getByRole } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + const edit = getByRole("menuitem", { name: /edit/i }); + await userEvent.click(edit); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + hrefs.workspaces.edit("foo-bar"), + undefined, + ); + }); +}); + +it("can archive normal workspace", async () => { + const { getByRole, getByText } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + await userEvent.click(getByRole("menuitem", { name: /archive/i })); + + await waitFor(() => { + expect(getByText(/archived "foo-bar" workspace/i)).toBeVisible(); + }); +}); + +it("can restore archived workspace", async () => { + const { getByRole, getByText } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + await userEvent.click(getByRole("menuitem", { name: /restore/i })); + + await waitFor(() => { + expect(getByText(/restored "foo-bar" workspace/i)).toBeVisible(); + }); +}); + +it("can permanently delete archived workspace", async () => { + const { getByRole, getByText } = render( + , + ); + + await userEvent.click(getByRole("button", { name: /actions/i })); + + await waitFor(() => { + expect(getByRole("menu")).toBeVisible(); + }); + + await userEvent.click(getByRole("menuitem", { name: /permanently/i })); + + await waitFor(() => { + expect(getByRole("dialog", { name: /permanently delete/i })).toBeVisible(); + }); + + await userEvent.click(getByRole("button", { name: /delete/i })); + + await waitFor(() => { + expect(getByText(/permanently deleted "foo-bar" workspace/i)).toBeVisible(); + }); +}); diff --git a/src/features/workspace-system-prompt/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-system-prompt/components/__tests__/system-prompt-editor.test.tsx rename to src/features/workspace/components/__tests__/workspace-custom-instructions.tsx index eaa5acf5..cf63e5fb 100644 --- a/src/features/workspace-system-prompt/components/__tests__/system-prompt-editor.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-custom-instructions.tsx @@ -1,11 +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"; - -vi.mock("../../lib/post-system-prompt"); +import { WorkspaceCustomInstructions } from "../workspace-custom-instructions"; vi.mock("@monaco-editor/react", () => { const FakeEditor = vi.fn((props) => { @@ -21,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(); @@ -43,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/__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/components/archive-workspace.tsx b/src/features/workspace/components/archive-workspace.tsx index 61ded26d..a599f847 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-workspaces.tsx b/src/features/workspace/components/table-workspaces.tsx new file mode 100644 index 00000000..46187e8c --- /dev/null +++ b/src/features/workspace/components/table-workspaces.tsx @@ -0,0 +1,83 @@ +import { + Badge, + Cell, + Column, + Row, + Table, + TableBody, + TableHeader, +} from "@stacklok/ui-kit"; + +import { useListAllWorkspaces } from "../hooks/use-query-list-all-workspaces"; +import { useActiveWorkspaceName } from "../hooks/use-active-workspace-name"; +import { TableActionsWorkspaces } from "./table-actions-workspaces"; +import { hrefs } from "@/lib/hrefs"; + +function CellName({ + name, + isArchived = false, + isActive = false, +}: { + name: string; + isArchived: boolean; + isActive: boolean; +}) { + if (isArchived) + return ( + + {name} +    + + Archived + + + ); + + if (isActive) + return ( + + {name} +    + + Active + + + ); + + return {name}; +} + +export function TableWorkspaces() { + const { data: workspaces } = useListAllWorkspaces(); + const { data: activeWorkspaceName } = useActiveWorkspaceName(); + + return ( + + + + + Name + + + + + + {workspaces.map((workspace) => ( + + + + + + + ))} + +
+ ); +} diff --git a/src/features/workspace-system-prompt/components/system-prompt-editor.tsx b/src/features/workspace/components/workspace-custom-instructions.tsx similarity index 76% rename from src/features/workspace-system-prompt/components/system-prompt-editor.tsx rename to src/features/workspace/components/workspace-custom-instructions.tsx index 4071bfb2..2d878842 100644 --- a/src/features/workspace-system-prompt/components/system-prompt-editor.tsx +++ b/src/features/workspace/components/workspace-custom-instructions.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 { useQueryGetWorkspaceCustomInstructions } 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 @@ -75,7 +65,7 @@ function EditorLoadingUI() { ); } -function isGetSystemPromptQuery( +function isGetWorkspaceCustomInstructionsQuery( queryKey: unknown, options: V1GetWorkspaceCustomInstructionsData, ): boolean { @@ -86,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 ( @@ -99,7 +91,7 @@ function getPromptFromNotifyEvent(event: QueryCacheNotifyEvent): string | null { ); } -function usePromptValue({ +function useCustomInstructionsValue({ initialValue, options, queryClient, @@ -117,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); @@ -134,7 +129,7 @@ function usePromptValue({ return { value, setValue }; } -export function SystemPromptEditor({ +export function WorkspaceCustomInstructions({ className, workspaceName, isArchived, @@ -156,21 +151,22 @@ export function SystemPromptEditor({ const queryClient = useQueryClient(); - const { data: systemPromptResponse, isPending: isGetPromptPending } = - useGetSystemPrompt(options); - const { mutate, isPending: isMutationPending } = usePostSystemPrompt(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, }); - const { saved, setSaved } = useSavedStatus(); - const handleSubmit = useCallback( (value: string) => { - mutate( + mutateAsync( { ...options, body: { prompt: value } }, { onSuccess: () => { @@ -178,24 +174,23 @@ 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.
- {isGetPromptPending ? ( + {isCustomInstructionsPending ? ( ) : ( 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-archive-workspace-button.tsx b/src/features/workspace/hooks/use-archive-workspace-button.tsx index d2746510..cc8e0ebe 100644 --- a/src/features/workspace/hooks/use-archive-workspace-button.tsx +++ b/src/features/workspace/hooks/use-archive-workspace-button.tsx @@ -1,7 +1,6 @@ import { Button } from "@stacklok/ui-kit"; import { ComponentProps } from "react"; import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace"; -import { useNavigate } from "react-router-dom"; export function useArchiveWorkspaceButton({ workspaceName, @@ -9,18 +8,11 @@ export function useArchiveWorkspaceButton({ workspaceName: string; }): ComponentProps { const { mutateAsync, isPending } = useMutationArchiveWorkspace(); - const navigate = useNavigate(); return { isPending, isDisabled: isPending, - onPress: () => - mutateAsync( - { path: { workspace_name: workspaceName } }, - { - onSuccess: () => navigate("/workspaces"), - }, - ), + onPress: () => mutateAsync({ path: { workspace_name: workspaceName } }), isDestructive: true, children: "Archive", }; 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..120eec16 --- /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: "Delete", + no: "Cancel", + }, + title: "Permanently delete workspace", + isDestructive: true, + }, + ); + if (answer) { + return 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..7dc0267e 100644 --- a/src/features/workspace/hooks/use-mutation-archive-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-archive-workspace.ts @@ -1,13 +1,87 @@ -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(), + }); + + // 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(); + }, + // 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-create-workspace.ts b/src/features/workspace/hooks/use-mutation-create-workspace.ts index 955e2f71..92e35c43 100644 --- a/src/features/workspace/hooks/use-mutation-create-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-create-workspace.ts @@ -7,7 +7,12 @@ export function useMutationCreateWorkspace() { return useToastMutation({ ...v1CreateWorkspaceMutation(), - onSuccess: () => invalidate(), - successMsg: (variables) => `Created "${variables.body.name}" workspace`, + onSuccess: async () => { + await invalidate(); + }, + successMsg: (variables) => + variables.body.rename_to + ? `Renamed workspace to "${variables.body.rename_to}"` + : `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 index 9456788b..7ec91fea 100644 --- a/src/features/workspace/hooks/use-mutation-hard-delete-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-hard-delete-workspace.ts @@ -9,6 +9,6 @@ export function useMutationHardDeleteWorkspace() { ...v1HardDeleteWorkspaceMutation(), onSuccess: () => invalidate(), successMsg: (variables) => - `Permanently deleted "${variables.path.name}" workspace`, + `Permanently deleted "${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 913b7c01..f984a238 100644 --- a/src/features/workspace/hooks/use-mutation-restore-workspace.ts +++ b/src/features/workspace/hooks/use-mutation-restore-workspace.ts @@ -1,13 +1,81 @@ -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(), + }); + + // 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) => ({ + 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 { + prevWorkspaces, + prevArchivedWorkspaces, + }; + }, + onSettled: 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/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 81% 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 index ec85e20a..a39f01b2 100644 --- a/src/features/workspace-system-prompt/hooks/use-get-system-prompt.ts +++ b/src/features/workspace/hooks/use-query-get-workspace-custom-instructions.ts @@ -1,7 +1,7 @@ import { v1GetWorkspaceCustomInstructionsOptions } from "@/api/generated/@tanstack/react-query.gen"; import { useQuery } from "@tanstack/react-query"; -export function useGetSystemPrompt(options: { +export function useQueryGetWorkspaceCustomInstructions(options: { path: { workspace_name: string; }; 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(), + }, + ], + }); +}; 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/hrefs.ts b/src/lib/hrefs.ts index 5c124f3f..cb3b9407 100644 --- a/src/lib/hrefs.ts +++ b/src/lib/hrefs.ts @@ -1,5 +1,7 @@ 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 d58c2430..8dc22683 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"; @@ -9,6 +10,7 @@ import { Route, Routes, } from "react-router-dom"; +import { UiKitClientSideRoutingProvider } from "./ui-kit-client-side-routing"; type RoutConfig = { routeConfig?: MemoryRouterProps; @@ -45,15 +47,19 @@ 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/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index fa5f231b..c0c6f231 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -14,13 +14,15 @@ export const handlers = [ ), http.get("*/api/v1/version", () => 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); @@ -44,16 +46,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/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-workspace.tsx b/src/routes/route-workspace.tsx index 816e1684..968a3609 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 { WorkspaceCustomInstructions } from "@/features/workspace/components/workspace-custom-instructions"; function WorkspaceArchivedBanner({ name }: { name: string }) { const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName: name }); @@ -51,7 +52,7 @@ export function RouteWorkspace() { className="mb-4" workspaceName={name} /> - - {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)]]); @@ -104,37 +27,18 @@ export function RouteWorkspaces() { - - Create Workspace - + + + Create + + + Create a new workspace + C + + - - - - - Name - - - Configuration - - - - - {workspaces.map((workspace) => ( - - - - - ))} - -
+ ); } 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)); + }, });