mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user