Skip to content

Commit

Permalink
[AN-150] FC UI migration - Adds Clone method snapshot functionality (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
salonishah11 authored Nov 18, 2024
1 parent b8baa5b commit a4b8555
Show file tree
Hide file tree
Showing 10 changed files with 394 additions and 65 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Methods, MethodsAjaxContract } from 'src/libs/ajax/methods/Methods';
import { MethodResponse } from 'src/libs/ajax/methods/methods-models';
import { createMethodProvider } from 'src/libs/ajax/methods/providers/CreateMethodProvider';
import { postMethodProvider } from 'src/libs/ajax/methods/providers/PostMethodProvider';
import { asMockedFn, partial } from 'src/testing/test-utils';

jest.mock('src/libs/ajax/methods/Methods');
Expand Down Expand Up @@ -29,15 +29,15 @@ const mockMethodResponse: MethodResponse = {
url: 'http://agora.dsde-dev.broadinstitute.org/api/v1/methods/sschu/response-test/1',
};

describe('create method provider', () => {
it('handles create call', async () => {
describe('post method provider', () => {
it('handles post call', async () => {
// Arrange
const methodsMock = mockMethodsNeeds();
asMockedFn(methodsMock.postMethod).mockResolvedValue(mockMethodResponse);
const signal = new window.AbortController().signal;

// Act
const result = await createMethodProvider.create(
const result = await postMethodProvider.postMethod(
'input-namespace',
'input-name',
'workflow input {}',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { AbortOption } from '@terra-ui-packages/data-client-core';
import { Methods } from 'src/libs/ajax/methods/Methods';
import { MethodResponse } from 'src/libs/ajax/methods/methods-models';

export interface CreateMethodProvider {
create: (
export interface PostMethodProvider {
postMethod: (
namespace: string,
name: string,
wdl: string,
Expand All @@ -14,8 +14,8 @@ export interface CreateMethodProvider {
) => Promise<MethodResponse>;
}

export const createMethodProvider: CreateMethodProvider = {
create: async (
export const postMethodProvider: PostMethodProvider = {
postMethod: async (
namespace: string,
name: string,
wdl: string,
Expand Down
6 changes: 3 additions & 3 deletions src/pages/methods/WorkflowList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Ajax, AjaxContract } from 'src/libs/ajax';
import { MethodsAjaxContract } from 'src/libs/ajax/methods/Methods';
import { MethodResponse } from 'src/libs/ajax/methods/methods-models';
import { MethodDefinition } from 'src/libs/ajax/methods/methods-models';
import { createMethodProvider } from 'src/libs/ajax/methods/providers/CreateMethodProvider';
import { postMethodProvider } from 'src/libs/ajax/methods/providers/PostMethodProvider';
import * as Nav from 'src/libs/nav';
import { getLink } from 'src/libs/nav';
import { notify } from 'src/libs/notifications';
Expand Down Expand Up @@ -950,7 +950,7 @@ describe('create workflow modal', () => {
// Arrange
asMockedFn(Ajax).mockImplementation(() => mockAjax([]) as AjaxContract);

jest.spyOn(createMethodProvider, 'create').mockResolvedValue(mockCreateMethodResponse);
jest.spyOn(postMethodProvider, 'postMethod').mockResolvedValue(mockCreateMethodResponse);

const user: UserEvent = userEvent.setup();

Expand All @@ -973,7 +973,7 @@ describe('create workflow modal', () => {
await user.click(screen.getByRole('button', { name: 'Upload' }));

// Assert
expect(createMethodProvider.create).toHaveBeenCalled();
expect(postMethodProvider.postMethod).toHaveBeenCalled();

expect(Nav.goToPath).toHaveBeenCalledWith('workflow-dashboard', {
name: 'response-name',
Expand Down
4 changes: 2 additions & 2 deletions src/pages/methods/WorkflowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { FlexTable, HeaderCell, Paginator, Sortable, TooltipCell } from 'src/com
import { TopBar } from 'src/components/TopBar';
import { Ajax } from 'src/libs/ajax';
import { MethodDefinition } from 'src/libs/ajax/methods/methods-models';
import { createMethodProvider } from 'src/libs/ajax/methods/providers/CreateMethodProvider';
import { postMethodProvider } from 'src/libs/ajax/methods/providers/PostMethodProvider';
import * as Nav from 'src/libs/nav';
import { notify } from 'src/libs/notifications';
import { useCancellation, useOnMount } from 'src/libs/react-utils';
Expand Down Expand Up @@ -291,7 +291,7 @@ export const WorkflowList = (props: WorkflowListProps) => {
<WorkflowModal
title='Create New Method'
buttonActionName='Upload'
createMethodProvider={createMethodProvider}
postMethodProvider={postMethodProvider}
onSuccess={navigateToWorkflow}
onDismiss={() => setCreateWorkflowModalOpen(false)}
/>
Expand Down
165 changes: 162 additions & 3 deletions src/pages/methods/workflow-details/WorkflowWrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import React from 'react';
import * as breadcrumbs from 'src/components/breadcrumbs';
import { Ajax, AjaxContract } from 'src/libs/ajax';
import { MethodsAjaxContract } from 'src/libs/ajax/methods/Methods';
import { Snapshot } from 'src/libs/ajax/methods/methods-models';
import { MethodResponse, Snapshot } from 'src/libs/ajax/methods/methods-models';
import { postMethodProvider } from 'src/libs/ajax/methods/providers/PostMethodProvider';
import * as ExportWorkflowToWorkspaceProvider from 'src/libs/ajax/workspaces/providers/ExportWorkflowToWorkspaceProvider';
import { errorWatcher } from 'src/libs/error.mock';
import { goToPath } from 'src/libs/nav';
import * as Nav from 'src/libs/nav';
import { forwardRefWithName } from 'src/libs/react-utils';
import { snapshotsListStore, snapshotStore, TerraUser, TerraUserState, userStore } from 'src/libs/state';
import { WorkflowsContainer, wrapWorkflows } from 'src/pages/methods/workflow-details/WorkflowWrapper';
Expand All @@ -22,6 +24,14 @@ jest.mock('src/libs/notifications');

type NavExports = typeof import('src/libs/nav');

type WDLEditorExports = typeof import('src/workflows/methods/WDLEditor');
jest.mock('src/workflows/methods/WDLEditor', (): WDLEditorExports => {
const mockWDLEditorModule = jest.requireActual('src/workflows/methods/WDLEditor.mock');
return {
WDLEditor: mockWDLEditorModule.MockWDLEditor,
};
});

jest.mock(
'src/libs/nav',
(): NavExports => ({
Expand All @@ -37,9 +47,9 @@ const mockSnapshot: Snapshot = {
managers: ['hello@world.org'],
name: 'testname',
createDate: '2024-09-04T15:37:57Z',
documentation: '',
documentation: 'mock documentation',
entityType: 'Workflow',
snapshotComment: '',
snapshotComment: 'mock snapshot comment',
snapshotId: 1,
namespace: 'testnamespace',
payload:
Expand Down Expand Up @@ -251,6 +261,21 @@ const snapshotStoreInitialValue = {
url: '',
};

const mockCloneSnapshotResponse: MethodResponse = {
name: 'testname_copy',
createDate: '2024-11-15T15:41:38Z',
documentation: 'mock documentation',
synopsis: '',
entityType: 'Workflow',
snapshotComment: 'groot-new-snapshot',
snapshotId: 1,
namespace: 'groot-new-namespace',
payload:
// eslint-disable-next-line no-template-curly-in-string
'task echo_files {\\n String? input1\\n String? input2\\n String? input3\\n \\n output {\\n String out = read_string(stdout())\\n }\\n\\n command {\\n echo \\"result: ${input1} ${input2} ${input3}\\"\\n }\\n\\n runtime {\\n docker: \\"ubuntu:latest\\"\\n }\\n}\\n\\nworkflow echo_strings {\\n call echo_files\\n}',
url: 'http://agora.dsde-dev.broadinstitute.org/api/v1/methods/groot-new-namespace/testname_copy/1',
};

describe('workflow wrapper', () => {
it('displays the method not found page if a method does not exist or the user does not have access', async () => {
// Arrange
Expand Down Expand Up @@ -407,6 +432,140 @@ describe('workflows container', () => {
expect(goToPath).toHaveBeenCalledWith('workflows');
});

it('renders the save as new method modal when the corresponding button is pressed', async () => {
// Arrange
mockAjax();

// set the user's email
jest.spyOn(userStore, 'get').mockImplementation(jest.fn().mockReturnValue(mockUserState('hello@world.org')));

const user: UserEvent = userEvent.setup();

// Act
await act(async () => {
render(
<WorkflowsContainer
namespace={mockSnapshot.namespace}
name={mockSnapshot.name}
snapshotId={`${mockSnapshot.snapshotId}`}
tabName='dashboard'
/>
);
});

await user.click(screen.getByRole('button', { name: 'Snapshot action menu' }));
await user.click(screen.getByRole('button', { name: 'Save as' }));

const dialog = screen.getByRole('dialog', { name: /create new method/i });

// Assert
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole('textbox', { name: 'Namespace *' })).toHaveDisplayValue('');
expect(within(dialog).getByRole('textbox', { name: 'Name *' })).toHaveDisplayValue('testname_copy');
expect(within(dialog).getByRole('textbox', { name: 'Documentation' })).toHaveDisplayValue('mock documentation');
expect(within(dialog).getByRole('textbox', { name: 'Synopsis (80 characters max)' })).toHaveDisplayValue('');
expect(within(dialog).getByRole('textbox', { name: 'Snapshot comment' })).toHaveDisplayValue('');
expect(within(dialog).getByTestId('wdl editor')).toHaveDisplayValue(mockSnapshot.payload.toString());
});

it('calls right provider with expected arguments when snapshot is cloned', async () => {
// Arrange
mockAjax();

jest.spyOn(postMethodProvider, 'postMethod').mockResolvedValue(mockCloneSnapshotResponse);

const user: UserEvent = userEvent.setup();

// Act
await act(async () => {
render(
<WorkflowsContainer
namespace={mockSnapshot.namespace}
name={mockSnapshot.name}
snapshotId={`${mockSnapshot.snapshotId}`}
tabName='dashboard'
/>
);
});

await user.click(screen.getByRole('button', { name: 'Snapshot action menu' }));
await user.click(screen.getByRole('button', { name: 'Save as' }));

const dialog = screen.getByRole('dialog', { name: /create new method/i });

// Assert
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole('textbox', { name: 'Namespace *' })).toHaveDisplayValue('');
expect(within(dialog).getByRole('textbox', { name: 'Name *' })).toHaveDisplayValue('testname_copy');
expect(within(dialog).getByRole('textbox', { name: 'Documentation' })).toHaveDisplayValue('mock documentation');
expect(within(dialog).getByRole('textbox', { name: 'Synopsis (80 characters max)' })).toHaveDisplayValue('');
expect(within(dialog).getByRole('textbox', { name: 'Snapshot comment' })).toHaveDisplayValue('');
expect(within(dialog).getByTestId('wdl editor')).toHaveDisplayValue(mockSnapshot.payload.toString());

// Act
fireEvent.change(screen.getByRole('textbox', { name: 'Namespace *' }), {
target: { value: 'groot-new-namespace' },
});
fireEvent.change(screen.getByRole('textbox', { name: 'Snapshot comment' }), {
target: { value: 'groot-new-snapshot' },
});

await user.click(screen.getByRole('button', { name: 'Create new method' }));

// Assert
expect(postMethodProvider.postMethod).toHaveBeenCalled();
expect(postMethodProvider.postMethod).toHaveBeenCalledWith(
'groot-new-namespace',
'testname_copy',
mockSnapshot.payload,
'mock documentation',
'',
'groot-new-snapshot'
);

expect(Nav.goToPath).toHaveBeenCalledWith('workflow-dashboard', {
name: 'testname_copy',
namespace: 'groot-new-namespace',
snapshotId: 1,
});
});

it('hides the save as new method modal when it is dismissed', async () => {
// Arrange
mockAjax();

// set the user's email
jest.spyOn(userStore, 'get').mockImplementation(jest.fn().mockReturnValue(mockUserState('hello@world.org')));

const user: UserEvent = userEvent.setup();

// Act
await act(async () => {
render(
<WorkflowsContainer
namespace={mockSnapshot.namespace}
name={mockSnapshot.name}
snapshotId={`${mockSnapshot.snapshotId}`}
tabName='dashboard'
/>
);
});

await user.click(screen.getByRole('button', { name: 'Snapshot action menu' }));
await user.click(screen.getByRole('button', { name: 'Save as' }));

// Assert
const dialog1 = screen.queryByRole('dialog', { name: /create new method/i });
expect(dialog1).toBeInTheDocument();

// Act
await user.click(screen.getByRole('button', { name: 'Cancel' }));

// Assert
const dialog2 = screen.queryByRole('dialog', { name: /create new method/i });
expect(dialog2).not.toBeInTheDocument();
});

it('hides the delete snapshot modal and displays a loading spinner when the deletion is confirmed', async () => {
// Arrange
mockAjax({
Expand Down
28 changes: 28 additions & 0 deletions src/pages/methods/workflow-details/WorkflowWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TabBar } from 'src/components/tabBars';
import { TopBar } from 'src/components/TopBar';
import { Ajax } from 'src/libs/ajax';
import { Snapshot } from 'src/libs/ajax/methods/methods-models';
import { postMethodProvider } from 'src/libs/ajax/methods/providers/PostMethodProvider';
import { makeExportWorkflowFromMethodsRepoProvider } from 'src/libs/ajax/workspaces/providers/ExportWorkflowToWorkspaceProvider';
import { ErrorCallback, withErrorReporting } from 'src/libs/error';
import * as Nav from 'src/libs/nav';
Expand All @@ -19,6 +20,7 @@ import * as Utils from 'src/libs/utils';
import { withBusyState } from 'src/libs/utils';
import DeleteSnapshotModal from 'src/workflows/methods/modals/DeleteSnapshotModal';
import { PermissionsModal } from 'src/workflows/methods/modals/PermissionsModal';
import { WorkflowModal } from 'src/workflows/methods/modals/WorkflowModal';
import SnapshotActionMenu from 'src/workflows/methods/SnapshotActionMenu';
import ExportWorkflowModal from 'src/workflows/modals/ExportWorkflowModal';
import { isGoogleWorkspace, WorkspaceInfo, WorkspaceWrapper } from 'src/workspaces/utils';
Expand Down Expand Up @@ -122,6 +124,7 @@ export const WorkflowsContainer = (props: WorkflowContainerProps) => {
const [snapshotNotFound, setSnapshotNotFound] = useState<boolean>(false);
const [exportingWorkflow, setExportingWorkflow] = useState<boolean>(false);
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const [showCloneModal, setShowCloneModal] = useState<boolean>(false);
const [busy, setBusy] = useState<boolean>(false);
const [permissionsModalOpen, setPermissionsModalOpen] = useState<boolean>(false);

Expand Down Expand Up @@ -239,6 +242,7 @@ export const WorkflowsContainer = (props: WorkflowContainerProps) => {
isSnapshotOwner,
onEditPermissions: () => setPermissionsModalOpen(true),
onDelete: () => setShowDeleteModal(true),
onClone: () => setShowCloneModal(true),
}),
]),
]
Expand Down Expand Up @@ -289,6 +293,30 @@ export const WorkflowsContainer = (props: WorkflowContainerProps) => {
setPermissionsModalOpen,
refresh: loadSnapshot,
}),
showCloneModal &&
h(WorkflowModal, {
title: 'Create new method',
defaultName: name.concat('_copy'),
defaultWdl: snapshot!.payload,
defaultDocumentation: snapshot!.documentation,
defaultSynopsis: snapshot!.synopsis,
buttonActionName: 'Create new method',
postMethodProvider,
onSuccess: (namespace: string, name: string, snapshotId: number) => {
// when the user has owner permissions on the original method, there is an interesting situation where
// if the user types in the same namespace and name for the cloned method as the original method,
// instead of creating a new method Agora will create a new snapshot of the original method.
// Hence, to ensure the data is correct in the UI we reset the cached snapshot list store and then load the page.
// (Note: this behaviour is same as in Firecloud UI)
snapshotsListStore.reset();
Nav.goToPath('workflow-dashboard', {
namespace,
name,
snapshotId,
});
},
onDismiss: () => setShowCloneModal(false),
}),
busy && spinnerOverlay,
snapshotNotFound && h(NotFoundMessage, { subject: 'snapshot' }),
snapshot && div({ style: { flex: 1, display: 'flex', flexDirection: 'column' } }, [children]),
Expand Down
Loading

0 comments on commit a4b8555

Please sign in to comment.