From 472baf9bc133cb6b7f33f5cb04e151e3e7f1a4c4 Mon Sep 17 00:00:00 2001 From: Guangqing Tang <40620519+gt2345@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:35:04 -0500 Subject: [PATCH] feat: Add copy task id to task list (#10058) --- .../react/src/components/TaskActionDropdown.tsx | 16 +++++++++++++++- webui/react/src/components/TaskList.tsx | 1 + .../react/src/e2e/models/components/TaskList.ts | 5 +++++ webui/react/src/e2e/tests/workspaceTasks.spec.ts | 16 +++++++++++++++- webui/react/src/types.ts | 1 + 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/webui/react/src/components/TaskActionDropdown.tsx b/webui/react/src/components/TaskActionDropdown.tsx index 1cda970fa2a..07fb0559cdb 100644 --- a/webui/react/src/components/TaskActionDropdown.tsx +++ b/webui/react/src/components/TaskActionDropdown.tsx @@ -2,6 +2,7 @@ import Button from 'hew/Button'; import Dropdown, { MenuItem } from 'hew/Dropdown'; import Icon from 'hew/Icon'; import { useModal } from 'hew/Modal'; +import { useToast } from 'hew/Toast'; import useConfirm from 'hew/useConfirm'; import React, { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -12,6 +13,7 @@ import usePermissions from 'hooks/usePermissions'; import { paths, serverAddress } from 'routes/utils'; import { killTask } from 'services/api'; import { TaskAction as Action, CommandState, CommandTask, CommandType, DetailedUser } from 'types'; +import { copyToClipboard } from 'utils/dom'; import handleError, { ErrorLevel, ErrorType } from 'utils/error'; import { capitalize } from 'utils/string'; import { isTaskKillable } from 'utils/task'; @@ -26,6 +28,7 @@ interface Props { const TaskActionDropdown: React.FC = ({ task, onComplete, children }: Props) => { const { canModifyWorkspaceNSC } = usePermissions(); + const { openToast } = useToast(); const TaskConnectModal = useModal(TaskConnectModalComponent); const isConnectable = (task: CommandTask): boolean => { @@ -62,6 +65,10 @@ const TaskActionDropdown: React.FC = ({ task, onComplete, children }: Pro key: Action.ViewLogs, label: 'View Logs', }, + { + key: Action.CopyTaskID, + label: 'Copy Task ID', + }, ]; if (isTaskKillable(task, canModifyWorkspaceNSC({ workspace: { id: task.workspaceId } }))) { items.push({ key: Action.Kill, label: 'Kill' }); @@ -74,7 +81,7 @@ const TaskActionDropdown: React.FC = ({ task, onComplete, children }: Pro const navigate = useNavigate(); - const handleDropdown = (key: string) => { + const handleDropdown = async (key: string) => { try { switch (key) { case Action.Connect: @@ -97,6 +104,13 @@ const TaskActionDropdown: React.FC = ({ task, onComplete, children }: Pro onComplete?.(key); navigate(paths.taskLogs(task)); break; + case Action.CopyTaskID: + await copyToClipboard(task.id); + openToast({ + severity: 'Confirm', + title: 'Task ID has been copied to clipboard.', + }); + break; } } catch (e) { handleError(e, { diff --git a/webui/react/src/components/TaskList.tsx b/webui/react/src/components/TaskList.tsx index a60ba4c2095..065166f758e 100644 --- a/webui/react/src/components/TaskList.tsx +++ b/webui/react/src/components/TaskList.tsx @@ -385,6 +385,7 @@ const TaskList: React.FC = ({ workspace }: Props) => { dataIndex: 'id', defaultWidth: DEFAULT_COLUMN_WIDTHS['id'], key: 'id', + onCell: () => ({ 'data-testid': 'taskID' }), render: taskIdRenderer, sorter: (a: CommandTask, b: CommandTask): number => alphaNumericSorter(a.id, b.id), title: 'Short ID', diff --git a/webui/react/src/e2e/models/components/TaskList.ts b/webui/react/src/e2e/models/components/TaskList.ts index ea5bb260f3a..fac51ed4b77 100644 --- a/webui/react/src/e2e/models/components/TaskList.ts +++ b/webui/react/src/e2e/models/components/TaskList.ts @@ -20,6 +20,10 @@ class TaskRow extends Row { parent: this, selector: '[data-testid="state"]', }); + readonly taskID = new BaseComponent({ + parent: this, + selector: '[data-testid="taskID"]', + }); } /** @@ -27,6 +31,7 @@ class TaskRow extends Row { */ class TaskActionDropdown extends DropdownMenu { readonly kill = this.menuItem(TaskAction.Kill); + readonly copy = this.menuItem(TaskAction.CopyTaskID); readonly viewLogs = this.menuItem(TaskAction.ViewLogs); readonly connect = this.menuItem(TaskAction.Connect); } diff --git a/webui/react/src/e2e/tests/workspaceTasks.spec.ts b/webui/react/src/e2e/tests/workspaceTasks.spec.ts index 24ef8e96ab0..bf71ee02b18 100644 --- a/webui/react/src/e2e/tests/workspaceTasks.spec.ts +++ b/webui/react/src/e2e/tests/workspaceTasks.spec.ts @@ -1,3 +1,5 @@ +import { validate } from 'uuid'; + import { expect, test } from 'e2e/fixtures/global-fixtures'; import { TaskLogs } from 'e2e/models/pages/TaskLogs'; import { WorkspaceDetails } from 'e2e/models/pages/WorkspaceDetails'; @@ -27,10 +29,22 @@ test.describe('Workspace Tasks', () => { await workspaceDetails.taskList.taskKillModal.pwLocator.waitFor(); await workspaceDetails.taskList.taskKillModal.killButton.pwLocator.click(); - await expect(firstRow.state.pwLocator).toHaveText('Terminated'); }); + await test.step('Copy task ID', async () => { + try { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + } catch { + return; + } + + await (await firstRow.actions.open()).copy.pwLocator.click(); + const handle = await authedPage.evaluateHandle(() => navigator.clipboard.readText()); + const clipboard = await handle.jsonValue(); + expect(validate(clipboard)).toBeTruthy(); + }); + await test.step('View logs', async () => { await (await firstRow.actions.open()).viewLogs.pwLocator.click(); diff --git a/webui/react/src/types.ts b/webui/react/src/types.ts index e2e83dd69a2..bd8a64e84a9 100644 --- a/webui/react/src/types.ts +++ b/webui/react/src/types.ts @@ -944,6 +944,7 @@ export interface CommandTask extends Task { export const TaskAction = { Connect: 'Connect', + CopyTaskID: 'Copy Task ID', Kill: 'Kill', ViewLogs: 'View Logs', } as const;