mirror of
https://github.com/coder/coder.git
synced 2026-09-22 13:10:21 +08:00
feat: add workspace sharing buttons to tasks (#21491)
resolves coder/internal#1130 This adds a workspace sharing button to tasks in 3 places Figma: https://www.figma.com/design/KriBGfS73GAwkplnVhCBoU/Tasks?node-id=278-2455&t=vhU6Q8G1b7fDWiAP-1 <img width="320" height="374" alt="Screenshot 2026-01-13 at 15 16 06" src="https://github.com/user-attachments/assets/cf232a12-b0c8-4f5c-91fa-d84eac8cb106" /> <img width="582" height="372" alt="Screenshot 2026-01-13 at 15 16 36" src="https://github.com/user-attachments/assets/90654afc-720a-4bfe-9c67-fcbcebb4aa2b" /> <img width="768" height="317" alt="Screenshot 2026-01-13 at 15 18 03" src="https://github.com/user-attachments/assets/0281cb84-c941-4075-9a20-00ad3958864b" />
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { CoderIcon } from "components/Icons/CoderIcon";
|
||||
@@ -21,7 +22,13 @@ import {
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
|
||||
import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react";
|
||||
import {
|
||||
EditIcon,
|
||||
EllipsisIcon,
|
||||
PanelLeftIcon,
|
||||
Share2Icon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
@@ -227,6 +234,15 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<RouterLink
|
||||
to={`/@${task.owner_name}/${task.workspace_name}/settings/sharing`}
|
||||
>
|
||||
<Share2Icon />
|
||||
Share workspace
|
||||
</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={(e) => {
|
||||
@@ -235,7 +251,7 @@ const TaskSidebarMenuItem: FC<TaskSidebarMenuItemProps> = ({ task }) => {
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
|
||||
import {
|
||||
startWorkspace,
|
||||
workspaceByOwnerAndName,
|
||||
workspacePermissions,
|
||||
} from "api/queries/workspaces";
|
||||
import type {
|
||||
Workspace,
|
||||
@@ -78,6 +79,7 @@ const TaskPage = () => {
|
||||
return state.error ? false : 5_000;
|
||||
},
|
||||
});
|
||||
const { data: permissions } = useQuery(workspacePermissions(workspace));
|
||||
const refetch = taskQuery.error ? taskQuery.refetch : workspaceQuery.refetch;
|
||||
const error = taskQuery.error ?? workspaceQuery.error;
|
||||
const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"];
|
||||
@@ -198,7 +200,11 @@ const TaskPage = () => {
|
||||
<TaskPageLayout>
|
||||
<title>{pageTitle(task.display_name)}</title>
|
||||
|
||||
<TaskTopbar task={task} workspace={workspace} />
|
||||
<TaskTopbar
|
||||
task={task}
|
||||
workspace={workspace}
|
||||
canUpdatePermissions={permissions?.updateWorkspace ?? false}
|
||||
/>
|
||||
{content}
|
||||
|
||||
<ModifyPromptDialog
|
||||
|
||||
@@ -11,17 +11,26 @@ import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
LaptopMinimalIcon,
|
||||
TerminalIcon,
|
||||
LayoutPanelTopIcon,
|
||||
SquareTerminalIcon,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { ShareButton } from "../WorkspacePage/WorkspaceActions/ShareButton";
|
||||
import { TaskStartupWarningButton } from "./TaskStartupWarningButton";
|
||||
import { TaskStatusLink } from "./TaskStatusLink";
|
||||
|
||||
type TaskTopbarProps = { task: Task; workspace: Workspace };
|
||||
type TaskTopbarProps = {
|
||||
task: Task;
|
||||
workspace: Workspace;
|
||||
canUpdatePermissions: boolean;
|
||||
};
|
||||
|
||||
export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
|
||||
export const TaskTopbar: FC<TaskTopbarProps> = ({
|
||||
task,
|
||||
workspace,
|
||||
canUpdatePermissions,
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex flex-shrink-0 items-center gap-2 p-3 border-solid border-border border-0 border-b">
|
||||
<TooltipProvider>
|
||||
@@ -57,8 +66,8 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<TerminalIcon />
|
||||
Prompt
|
||||
<SquareTerminalIcon />
|
||||
View Prompt
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs bg-surface-secondary p-4">
|
||||
@@ -70,10 +79,15 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<ShareButton
|
||||
workspace={workspace}
|
||||
canUpdatePermissions={canUpdatePermissions}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<RouterLink to={`/@${workspace.owner_name}/${workspace.name}`}>
|
||||
<LaptopMinimalIcon />
|
||||
Workspace
|
||||
<LayoutPanelTopIcon />
|
||||
Go to workspace
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { API } from "api/api";
|
||||
import { getTemplatesQueryKey } from "api/queries/templates";
|
||||
import { MockUsers } from "pages/UsersPage/storybookData/users";
|
||||
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
|
||||
import {
|
||||
expect,
|
||||
fireEvent,
|
||||
screen,
|
||||
spyOn,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "storybook/test";
|
||||
import TasksPage from "./TasksPage";
|
||||
|
||||
const meta: Meta<typeof TasksPage> = {
|
||||
@@ -232,6 +240,20 @@ export const NonAdmin: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenKebabMenu: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
spyOn(API, "getTasks").mockResolvedValue(MockTasks);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const kebabButtons = await canvas.findAllByRole("button", {
|
||||
name: /open task actions/i,
|
||||
});
|
||||
await userEvent.click(kebabButtons[0]);
|
||||
},
|
||||
};
|
||||
|
||||
export const OpenDeleteDialog: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]);
|
||||
@@ -239,10 +261,15 @@ export const OpenDeleteDialog: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const deleteButtons = await canvas.findAllByRole("button", {
|
||||
name: /delete task/i,
|
||||
const kebabButtons = await canvas.findAllByRole("button", {
|
||||
name: /open task actions/i,
|
||||
});
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
await userEvent.click(kebabButtons[0]);
|
||||
|
||||
const deleteMenuItem = await screen.findByRole("menuitem", {
|
||||
name: /delete/i,
|
||||
});
|
||||
fireEvent.click(deleteMenuItem);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { Skeleton } from "components/Skeleton/Skeleton";
|
||||
import {
|
||||
Table,
|
||||
@@ -18,14 +25,13 @@ import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "components/TableLoader/TableLoader";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useClickableTableRow } from "hooks";
|
||||
import { RotateCcwIcon, TrashIcon } from "lucide-react";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
RotateCcwIcon,
|
||||
Share2Icon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog";
|
||||
import { TaskStatus } from "modules/tasks/TaskStatus/TaskStatus";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
@@ -255,24 +261,41 @@ const TaskRow: FC<TaskRowProps> = ({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Delete task</span>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete task</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
variant="subtle"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical aria-hidden="true" />
|
||||
<span className="sr-only">Open task actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
navigate(
|
||||
`/@${task.owner_name}/${task.workspace_name}/settings/sharing`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Share2Icon />
|
||||
Share workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { Share2Icon } from "lucide-react";
|
||||
import { isGroup } from "modules/groups";
|
||||
import { AddWorkspaceUserOrGroup } from "modules/workspaces/WorkspaceSharingForm/AddWorkspaceUserOrGroup";
|
||||
import { useWorkspaceSharing } from "modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing";
|
||||
@@ -27,8 +27,8 @@ export const ShareButton: FC<ShareButtonProps> = ({
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<TopbarButton data-testid="workspace-share-button">
|
||||
<UsersIcon />
|
||||
Share
|
||||
<Share2Icon />
|
||||
Share Workspace
|
||||
</TopbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[580px] p-4">
|
||||
|
||||
Reference in New Issue
Block a user