Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof WorkspaceSharingIndicator> = {
title: "pages/WorkspacesPage/WorkspaceSharingIndicator",
component: WorkspaceSharingIndicator,
args: {
settingsPath: "/@owner/my-workspace/settings/sharing",
},
decorators: [
(Story) => (
<div className="p-8">
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof WorkspaceSharingIndicator>;

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",
),
);
});
},
};
72 changes: 72 additions & 0 deletions site/src/pages/WorkspacesPage/WorkspaceSharingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<WorkspaceSharingIndicatorProps> = ({
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 (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center text-content-secondary hover:text-content-primary">
<UsersIcon className="size-icon-xs" />
</span>
</TooltipTrigger>
<TooltipContent className="w-56 p-0">
<div className="px-3 py-2">
<p className="m-0 text-sm font-semibold text-content-primary">
Workspace permissions
</p>
</div>
<ul className="flex flex-col gap-1 m-0 p-0 list-none max-h-48 overflow-y-auto">
{sortedActors.map((actor) => {
const isAdmin = actor.roles.includes("admin");
return (
<li
key={actor.id}
className="flex px-3 gap-2 text-sm text-content-secondary"
>
<span className="text-sm truncate">{actor.name}</span>
{isAdmin && (
<Badge size="sm" variant="default">
Admin
</Badge>
)}
</li>
);
})}
</ul>
<div className="px-3 pb-3 pt-4">
<Link
href={settingsPath}
className="text-sm text-content-link font-medium"
onClick={(e) => e.stopPropagation()}
>
Change permissions
</Link>
</div>
</TooltipContent>
</Tooltip>
);
};
14 changes: 12 additions & 2 deletions site/src/pages/WorkspacesPage/WorkspacesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -216,9 +217,18 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
</Stack>
}
subtitle={
<div>
<div className="flex items-center gap-1">
<span className="sr-only">Owner: </span>
{workspace.owner_name}
<div className="flex gap-2">
{workspace.owner_name}
{workspace.shared_with &&
workspace.shared_with.length > 0 && (
<WorkspaceSharingIndicator
sharedWith={workspace.shared_with}
settingsPath={`/@${workspace.owner_name}/${workspace.name}/settings/sharing`}
/>
)}
</div>
</div>
}
avatar={
Expand Down
Loading