From d28982b77afe851036cc647c1a58d085449d4001 Mon Sep 17 00:00:00 2001
From: alex-mcgovern <alex_mcgovernsmith@icloud.com>
Date: Mon, 20 Jan 2025 13:16:17 +0000
Subject: [PATCH 1/3] feat: enable toggle workspace, invalidate on workspace
 update

---
 src/components/react-query-provider.tsx       | 85 +++++++++++++++++++
 .../components/workspaces-selection.tsx       | 62 ++++++++------
 .../workspace/hooks/use-activate-workspace.ts |  8 ++
 .../workspace/hooks/use-active-workspaces.ts  | 14 +++
 .../workspace/hooks/use-list-workspaces.ts    | 14 +++
 src/hooks/useWorkspacesData.ts                |  8 --
 src/main.tsx                                  |  7 +-
 src/routes/route-workspaces.tsx               |  4 +-
 8 files changed, 161 insertions(+), 41 deletions(-)
 create mode 100644 src/components/react-query-provider.tsx
 create mode 100644 src/features/workspace/hooks/use-activate-workspace.ts
 create mode 100644 src/features/workspace/hooks/use-active-workspaces.ts
 create mode 100644 src/features/workspace/hooks/use-list-workspaces.ts
 delete mode 100644 src/hooks/useWorkspacesData.ts

