diff --git a/webui/react/src/components/ExperimentActionDropdown.test.mock.tsx b/webui/react/src/components/ExperimentActionDropdown.test.mock.tsx new file mode 100644 index 00000000000..4dedcb273a1 --- /dev/null +++ b/webui/react/src/components/ExperimentActionDropdown.test.mock.tsx @@ -0,0 +1,65 @@ +import { GridCell, GridCellKind } from '@glideapps/glide-data-grid'; + +import { ProjectExperiment } from 'types'; + +export const cell: GridCell = { + allowOverlay: false, + copyData: 'core-api-stage-3', + cursor: 'pointer', + data: { + kind: 'link-cell', + link: { + href: '/experiments/7261', + title: 'core-api-stage-3', + unmanaged: false, + }, + navigateOn: 'click', + underlineOffset: 6, + }, + kind: GridCellKind.Custom, + readonly: true, +}; + +export const experiment: ProjectExperiment = { + archived: false, + checkpoints: 0, + checkpointSize: 0, + description: 'Continuation of trial 49300, experiment 7229', + duration: 12, + endTime: '2024-06-27T22:35:00.745298Z', + forkedFrom: 7229, + hyperparameters: { + increment_by: { + type: 'const', + val: 1, + }, + irrelevant1: { + type: 'const', + val: 1, + }, + irrelevant2: { + type: 'const', + val: 1, + }, + }, + id: 7261, + jobId: '742ae9dc-e712-4348-9b15-a1b9f652d6d5', + labels: [], + name: 'core-api-stage-3', + notes: '', + numTrials: 1, + parentArchived: false, + progress: 0, + projectId: 1, + projectName: 'Uncategorized', + projectOwnerId: 1, + resourcePool: 'aux-pool', + searcherType: 'single', + startTime: '2024-06-27T22:34:49.194301Z', + state: 'CANCELED', + trialIds: [], + unmanaged: false, + userId: 1288, + workspaceId: 1, + workspaceName: 'Uncategorized', +}; diff --git a/webui/react/src/components/ExperimentActionDropdown.test.tsx b/webui/react/src/components/ExperimentActionDropdown.test.tsx new file mode 100644 index 00000000000..20e442e319c --- /dev/null +++ b/webui/react/src/components/ExperimentActionDropdown.test.tsx @@ -0,0 +1,263 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import UIProvider, { DefaultTheme } from 'hew/Theme'; +import { ConfirmationProvider } from 'hew/useConfirm'; + +import { handlePath } from 'routes/utils'; +import { + archiveExperiment, + cancelExperiment, + deleteExperiment, + killExperiment, + patchExperiment, + pauseExperiment, + unarchiveExperiment, +} from 'services/api'; +import { RunState } from 'types'; + +import ExperimentActionDropdown, { Action } from './ExperimentActionDropdown'; +import { cell, experiment } from './ExperimentActionDropdown.test.mock'; + +const user = userEvent.setup(); + +const mockNavigatorClipboard = () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: vi.fn(), + writeText: vi.fn(), + }, + writable: true, + }); +}; + +vi.mock('routes/utils', () => ({ + handlePath: vi.fn(), + serverAddress: () => 'http://localhost', +})); + +vi.mock('services/api', () => ({ + archiveExperiment: vi.fn(), + cancelExperiment: vi.fn(), + deleteExperiment: vi.fn(), + getWorkspaces: vi.fn(() => Promise.resolve({ workspaces: [] })), + killExperiment: vi.fn(), + patchExperiment: vi.fn(), + pauseExperiment: vi.fn(), + unarchiveExperiment: vi.fn(), +})); + +const mocks = vi.hoisted(() => { + return { + canCreateExperiment: vi.fn(), + canDeleteExperiment: vi.fn(), + canModifyExperiment: vi.fn(), + canModifyExperimentMetadata: vi.fn(), + canMoveExperiment: vi.fn(), + canViewExperimentArtifacts: vi.fn(), + }; +}); + +vi.mock('hooks/usePermissions', () => { + const usePermissions = vi.fn(() => { + return { + canCreateExperiment: mocks.canCreateExperiment, + canDeleteExperiment: mocks.canDeleteExperiment, + canModifyExperiment: mocks.canModifyExperiment, + canModifyExperimentMetadata: mocks.canModifyExperimentMetadata, + canMoveExperiment: mocks.canMoveExperiment, + canViewExperimentArtifacts: mocks.canViewExperimentArtifacts, + }; + }); + return { + default: usePermissions, + }; +}); + +const setup = (link?: string, state?: RunState, archived?: boolean) => { + const onComplete = vi.fn(); + const onVisibleChange = vi.fn(); + render( + + + +
+ + + , + ); + return { + onComplete, + onVisibleChange, + }; +}; + +describe('ExperimentActionDropdown', () => { + it('should provide Copy Data option', async () => { + setup(); + mockNavigatorClipboard(); + await user.click(screen.getByText(Action.Copy)); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(cell.copyData); + }); + + it('should provide Link option', async () => { + const link = 'https://www.google.com/'; + setup(link); + await user.click(screen.getByText(Action.NewTab)); + const tabClick = vi.mocked(handlePath).mock.calls[0]; + expect(tabClick[0]).toMatchObject({ type: 'click' }); + expect(tabClick[1]).toMatchObject({ + path: link, + popout: 'tab', + }); + await user.click(screen.getByText(Action.NewWindow)); + const windowClick = vi.mocked(handlePath).mock.calls[1]; + expect(windowClick[0]).toMatchObject({ type: 'click' }); + expect(windowClick[1]).toMatchObject({ + path: link, + popout: 'window', + }); + }); + + it('should provide Delete option', async () => { + mocks.canDeleteExperiment.mockImplementation(() => true); + setup(); + await user.click(screen.getByText(Action.Delete)); + await user.click(screen.getByRole('button', { name: Action.Delete })); + expect(vi.mocked(deleteExperiment)).toBeCalled(); + }); + + it('should hide Delete option without permissions', () => { + mocks.canDeleteExperiment.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Delete)).not.toBeInTheDocument(); + }); + + it('should provide Kill option', async () => { + mocks.canModifyExperiment.mockImplementation(() => true); + setup(undefined, RunState.Paused, undefined); + await user.click(screen.getByText(Action.Kill)); + await user.click(screen.getByRole('button', { name: Action.Kill })); + expect(vi.mocked(killExperiment)).toBeCalled(); + }); + + it('should hide Kill option without permissions', () => { + mocks.canModifyExperiment.mockImplementation(() => false); + setup(undefined, RunState.Paused, undefined); + expect(screen.queryByText(Action.Kill)).not.toBeInTheDocument(); + }); + + it('should provide Archive option', async () => { + mocks.canModifyExperiment.mockImplementation(() => true); + setup(); + await user.click(screen.getByText(Action.Archive)); + expect(vi.mocked(archiveExperiment)).toBeCalled(); + }); + + it('should hide Archive option without permissions', () => { + mocks.canModifyExperiment.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Archive)).not.toBeInTheDocument(); + }); + + it('should provide Unarchive option', async () => { + mocks.canModifyExperiment.mockImplementation(() => true); + setup(undefined, undefined, true); + await user.click(screen.getByText(Action.Unarchive)); + expect(vi.mocked(unarchiveExperiment)).toBeCalled(); + }); + + it('should hide Unarchive option without permissions', () => { + mocks.canModifyExperiment.mockImplementation(() => false); + setup(undefined, undefined, true); + expect(screen.queryByText(Action.Unarchive)).not.toBeInTheDocument(); + }); + + it('should provide Move option', () => { + mocks.canMoveExperiment.mockImplementation(() => true); + setup(); + expect(screen.getByText(Action.Move)).toBeInTheDocument(); + }); + + it('should hide Move option without permissions', () => { + mocks.canMoveExperiment.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Move)).not.toBeInTheDocument(); + }); + + it('should provide Edit option', async () => { + mocks.canModifyExperimentMetadata.mockImplementation(() => true); + setup(); + await user.click(screen.getByText(Action.Edit)); + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'edit'); + await user.click(screen.getByText('Save')); + expect(vi.mocked(patchExperiment)).toBeCalled(); + }); + + it('should hide Edit option without permissions', () => { + mocks.canModifyExperimentMetadata.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Edit)).not.toBeInTheDocument(); + }); + + it('should provide Pause option', async () => { + mocks.canModifyExperiment.mockImplementation(() => true); + setup(undefined, RunState.Running); + await user.click(screen.getByText(Action.Pause)); + expect(vi.mocked(pauseExperiment)).toBeCalled(); + }); + + it('should hide Pause option without permissions', () => { + mocks.canModifyExperiment.mockImplementation(() => false); + setup(undefined, RunState.Running); + expect(screen.queryByText(Action.Pause)).not.toBeInTheDocument(); + }); + + it('should provide Cancel option', async () => { + mocks.canModifyExperiment.mockImplementation(() => true); + setup(undefined, RunState.Running); + await user.click(screen.getByText(Action.Cancel)); + expect(vi.mocked(cancelExperiment)).toBeCalled(); + }); + + it('should hide Cancel option without permissions', () => { + mocks.canModifyExperiment.mockImplementation(() => false); + setup(undefined, RunState.Running); + expect(screen.queryByText(Action.Cancel)).not.toBeInTheDocument(); + }); + + it('should provide Retain Logs option', () => { + mocks.canModifyExperiment.mockImplementation(() => true); + setup(); + expect(screen.queryByText(Action.RetainLogs)).toBeInTheDocument(); + }); + + it('should hide Retain Logs option without permissions', () => { + mocks.canModifyExperiment.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.RetainLogs)).not.toBeInTheDocument(); + }); + + it('should provide Tensor Board option', () => { + mocks.canViewExperimentArtifacts.mockImplementation(() => true); + setup(); + expect(screen.getByText(Action.OpenTensorBoard)).toBeInTheDocument(); + }); + + it('should hide Tensor Board option without permissions', () => { + mocks.canViewExperimentArtifacts.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.OpenTensorBoard)).not.toBeInTheDocument(); + }); +}); diff --git a/webui/react/src/components/ExperimentActionDropdown.tsx b/webui/react/src/components/ExperimentActionDropdown.tsx index bcfe8256de9..0b152c23cb8 100644 --- a/webui/react/src/components/ExperimentActionDropdown.tsx +++ b/webui/react/src/components/ExperimentActionDropdown.tsx @@ -8,7 +8,8 @@ import { useToast } from 'hew/Toast'; import useConfirm from 'hew/useConfirm'; import { copyToClipboard } from 'hew/utils/functions'; import { Failed, Loadable, Loaded, NotLoaded } from 'hew/utils/loadable'; -import React, { MouseEvent, useCallback, useMemo, useRef, useState } from 'react'; +import { isString } from 'lodash'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import css from 'components/ActionDropdown/ActionDropdown.module.scss'; import ExperimentEditModalComponent from 'components/ExperimentEditModal'; @@ -56,7 +57,7 @@ interface Props { workspaceId?: number; } -const Action = { +export const Action = { Copy: 'Copy Value', NewTab: 'Open Link in New Tab', NewWindow: 'Open Link in New Window', @@ -92,7 +93,6 @@ const ExperimentActionDropdown: React.FC = ({ onVisibleChange, children, }: Props) => { - const id = experiment.id; const ExperimentEditModal = useModal(ExperimentEditModalComponent); const ExperimentMoveModal = useModal(ExperimentMoveModalComponent); const ExperimentRetainLogsModal = useModal(ExperimentRetainLogsModalComponent); @@ -147,18 +147,18 @@ const ExperimentActionDropdown: React.FC = ({ const handleEditComplete = useCallback( (data: Partial) => { - onComplete?.(ExperimentAction.Edit, id, data); + onComplete?.(ExperimentAction.Edit, experiment.id, data); }, - [id, onComplete], + [experiment.id, onComplete], ); const handleMoveComplete = useCallback(() => { - onComplete?.(ExperimentAction.Move, id); - }, [id, onComplete]); + onComplete?.(ExperimentAction.Move, experiment.id); + }, [experiment.id, onComplete]); const handleRetainLogsComplete = useCallback(() => { - onComplete?.(ExperimentAction.RetainLogs, id); - }, [id, onComplete]); + onComplete?.(ExperimentAction.RetainLogs, experiment.id); + }, [experiment.id, onComplete]); const menuItems = getActionsForExperiment(experiment, dropdownActions, usePermissions()) .filter((action) => action !== Action.SwitchPin) @@ -166,49 +166,55 @@ const ExperimentActionDropdown: React.FC = ({ return { danger: action === Action.Delete, key: action, label: action }; }); + const cellCopyData = useMemo(() => { + if (cell && 'displayData' in cell && isString(cell.displayData)) return cell.displayData; + if (cell?.copyData) return cell.copyData; + return undefined; + }, [cell]); + const dropdownMenu = useMemo(() => { - const items: MenuItem[] = [...menuItems]; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - if (cell && (cell.copyData || (cell as any).displayData)) { - items.unshift({ key: Action.Copy, label: Action.Copy }); - } + const items: MenuItem[] = []; if (link) { - items.unshift( + items.push( { key: Action.NewTab, label: Action.NewTab }, { key: Action.NewWindow, label: Action.NewWindow }, { type: 'divider' }, ); } + if (cellCopyData) { + items.push({ key: Action.Copy, label: Action.Copy }); + } + items.push(...menuItems); return items; - }, [link, menuItems, cell]); + }, [link, menuItems, cellCopyData]); const handleDropdown = useCallback( async (action: string, e: DropdownEvent) => { try { switch (action) { case Action.NewTab: - handlePath(e as MouseEvent, { path: link, popout: 'tab' }); + handlePath(e, { path: link, popout: 'tab' }); await onLink?.(); break; case Action.NewWindow: - handlePath(e as MouseEvent, { path: link, popout: 'window' }); + handlePath(e, { path: link, popout: 'window' }); await onLink?.(); break; case Action.Activate: - await activateExperiment({ experimentId: id }); - await onComplete?.(action, id); + await activateExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); break; case Action.Archive: - await archiveExperiment({ experimentId: id }); - await onComplete?.(action, id); + await archiveExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); break; case Action.Cancel: - await cancelExperiment({ experimentId: id }); - await onComplete?.(action, id); + await cancelExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); break; case Action.OpenTensorBoard: { const commandResponse = await openOrCreateTensorBoard({ - experimentIds: [id], + experimentIds: [experiment.id], workspaceId: experiment.workspaceId, }); openCommandResponse(commandResponse); @@ -237,33 +243,33 @@ const ExperimentActionDropdown: React.FC = ({ } case Action.Kill: confirm({ - content: `Are you sure you want to kill experiment ${id}?`, + content: `Are you sure you want to kill experiment ${experiment.id}?`, danger: true, okText: 'Kill', onConfirm: async () => { - await killExperiment({ experimentId: id }); - await onComplete?.(action, id); + await killExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); }, onError: handleError, title: 'Confirm Experiment Kill', }); break; case Action.Pause: - await pauseExperiment({ experimentId: id }); - await onComplete?.(action, id); + await pauseExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); break; case Action.Unarchive: - await unarchiveExperiment({ experimentId: id }); - await onComplete?.(action, id); + await unarchiveExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); break; case Action.Delete: confirm({ - content: `Are you sure you want to delete experiment ${id}?`, + content: `Are you sure you want to delete experiment ${experiment.id}?`, danger: true, okText: 'Delete', onConfirm: async () => { - await deleteExperiment({ experimentId: id }); - await onComplete?.(action, id); + await deleteExperiment({ experimentId: experiment.id }); + await onComplete?.(action, experiment.id); }, onError: handleError, title: 'Confirm Experiment Deletion', @@ -283,8 +289,7 @@ const ExperimentActionDropdown: React.FC = ({ fetchedExperimentItem(); break; case Action.Copy: - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - await copyToClipboard((cell as any).displayData || cell?.copyData); + await copyToClipboard(cellCopyData ?? ''); openToast({ severity: 'Confirm', title: 'Value has been copied to clipboard.', @@ -294,7 +299,7 @@ const ExperimentActionDropdown: React.FC = ({ } catch (e) { handleError(e, { level: ErrorLevel.Error, - publicMessage: `Unable to ${action} experiment ${id}.`, + publicMessage: `Unable to ${action} experiment ${experiment.id}.`, publicSubject: `${capitalize(action)} failed.`, silent: false, type: ErrorType.Server, @@ -306,7 +311,7 @@ const ExperimentActionDropdown: React.FC = ({ [ link, onLink, - id, + experiment.id, onComplete, confirm, ExperimentEditModal, @@ -314,14 +319,14 @@ const ExperimentActionDropdown: React.FC = ({ ExperimentRetainLogsModal, interstitialModalOpen, fetchedExperimentItem, - cell, + cellCopyData, openToast, experiment.workspaceId, onVisibleChange, ], ); - if (menuItems.length === 0) { + if (dropdownMenu.length === 0) { return ( (children as JSX.Element) ?? (
@@ -342,13 +347,13 @@ const ExperimentActionDropdown: React.FC = ({ onEditComplete={handleEditComplete} /> diff --git a/webui/react/src/components/RunActionDropdown.test.mock.tsx b/webui/react/src/components/RunActionDropdown.test.mock.tsx new file mode 100644 index 00000000000..67defcde2c2 --- /dev/null +++ b/webui/react/src/components/RunActionDropdown.test.mock.tsx @@ -0,0 +1,108 @@ +import { GridCell, GridCellKind } from '@glideapps/glide-data-grid'; + +import { DateString, decode, optional } from 'ioTypes'; +import { FlatRun } from 'types'; + +import { FilterFormSetWithoutId } from './FilterForm/components/type'; + +export const run: FlatRun = { + archived: false, + checkpointCount: 1, + checkpointSize: 43090, + duration: 256, + endTime: decode(optional(DateString), '2024-06-03T17:50:38.703259Z'), + experiment: { + description: '', + forkedFrom: 6634, + id: 6833, + isMultitrial: true, + name: 'iris_tf_keras_adaptive_search', + progress: 0.9444444, + resourcePool: 'compute-pool', + searcherMetric: 'val_categorical_accuracy', + searcherType: 'adaptive_asha', + unmanaged: false, + }, + hyperparameters: { + global_batch_size: 22, + layer1_dense_size: 29, + learning_rate: 0.00004998215062737775, + learning_rate_decay: 0.000001, + }, + id: 45888, + labels: ['a', 'b'], + parentArchived: false, + projectId: 1, + projectName: 'Uncategorized', + searcherMetricValue: 0.46666666865348816, + startTime: decode(optional(DateString), '2024-06-03T17:46:22.682019Z'), + state: 'COMPLETED', + summaryMetrics: { + avgMetrics: { + categorical_accuracy: { + count: 1, + last: 0.2968127429485321, + max: 0.2968127429485321, + min: 0.2968127429485321, + sum: 0.2968127429485321, + type: 'number', + }, + loss: { + count: 1, + last: 2.4582924842834473, + max: 2.4582924842834473, + min: 2.4582924842834473, + sum: 2.4582924842834473, + type: 'number', + }, + }, + validationMetrics: { + val_categorical_accuracy: { + count: 1, + last: 0.46666666865348816, + max: 0.46666666865348816, + min: 0.46666666865348816, + sum: 0.46666666865348816, + type: 'number', + }, + val_loss: { + count: 1, + last: 1.8627476692199707, + max: 1.8627476692199707, + min: 1.8627476692199707, + sum: 1.8627476692199707, + type: 'number', + }, + }, + }, + userId: 1354, + workspaceId: 1, + workspaceName: 'Uncategorized', +}; + +export const cell: GridCell = { + allowOverlay: false, + copyData: '45888', + cursor: 'pointer', + data: { + kind: 'link-cell', + link: { + href: '/experiments/6833/trials/45888', + title: '45888', + unmanaged: false, + }, + navigateOn: 'click', + underlineOffset: 6, + }, + kind: GridCellKind.Custom, + readonly: true, +}; + +export const filterFormSetWithoutId: FilterFormSetWithoutId = { + filterGroup: { + children: [], + conjunction: 'and', + kind: 'group', + }, + showArchived: false, +}; diff --git a/webui/react/src/components/RunActionDropdown.test.tsx b/webui/react/src/components/RunActionDropdown.test.tsx new file mode 100644 index 00000000000..2a43008bec9 --- /dev/null +++ b/webui/react/src/components/RunActionDropdown.test.tsx @@ -0,0 +1,179 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import UIProvider, { DefaultTheme } from 'hew/Theme'; +import { ConfirmationProvider } from 'hew/useConfirm'; + +import { handlePath } from 'routes/utils'; +import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api'; +import { RunState } from 'types'; + +import RunActionDropdown, { Action } from './RunActionDropdown'; +import { cell, run } from './RunActionDropdown.test.mock'; + +const mockNavigatorClipboard = () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + readText: vi.fn(), + writeText: vi.fn(), + }, + writable: true, + }); +}; + +vi.mock('routes/utils', () => ({ + handlePath: vi.fn(), + serverAddress: () => 'http://localhost', +})); + +vi.mock('services/api', () => ({ + archiveRuns: vi.fn(), + deleteRuns: vi.fn(), + killRuns: vi.fn(), + unarchiveRuns: vi.fn(), +})); + +const mocks = vi.hoisted(() => { + return { + canDeleteFlatRun: vi.fn(), + canModifyFlatRun: vi.fn(), + canMoveFlatRun: vi.fn(), + }; +}); + +vi.mock('hooks/usePermissions', () => { + const usePermissions = vi.fn(() => { + return { + canDeleteFlatRun: mocks.canDeleteFlatRun, + canModifyFlatRun: mocks.canModifyFlatRun, + canMoveFlatRun: mocks.canMoveFlatRun, + }; + }); + return { + default: usePermissions, + }; +}); + +const setup = (link?: string, state?: RunState, archived?: boolean) => { + const onComplete = vi.fn(); + const onVisibleChange = vi.fn(); + render( + + + + + , + ); + return { + onComplete, + onVisibleChange, + }; +}; + +const user = userEvent.setup(); + +describe('RunActionDropdown', () => { + it('should provide Copy Data option', async () => { + setup(); + mockNavigatorClipboard(); + await user.click(screen.getByText(Action.Copy)); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(cell.copyData); + }); + + it('should provide Link option', async () => { + const link = 'https://www.google.com/'; + setup(link); + await user.click(screen.getByText(Action.NewTab)); + const tabClick = vi.mocked(handlePath).mock.calls[0]; + expect(tabClick[0]).toMatchObject({ type: 'click' }); + expect(tabClick[1]).toMatchObject({ + path: link, + popout: 'tab', + }); + await user.click(screen.getByText(Action.NewWindow)); + const windowClick = vi.mocked(handlePath).mock.calls[1]; + expect(windowClick[0]).toMatchObject({ type: 'click' }); + expect(windowClick[1]).toMatchObject({ + path: link, + popout: 'window', + }); + }); + + it('should provide Delete option', async () => { + mocks.canDeleteFlatRun.mockImplementation(() => true); + setup(); + await user.click(screen.getByText(Action.Delete)); + await user.click(screen.getByRole('button', { name: Action.Delete })); + expect(vi.mocked(deleteRuns)).toBeCalled(); + }); + + it('should hide Delete option without permissions', () => { + mocks.canDeleteFlatRun.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Delete)).not.toBeInTheDocument(); + }); + + it('should provide Kill option', async () => { + mocks.canModifyFlatRun.mockImplementation(() => true); + setup(undefined, RunState.Paused, undefined); + await user.click(screen.getByText(Action.Kill)); + await user.click(screen.getByRole('button', { name: Action.Kill })); + expect(vi.mocked(killRuns)).toBeCalled(); + }); + + it('should hide Kill option without permissions', () => { + mocks.canModifyFlatRun.mockImplementation(() => false); + setup(undefined, RunState.Paused, undefined); + expect(screen.queryByText(Action.Kill)).not.toBeInTheDocument(); + }); + + it('should provide Archive option', async () => { + mocks.canModifyFlatRun.mockImplementation(() => true); + setup(); + await user.click(screen.getByText(Action.Archive)); + expect(vi.mocked(archiveRuns)).toBeCalled(); + }); + + it('should hide Archive option without permissions', () => { + mocks.canModifyFlatRun.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Archive)).not.toBeInTheDocument(); + }); + + it('should provide Unarchive option', async () => { + mocks.canModifyFlatRun.mockImplementation(() => true); + setup(undefined, undefined, true); + await user.click(screen.getByText(Action.Unarchive)); + expect(vi.mocked(unarchiveRuns)).toBeCalled(); + }); + + it('should hide Unarchive option without permissions', () => { + mocks.canModifyFlatRun.mockImplementation(() => false); + setup(undefined, undefined, true); + expect(screen.queryByText(Action.Unarchive)).not.toBeInTheDocument(); + }); + + it('should provide Move option', () => { + mocks.canMoveFlatRun.mockImplementation(() => true); + setup(); + expect(screen.getByText(Action.Move)).toBeInTheDocument(); + }); + + it('should hide Move option without permissions', () => { + mocks.canMoveFlatRun.mockImplementation(() => false); + setup(); + expect(screen.queryByText(Action.Move)).not.toBeInTheDocument(); + }); +}); diff --git a/webui/react/src/components/RunActionDropdown.tsx b/webui/react/src/components/RunActionDropdown.tsx new file mode 100644 index 00000000000..647f5d213ef --- /dev/null +++ b/webui/react/src/components/RunActionDropdown.tsx @@ -0,0 +1,188 @@ +import { GridCell } from '@glideapps/glide-data-grid'; +import { ContextMenuCompleteHandlerProps } from 'hew/DataGrid/contextMenu'; +import Dropdown, { DropdownEvent, MenuItem } from 'hew/Dropdown'; +import { useModal } from 'hew/Modal'; +import { useToast } from 'hew/Toast'; +import useConfirm from 'hew/useConfirm'; +import { copyToClipboard } from 'hew/utils/functions'; +import { isString } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; + +import usePermissions from 'hooks/usePermissions'; +import FlatRunMoveModalComponent from 'pages/FlatRuns/FlatRunMoveModal'; +import { handlePath } from 'routes/utils'; +import { archiveRuns, deleteRuns, killRuns, unarchiveRuns } from 'services/api'; +import { FlatRun, FlatRunAction, ValueOf } from 'types'; +import handleError, { ErrorLevel, ErrorType } from 'utils/error'; +import { getActionsForFlatRun } from 'utils/flatRun'; +import { capitalize } from 'utils/string'; + +interface Props { + children?: React.ReactNode; + cell?: GridCell; + run: FlatRun; + link?: string; + makeOpen?: boolean; + onComplete?: ContextMenuCompleteHandlerProps; + onLink?: () => void; + onVisibleChange?: (visible: boolean) => void; + projectId: number; +} + +export const Action = { + Copy: 'Copy Value', + NewTab: 'Open Link in New Tab', + NewWindow: 'Open Link in New Window', + ...FlatRunAction, +}; + +type Action = ValueOf; + +const dropdownActions = [Action.Archive, Action.Unarchive, Action.Kill, Action.Move, Action.Delete]; + +const RunActionDropdown: React.FC = ({ + run, + cell, + link, + makeOpen, + onComplete, + onLink, + onVisibleChange, + projectId, +}: Props) => { + const { Component: FlatRunMoveComponentModal, open: flatRunMoveModalOpen } = + useModal(FlatRunMoveModalComponent); + const confirm = useConfirm(); + const { openToast } = useToast(); + + const menuItems = getActionsForFlatRun(run, dropdownActions, usePermissions()).map( + (action: FlatRunAction) => { + return { danger: action === Action.Delete, key: action, label: action }; + }, + ); + + const cellCopyData = useMemo(() => { + if (cell && 'displayData' in cell && isString(cell.displayData)) return cell.displayData; + if (cell?.copyData) return cell.copyData; + return undefined; + }, [cell]); + + const dropdownMenu = useMemo(() => { + const items: MenuItem[] = []; + if (link) { + items.push( + { key: Action.NewTab, label: Action.NewTab }, + { key: Action.NewWindow, label: Action.NewWindow }, + { type: 'divider' }, + ); + } + if (cellCopyData) { + items.push({ key: Action.Copy, label: Action.Copy }); + } + items.push(...menuItems); + return items; + }, [link, menuItems, cellCopyData]); + + const handleDropdown = useCallback( + async (action: string, e: DropdownEvent) => { + try { + switch (action) { + case Action.NewTab: + handlePath(e, { path: link, popout: 'tab' }); + await onLink?.(); + break; + case Action.NewWindow: + handlePath(e, { path: link, popout: 'window' }); + await onLink?.(); + break; + case Action.Archive: + await archiveRuns({ projectId, runIds: [run.id] }); + await onComplete?.(action, run.id); + break; + case Action.Kill: + confirm({ + content: `Are you sure you want to kill run ${run.id}?`, + danger: true, + okText: 'Kill', + onConfirm: async () => { + await killRuns({ projectId, runIds: [run.id] }); + await onComplete?.(action, run.id); + }, + onError: handleError, + title: 'Confirm Run Kill', + }); + break; + case Action.Unarchive: + await unarchiveRuns({ projectId, runIds: [run.id] }); + await onComplete?.(action, run.id); + break; + case Action.Delete: + confirm({ + content: `Are you sure you want to delete run ${run.id}?`, + danger: true, + okText: 'Delete', + onConfirm: async () => { + await deleteRuns({ projectId, runIds: [run.id] }); + await onComplete?.(action, run.id); + }, + onError: handleError, + title: 'Confirm Run Deletion', + }); + break; + case Action.Move: + flatRunMoveModalOpen(); + break; + case Action.Copy: + await copyToClipboard(cellCopyData ?? ''); + openToast({ + severity: 'Confirm', + title: 'Value has been copied to clipboard.', + }); + break; + } + } catch (e) { + handleError(e, { + level: ErrorLevel.Error, + publicMessage: `Unable to ${action} experiment ${run.id}.`, + publicSubject: `${capitalize(action)} failed.`, + silent: false, + type: ErrorType.Server, + }); + } finally { + onVisibleChange?.(false); + } + }, + [ + link, + onLink, + run.id, + onComplete, + confirm, + cellCopyData, + openToast, + onVisibleChange, + projectId, + flatRunMoveModalOpen, + ], + ); + + const shared = ( + onComplete?.(FlatRunAction.Move, run.id)} + /> + ); + + return ( + <> + +
+ + {shared} + + ); +}; + +export default RunActionDropdown; diff --git a/webui/react/src/pages/FlatRuns/FlatRuns.tsx b/webui/react/src/pages/FlatRuns/FlatRuns.tsx index 60a91fa448b..e544ede7684 100644 --- a/webui/react/src/pages/FlatRuns/FlatRuns.tsx +++ b/webui/react/src/pages/FlatRuns/FlatRuns.tsx @@ -28,6 +28,7 @@ import Link from 'hew/Link'; import Message from 'hew/Message'; import Pagination from 'hew/Pagination'; import Row from 'hew/Row'; +import { useToast } from 'hew/Toast'; import { Loadable, Loaded, NotLoaded } from 'hew/utils/loadable'; import { useObservable } from 'micro-observables'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -52,6 +53,7 @@ import { rowHeightMap, settingsConfigGlobal, } from 'components/OptionsMenu.settings'; +import RunActionDropdown from 'components/RunActionDropdown'; import useUI from 'components/ThemeProvider'; import { useAsync } from 'hooks/useAsync'; import { useDebouncedSettings } from 'hooks/useDebouncedSettings'; @@ -72,7 +74,7 @@ import { getProjectColumns, getProjectNumericMetricsRange, searchRuns } from 'se import { V1ColumnType, V1LocationType, V1TableType } from 'services/api-ts-sdk'; import userStore from 'stores/users'; import userSettings from 'stores/userSettings'; -import { DetailedUser, ExperimentAction, FlatRun, ProjectColumn } from 'types'; +import { DetailedUser, FlatRun, FlatRunAction, ProjectColumn, RunState } from 'types'; import handleError from 'utils/error'; import { eagerSubscribe } from 'utils/observable'; import { pluralizer } from 'utils/string'; @@ -185,6 +187,7 @@ const FlatRuns: React.FC = ({ projectId, workspaceId, searchId }) => { const [canceler] = useState(new AbortController()); const users = useObservable>(userStore.getUsers()); + const { openToast } = useToast(); const { width: containerWidth } = useResize(contentRef); const { @@ -712,8 +715,59 @@ const FlatRuns: React.FC = ({ projectId, workspaceId, searchId }) => { await fetchRuns(); }, [fetchRuns, handleSelectionChange]); - const handleContextMenuComplete: ContextMenuCompleteHandlerProps = - useCallback(() => {}, []); + const handleActionSuccess = useCallback( + (action: FlatRunAction, id: number): void => { + const updateRun = (updated: Partial) => { + setRuns((prev) => + prev.map((runs) => + Loadable.map(runs, (run) => { + if (run.id === id) { + return { ...run, ...updated }; + } + return run; + }), + ), + ); + }; + switch (action) { + case FlatRunAction.Archive: + updateRun({ archived: true }); + break; + case FlatRunAction.Kill: + updateRun({ state: RunState.StoppingKilled }); + break; + case FlatRunAction.Unarchive: + updateRun({ archived: false }); + break; + case FlatRunAction.Move: + case FlatRunAction.Delete: + setRuns((prev) => + prev.filter((runs) => + Loadable.match(runs, { + _: () => true, + Loaded: (run) => run.id !== id, + }), + ), + ); + break; + default: + break; + } + openToast({ + severity: 'Confirm', + title: `Run ${action.split('')[action.length - 1] === 'e' ? action.toLowerCase() : `${action.toLowerCase()}e`}d successfully`, + }); + }, + [openToast], + ); + + const handleContextMenuComplete: ContextMenuCompleteHandlerProps = + useCallback( + (action: FlatRunAction, id: number) => { + handleActionSuccess(action, id); + }, + [handleActionSuccess], + ); const handleColumnsOrderChange = useCallback( // changing both column order and pinned count should happen in one update: @@ -1056,13 +1110,35 @@ const FlatRuns: React.FC = ({ projectId, workspaceId, searchId }) => { projectId={projectId} selectedRuns={loadedSelectedRuns} onWidthChange={handleCompareWidthChange}> - columns={columns} data={runs} getHeaderMenuItems={getHeaderMenuItems} getRowAccentColor={getRowAccentColor} imperativeRef={dataGridRef} pinnedColumnsCount={isLoadingSettings ? 0 : settings.pinnedColumnsCount} + renderContextMenuComponent={({ + cell, + rowData, + link, + open, + onComplete, + onClose, + onVisibleChange, + }) => { + return ( + + ); + }} rowHeight={rowHeightMap[globalSettings.rowHeight as RowHeight]} selection={selection} sorts={sorts} diff --git a/webui/react/src/pages/FlatRuns/columns.ts b/webui/react/src/pages/FlatRuns/columns.ts index c0c80e13e29..6523d6f4fbe 100644 --- a/webui/react/src/pages/FlatRuns/columns.ts +++ b/webui/react/src/pages/FlatRuns/columns.ts @@ -473,7 +473,7 @@ export const getColumnDefs = ({ }); return { allowOverlay: true, - copyData: String(record.userId), + copyData: String(displayName), data: { image: undefined, initials: getInitials(displayName), diff --git a/webui/react/src/routes/utils.ts b/webui/react/src/routes/utils.ts index 03ef0f83b5b..14494d4d10e 100644 --- a/webui/react/src/routes/utils.ts +++ b/webui/react/src/routes/utils.ts @@ -1,4 +1,5 @@ import { pathToRegexp } from 'path-to-regexp'; +import { KeyboardEvent } from 'react'; import { globalStorage } from 'globalStorage'; import { ClusterApi, Configuration } from 'services/api-ts-sdk'; @@ -9,6 +10,7 @@ import { AnyMouseEventHandler, isAbsolutePath, isFullPath, + isMouseEvent, isNewTabClickEvent, openBlank, reactHostAddress, @@ -200,7 +202,7 @@ export const routeAll = (path: string): void => { } }; export const handlePath = ( - event: AnyMouseEvent, + event: AnyMouseEvent | KeyboardEvent, options: { external?: boolean; onClick?: AnyMouseEventHandler; @@ -212,10 +214,10 @@ export const handlePath = ( const href = options.path ? linkPath(options.path, options.external) : undefined; - if (options.onClick) { + if (options.onClick && isMouseEvent(event)) { options.onClick(event); } else if (href) { - if (isNewTabClickEvent(event) || options.popout) { + if ((isMouseEvent(event) && isNewTabClickEvent(event)) || options.popout) { /** * `location=0` forces a new window instead of a tab to open. * https://stackoverflow.com/questions/726761/javascript-open-in-a-new-window-not-tab