Skip to content

Commit

Permalink
feat: implement hard delete for workspaces & refactor workspaces tabl…
Browse files Browse the repository at this point in the history
…e to allow multiple actions (#185)

* feat: useKbdShortcuts hook & example implementation

* chore: tidy up remnant

* feat: useToastMutation hook

* chore: remove junk comment

* feat: implement `useToastMutation` for workspaces

* refactor: `useQueries` to fetch workspaces data

* feat: implement "hard delete" from workspaces table

* chore: tidy ups

* feat: add keyboard tooltip to create workspace

* fix(workspaces): add badge to active workspace

* test: test workspace actions & hard delete

* chore: tidy up test

* fix(workspace name): correct message on rename + test

* chore: move code around

* fix(custom instructions): failing test & rename to match API changes
alex-mcgovern authored Jan 24, 2025
1 parent 646ed5a commit a98492a
Showing 32 changed files with 1,157 additions and 273 deletions.
86 changes: 86 additions & 0 deletions src/context/confirm-context.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
};

export const ConfirmContext = createContext<ConfirmContextType | null>(null);

Check warning on line 37 in src/context/confirm-context.tsx

GitHub Actions / Static Checks / ESLint Check

Fast refresh only works when a file only exports components. Move your React context(s) to a separate file

export function ConfirmProvider({ children }: { children: ReactNode }) {
const [activeQuestion, setActiveQuestion] = useState<Question | null>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);

const handleAnswer = (answer: boolean) => {
if (activeQuestion === null) return;
activeQuestion.resolve(answer);
setIsOpen(false);
};

const confirm = (message: ReactNode, config: Config) => {
return new Promise<boolean>((resolve) => {
setActiveQuestion({ message, config, resolve });
setIsOpen(true);
});
};

return (
<ConfirmContext.Provider value={{ confirm }}>
{children}

<DialogModalOverlay isDismissable={false} isOpen={isOpen}>
<DialogModal>
<Dialog>
<DialogHeader>
<DialogTitle>{activeQuestion?.config.title}</DialogTitle>
</DialogHeader>
<DialogContent>{activeQuestion?.message}</DialogContent>
<DialogFooter>
<div className="flex grow justify-end gap-2">
<Button variant="secondary" onPress={() => handleAnswer(false)}>
{activeQuestion?.config.buttons.no ?? "&nbsp;"}
</Button>
<Button
isDestructive={activeQuestion?.config.isDestructive}
variant="primary"
onPress={() => handleAnswer(true)}
>
{activeQuestion?.config.buttons.yes ?? "&nbsp;"}
</Button>
</div>
</DialogFooter>
</Dialog>
</DialogModal>
</DialogModalOverlay>
</ConfirmContext.Provider>
);
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...original,
useNavigate: () => mockNavigate,
};
import { waitFor } from "@testing-library/react";

test("has correct buttons when not archived", async () => {
const { getByRole } = render(
<ArchiveWorkspace isArchived={false} workspaceName="foo-bar" />,
);

expect(getByRole("button", { name: /archive/i })).toBeVisible();
});

test("has correct buttons when archived", async () => {
const { getByRole } = render(
<ArchiveWorkspace isArchived={true} workspaceName="foo-bar" />,
);
expect(getByRole("button", { name: /restore/i })).toBeVisible();
expect(getByRole("button", { name: /permanently delete/i })).toBeVisible();
});

test("can archive workspace", async () => {
const { getByText, getByRole } = render(
<ArchiveWorkspace isArchived={false} workspaceName="foo-bar" />,
);

await userEvent.click(getByRole("button", { name: /archive/i }));

await waitFor(() => {
expect(getByText(/archived "foo-bar" workspace/i)).toBeVisible();
});
});

test("archive workspace", async () => {
render(<ArchiveWorkspace isArchived={false} workspaceName="foo" />);
test("can restore archived workspace", async () => {
const { getByText, getByRole } = render(
<ArchiveWorkspace isArchived={true} workspaceName="foo-bar" />,
);

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(
<ArchiveWorkspace isArchived={true} workspaceName="foo-bar" />,
);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...original,
useNavigate: () => mockNavigate,
};
});

it("has correct actions for default workspace when not active", async () => {
const { getByRole } = render(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "default" }}
activeWorkspaceName="foo-bar"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: true, isArchived: false, name: "default" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: true, isArchived: false, name: "foo-bar" }}
activeWorkspaceName="foo-bar"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: true, isArchived: true, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "default" }}
activeWorkspaceName="foo-bar"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "default" }}
activeWorkspaceName="foo-bar"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: false, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: true, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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(
<TableActionsWorkspaces
workspace={{ is_active: false, isArchived: true, name: "foo-bar" }}
activeWorkspaceName="default"
/>,
);

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();
});
});
Original file line number Diff line number Diff line change
@@ -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(<SystemPromptEditor isArchived={false} workspaceName="foo" />);
render(
<WorkspaceCustomInstructions isArchived={false} workspaceName="foo" />,
);

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");
});
Original file line number Diff line number Diff line change
@@ -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(
<WorkspaceName workspaceName="foo-bar" isArchived={false} />,
);

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(
<WorkspaceName workspaceName="foo" isArchived={true} />,
);

expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled();
expect(getByRole("button", { name: /save/i })).toBeDisabled();
});
43 changes: 39 additions & 4 deletions src/features/workspace/components/archive-workspace.tsx
Original file line number Diff line number Diff line change
@@ -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 <Button {...archiveButtonProps} />;
};

const ButtonsArchived = ({ workspaceName }: { workspaceName: string }) => {
const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName });
const hardDelete = useConfirmHardDeleteWorkspace();

const navigate = useNavigate();

return (
<div className="flex gap-1 items-center">
<Button {...restoreButtonProps} variant="secondary" />
<Button
variant="primary"
onPress={() =>
hardDelete(
{ path: { workspace_name: workspaceName } },
{ onSuccess: () => navigate(hrefs.workspaces.all) },
)
}
isDestructive
>
Permanently delete
</Button>
</div>
);
};

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 (
<Card className={twMerge(className, "shrink-0")}>
<CardBody className="flex justify-between items-center">
@@ -26,7 +57,11 @@ export function ArchiveWorkspace({
</Text>
</div>

<Button {...(isArchived ? restoreButtonProps : archiveButtonProps)} />
{isArchived ? (
<ButtonsArchived workspaceName={workspaceName} />
) : (
<ButtonsUnarchived workspaceName={workspaceName} />
)}
</CardBody>
</Card>
);
128 changes: 128 additions & 0 deletions src/features/workspace/components/table-actions-workspaces.tsx
Original file line number Diff line number Diff line change
@@ -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: <Check />,
id: "activate",
isDisabled: workspace.name === activeWorkspaceName,
onAction: () => activateWorkspace({ body: { name: workspace.name } }),
},
{
textValue: "Edit",
icon: <SlidersHorizontal />,
id: "edit",
href: hrefs.workspaces.edit(workspace.name),
},
{
textValue: "Archive",
icon: <X />,
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: <Undo2 />,
id: "restore",
onAction: () =>
restoreWorkspace({ path: { workspace_name: workspace.name } }),
},
{
textValue: "Permanently delete",
isDestructive: true,
icon: <X />,
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 (
<MenuTrigger>
<Button aria-label="Actions" isIcon variant="tertiary">
<Ellipsis />
</Button>
<Popover placement="bottom end">
<Menu
items={
workspace.isArchived
? getArchivedWorkspaceActions({
workspace,
restoreWorkspace,
hardDeleteWorkspace,
})
: getWorkspaceActions({
workspace,
archiveWorkspace,
activateWorkspace,
activeWorkspaceName,
})
}
/>
</Popover>
</MenuTrigger>
);
}
83 changes: 83 additions & 0 deletions src/features/workspace/components/table-workspaces.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Cell className="text-disabled">
<span>{name}</span>
&nbsp;&nbsp;
<Badge size="sm" className="text-tertiary">
Archived
</Badge>
</Cell>
);

if (isActive)
return (
<Cell>
<span>{name}</span>
&nbsp;&nbsp;
<Badge size="sm" variant="inverted">
Active
</Badge>
</Cell>
);

return <Cell>{name}</Cell>;
}

export function TableWorkspaces() {
const { data: workspaces } = useListAllWorkspaces();
const { data: activeWorkspaceName } = useActiveWorkspaceName();

return (
<Table aria-label="List of workspaces">
<Row>
<TableHeader>
<Column id="name" isRowHeader>
Name
</Column>
<Column id="configuration"></Column>
</TableHeader>
</Row>
<TableBody>
{workspaces.map((workspace) => (
<Row key={workspace.id} href={hrefs.workspaces.edit(workspace.name)}>
<CellName
name={workspace.name}
isActive={workspace.is_active}
isArchived={workspace.isArchived}
/>
<Cell alignment="end">
<TableActionsWorkspaces
activeWorkspaceName={activeWorkspaceName}
workspace={workspace}
/>
</Cell>
</Row>
))}
</TableBody>
</Table>
);
}
Original file line number Diff line number Diff line change
@@ -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<boolean>(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,46 +151,46 @@ 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: () => {
queryClient.invalidateQueries({
queryKey: v1GetWorkspaceCustomInstructionsQueryKey(options),
refetchType: "all",
});
setSaved(true);
},
},
);
},
[mutate, options, queryClient, setSaved],
[mutateAsync, options, queryClient],
);

