feat: add workspace sharing buttons to tasks (#22729)

Attempt to re-merge https://github.com/coder/coder/pull/21491 now that
the supporting backend work is done

Closes https://github.com/coder/coder/issues/22278
This commit is contained in:
Kayla はな
2026-03-10 12:26:33 -06:00
committed by GitHub
parent 53e52aef78
commit cbe46c816e
10 changed files with 72 additions and 56 deletions
-11
View File
@@ -2354,17 +2354,6 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
return
}
// Don't allow adding new groups or users to a workspace associated with a
// task. Sharing a task workspace without sharing the task itself is a broken
// half measure that we don't want to support right now. To be fixed!
if workspace.TaskID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Task workspaces cannot be shared.",
Detail: "This workspace is managed by a task. Task sharing has not yet been implemented.",
})
return
}
apiKey := httpmw.APIKey(r)
if _, ok := req.UserRoles[apiKey.UserID.String()]; ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -103,10 +103,8 @@ export const TasksSidebar: FC = () => {
<Button
variant={isCollapsed ? "subtle" : "default"}
size={isCollapsed ? "icon" : "sm"}
asChild={true}
className={cn({
"[&_svg]:p-0": isCollapsed,
})}
asChild
className={cn({ "[&_svg]:p-0": isCollapsed })}
>
<RouterLink to="/tasks">
<span className={isCollapsed ? "hidden" : ""}>New Task</span>{" "}
@@ -144,7 +144,6 @@ interface WorkspaceSharingFormProps {
organizationId: string;
workspaceACL: WorkspaceACL | undefined;
canUpdatePermissions: boolean;
isTaskWorkspace: boolean;
error: unknown;
onUpdateUser: (user: WorkspaceUser, role: WorkspaceRole) => void;
updatingUserId: WorkspaceUser["id"] | undefined;
@@ -161,7 +160,6 @@ export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
organizationId,
workspaceACL,
canUpdatePermissions,
isTaskWorkspace,
error,
updatingUserId,
onUpdateUser,
@@ -231,17 +229,7 @@ export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
const tableBody = (
<TableBody>
{isTaskWorkspace ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="Task workspaces cannot be shared"
description="This workspace is managed by a task. Task sharing has not yet been implemented."
isCompact={isCompact}
/>
</TableCell>
</TableRow>
) : !workspaceACL ? (
{!workspaceACL ? (
<TableLoader />
) : isEmpty ? (
<TableRow>
+7 -1
View File
@@ -5,6 +5,7 @@ import { template as templateQueryOptions } from "api/queries/templates";
import {
workspaceByOwnerAndName,
workspaceByOwnerAndNameKey,
workspacePermissions,
} from "api/queries/workspaces";
import type {
Task,
@@ -118,6 +119,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"];
@@ -361,7 +363,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
+16 -2
View File
@@ -21,12 +21,21 @@ import {
} 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>
@@ -81,6 +90,11 @@ export const TaskTopbar: FC<TaskTopbarProps> = ({ task, workspace }) => {
</PopoverContent>
</Popover>
<ShareButton
workspace={workspace}
canUpdatePermissions={canUpdatePermissions}
/>
<Button asChild variant="outline" size="sm">
<RouterLink to={`/@${workspace.owner_name}/${workspace.name}`}>
<LayoutPanelTopIcon />
@@ -195,7 +195,7 @@ export const LoadedTasksWaitingForInputTab: Story = {
const canvas = within(canvasElement);
await step("Switch to 'Waiting for input' tab", async () => {
const waitingForInputTab = await canvas.findByRole("button", {
const waitingForInputTab = await canvas.findByRole("switch", {
name: /waiting for input/i,
});
await userEvent.click(waitingForInputTab);
+39 -20
View File
@@ -26,6 +26,7 @@ import {
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import { TableToolbar } from "components/TableToolbar/TableToolbar";
import { useAuthenticated } from "hooks";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
@@ -57,10 +58,6 @@ const TasksPage: FC = () => {
key: "owner",
defaultValue: user.username,
});
const tab = useSearchParamsKey({
key: "tab",
defaultValue: "all",
});
const filter: TasksFilter = {
owner: ownerFilter.value,
};
@@ -69,11 +66,15 @@ const TasksPage: FC = () => {
queryFn: () => API.getTasks(filter),
refetchInterval: 10_000,
});
const statusFilter = useSearchParamsKey({
key: "status",
defaultValue: "",
});
const idleTasks = tasksQuery.data?.filter(
(task) => task.status === "active" && task.current_state?.state === "idle",
);
const displayedTasks =
tab.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
statusFilter.value === "waiting-for-input" ? idleTasks : tasksQuery.data;
const [checkedTaskIds, setCheckedTaskIds] = useState<Set<string>>(new Set());
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -171,28 +172,44 @@ const TasksPage: FC = () => {
aiTemplatesQuery.data &&
aiTemplatesQuery.data.length > 0 && (
<section className="py-8">
{permissions.viewDeploymentConfig && (
<section
className="mt-6 flex justify-between"
aria-label="Controls"
>
<section
className="mt-6 flex justify-between"
aria-label="Controls"
>
<div className="flex items-center gap-x-6">
<div className="flex items-center bg-surface-secondary rounded-lg p-1">
<PillButton
active={tab.value === "all"}
active={ownerFilter.value === user.username}
onClick={() => {
tab.setValue("all");
ownerFilter.setValue(user.username);
setCheckedTaskIds(new Set());
}}
>
My tasks
</PillButton>
<PillButton
active={ownerFilter.value === ""}
onClick={() => {
ownerFilter.setValue("");
setCheckedTaskIds(new Set());
}}
>
All tasks
</PillButton>
<PillButton
disabled={!idleTasks || idleTasks.length === 0}
active={tab.value === "waiting-for-input"}
onClick={() => {
tab.setValue("waiting-for-input");
</div>
<div className="flex items-center gap-2">
<Switch
id="waiting-for-input"
onCheckedChange={(checked) => {
statusFilter.setValue(
checked ? "waiting-for-input" : "",
);
setCheckedTaskIds(new Set());
}}
/>
<label
htmlFor="waiting-for-input"
className="flex items-center gap-2 text-sm text-content-primary select-none cursor-pointer"
>
Waiting for input
{idleTasks && idleTasks.length > 0 && (
@@ -200,9 +217,11 @@ const TasksPage: FC = () => {
{idleTasks.length}
</Badge>
)}
</PillButton>
</label>
</div>
</div>
{permissions.viewAllUsers && (
<UsersCombobox
value={ownerFilter.value}
onValueChange={(username) => {
@@ -212,8 +231,8 @@ const TasksPage: FC = () => {
setCheckedTaskIds(new Set());
}}
/>
</section>
)}
)}
</section>
<div className="mt-6">
<TableToolbar>
+3 -1
View File
@@ -209,7 +209,9 @@ const TaskRow: FC<TaskRowProps> = ({ task, checked, onCheckChange }) => {
const taskPageLink = `/tasks/${task.owner_name}/${task.id}`;
// Discard role, breaks Chromatic.
const { role, ...clickableRowProps } = useClickableTableRow({
onClick: () => navigate(taskPageLink),
onClick: () => {
navigate(taskPageLink);
},
});
return (
@@ -34,14 +34,15 @@ export const ShareButton: FC<ShareButtonProps> = ({
</PopoverTrigger>
<PopoverContent align="end" className="w-[580px] p-4">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold m-0">Workspace Sharing</h3>
<h3 className="text-lg font-semibold m-0">
{workspace.task_id ? "Task" : "Workspace"} Sharing
</h3>
<FeatureStageBadge contentType="beta" size="sm" />
</div>
<WorkspaceSharingForm
organizationId={workspace.organization_id}
workspaceACL={sharing.workspaceACL}
canUpdatePermissions={canUpdatePermissions}
isTaskWorkspace={Boolean(workspace.task_id)}
error={sharing.error ?? sharing.mutationError}
updatingUserId={sharing.updatingUserId}
onUpdateUser={sharing.updateUser}
@@ -55,7 +55,6 @@ export const WorkspaceSharingPageView: FC<WorkspaceSharingPageViewProps> = ({
organizationId={workspace.organization_id}
workspaceACL={workspaceACL}
canUpdatePermissions={canUpdatePermissions}
isTaskWorkspace={Boolean(workspace.task_id)}
error={error}
updatingUserId={updatingUserId}
onUpdateUser={onUpdateUser}