Skip to content

Commit 547e53f

Browse files
jaaydenhgeokat
andauthored
feat: add workspace sharing indicator (#21222)
<img width="320" height="263" alt="Screenshot 2025-12-11 at 13 10 01" src="https://github.com/user-attachments/assets/496a420b-788c-4d7a-ae2d-8f203ea33971" /> --------- Co-authored-by: George Katsitadze <george@coder.com>
1 parent 8fefd91 commit 547e53f

File tree

3 files changed

+238
-2
lines changed

3 files changed

+238
-2
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import type { SharedWorkspaceActor } from "api/typesGenerated";
3+
import { expect, screen, userEvent, waitFor } from "storybook/test";
4+
import { WorkspaceSharingIndicator } from "./WorkspaceSharingIndicator";
5+
6+
const mockUser = (
7+
name: string,
8+
roles: SharedWorkspaceActor["roles"] = ["use"],
9+
): SharedWorkspaceActor => ({
10+
id: crypto.randomUUID(),
11+
actor_type: "user",
12+
name,
13+
roles,
14+
});
15+
16+
const mockGroup = (
17+
name: string,
18+
roles: SharedWorkspaceActor["roles"] = ["use"],
19+
): SharedWorkspaceActor => ({
20+
id: crypto.randomUUID(),
21+
actor_type: "group",
22+
name,
23+
roles,
24+
});
25+
26+
const hoverTrigger = async (canvasElement: HTMLElement) => {
27+
const trigger = canvasElement.querySelector("svg");
28+
if (!trigger) {
29+
throw new Error("Could not find trigger element");
30+
}
31+
await userEvent.hover(trigger);
32+
};
33+
34+
const meta: Meta<typeof WorkspaceSharingIndicator> = {
35+
title: "pages/WorkspacesPage/WorkspaceSharingIndicator",
36+
component: WorkspaceSharingIndicator,
37+
args: {
38+
settingsPath: "/@owner/my-workspace/settings/sharing",
39+
},
40+
decorators: [
41+
(Story) => (
42+
<div className="p-8">
43+
<Story />
44+
</div>
45+
),
46+
],
47+
};
48+
49+
export default meta;
50+
type Story = StoryObj<typeof WorkspaceSharingIndicator>;
51+
52+
export const SingleUser: Story = {
53+
args: {
54+
sharedWith: [mockUser("alice")],
55+
},
56+
play: async ({ canvasElement, step }) => {
57+
await step("activate hover trigger", async () => {
58+
await hoverTrigger(canvasElement);
59+
await waitFor(() =>
60+
expect(screen.getByRole("tooltip")).toHaveTextContent("alice"),
61+
);
62+
});
63+
},
64+
};
65+
66+
export const SingleAdmin: Story = {
67+
args: {
68+
sharedWith: [mockUser("alice", ["admin"])],
69+
},
70+
play: async ({ canvasElement, step }) => {
71+
await step("activate hover trigger", async () => {
72+
await hoverTrigger(canvasElement);
73+
await waitFor(() => {
74+
const tooltip = screen.getByRole("tooltip");
75+
expect(tooltip).toHaveTextContent("alice");
76+
expect(tooltip).toHaveTextContent("Admin");
77+
});
78+
});
79+
},
80+
};
81+
82+
export const SingleGroup: Story = {
83+
args: {
84+
sharedWith: [mockGroup("Engineering")],
85+
},
86+
play: async ({ canvasElement, step }) => {
87+
await step("activate hover trigger", async () => {
88+
await hoverTrigger(canvasElement);
89+
await waitFor(() =>
90+
expect(screen.getByRole("tooltip")).toHaveTextContent("Engineering"),
91+
);
92+
});
93+
},
94+
};
95+
96+
export const UsersAndGroups: Story = {
97+
args: {
98+
sharedWith: [
99+
mockGroup("Engineering"),
100+
mockUser("alice", ["admin"]),
101+
mockGroup("DevOps", ["admin"]),
102+
mockUser("bob"),
103+
],
104+
},
105+
play: async ({ canvasElement, step }) => {
106+
await step("activate hover trigger", async () => {
107+
await hoverTrigger(canvasElement);
108+
await waitFor(() => {
109+
const tooltip = screen.getByRole("tooltip");
110+
expect(tooltip).toHaveTextContent("alice");
111+
expect(tooltip).toHaveTextContent("bob");
112+
expect(tooltip).toHaveTextContent("Engineering");
113+
expect(tooltip).toHaveTextContent("DevOps");
114+
});
115+
});
116+
},
117+
};
118+
119+
export const ManyActors: Story = {
120+
args: {
121+
sharedWith: [
122+
mockUser("alice", ["admin"]),
123+
mockUser("bob", ["admin"]),
124+
mockUser("charlie"),
125+
mockGroup("QA"),
126+
mockGroup("HR"),
127+
mockGroup("Finance"),
128+
mockGroup("Marketing"),
129+
mockGroup("Sales"),
130+
mockUser("david"),
131+
mockUser("eve"),
132+
mockUser("frank"),
133+
mockGroup("Engineering"),
134+
mockGroup("DevOps"),
135+
mockGroup("Platform", ["admin"]),
136+
mockGroup("Security", ["admin"]),
137+
mockGroup("IT"),
138+
mockGroup("Legal"),
139+
mockGroup("Customer Support"),
140+
mockGroup("Product"),
141+
],
142+
},
143+
play: async ({ canvasElement, step }) => {
144+
await step("activate hover trigger", async () => {
145+
await hoverTrigger(canvasElement);
146+
await waitFor(() =>
147+
expect(screen.getByRole("tooltip")).toHaveTextContent(
148+
"Workspace permissions",
149+
),
150+
);
151+
});
152+
},
153+
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { SharedWorkspaceActor } from "api/typesGenerated";
2+
import { Badge } from "components/Badge/Badge";
3+
import { Link } from "components/Link/Link";
4+
import {
5+
Tooltip,
6+
TooltipContent,
7+
TooltipTrigger,
8+
} from "components/Tooltip/Tooltip";
9+
import { UsersIcon } from "lucide-react";
10+
import type { FC } from "react";
11+
12+
interface WorkspaceSharingIndicatorProps {
13+
sharedWith: readonly SharedWorkspaceActor[];
14+
settingsPath: string;
15+
}
16+
17+
export const WorkspaceSharingIndicator: FC<WorkspaceSharingIndicatorProps> = ({
18+
sharedWith,
19+
settingsPath,
20+
}) => {
21+
// Sort by type (users then groups) and then alphabetically by name.
22+
const sortedActors = [...sharedWith].sort((a, b) => {
23+
if (a.actor_type !== b.actor_type) {
24+
return a.actor_type === "user" ? -1 : 1;
25+
}
26+
return a.name.localeCompare(b.name);
27+
});
28+
29+
return (
30+
<Tooltip>
31+
<TooltipTrigger asChild>
32+
<span className="flex items-center text-content-secondary hover:text-content-primary">
33+
<UsersIcon className="size-icon-xs" />
34+
</span>
35+
</TooltipTrigger>
36+
<TooltipContent className="w-56 p-0">
37+
<div className="px-3 py-2">
38+
<p className="m-0 text-sm font-semibold text-content-primary">
39+
Workspace permissions
40+
</p>
41+
</div>
42+
<ul className="flex flex-col gap-1 m-0 p-0 list-none max-h-48 overflow-y-auto">
43+
{sortedActors.map((actor) => {
44+
const isAdmin = actor.roles.includes("admin");
45+
return (
46+
<li
47+
key={actor.id}
48+
className="flex px-3 gap-2 text-sm text-content-secondary"
49+
>
50+
<span className="text-sm truncate">{actor.name}</span>
51+
{isAdmin && (
52+
<Badge size="sm" variant="default">
53+
Admin
54+
</Badge>
55+
)}
56+
</li>
57+
);
58+
})}
59+
</ul>
60+
<div className="px-3 pb-3 pt-4">
61+
<Link
62+
href={settingsPath}
63+
className="text-sm text-content-link font-medium"
64+
onClick={(e) => e.stopPropagation()}
65+
showExternalIcon={false}
66+
>
67+
Change permissions
68+
</Link>
69+
</div>
70+
</TooltipContent>
71+
</Tooltip>
72+
);
73+
};

site/src/pages/WorkspacesPage/WorkspacesTable.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
8484
import { useNavigate } from "react-router";
8585
import { cn } from "utils/cn";
8686
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
87+
import { WorkspaceSharingIndicator } from "./WorkspaceSharingIndicator";
8788
import { WorkspacesEmpty } from "./WorkspacesEmpty";
8889

8990
interface WorkspacesTableProps {
@@ -216,9 +217,18 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
216217
</Stack>
217218
}
218219
subtitle={
219-
<div>
220+
<div className="flex items-center gap-1">
220221
<span className="sr-only">Owner: </span>
221-
{workspace.owner_name}
222+
<div className="flex gap-2">
223+
{workspace.owner_name}
224+
{workspace.shared_with &&
225+
workspace.shared_with.length > 0 && (
226+
<WorkspaceSharingIndicator
227+
sharedWith={workspace.shared_with}
228+
settingsPath={`/@${workspace.owner_name}/${workspace.name}/settings/sharing`}
229+
/>
230+
)}
231+
</div>
222232
</div>
223233
}
224234
avatar={

0 commit comments

Comments
 (0)