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> )}