Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow cloning of running executions #93

Merged
merged 2 commits into from
Sep 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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