diff --git a/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx b/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx new file mode 100644 index 0000000000000..553f1050d8a84 --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx @@ -0,0 +1,153 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { SharedWorkspaceActor } from "api/typesGenerated"; +import { expect, screen, userEvent, waitFor } from "storybook/test"; +import { WorkspaceSharingIndicator } from "./WorkspaceSharingIndicator"; + +const mockUser = ( + name: string, + roles: SharedWorkspaceActor["roles"] = ["use"], +): SharedWorkspaceActor => ({ + id: crypto.randomUUID(), + actor_type: "user", + name, + roles, +}); + +const mockGroup = ( + name: string, + roles: SharedWorkspaceActor["roles"] = ["use"], +): SharedWorkspaceActor => ({ + id: crypto.randomUUID(), + actor_type: "group", + name, + roles, +}); + +const hoverTrigger = async (canvasElement: HTMLElement) => { + const trigger = canvasElement.querySelector("svg"); + if (!trigger) { + throw new Error("Could not find trigger element"); + } + await userEvent.hover(trigger); +}; + +const meta: Meta = { + title: "pages/WorkspacesPage/WorkspaceSharingIndicator", + component: WorkspaceSharingIndicator, + args: { + settingsPath: "/@owner/my-workspace/settings/sharing", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const SingleUser: Story = { + args: { + sharedWith: [mockUser("alice")], + }, + play: async ({ canvasElement, step }) => { + await step("activate hover trigger", async () => { + await hoverTrigger(canvasElement); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent("alice"), + ); + }); + }, +}; + +export const SingleAdmin: Story = { + args: { + sharedWith: [mockUser("alice", ["admin"])], + }, + play: async ({ canvasElement, step }) => { + await step("activate hover trigger", async () => { + await hoverTrigger(canvasElement); + await waitFor(() => { + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("alice"); + expect(tooltip).toHaveTextContent("Admin"); + }); + }); + }, +}; + +export const SingleGroup: Story = { + args: { + sharedWith: [mockGroup("Engineering")], + }, + play: async ({ canvasElement, step }) => { + await step("activate hover trigger", async () => { + await hoverTrigger(canvasElement); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent("Engineering"), + ); + }); + }, +}; + +export const UsersAndGroups: Story = { + args: { + sharedWith: [ + mockGroup("Engineering"), + mockUser("alice", ["admin"]), + mockGroup("DevOps", ["admin"]), + mockUser("bob"), + ], + }, + play: async ({ canvasElement, step }) => { + await step("activate hover trigger", async () => { + await hoverTrigger(canvasElement); + await waitFor(() => { + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("alice"); + expect(tooltip).toHaveTextContent("bob"); + expect(tooltip).toHaveTextContent("Engineering"); + expect(tooltip).toHaveTextContent("DevOps"); + }); + }); + }, +}; + +export const ManyActors: Story = { + args: { + sharedWith: [ + mockUser("alice", ["admin"]), + mockUser("bob", ["admin"]), + mockUser("charlie"), + mockGroup("QA"), + mockGroup("HR"), + mockGroup("Finance"), + mockGroup("Marketing"), + mockGroup("Sales"), + mockUser("david"), + mockUser("eve"), + mockUser("frank"), + mockGroup("Engineering"), + mockGroup("DevOps"), + mockGroup("Platform", ["admin"]), + mockGroup("Security", ["admin"]), + mockGroup("IT"), + mockGroup("Legal"), + mockGroup("Customer Support"), + mockGroup("Product"), + ], + }, + play: async ({ canvasElement, step }) => { + await step("activate hover trigger", async () => { + await hoverTrigger(canvasElement); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Workspace permissions", + ), + ); + }); + }, +}; diff --git a/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.tsx b/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.tsx new file mode 100644 index 0000000000000..f80f8b1f3f82e --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.tsx @@ -0,0 +1,72 @@ +import type { SharedWorkspaceActor } from "api/typesGenerated"; +import { Badge } from "components/Badge/Badge"; +import { Link } from "components/Link/Link"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { UsersIcon } from "lucide-react"; +import type { FC } from "react"; + +interface WorkspaceSharingIndicatorProps { + sharedWith: readonly SharedWorkspaceActor[]; + settingsPath: string; +} + +export const WorkspaceSharingIndicator: FC = ({ + sharedWith, + settingsPath, +}) => { + // Sort by type (users then groups) and then alphabetically by name. + const sortedActors = [...sharedWith].sort((a, b) => { + if (a.actor_type !== b.actor_type) { + return a.actor_type === "user" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return ( + + + + + + + +
+

+ Workspace permissions +

+
+
    + {sortedActors.map((actor) => { + const isAdmin = actor.roles.includes("admin"); + return ( +
  • + {actor.name} + {isAdmin && ( + + Admin + + )} +
  • + ); + })} +
+
+ e.stopPropagation()} + > + Change permissions + +
+
+
+ ); +}; diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 19b719540f938..0d72987751135 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -84,6 +84,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; import { cn } from "utils/cn"; import { getDisplayWorkspaceTemplateName } from "utils/workspace"; +import { WorkspaceSharingIndicator } from "./WorkspaceSharingIndicator"; import { WorkspacesEmpty } from "./WorkspacesEmpty"; interface WorkspacesTableProps { @@ -216,9 +217,18 @@ export const WorkspacesTable: FC = ({ } subtitle={ -
+
Owner: - {workspace.owner_name} +
+ {workspace.owner_name} + {workspace.shared_with && + workspace.shared_with.length > 0 && ( + + )} +
} avatar={