diff --git a/src/components/react-query-provider.tsx b/src/components/react-query-provider.tsx
new file mode 100644
index 00000000..9f3dfac3
--- /dev/null
+++ b/src/components/react-query-provider.tsx
@@ -0,0 +1,85 @@
+import { V1ListActiveWorkspacesResponse } from "@/api/generated";
+import { v1ListActiveWorkspacesQueryKey } from "@/api/generated/@tanstack/react-query.gen";
+import { toast } from "@stacklok/ui-kit";
+import {
+  QueryCacheNotifyEvent,
+  QueryClient,
+  QueryClientProvider as VendorQueryClientProvider,
+} from "@tanstack/react-query";
+import { ReactNode, useState, useEffect } from "react";
+
+/**
+ * Responsible for determining whether a queryKey attached to a queryCache event
+ * is for the "list active workspaces" query.
+ */
+function isActiveWorkspacesQueryKey(queryKey: unknown): boolean {
+  return (
+    Array.isArray(queryKey) &&
+    queryKey[0]._id === v1ListActiveWorkspacesQueryKey()[0]?._id
+  );
+}
+
+/**
+ * Responsible for extracting the incoming active workspace name from the deeply
+ * nested payload attached to a queryCache event.
+ */
+function getWorkspaceName(event: QueryCacheNotifyEvent): string | null {
+  if ("action" in event === false || "data" in event.action === false)
+    return null;
+  return (
+    (event.action.data as V1ListActiveWorkspacesResponse | undefined | null)
+      ?.workspaces[0]?.name ?? null
+  );
+}
+
+export function QueryClientProvider({ children }: { children: ReactNode }) {
+  const [activeWorkspaceName, setActiveWorkspaceName] = useState<string | null>(
+    null,
+  );
+
+  const [queryClient] = useState(() => new QueryClient());
+
+  useEffect(() => {
+    const queryCache = queryClient.getQueryCache();
+    const unsubscribe = queryCache.subscribe((event) => {
+      if (
+        event.type === "updated" &&
+        event.action.type === "success" &&
+        isActiveWorkspacesQueryKey(event.query.options.queryKey)
+      ) {
+        const newWorkspaceName: string | null = getWorkspaceName(event);
+        if (
+          newWorkspaceName === activeWorkspaceName ||
+          newWorkspaceName === null
+        )
+          return;
+
+        setActiveWorkspaceName(newWorkspaceName);
+        toast.info(
+          <span className="block whitespace-nowrap">
+            Activated workspace:{" "}
+            <span className="font-semibold">"{newWorkspaceName}"</span>
+          </span>,
+        );
+
+        void queryClient.invalidateQueries({
+          refetchType: "all",
+          // Avoid a continuous loop
+          predicate(query) {
+            return !isActiveWorkspacesQueryKey(query.queryKey);
+          },
+        });
+      }
+    });
+
+    return () => {
+      return unsubscribe();
+    };
+  }, [activeWorkspaceName, queryClient]);
+
+  return (
+    <VendorQueryClientProvider client={queryClient}>
+      {children}
+    </VendorQueryClientProvider>
+  );
+}
diff --git a/src/features/workspace/components/workspaces-selection.tsx b/src/features/workspace/components/workspaces-selection.tsx
index 010eac1e..68a5716a 100644
--- a/src/features/workspace/components/workspaces-selection.tsx
+++ b/src/features/workspace/components/workspaces-selection.tsx
@@ -1,8 +1,9 @@
-import { useWorkspacesData } from "@/hooks/useWorkspacesData";
+import { useListWorkspaces } from "@/features/workspace/hooks/use-list-workspaces";
 import {
   Button,
   DialogTrigger,
   Input,
+  LinkButton,
   ListBox,
   ListBoxItem,
   Popover,
@@ -10,31 +11,39 @@ import {
   Separator,
 } from "@stacklok/ui-kit";
 import { useQueryClient } from "@tanstack/react-query";
-import clsx from "clsx";
 import { ChevronDown, Search, Settings } from "lucide-react";
 import { useState } from "react";
-import { Link } from "react-router-dom";
+import { useActiveWorkspaces } from "../hooks/use-active-workspaces";
+import { useActivateWorkspace } from "../hooks/use-activate-workspace";
 
 export function WorkspacesSelection() {
   const queryClient = useQueryClient();
-  const { data } = useWorkspacesData();
+
+  const { data: workspacesResponse } = useListWorkspaces();
+  const { data: activeWorkspacesResponse } = useActiveWorkspaces();
+  const { mutateAsync: activateWorkspace } = useActivateWorkspace();
+
+  const activeWorkspaceName: string | null =
+    activeWorkspacesResponse?.workspaces[0]?.name ?? null;
+
   const [isOpen, setIsOpen] = useState(false);
   const [searchWorkspace, setSearchWorkspace] = useState("");
-  const workspaces = data?.workspaces ?? [];
+  const workspaces = workspacesResponse?.workspaces ?? [];
   const filteredWorkspaces = workspaces.filter((workspace) =>
     workspace.name.toLowerCase().includes(searchWorkspace.toLowerCase()),
   );
-  const activeWorkspace = workspaces.find((workspace) => workspace.is_active);
 
-  const handleWorkspaceClick = () => {
-    queryClient.invalidateQueries({ refetchType: "all" });
-    setIsOpen(false);
+  const handleWorkspaceClick = (name: string) => {
+    activateWorkspace({ body: { name } }).then(() => {
+      queryClient.invalidateQueries({ refetchType: "all" });
+      setIsOpen(false);
+    });
   };
 
   return (
     <DialogTrigger isOpen={isOpen} onOpenChange={(test) => setIsOpen(test)}>
       <Button variant="tertiary" className="flex cursor-pointer">
-        Workspace {activeWorkspace?.name ?? "default"}
+        Workspace {activeWorkspaceName ?? "default"}
         <ChevronDown />
       </Button>
 
@@ -54,37 +63,34 @@ export function WorkspacesSelection() {
             className="pb-2 pt-3"
             aria-label="Workspaces"
             items={filteredWorkspaces}
-            selectedKeys={activeWorkspace?.name ?? []}
+            selectedKeys={activeWorkspaceName ? [activeWorkspaceName] : []}
+            selectionMode="single"
+            onSelectionChange={(v) => {
+              if (v === "all") return; // Not possible with `selectionMode="single"`
+              const [key] = v.keys();
+              if (!key) return;
+              handleWorkspaceClick(key?.toString());
+            }}
             renderEmptyState={() => (
               <p className="text-center">No workspaces found</p>
             )}
           >
             {(item) => (
-              <ListBoxItem
-                id={item.name}
-                onAction={() => handleWorkspaceClick()}
-                className={clsx(
-                  "cursor-pointer py-2 m-1 text-base hover:bg-gray-300",
-                  {
-                    "bg-gray-900 text-white hover:text-secondary":
-                      item.is_active,
-                  },
-                )}
-                key={item.name}
-              >
+              <ListBoxItem id={item.name} key={item.name}>
                 {item.name}
               </ListBoxItem>
             )}
           </ListBox>
           <Separator className="" />
-          <Link
-            to="/workspaces"
-            onClick={() => setIsOpen(false)}
-            className="text-secondary pt-3 px-2 gap-2 flex"
+          <LinkButton
+            href="/workspaces"
+            onPress={() => setIsOpen(false)}
+            variant="tertiary"
+            className="text-secondary h-8 pl-2 gap-2 flex mt-2 justify-start"
           >
             <Settings />
             Manage Workspaces
-          </Link>
+          </LinkButton>
         </div>
       </Popover>
     </DialogTrigger>
diff --git a/src/features/workspace/hooks/use-activate-workspace.ts b/src/features/workspace/hooks/use-activate-workspace.ts
new file mode 100644
index 00000000..fa332849
--- /dev/null
+++ b/src/features/workspace/hooks/use-activate-workspace.ts
@@ -0,0 +1,8 @@
+import { v1ActivateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
+import { useMutation } from "@tanstack/react-query";
+
+export function useActivateWorkspace() {
+  return useMutation({
+    ...v1ActivateWorkspaceMutation(),
+  });
+}
diff --git a/src/features/workspace/hooks/use-active-workspaces.ts b/src/features/workspace/hooks/use-active-workspaces.ts
new file mode 100644
index 00000000..8079bd7d
--- /dev/null
+++ b/src/features/workspace/hooks/use-active-workspaces.ts
@@ -0,0 +1,14 @@
+import { v1ListActiveWorkspacesOptions } from "@/api/generated/@tanstack/react-query.gen";
+import { useQuery } from "@tanstack/react-query";
+
+export function useActiveWorkspaces() {
+  return useQuery({
+    ...v1ListActiveWorkspacesOptions(),
+    refetchInterval: 5_000,
+    refetchIntervalInBackground: true,
+    refetchOnMount: true,
+    refetchOnReconnect: true,
+    refetchOnWindowFocus: true,
+    retry: false,
+  });
+}
diff --git a/src/features/workspace/hooks/use-list-workspaces.ts b/src/features/workspace/hooks/use-list-workspaces.ts
new file mode 100644
index 00000000..0b0b28b9
--- /dev/null
+++ b/src/features/workspace/hooks/use-list-workspaces.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import { v1ListWorkspacesOptions } from "@/api/generated/@tanstack/react-query.gen";
+
+export const useListWorkspaces = () => {
+  return useQuery({
+    ...v1ListWorkspacesOptions(),
+    refetchInterval: 5_000,
+    refetchIntervalInBackground: true,
+    refetchOnMount: true,
+    refetchOnReconnect: true,
+    refetchOnWindowFocus: true,
+    retry: false,
+  });
+};
diff --git a/src/hooks/useWorkspacesData.ts b/src/hooks/useWorkspacesData.ts
deleted file mode 100644
index 9d4cae13..00000000
--- a/src/hooks/useWorkspacesData.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { v1ListWorkspacesOptions } from "@/api/generated/@tanstack/react-query.gen";
-
-export const useWorkspacesData = () => {
-  return useQuery({
-    ...v1ListWorkspacesOptions(),
-  });
-};
diff --git a/src/main.tsx b/src/main.tsx
index 50d0dc94..8ecc9112 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,11 +5,11 @@ import "@stacklok/ui-kit/style";
 import App from "./App.tsx";
 import { BrowserRouter } from "react-router-dom";
 import { SidebarProvider } from "./components/ui/sidebar.tsx";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import ErrorBoundary from "./components/ErrorBoundary.tsx";
 import { Error } from "./components/Error.tsx";
-import { DarkModeProvider } from "@stacklok/ui-kit";
+import { DarkModeProvider, Toaster } from "@stacklok/ui-kit";
 import { client } from "./api/generated/index.ts";
+import { QueryClientProvider } from "./components/react-query-provider.tsx";
 
 // Initialize the API client
 client.setConfig({
@@ -22,7 +22,8 @@ createRoot(document.getElementById("root")!).render(
       <DarkModeProvider>
         <SidebarProvider>
           <ErrorBoundary fallback={<Error />}>
-            <QueryClientProvider client={new QueryClient()}>
+            <QueryClientProvider>
+              <Toaster />
               <App />
             </QueryClientProvider>
           </ErrorBoundary>
diff --git a/src/routes/route-workspaces.tsx b/src/routes/route-workspaces.tsx
index f7317cd3..ac2ce14f 100644
--- a/src/routes/route-workspaces.tsx
+++ b/src/routes/route-workspaces.tsx
@@ -1,4 +1,4 @@
-import { useWorkspacesData } from "@/hooks/useWorkspacesData";
+import { useListWorkspaces } from "@/features/workspace/hooks/use-list-workspaces";
 import {
   Cell,
   Column,
@@ -12,7 +12,7 @@ import {
 import { Settings } from "lucide-react";
 
 export function RouteWorkspaces() {
-  const result = useWorkspacesData();
+  const result = useListWorkspaces();
   const workspaces = result.data?.workspaces ?? [];
 
   return (

From d610a1ad30015485ff92b918adf7461c663c57e7 Mon Sep 17 00:00:00 2001
From: alex-mcgovern <alex_mcgovernsmith@icloud.com>
Date: Mon, 20 Jan 2025 13:26:55 +0000
Subject: [PATCH 2/3] fix: failing tests

---
 src/mocks/msw/handlers.ts | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts
index f202e94f..dbefe629 100644
--- a/src/mocks/msw/handlers.ts
+++ b/src/mocks/msw/handlers.ts
@@ -12,16 +12,25 @@ export const handlers = [
       error: null,
     }),
   ),
-  http.get("*/dashboard/version", () =>
+  http.get("*/api/v1/dashboard/version", () =>
     HttpResponse.json({ status: "healthy" }),
   ),
-  http.get("*/dashboard/messages", () => {
+  http.get("*/api/v1/workspaces/active", () =>
+    HttpResponse.json([
+      {
+        name: "my-awesome-workspace",
+        is_active: true,
+        last_updated: new Date(Date.now()).toISOString(),
+      },
+    ]),
+  ),
+  http.get("*/api/v1/dashboard/messages", () => {
     return HttpResponse.json(mockedPrompts);
   }),
-  http.get("*/dashboard/alerts", () => {
+  http.get("*/api/v1/dashboard/alerts", () => {
     return HttpResponse.json(mockedAlerts);
   }),
-  http.get("*/workspaces", () => {
+  http.get("*/api/v1/workspaces", () => {
     return HttpResponse.json(mockedWorkspaces);
   }),
 ];

From bfaa1741fe60702d06b1316cb0edd78e08498b4f Mon Sep 17 00:00:00 2001
From: alex-mcgovern <alex_mcgovernsmith@icloud.com>
Date: Mon, 20 Jan 2025 13:36:55 +0000
Subject: [PATCH 3/3] fix(workspaces selection): checkbox & bg color

---
 .../components/workspaces-selection.tsx       | 24 ++++++++++++-------
 1 file changed, 16 insertions(+), 8 deletions(-)

diff --git a/src/features/workspace/components/workspaces-selection.tsx b/src/features/workspace/components/workspaces-selection.tsx
index 68a5716a..aab1971e 100644
--- a/src/features/workspace/components/workspaces-selection.tsx
+++ b/src/features/workspace/components/workspaces-selection.tsx
@@ -15,6 +15,7 @@ import { ChevronDown, Search, Settings } from "lucide-react";
 import { useState } from "react";
 import { useActiveWorkspaces } from "../hooks/use-active-workspaces";
 import { useActivateWorkspace } from "../hooks/use-activate-workspace";
+import clsx from "clsx";
 
 export function WorkspacesSelection() {
   const queryClient = useQueryClient();
@@ -60,23 +61,30 @@ export function WorkspacesSelection() {
           </div>
 
           <ListBox
-            className="pb-2 pt-3"
             aria-label="Workspaces"
             items={filteredWorkspaces}
             selectedKeys={activeWorkspaceName ? [activeWorkspaceName] : []}
-            selectionMode="single"
-            onSelectionChange={(v) => {
-              if (v === "all") return; // Not possible with `selectionMode="single"`
-              const [key] = v.keys();
-              if (!key) return;
-              handleWorkspaceClick(key?.toString());
+            onAction={(v) => {
+              handleWorkspaceClick(v?.toString());
             }}
+            className="py-2 pt-3"
             renderEmptyState={() => (
               <p className="text-center">No workspaces found</p>
             )}
           >
             {(item) => (
-              <ListBoxItem id={item.name} key={item.name}>
+              <ListBoxItem
+                id={item.name}
+                key={item.name}
+                data-is-selected={item.name === activeWorkspaceName}
+                className={clsx(
+                  "cursor-pointer py-2 m-1 text-base hover:bg-gray-300",
+                  {
+                    "!bg-gray-900 hover:bg-gray-900 !text-gray-25 hover:!text-gray-25":
+                      item.is_active,
+                  },
+                )}
+              >
                 {item.name}
               </ListBoxItem>
             )}