diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0280a95a..61fef014 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,35 @@
# Changelog
+## [0.9.0](https://github.com/stacklok/codegate-ui/compare/v0.8.0...v0.9.0) (2025-01-24)
+
+
+### Features
+
+* add not_found route ([#194](https://github.com/stacklok/codegate-ui/issues/194)) ([d865730](https://github.com/stacklok/codegate-ui/commit/d865730201eaff07e0e1852750803d0a262e5df6))
+
+
+### Bug Fixes
+
+* workspace hard delete nits ([#202](https://github.com/stacklok/codegate-ui/issues/202)) ([9129a5b](https://github.com/stacklok/codegate-ui/commit/9129a5b983a1b54f7d3b5cffc19cce85a955045a))
+
+## [0.8.0](https://github.com/stacklok/codegate-ui/compare/v0.7.2...v0.8.0) (2025-01-24)
+
+
+### Features
+
+* implement hard delete for workspaces & refactor workspaces table to allow multiple actions ([#185](https://github.com/stacklok/codegate-ui/issues/185)) ([a98492a](https://github.com/stacklok/codegate-ui/commit/a98492a8c24067ccd037e6fa753caf9994558967))
+* implement use toast mutation for workspaces ([#184](https://github.com/stacklok/codegate-ui/issues/184)) ([a67b265](https://github.com/stacklok/codegate-ui/commit/a67b26518cd5ef1c3f6106b604d45456ffc03cf4))
+* redirect to conversation from alerts table ([#191](https://github.com/stacklok/codegate-ui/issues/191)) ([646ed5a](https://github.com/stacklok/codegate-ui/commit/646ed5a6b14661b3956dd2685d9065c4c0d110aa))
+* useKbdShortcuts hook & example implementation ([#180](https://github.com/stacklok/codegate-ui/issues/180)) ([0d935a3](https://github.com/stacklok/codegate-ui/commit/0d935a3b263d5c18c86d5ba669554cf3f2e1f3a7))
+* useToastMutation hook ([#183](https://github.com/stacklok/codegate-ui/issues/183)) ([9fe55a5](https://github.com/stacklok/codegate-ui/commit/9fe55a524fa776348fcb6719d625f57aac36d60a))
+
+
+### Bug Fixes
+
+* fix small visual glithc in help menu ([#175](https://github.com/stacklok/codegate-ui/issues/175)) ([7031047](https://github.com/stacklok/codegate-ui/commit/70310476e59085c18755dc3eed6d9a2f09523f0f))
+* parsing promptList text and breadcrumb ([#177](https://github.com/stacklok/codegate-ui/issues/177)) ([6da034d](https://github.com/stacklok/codegate-ui/commit/6da034d9ddb028202c14851319e380f61b139473))
+* sort filtered alerts before pagination ([#190](https://github.com/stacklok/codegate-ui/issues/190)) ([d844610](https://github.com/stacklok/codegate-ui/commit/d84461041076f9647ceae93220deb071d1897a17))
+
## [0.7.2](https://github.com/stacklok/codegate-ui/compare/v0.7.1...v0.7.2) (2025-01-22)
diff --git a/eslint.config.js b/eslint.config.js
index 6a4c2802..25f88697 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -3,7 +3,7 @@ import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
-import tailwindPlugin from 'eslint-plugin-tailwindcss'
+import tailwindPlugin from "eslint-plugin-tailwindcss";
export default tseslint.config(
{ ignores: ["dist"] },
@@ -56,6 +56,18 @@ export default tseslint.config(
],
},
],
+ "no-restricted-imports": [
+ "error",
+ {
+ paths: [
+ {
+ importNames: ["useMutation"],
+ message: "Use the custom `useToastMutation` instead",
+ name: "@tanstack/react-query",
+ },
+ ],
+ },
+ ],
},
- }
+ },
);
diff --git a/icons/FlipBackward.svg b/icons/FlipBackward.svg
deleted file mode 100644
index c3d42473..00000000
--- a/icons/FlipBackward.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/package-lock.json b/package-lock.json
index 4f8c739c..ac94dcef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,14 @@
{
"name": "vite-project",
- "version": "0.7.2",
+ "version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vite-project",
- "version": "0.7.2",
+ "version": "0.9.0",
"dependencies": {
- "@hey-api/client-fetch": "^0.6.0",
+ "@hey-api/client-fetch": "^0.7.1",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-separator": "^1.1.0",
@@ -1046,9 +1046,9 @@
}
},
"node_modules/@hey-api/client-fetch": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.6.0.tgz",
- "integrity": "sha512-FlhFsVeH8RxJe/nq8xUzxNbiOpe+GadxlD2pfvDyOyLdCTU4o/LRv46ZVWstaW7DgF4nxhI328chy3+AulwVXw==",
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.7.1.tgz",
+ "integrity": "sha512-cN9SCpED64PjbP4uUuC+TSigYtk4kJ8bNzf9v5Kzmcl9smMUrwzC1kaUug20wThQXhBiKL4ymDHKa8iRwE1bRg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/hey-api"
diff --git a/package.json b/package.json
index dbedc5b8..5e00c7d7 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "vite-project",
"private": true,
- "version": "0.7.2",
+ "version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,7 +19,7 @@
"generate-icons": "npx @svgr/cli --typescript --no-dimensions --replace-attr-values '#2E323A=currentColor' --jsx-runtime automatic --out-dir ./src/components/icons/ -- icons"
},
"dependencies": {
- "@hey-api/client-fetch": "^0.6.0",
+ "@hey-api/client-fetch": "^0.7.1",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-separator": "^1.1.0",
diff --git a/src/Page.test.tsx b/src/Page.test.tsx
new file mode 100644
index 00000000..b01d95cb
--- /dev/null
+++ b/src/Page.test.tsx
@@ -0,0 +1,13 @@
+import { render } from "./lib/test-utils";
+import Page from "./Page";
+
+test("render NotFound route", () => {
+ const { getByText, getByRole } = render(, {
+ routeConfig: {
+ initialEntries: ["/fake-route"],
+ },
+ });
+
+ expect(getByText(/Oops! There's nothing here/i)).toBeVisible();
+ expect(getByRole("button", { name: "Home" })).toBeVisible();
+});
diff --git a/src/Page.tsx b/src/Page.tsx
index 07799fb0..6daf8a94 100644
--- a/src/Page.tsx
+++ b/src/Page.tsx
@@ -8,6 +8,7 @@ import { RouteChat } from "./routes/route-chat";
import { RouteDashboard } from "./routes/route-dashboard";
import { RouteCertificateSecurity } from "./routes/route-certificate-security";
import { RouteWorkspaceCreation } from "./routes/route-workspace-creation";
+import { RouteNotFound } from "./routes/route-not-found";
export default function Page() {
return (
@@ -23,6 +24,7 @@ export default function Page() {
path="/certificates/security"
element={}
/>
+ } />
);
}
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx
new file mode 100644
index 00000000..0c8e60bc
--- /dev/null
+++ b/src/components/EmptyState.tsx
@@ -0,0 +1,34 @@
+interface EmptyStateProps {
+ children?: React.ReactNode;
+ title?: string | React.ReactNode;
+ description?: string | React.ReactNode;
+ illustration?: React.ReactNode;
+}
+
+export function EmptyState({
+ illustration,
+ title,
+ children,
+ description,
+}: EmptyStateProps) {
+ return (
+ <>
+
+ {illustration != null && (
+
+ {illustration}
+
+ )}
+
+ {title}
+
+
+ {description}
+
+ {children != null && (
+
{children}
+ )}
+
+ >
+ );
+}
diff --git a/src/components/icons/FlipBackward.tsx b/src/components/icons/FlipBackward.tsx
deleted file mode 100644
index b96b865e..00000000
--- a/src/components/icons/FlipBackward.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { SVGProps } from "react";
-const SvgFlipBackward = (props: SVGProps) => (
-
-);
-export default SvgFlipBackward;
diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts
index 2f138811..b1107dfe 100644
--- a/src/components/icons/index.ts
+++ b/src/components/icons/index.ts
@@ -1,6 +1,5 @@
export { default as Continue } from "./Continue";
export { default as Copilot } from "./Copilot";
export { default as Discord } from "./Discord";
-export { default as FlipBackward } from "./FlipBackward";
export { default as Github } from "./Github";
export { default as Youtube } from "./Youtube";
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}
+
+
+
+
+
+
+
+ );
+}
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..f27d6183 100644
--- a/src/features/workspace/components/__tests__/archive-workspace.test.tsx
+++ b/src/features/workspace/components/__tests__/archive-workspace.test.tsx
@@ -1,29 +1,100 @@
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";
+import { server } from "@/mocks/msw/node";
+import { http, HttpResponse } from "msw";
+
+test("has correct buttons when not archived", async () => {
+ const { getByRole, queryByRole } = render(
+ ,
+ );
+
+ expect(getByRole("button", { name: /archive/i })).toBeVisible();
+ expect(queryByRole("button", { name: /contextual help/i })).toBe(null);
+});
+
+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("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("archive workspace", async () => {
- render();
+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(getByRole("button", { name: /delete/i }));
+
+ await waitFor(() => {
+ expect(getByText(/permanently deleted "foo-bar" workspace/i)).toBeVisible();
+ });
+});
+
+test("can't archive active workspace", async () => {
+ server.use(
+ http.get("*/api/v1/workspaces/active", () =>
+ HttpResponse.json({
+ workspaces: [
+ {
+ name: "foo",
+ is_active: true,
+ last_updated: new Date(Date.now()).toISOString(),
+ },
+ ],
+ }),
+ ),
+ );
+ const { getByRole } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(getByRole("button", { name: /archive/i })).toBeDisabled();
+ expect(getByRole("button", { name: /contextual help/i })).toBeVisible();
+ });
+});
- await userEvent.click(screen.getByRole("button", { name: /archive/i }));
- await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1));
- expect(mockNavigate).toHaveBeenCalledWith("/workspaces");
+test("can't archive default workspace", async () => {
+ const { getByRole } = render(
+ ,
+ );
await waitFor(() => {
- expect(screen.getByText(/archived "(.*)" workspace/i)).toBeVisible();
+ expect(getByRole("button", { name: /archive/i })).toBeDisabled();
+ expect(getByRole("button", { name: /contextual help/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..d92b25b4
--- /dev/null
+++ b/src/features/workspace/components/__tests__/workspace-name.test.tsx
@@ -0,0 +1,64 @@
+import { test, expect } from "vitest";
+import { WorkspaceName } from "../workspace-name";
+import { render, waitFor } from "@/lib/test-utils";
+import userEvent from "@testing-library/user-event";
+import { server } from "@/mocks/msw/node";
+import { http, HttpResponse } from "msw";
+
+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();
+});
+
+test("can't rename active workspace", async () => {
+ server.use(
+ http.get("*/api/v1/workspaces/active", () =>
+ HttpResponse.json({
+ workspaces: [
+ {
+ name: "foo",
+ is_active: true,
+ last_updated: new Date(Date.now()).toISOString(),
+ },
+ ],
+ }),
+ ),
+ );
+ const { getByRole } = render(
+ ,
+ );
+
+ expect(getByRole("textbox", { name: /workspace name/i })).toBeDisabled();
+ expect(getByRole("button", { name: /save/i })).toBeDisabled();
+});
+
+test("can't rename default 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..aedb2fbf 100644
--- a/src/features/workspace/components/archive-workspace.tsx
+++ b/src/features/workspace/components/archive-workspace.tsx
@@ -1,7 +1,88 @@
-import { Card, CardBody, Button, Text } from "@stacklok/ui-kit";
+import {
+ Card,
+ CardBody,
+ Button,
+ Text,
+ TooltipTrigger,
+ Tooltip,
+ TooltipInfoButton,
+} 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";
+import { useActiveWorkspaceName } from "../hooks/use-active-workspace-name";
+
+function getContextualText({
+ activeWorkspaceName,
+ workspaceName,
+}: {
+ workspaceName: string;
+ activeWorkspaceName: string;
+}) {
+ if (workspaceName === activeWorkspaceName) {
+ return "Cannot archive the active workspace";
+ }
+ if (workspaceName === "default") {
+ return "Cannot archive the default workspace";
+ }
+ return null;
+}
+
+// NOTE: You can't show a tooltip on a disabled button
+// React Aria's recommended approach is https://spectrum.adobe.com/page/contextual-help/
+function ContextualHelp({ workspaceName }: { workspaceName: string }) {
+ const { data: activeWorkspaceName } = useActiveWorkspaceName();
+ if (!activeWorkspaceName) return null;
+
+ const text = getContextualText({ activeWorkspaceName, workspaceName });
+ if (!text) return null;
+
+ return (
+
+
+ {text}
+
+ );
+}
+
+const ButtonsUnarchived = ({ workspaceName }: { workspaceName: string }) => {
+ const archiveButtonProps = useArchiveWorkspaceButton({ workspaceName });
+
+ return (
+
+
+
+
+ );
+};
+
+const ButtonsArchived = ({ workspaceName }: { workspaceName: string }) => {
+ const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName });
+ const hardDelete = useConfirmHardDeleteWorkspace();
+
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+ );
+};
export function ArchiveWorkspace({
className,
@@ -12,21 +93,22 @@ export function ArchiveWorkspace({
className?: string;
isArchived: boolean | undefined;
}) {
- const restoreButtonProps = useRestoreWorkspaceButton({ workspaceName });
- const archiveButtonProps = useArchiveWorkspaceButton({ workspaceName });
-
return (
Archive Workspace
-
+
Archiving this workspace removes it from the main workspaces list,
though it can be restored if needed.
-
+ {isArchived ? (
+
+ ) : (
+
+ )}
);
diff --git a/src/features/workspace/components/table-actions-workspaces.tsx b/src/features/workspace/components/table-actions-workspaces.tsx
new file mode 100644
index 00000000..2b69235c
--- /dev/null
+++ b/src/features/workspace/components/table-actions-workspaces.tsx
@@ -0,0 +1,128 @@
+import { Workspace } from "@/api/generated";
+import {
+ Button,
+ Menu,
+ MenuTrigger,
+ OptionsSchema,
+ Popover,
+} from "@stacklok/ui-kit";
+
+import { Undo2, X, SlidersHorizontal, Check, Ellipsis } from "lucide-react";
+import { useMutationArchiveWorkspace } from "@/features/workspace/hooks/use-mutation-archive-workspace";
+import { useMutationRestoreWorkspace } from "../hooks/use-mutation-restore-workspace";
+import { useMutationHardDeleteWorkspace } from "../hooks/use-mutation-hard-delete-workspace";
+import { useMutationActivateWorkspace } from "../hooks/use-mutation-activate-workspace";
+import { useConfirmHardDeleteWorkspace } from "../hooks/use-confirm-hard-delete-workspace";
+import { hrefs } from "@/lib/hrefs";
+
+const getWorkspaceActions = ({
+ archiveWorkspace,
+ workspace,
+ activateWorkspace,
+ activeWorkspaceName,
+}: {
+ workspace: Workspace & {
+ isArchived?: boolean;
+ };
+ archiveWorkspace: ReturnType<
+ typeof useMutationArchiveWorkspace
+ >["mutateAsync"];
+ activateWorkspace: ReturnType<
+ typeof useMutationActivateWorkspace
+ >["mutateAsync"];
+ activeWorkspaceName: string | null | undefined;
+}): OptionsSchema<"menu">[] => [
+ {
+ textValue: "Activate",
+ icon: ,
+ id: "activate",
+ isDisabled: workspace.name === activeWorkspaceName,
+ onAction: () => activateWorkspace({ body: { name: workspace.name } }),
+ },
+ {
+ textValue: "Edit",
+ icon: ,
+ id: "edit",
+ href: hrefs.workspaces.edit(workspace.name),
+ },
+ {
+ textValue: "Archive",
+ icon: ,
+ id: "archive",
+ isDisabled:
+ workspace.name === activeWorkspaceName || workspace.name === "default",
+ onAction: () =>
+ void archiveWorkspace({ path: { workspace_name: workspace.name } }),
+ },
+];
+
+const getArchivedWorkspaceActions = ({
+ workspace,
+ restoreWorkspace,
+ hardDeleteWorkspace,
+}: {
+ workspace: Workspace & {
+ isArchived?: boolean;
+ };
+ restoreWorkspace: ReturnType<
+ typeof useMutationArchiveWorkspace
+ >["mutateAsync"];
+ hardDeleteWorkspace: ReturnType<
+ typeof useMutationHardDeleteWorkspace
+ >["mutateAsync"];
+}): OptionsSchema<"menu">[] => [
+ {
+ textValue: "Restore",
+ icon: ,
+ id: "restore",
+ onAction: () =>
+ restoreWorkspace({ path: { workspace_name: workspace.name } }),
+ },
+ {
+ textValue: "Permanently delete",
+ isDestructive: true,
+ icon: ,
+ id: "permanently-delete",
+ onAction: () =>
+ hardDeleteWorkspace({ path: { workspace_name: workspace.name } }),
+ },
+];
+
+export function TableActionsWorkspaces({
+ workspace,
+ activeWorkspaceName,
+}: {
+ activeWorkspaceName: string | null | undefined;
+ workspace: Workspace & { isArchived: boolean };
+}) {
+ const { mutateAsync: archiveWorkspace } = useMutationArchiveWorkspace();
+ const { mutateAsync: restoreWorkspace } = useMutationRestoreWorkspace();
+ const { mutateAsync: activateWorkspace } = useMutationActivateWorkspace();
+ const hardDeleteWorkspace = useConfirmHardDeleteWorkspace();
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/workspace/components/table-workspaces.tsx b/src/features/workspace/components/table-workspaces.tsx
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 74%
rename from src/features/workspace-system-prompt/components/system-prompt-editor.tsx
rename to src/features/workspace/components/workspace-custom-instructions.tsx
index 4071bfb2..d2387a8b 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.
+ Pass custom instructions to your LLM to augment its 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..e6782ed4 100644
--- a/src/features/workspace/hooks/use-archive-workspace-button.tsx
+++ b/src/features/workspace/hooks/use-archive-workspace-button.tsx
@@ -1,26 +1,23 @@
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";
+import { useActiveWorkspaceName } from "./use-active-workspace-name";
export function useArchiveWorkspaceButton({
workspaceName,
}: {
workspaceName: string;
}): ComponentProps {
+ const { data: activeWorkspaceName } = useActiveWorkspaceName();
const { mutateAsync, isPending } = useMutationArchiveWorkspace();
- const navigate = useNavigate();
return {
isPending,
- isDisabled: isPending,
- onPress: () =>
- mutateAsync(
- { path: { workspace_name: workspaceName } },
- {
- onSuccess: () => navigate("/workspaces"),
- },
- ),
+ isDisabled:
+ isPending ||
+ workspaceName === activeWorkspaceName ||
+ workspaceName === "default",
+ 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..74e6350a
--- /dev/null
+++ b/src/features/workspace/hooks/use-confirm-hard-delete-workspace.tsx
@@ -0,0 +1,37 @@
+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 permanently delete this workspace?
+
+
+ You will lose all configuration and data associated with this
+ workspace, like prompt history or alerts.
+
+ >,
+ {
+ 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/hooks/use-toast-mutation.ts b/src/hooks/use-toast-mutation.ts
index a087de57..29b8686a 100644
--- a/src/hooks/use-toast-mutation.ts
+++ b/src/hooks/use-toast-mutation.ts
@@ -1,6 +1,7 @@
import { toast } from "@stacklok/ui-kit";
import {
DefaultError,
+ // eslint-disable-next-line no-restricted-imports
useMutation,
UseMutationOptions,
} from "@tanstack/react-query";
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-not-found.tsx b/src/routes/route-not-found.tsx
new file mode 100644
index 00000000..8207343e
--- /dev/null
+++ b/src/routes/route-not-found.tsx
@@ -0,0 +1,22 @@
+import { EmptyState } from "@/components/EmptyState";
+import { IllustrationError, Button } from "@stacklok/ui-kit";
+import { useNavigate } from "react-router-dom";
+import { FlipBackward } from "@untitled-ui/icons-react";
+
+export function RouteNotFound() {
+ const navigate = useNavigate();
+
+ return (
+
+ }
+ >
+
+
+
+ );
+}
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));
+ },
});