return (
<Card className={twMerge(className, "shrink-0")}>
<CardBody>
<Text className="text-primary">Custom prompt</Text>
<Text className="text-primary">Custom instructions</Text>
<Text className="text-secondary mb-4">
Pass custom instructions to your LLM to augment it's behavior, and
save time & tokens.
</Text>
<div className="border border-gray-200 rounded overflow-hidden">
{isGetPromptPending ? (
{isCustomInstructionsPending ? (
<EditorLoadingUI />
) : (
<Editor
@@ -216,16 +211,11 @@ export function SystemPromptEditor({
<CardFooter className="justify-end gap-2">
<Button
isPending={isMutationPending}
isDisabled={Boolean(isArchived ?? isGetPromptPending ?? saved)}
isDisabled={Boolean(isArchived ?? isCustomInstructionsPending)}
onPress={() => handleSubmit(value)}
variant="secondary"
>
{saved ? (
<>
<span>Saved</span> <Check />
</>
) : (
"Save"
)}
Save
</Button>
</CardFooter>
</Card>
10 changes: 1 addition & 9 deletions src/features/workspace/hooks/use-archive-workspace-button.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
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,
}: {
workspaceName: string;
}): ComponentProps<typeof Button> {
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",
};
35 changes: 35 additions & 0 deletions src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof hardDeleteWorkspace>) => {
const answer = await confirm(
<>
<p>Are you sure you want to delete this workspace?</p>
<p>
You will lose any custom instructions, or other configuration.{" "}
<b>This action cannot be undone.</b>
</p>
</>,
{
buttons: {
yes: "Delete",
no: "Cancel",
},
title: "Permanently delete workspace",
isDestructive: true,
},
);
if (answer) {
return hardDeleteWorkspace(...params);
}
},
[confirm, hardDeleteWorkspace],
);
}
Original file line number Diff line number Diff line change
@@ -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",
});
Original file line number Diff line number Diff line change
@@ -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`,
});
}
78 changes: 76 additions & 2 deletions src/features/workspace/hooks/use-mutation-archive-workspace.ts
Original file line number Diff line number Diff line change
@@ -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`,
});
9 changes: 7 additions & 2 deletions src/features/workspace/hooks/use-mutation-create-workspace.ts
Original file line number Diff line number Diff line change
@@ -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`,
});
}
Original file line number Diff line number Diff line change
@@ -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`,
});
}
72 changes: 70 additions & 2 deletions src/features/workspace/hooks/use-mutation-restore-workspace.ts
Original file line number Diff line number Diff line change
@@ -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`,
});
Original file line number Diff line number Diff line change
@@ -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",
});
}
Original file line number Diff line number Diff line change
@@ -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;
};
67 changes: 67 additions & 0 deletions src/features/workspace/hooks/use-query-list-all-workspaces.ts
Original file line number Diff line number Diff line change
@@ -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<T> =
| DefinedUseQueryResult<T, Error>
| QueryObserverLoadingErrorResult<T, Error>
| QueryObserverLoadingResult<T, Error>
| QueryObserverPendingResult<T, Error>
| QueryObserverRefetchErrorResult<T, Error>;

type UseQueryDataReturn = [
QueryResult<V1ListWorkspacesResponse>,
QueryResult<V1ListArchivedWorkspacesResponse>,
];

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(),
},
],
});
};
29 changes: 29 additions & 0 deletions src/hooks/use-confirm.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;

export const useConfirm = () => {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error("useConfirmContext must be used within a ConfirmProvider");
}
return context;
};
2 changes: 2 additions & 0 deletions src/lib/hrefs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export const hrefs = {
workspaces: {
all: "/workspaces",
create: "/workspace/create",
edit: (name: string) => `/workspace/${name}`,
},
};
24 changes: 15 additions & 9 deletions src/lib/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -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 @@
Route,
Routes,
} from "react-router-dom";
import { UiKitClientSideRoutingProvider } from "./ui-kit-client-side-routing";

type RoutConfig = {
routeConfig?: MemoryRouterProps;
@@ -45,19 +47,23 @@
render(
<TestQueryClientProvider>
<DarkModeProvider>
<Toaster />
<MemoryRouter {...options?.routeConfig}>
<Routes>
<Route
path={options?.pathConfig ?? "*"}
element={<SidebarProvider>{children}</SidebarProvider>}
/>
</Routes>
</MemoryRouter>
<ConfirmProvider>
<Toaster />
<MemoryRouter {...options?.routeConfig}>
<UiKitClientSideRoutingProvider>
<Routes>
<Route
path={options?.pathConfig ?? "*"}
element={<SidebarProvider>{children}</SidebarProvider>}
/>
</Routes>
</UiKitClientSideRoutingProvider>
</MemoryRouter>
</ConfirmProvider>
</DarkModeProvider>
</TestQueryClientProvider>,
);

export * from "@testing-library/react";

Check warning on line 67 in src/lib/test-utils.tsx

GitHub Actions / Static Checks / ESLint Check

This rule can't verify that `export *` only exports components

export { renderWithProviders as render };

Check warning on line 69 in src/lib/test-utils.tsx

GitHub Actions / Static Checks / ESLint Check

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
7 changes: 5 additions & 2 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SidebarProvider>
<QueryClientProvider>
<ErrorBoundary fallback={<Error />}>
<Toaster />
<App />
<ConfirmProvider>
<Toaster />
<App />
</ConfirmProvider>
</ErrorBoundary>
</QueryClientProvider>
</SidebarProvider>
43 changes: 28 additions & 15 deletions src/mocks/msw/handlers.ts
Original file line number Diff line number Diff line change
@@ -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 }),
),
];
15 changes: 1 addition & 14 deletions src/routes/__tests__/route-workspaces.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 3 additions & 2 deletions src/routes/route-workspace.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>
<SystemPromptEditor
<WorkspaceCustomInstructions
isArchived={isArchived}
workspaceName={name}
className="mb-4"
126 changes: 15 additions & 111 deletions src/routes/route-workspaces.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,20 @@
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,
Kbd,
LinkButton,
Row,
Table,
TableBody,
TableHeader,
Tooltip,
TooltipTrigger,
} 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 { 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 (
<Cell className="text-disabled">
<span>{name}</span>
&nbsp;&nbsp;
<Badge size="sm" className="text-tertiary">
Archived
</Badge>
</Cell>
);

return <Cell>{name}</Cell>;
}

function CellConfiguration({
name,
isArchived = false,
}: {
name: string;
isArchived?: boolean;
}) {
const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName: name });

if (isArchived) {
return (
<Cell>
<Button
variant="tertiary"
className="flex w-full gap-2 items-center"
{...restoreButtonProps}
>
<SvgFlipBackward /> Restore Configuration
</Button>
</Cell>
);
}

return (
<Cell>
<LinkButton
href={`/workspace/${name}`}
className="w-full"
variant="tertiary"
>
<Settings />
Settings
</LinkButton>
</Cell>
);
}

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() {
</Breadcrumbs>

<WorkspaceHeading title="Manage Workspaces">
<LinkButton href={hrefs.workspaces.create} className="w-fit gap-2">
<SquarePlus /> Create Workspace
</LinkButton>
<TooltipTrigger delay={0}>
<LinkButton href={hrefs.workspaces.create} className="w-fit gap-2">
<SquarePlus /> Create
</LinkButton>
<Tooltip className="flex gap-2 items-center">
<span className="block">Create a new workspace</span>
<Kbd>C</Kbd>
</Tooltip>
</TooltipTrigger>
</WorkspaceHeading>

<Table aria-label="List of workspaces">
<Row>
<TableHeader>
<Column id="name" isRowHeader className="w-4/5">
Name
</Column>
<Column id="configuration" className="flex justify-center">
Configuration
</Column>
</TableHeader>
</Row>
<TableBody>
{workspaces.map((workspace) => (
<Row key={workspace.name}>
<CellName
name={workspace.name}
isArchived={workspace.isArchived}
/>
<CellConfiguration
name={workspace.name}
isArchived={workspace.isArchived}
/>
</Row>
))}
</TableBody>
</Table>
<TableWorkspaces />
</>
);
}
7 changes: 7 additions & 0 deletions vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -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));
},
});

0 comments on commit a98492a

Please sign in to comment.