Skip to content

Commit

Permalink
feat: Allow cloning of running executions (#93)
Browse files Browse the repository at this point in the history
* feat: add clone functionality for running executions

* fix: updating deprecated method in test
  • Loading branch information
schottra authored Sep 2, 2020
1 parent e0b1f5c commit dfa7e26
Show file tree
Hide file tree
Showing 8 changed files with 298 additions and 20 deletions.
4 changes: 2 additions & 2 deletions src/components/Errors/test/DataError.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ describe('DataError', () => {
const { container } = render(
<DataError {...defaultProps} error={new NotAuthorizedError()} />
);
expect(container).toBeEmpty();
expect(container).toBeEmptyDOMElement();
});

it('renders not found for NotFound errors', () => {
const { getByText } = render(
<DataError {...defaultProps} error={new NotFoundError('')} />
);
expect(getByText('Not found')).not.toBeEmpty();
expect(getByText('Not found')).not.toBeEmptyDOMElement();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { makeStyles, Theme } from '@material-ui/core/styles';
import ArrowBack from '@material-ui/icons/ArrowBack';
import * as classnames from 'classnames';
import { navbarGridHeight } from 'common/layout';
import { MoreOptionsMenu } from 'components/common/MoreOptionsMenu';
import { useCommonStyles } from 'components/common/styles';
import { useLocationState } from 'components/hooks/useLocationState';
import { NavBarContent } from 'components/Navigation/NavBarContent';
import { interactiveTextDisabledColor, smallFontSize } from 'components/Theme';
import { interactiveTextDisabledColor } from 'components/Theme';
import { Execution } from 'models';
import * as React from 'react';
import { Link as RouterLink } from 'react-router-dom';
Expand All @@ -15,10 +16,14 @@ import { ExecutionInputsOutputsModal } from '../ExecutionInputsOutputsModal';
import { ExecutionStatusBadge } from '../ExecutionStatusBadge';
import { TerminateExecutionButton } from '../TerminateExecution';
import { executionIsTerminal } from '../utils';
import { executionActionStrings } from './constants';
import { RelaunchExecutionForm } from './RelaunchExecutionForm';

const useStyles = makeStyles((theme: Theme) => {
return {
actionButton: {
marginLeft: theme.spacing(2)
},
actions: {
alignItems: 'center',
display: 'flex',
Expand All @@ -37,24 +42,25 @@ const useStyles = makeStyles((theme: Theme) => {
flex: '1 1 auto',
maxWidth: '100%'
},
titleContainer: {
alignItems: 'center',
display: 'flex',
flex: '0 1 auto',
flexDirection: 'column',
maxHeight: theme.spacing(navbarGridHeight),
overflow: 'hidden'
},
inputsOutputsLink: {
color: interactiveTextDisabledColor
},
actionButton: {
marginLeft: theme.spacing(2)
moreActions: {
marginLeft: theme.spacing(1),
marginRight: theme.spacing(-2)
},
title: {
flex: '0 1 auto',
marginLeft: theme.spacing(2)
},
titleContainer: {
alignItems: 'center',
display: 'flex',
flex: '0 1 auto',
flexDirection: 'column',
maxHeight: theme.spacing(navbarGridHeight),
overflow: 'hidden'
},
version: {
flex: '0 1 auto',
overflow: 'hidden'
Expand All @@ -70,17 +76,19 @@ export const ExecutionDetailsAppBarContent: React.FC<{
const styles = useStyles();
const [showInputsOutputs, setShowInputsOutputs] = React.useState(false);
const [showRelaunchForm, setShowRelaunchForm] = React.useState(false);

const { domain, name, project } = execution.id;
const { phase, workflowId } = execution.closure;

const {
backLink = Routes.WorkflowDetails.makeUrl(
workflowId.project,
workflowId.domain,
workflowId.name
)
} = useLocationState();
const isTerminal = executionIsTerminal(execution);
const onClickShowInputsOutputs = () => setShowInputsOutputs(true);
const onClickRelaunch = () => setShowRelaunchForm(true);
const onCloseRelaunch = () => setShowRelaunchForm(false);

let modalContent: JSX.Element | null = null;
if (showInputsOutputs) {
Expand All @@ -92,11 +100,8 @@ export const ExecutionDetailsAppBarContent: React.FC<{
/>
);
}
const onClickShowInputsOutputs = () => setShowInputsOutputs(true);
const onClickRelaunch = () => setShowRelaunchForm(true);
const onCloseRelaunch = () => setShowRelaunchForm(false);

const actionContent = executionIsTerminal(execution) ? (
const actionContent = isTerminal ? (
<Button
variant="outlined"
color="primary"
Expand All @@ -113,6 +118,20 @@ export const ExecutionDetailsAppBarContent: React.FC<{
<TerminateExecutionButton className={styles.actionButton} />
);

// For running executions, add an overflow menu with the ability to clone
// while we are still running.
const moreActionsContent = !isTerminal ? (
<MoreOptionsMenu
className={styles.moreActions}
options={[
{
label: executionActionStrings.clone,
onClick: onClickRelaunch
}
]}
/>
) : null;

return (
<>
<NavBarContent>
Expand Down Expand Up @@ -145,6 +164,7 @@ export const ExecutionDetailsAppBarContent: React.FC<{
View Inputs &amp; Outputs
</Link>
{actionContent}
{moreActionsContent}
</div>
</div>
<Dialog
Expand Down
4 changes: 4 additions & 0 deletions src/components/Executions/ExecutionDetails/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ export const tabs = {
label: 'Graph'
}
};

export const executionActionStrings = {
clone: 'Clone Execution'
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
fireEvent,
render,
RenderResult,
waitFor
} from '@testing-library/react';
import { labels as commonLabels } from 'components/common/constants';
import {
ExecutionContext,
ExecutionContextData
} from 'components/Executions/contexts';
import { Execution } from 'models';
import { createMockExecution } from 'models/__mocks__/executionsData';
import { WorkflowExecutionPhase } from 'models/Execution/enums';
import * as React from 'react';
import { MemoryRouter } from 'react-router';
import { delayedPromise, DelayedPromiseResult } from 'test/utils';
import { executionActionStrings } from '../constants';
import { ExecutionDetailsAppBarContent } from '../ExecutionDetailsAppBarContent';

jest.mock('components/Navigation/NavBarContent', () => ({
NavBarContent: ({ children }: React.Props<any>) => children
}));

describe('ExecutionDetailsAppBarContent', () => {
let execution: Execution;
let executionContext: ExecutionContextData;
let mockTerminateExecution: jest.Mock<Promise<void>>;
let terminatePromise: DelayedPromiseResult<void>;

beforeEach(() => {
execution = createMockExecution();
mockTerminateExecution = jest.fn().mockImplementation(() => {
terminatePromise = delayedPromise();
return terminatePromise;
});
executionContext = {
execution,
terminateExecution: mockTerminateExecution
};
});

const renderContent = () =>
render(
<MemoryRouter>
<ExecutionContext.Provider value={executionContext}>
<ExecutionDetailsAppBarContent execution={execution} />
</ExecutionContext.Provider>
</MemoryRouter>
);

describe('for running executions', () => {
beforeEach(() => {
execution.closure.phase = WorkflowExecutionPhase.RUNNING;
});

it('renders an overflow menu', async () => {
const { getByLabelText } = renderContent();
await waitFor(() => getByLabelText(commonLabels.moreOptionsButton));
});

describe('in overflow menu', () => {
let renderResult: RenderResult;
let buttonEl: HTMLElement;
let menuEl: HTMLElement;

beforeEach(async () => {
renderResult = renderContent();
const { getByLabelText } = renderResult;
buttonEl = await waitFor(() =>
getByLabelText(commonLabels.moreOptionsButton)
);
fireEvent.click(buttonEl);
menuEl = await waitFor(() =>
getByLabelText(commonLabels.moreOptionsMenu)
);
});

it('renders a clone option', () => {
const { getByText } = renderResult;
expect(
getByText(executionActionStrings.clone)
).toBeInTheDocument();
});
});
});

describe('for terminal executions', () => {
beforeEach(() => {
execution.closure.phase = WorkflowExecutionPhase.SUCCEEDED;
});

it('does not render an overflow menu', async () => {
const { queryByLabelText } = renderContent();
expect(queryByLabelText(commonLabels.moreOptionsButton)).toBeNull();
});
});
});
79 changes: 79 additions & 0 deletions src/components/common/MoreOptionsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import IconButton from '@material-ui/core/IconButton';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import MoreVert from '@material-ui/icons/MoreVert';
import * as React from 'react';
import { labels } from './constants';

export interface MoreOptionsMenuItem {
label: string;
onClick: () => void;
}

export interface MoreOptionsMenuProps {
className?: string;
options: MoreOptionsMenuItem[];
}
/** Renders a vertical three-dots menu button with the provided options.
* Each option should have a label and corresponding onClick handler, which will
* be invoked when the item is clicked.
*/
export const MoreOptionsMenu: React.FC<MoreOptionsMenuProps> = ({
className,
options
}) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const {
handleClose,
handleClickMenuButton,
listItems
} = React.useMemo(() => {
const handleClickMenuButton = (
event: React.MouseEvent<HTMLButtonElement>
) => {
setAnchorEl(event.currentTarget);
};

const handleClose = () => {
setAnchorEl(null);
};

const listItems = options.map(({ label, onClick: handleItemClick }) => {
const onClick = () => {
setAnchorEl(null);
handleItemClick();
};

return (
<MenuItem key={label} onClick={onClick}>
{label}
</MenuItem>
);
});

return { handleClickMenuButton, handleClose, listItems };
}, [options, setAnchorEl]);

return (
<div className={className}>
<IconButton
aria-controls="more-options-menu"
aria-haspopup="true"
aria-label={labels.moreOptionsButton}
color="inherit"
onClick={handleClickMenuButton}
>
<MoreVert />
</IconButton>
<Menu
aria-label={labels.moreOptionsMenu}
id="more-options-menu"
anchorEl={anchorEl}
open={!!anchorEl}
onClose={handleClose}
>
{listItems}
</Menu>
</div>
);
};
5 changes: 5 additions & 0 deletions src/components/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export const detailsPanelWidth = 432;

export const labels = {
moreOptionsButton: 'Display more options',
moreOptionsMenu: 'More options menu'
};
Loading

0 comments on commit dfa7e26

Please sign in to comment.