Skip to content

Commit 8904d8b

Browse files
feat: enable toggle workspace, invalidate on workspace update (#130)
* feat: enable toggle workspace, invalidate on workspace update * fix: failing tests * fix(workspaces selection): checkbox & bg color
1 parent 987b3ee commit 8904d8b

File tree

9 files changed

+174
-37
lines changed

9 files changed

+174
-37
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { V1ListActiveWorkspacesResponse } from "@/api/generated";
2+
import { v1ListActiveWorkspacesQueryKey } from "@/api/generated/@tanstack/react-query.gen";
3+
import { toast } from "@stacklok/ui-kit";
4+
import {
5+
QueryCacheNotifyEvent,
6+
QueryClient,
7+
QueryClientProvider as VendorQueryClientProvider,
8+
} from "@tanstack/react-query";
9+
import { ReactNode, useState, useEffect } from "react";
10+
11+
/**
12+
* Responsible for determining whether a queryKey attached to a queryCache event
13+
* is for the "list active workspaces" query.
14+
*/
15+
function isActiveWorkspacesQueryKey(queryKey: unknown): boolean {
16+
return (
17+
Array.isArray(queryKey) &&
18+
queryKey[0]._id === v1ListActiveWorkspacesQueryKey()[0]?._id
19+
);
20+
}
21+
22+
/**
23+
* Responsible for extracting the incoming active workspace name from the deeply
24+
* nested payload attached to a queryCache event.
25+
*/
26+
function getWorkspaceName(event: QueryCacheNotifyEvent): string | null {
27+
if ("action" in event === false || "data" in event.action === false)
28+
return null;
29+
return (
30+
(event.action.data as V1ListActiveWorkspacesResponse | undefined | null)
31+
?.workspaces[0]?.name ?? null
32+
);
33+
}
34+
35+
export function QueryClientProvider({ children }: { children: ReactNode }) {
36+
const [activeWorkspaceName, setActiveWorkspaceName] = useState<string | null>(
37+
null,
38+
);
39+
40+
const [queryClient] = useState(() => new QueryClient());
41+
42+
useEffect(() => {
43+
const queryCache = queryClient.getQueryCache();
44+
const unsubscribe = queryCache.subscribe((event) => {
45+
if (
46+
event.type === "updated" &&
47+
event.action.type === "success" &&
48+
isActiveWorkspacesQueryKey(event.query.options.queryKey)
49+
) {
50+
const newWorkspaceName: string | null = getWorkspaceName(event);
51+
if (
52+
newWorkspaceName === activeWorkspaceName ||
53+
newWorkspaceName === null
54+
)
55+
return;
56+
57+
setActiveWorkspaceName(newWorkspaceName);
58+
toast.info(
59+
<span className="block whitespace-nowrap">
60+
Activated workspace:{" "}
61+
<span className="font-semibold">"{newWorkspaceName}"</span>
62+
</span>,
63+
);
64+
65+
void queryClient.invalidateQueries({
66+
refetchType: "all",
67+
// Avoid a continuous loop
68+
predicate(query) {
69+
return !isActiveWorkspacesQueryKey(query.queryKey);
70+
},
71+
});
72+
}
73+
});
74+
75+
return () => {
76+
return unsubscribe();
77+
};
78+
}, [activeWorkspaceName, queryClient]);
79+
80+
return (
81+
<VendorQueryClientProvider client={queryClient}>
82+
{children}
83+
</VendorQueryClientProvider>
84+
);
85+
}

src/features/workspace/components/workspaces-selection.tsx

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,50 @@
1-
import { useWorkspacesData } from "@/hooks/useWorkspacesData";
1+
import { useListWorkspaces } from "@/features/workspace/hooks/use-list-workspaces";
22
import {
33
Button,
44
DialogTrigger,
55
Input,
6+
LinkButton,
67
ListBox,
78
ListBoxItem,
89
Popover,
910
SearchField,
1011
Separator,
1112
} from "@stacklok/ui-kit";
1213
import { useQueryClient } from "@tanstack/react-query";
13-
import clsx from "clsx";
1414
import { ChevronDown, Search, Settings } from "lucide-react";
1515
import { useState } from "react";
16-
import { Link } from "react-router-dom";
16+
import { useActiveWorkspaces } from "../hooks/use-active-workspaces";
17+
import { useActivateWorkspace } from "../hooks/use-activate-workspace";
18+
import clsx from "clsx";
1719

1820
export function WorkspacesSelection() {
1921
const queryClient = useQueryClient();
20-
const { data } = useWorkspacesData();
22+
23+
const { data: workspacesResponse } = useListWorkspaces();
24+
const { data: activeWorkspacesResponse } = useActiveWorkspaces();
25+
const { mutateAsync: activateWorkspace } = useActivateWorkspace();
26+
27+
const activeWorkspaceName: string | null =
28+
activeWorkspacesResponse?.workspaces[0]?.name ?? null;
29+
2130
const [isOpen, setIsOpen] = useState(false);
2231
const [searchWorkspace, setSearchWorkspace] = useState("");
23-
const workspaces = data?.workspaces ?? [];
32+
const workspaces = workspacesResponse?.workspaces ?? [];
2433
const filteredWorkspaces = workspaces.filter((workspace) =>
2534
workspace.name.toLowerCase().includes(searchWorkspace.toLowerCase()),
2635
);
27-
const activeWorkspace = workspaces.find((workspace) => workspace.is_active);
2836

29-
const handleWorkspaceClick = () => {
30-
queryClient.invalidateQueries({ refetchType: "all" });
31-
setIsOpen(false);
37+
const handleWorkspaceClick = (name: string) => {
38+
activateWorkspace({ body: { name } }).then(() => {
39+
queryClient.invalidateQueries({ refetchType: "all" });
40+
setIsOpen(false);
41+
});
3242
};
3343

3444
return (
3545
<DialogTrigger isOpen={isOpen} onOpenChange={(test) => setIsOpen(test)}>
3646
<Button variant="tertiary" className="flex cursor-pointer">
37-
Workspace {activeWorkspace?.name ?? "default"}
47+
Workspace {activeWorkspaceName ?? "default"}
3848
<ChevronDown />
3949
</Button>
4050

@@ -51,40 +61,44 @@ export function WorkspacesSelection() {
5161
</div>
5262

5363
<ListBox
54-
className="pb-2 pt-3"
5564
aria-label="Workspaces"
5665
items={filteredWorkspaces}
57-
selectedKeys={activeWorkspace?.name ?? []}
66+
selectedKeys={activeWorkspaceName ? [activeWorkspaceName] : []}
67+
onAction={(v) => {
68+
handleWorkspaceClick(v?.toString());
69+
}}
70+
className="py-2 pt-3"
5871
renderEmptyState={() => (
5972
<p className="text-center">No workspaces found</p>
6073
)}
6174
>
6275
{(item) => (
6376
<ListBoxItem
6477
id={item.name}
65-
onAction={() => handleWorkspaceClick()}
78+
key={item.name}
79+
data-is-selected={item.name === activeWorkspaceName}
6680
className={clsx(
6781
"cursor-pointer py-2 m-1 text-base hover:bg-gray-300",
6882
{
69-
"bg-gray-900 text-white hover:text-secondary":
83+
"!bg-gray-900 hover:bg-gray-900 !text-gray-25 hover:!text-gray-25":
7084
item.is_active,
7185
},
7286
)}
73-
key={item.name}
7487
>
7588
{item.name}
7689
</ListBoxItem>
7790
)}
7891
</ListBox>
7992
<Separator className="" />
80-
<Link
81-
to="/workspaces"
82-
onClick={() => setIsOpen(false)}
83-
className="text-secondary pt-3 px-2 gap-2 flex"
93+
<LinkButton
94+
href="/workspaces"
95+
onPress={() => setIsOpen(false)}
96+
variant="tertiary"
97+
className="text-secondary h-8 pl-2 gap-2 flex mt-2 justify-start"
8498
>
8599
<Settings />
86100
Manage Workspaces
87-
</Link>
101+
</LinkButton>
88102
</div>
89103
</Popover>
90104
</DialogTrigger>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { v1ActivateWorkspaceMutation } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useMutation } from "@tanstack/react-query";
3+
4+
export function useActivateWorkspace() {
5+
return useMutation({
6+
...v1ActivateWorkspaceMutation(),
7+
});
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { v1ListActiveWorkspacesOptions } from "@/api/generated/@tanstack/react-query.gen";
2+
import { useQuery } from "@tanstack/react-query";
3+
4+
export function useActiveWorkspaces() {
5+
return useQuery({
6+
...v1ListActiveWorkspacesOptions(),
7+
refetchInterval: 5_000,
8+
refetchIntervalInBackground: true,
9+
refetchOnMount: true,
10+
refetchOnReconnect: true,
11+
refetchOnWindowFocus: true,
12+
retry: false,
13+
});
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { v1ListWorkspacesOptions } from "@/api/generated/@tanstack/react-query.gen";
3+
4+
export const useListWorkspaces = () => {
5+
return useQuery({
6+
...v1ListWorkspacesOptions(),
7+
refetchInterval: 5_000,
8+
refetchIntervalInBackground: true,
9+
refetchOnMount: true,
10+
refetchOnReconnect: true,
11+
refetchOnWindowFocus: true,
12+
retry: false,
13+
});
14+
};

src/hooks/useWorkspacesData.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

src/main.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import "@stacklok/ui-kit/style";
55
import App from "./App.tsx";
66
import { BrowserRouter } from "react-router-dom";
77
import { SidebarProvider } from "./components/ui/sidebar.tsx";
8-
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
98
import ErrorBoundary from "./components/ErrorBoundary.tsx";
109
import { Error } from "./components/Error.tsx";
11-
import { DarkModeProvider } from "@stacklok/ui-kit";
10+
import { DarkModeProvider, Toaster } from "@stacklok/ui-kit";
1211
import { client } from "./api/generated/index.ts";
12+
import { QueryClientProvider } from "./components/react-query-provider.tsx";
1313

1414
// Initialize the API client
1515
client.setConfig({
@@ -22,7 +22,8 @@ createRoot(document.getElementById("root")!).render(
2222
<DarkModeProvider>
2323
<SidebarProvider>
2424
<ErrorBoundary fallback={<Error />}>
25-
<QueryClientProvider client={new QueryClient()}>
25+
<QueryClientProvider>
26+
<Toaster />
2627
<App />
2728
</QueryClientProvider>
2829
</ErrorBoundary>

src/mocks/msw/handlers.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,25 @@ export const handlers = [
1212
error: null,
1313
}),
1414
),
15-
http.get("*/dashboard/version", () =>
15+
http.get("*/api/v1/dashboard/version", () =>
1616
HttpResponse.json({ status: "healthy" }),
1717
),
18-
http.get("*/dashboard/messages", () => {
18+
http.get("*/api/v1/workspaces/active", () =>
19+
HttpResponse.json([
20+
{
21+
name: "my-awesome-workspace",
22+
is_active: true,
23+
last_updated: new Date(Date.now()).toISOString(),
24+
},
25+
]),
26+
),
27+
http.get("*/api/v1/dashboard/messages", () => {
1928
return HttpResponse.json(mockedPrompts);
2029
}),
21-
http.get("*/dashboard/alerts", () => {
30+
http.get("*/api/v1/dashboard/alerts", () => {
2231
return HttpResponse.json(mockedAlerts);
2332
}),
24-
http.get("*/workspaces", () => {
33+
http.get("*/api/v1/workspaces", () => {
2534
return HttpResponse.json(mockedWorkspaces);
2635
}),
2736
];

src/routes/route-workspaces.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useWorkspacesData } from "@/hooks/useWorkspacesData";
1+
import { useListWorkspaces } from "@/features/workspace/hooks/use-list-workspaces";
22
import {
33
Cell,
44
Column,
@@ -12,7 +12,7 @@ import {
1212
import { Settings } from "lucide-react";
1313

1414
export function RouteWorkspaces() {
15-
const result = useWorkspacesData();
15+
const result = useListWorkspaces();
1616
const workspaces = result.data?.workspaces ?? [];
1717

1818
return (

0 commit comments

Comments
 (0)