Merge pull request #9925 from keithjgrant/4249-prevent-double-launch

Prevent double-clicking/double-launching jobs

SUMMARY
Prevents double-launching a job if the user double-clicks the launch icon. This is done by disabling the button upon first launch. Applied to all instances of <LaunchButton>.
Addresses: #4249
ISSUE TYPE

Bugfix Pull Request

COMPONENT NAME

UI

Reviewed-by: Alex Corey <Alex.swansboro@gmail.com>
Reviewed-by: Kersom <None>
Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-04-19 22:17:21 +00:00
committed by GitHub
11 changed files with 90 additions and 17 deletions

View File

@@ -91,18 +91,22 @@ function JobListItem({
> >
{job.status === 'failed' && job.type === 'job' ? ( {job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}> <LaunchButton resource={job}>
{({ handleRelaunch }) => ( {({ handleRelaunch, isLaunching }) => (
<ReLaunchDropDown handleRelaunch={handleRelaunch} /> <ReLaunchDropDown
handleRelaunch={handleRelaunch}
isLaunching={isLaunching}
/>
)} )}
</LaunchButton> </LaunchButton>
) : ( ) : (
<LaunchButton resource={job}> <LaunchButton resource={job}>
{({ handleRelaunch }) => ( {({ handleRelaunch, isLaunching }) => (
<Button <Button
ouiaId={`${job.id}-relaunch-button`} ouiaId={`${job.id}-relaunch-button`}
variant="plain" variant="plain"
onClick={handleRelaunch} onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)} aria-label={i18n._(t`Relaunch`)}
isDisabled={isLaunching}
> >
<RocketIcon /> <RocketIcon />
</Button> </Button>

View File

@@ -36,9 +36,12 @@ function LaunchButton({ resource, i18n, children, history }) {
const [showLaunchPrompt, setShowLaunchPrompt] = useState(false); const [showLaunchPrompt, setShowLaunchPrompt] = useState(false);
const [launchConfig, setLaunchConfig] = useState(null); const [launchConfig, setLaunchConfig] = useState(null);
const [surveyConfig, setSurveyConfig] = useState(null); const [surveyConfig, setSurveyConfig] = useState(null);
const [isLaunching, setIsLaunching] = useState(false);
const [resourceCredentials, setResourceCredentials] = useState([]); const [resourceCredentials, setResourceCredentials] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const handleLaunch = async () => { const handleLaunch = async () => {
setIsLaunching(true);
const readLaunch = const readLaunch =
resource.type === 'workflow_job_template' resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(resource.id) ? WorkflowJobTemplatesAPI.readLaunch(resource.id)
@@ -96,6 +99,8 @@ function LaunchButton({ resource, i18n, children, history }) {
history.push(`/jobs/${job.id}/output`); history.push(`/jobs/${job.id}/output`);
} catch (launchError) { } catch (launchError) {
setError(launchError); setError(launchError);
} finally {
setIsLaunching(false);
} }
}; };
@@ -103,6 +108,7 @@ function LaunchButton({ resource, i18n, children, history }) {
let readRelaunch; let readRelaunch;
let relaunch; let relaunch;
setIsLaunching(true);
if (resource.type === 'inventory_update') { if (resource.type === 'inventory_update') {
// We'll need to handle the scenario where the src no longer exists // We'll need to handle the scenario where the src no longer exists
readRelaunch = InventorySourcesAPI.readLaunchUpdate( readRelaunch = InventorySourcesAPI.readLaunchUpdate(
@@ -146,6 +152,8 @@ function LaunchButton({ resource, i18n, children, history }) {
} }
} catch (err) { } catch (err) {
setError(err); setError(err);
} finally {
setIsLaunching(false);
} }
}; };
@@ -154,6 +162,7 @@ function LaunchButton({ resource, i18n, children, history }) {
{children({ {children({
handleLaunch, handleLaunch,
handleRelaunch, handleRelaunch,
isLaunching,
})} })}
{error && ( {error && (
<AlertModal <AlertModal

View File

@@ -114,6 +114,49 @@ describe('LaunchButton', () => {
expect(history.location.pathname).toEqual('/jobs/9000/output'); expect(history.location.pathname).toEqual('/jobs/9000/output');
}); });
test('should disable button to prevent duplicate clicks', async () => {
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
WorkflowJobTemplatesAPI.launch.mockImplementation(async () => {
// return asynchronously so isLaunching isn't set back to false in the
// same tick
await sleep(10);
return {
data: {
id: 9000,
},
};
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'workflow_job_template',
}}
>
{({ handleLaunch, isLaunching }) => (
<button type="submit" onClick={handleLaunch} disabled={isLaunching} />
)}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
await act(() => button.prop('onClick')());
wrapper.update();
expect(wrapper.find('button').prop('disabled')).toEqual(true);
});
test('should relaunch job correctly', async () => { test('should relaunch job correctly', async () => {
JobsAPI.readRelaunch.mockResolvedValue({ JobsAPI.readRelaunch.mockResolvedValue({
data: { data: {

View File

@@ -11,11 +11,17 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons'; import { RocketIcon } from '@patternfly/react-icons';
function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n, ouiaId }) { function ReLaunchDropDown({
const [isOpen, setIsOPen] = useState(false); isPrimary = false,
handleRelaunch,
isLaunching,
i18n,
ouiaId,
}) {
const [isOpen, setIsOpen] = useState(false);
const onToggle = () => { const onToggle = () => {
setIsOPen(prev => !prev); setIsOpen(prev => !prev);
}; };
const dropdownItems = [ const dropdownItems = [
@@ -35,6 +41,7 @@ function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n, ouiaId }) {
onClick={() => { onClick={() => {
handleRelaunch({ hosts: 'all' }); handleRelaunch({ hosts: 'all' });
}} }}
isDisabled={isLaunching}
> >
{i18n._(t`All`)} {i18n._(t`All`)}
</DropdownItem>, </DropdownItem>,
@@ -46,6 +53,7 @@ function ReLaunchDropDown({ isPrimary = false, handleRelaunch, i18n, ouiaId }) {
onClick={() => { onClick={() => {
handleRelaunch({ hosts: 'failed' }); handleRelaunch({ hosts: 'failed' });
}} }}
isDisabled={isLaunching}
> >
{i18n._(t`Failed hosts`)} {i18n._(t`Failed hosts`)}
</DropdownItem>, </DropdownItem>,

View File

@@ -176,11 +176,11 @@ function TemplateListItem({
tooltip={i18n._(t`Launch Template`)} tooltip={i18n._(t`Launch Template`)}
> >
<LaunchButton resource={template}> <LaunchButton resource={template}>
{({ handleLaunch }) => ( {({ handleLaunch, isLaunching }) => (
<Button <Button
ouiaId={`${template.id}-launch-button`} ouiaId={`${template.id}-launch-button`}
id={`template-action-launch-${template.id}`} id={`template-action-launch-${template.id}`}
isDisabled={isDisabled} isDisabled={isDisabled || isLaunching}
aria-label={i18n._(t`Launch template`)} aria-label={i18n._(t`Launch template`)}
variant="plain" variant="plain"
onClick={handleLaunch} onClick={handleLaunch}

View File

@@ -371,21 +371,23 @@ function JobDetail({ job, i18n }) {
job.summary_fields.user_capabilities.start && job.summary_fields.user_capabilities.start &&
(job.status === 'failed' && job.type === 'job' ? ( (job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}> <LaunchButton resource={job}>
{({ handleRelaunch }) => ( {({ handleRelaunch, isLaunching }) => (
<ReLaunchDropDown <ReLaunchDropDown
ouiaId="job-detail-relaunch-dropdown" ouiaId="job-detail-relaunch-dropdown"
isPrimary isPrimary
handleRelaunch={handleRelaunch} handleRelaunch={handleRelaunch}
isLaunching={isLaunching}
/> />
)} )}
</LaunchButton> </LaunchButton>
) : ( ) : (
<LaunchButton resource={job} aria-label={i18n._(t`Relaunch`)}> <LaunchButton resource={job} aria-label={i18n._(t`Relaunch`)}>
{({ handleRelaunch }) => ( {({ handleRelaunch, isLaunching }) => (
<Button <Button
ouiaId="job-detail-relaunch-button" ouiaId="job-detail-relaunch-button"
type="submit" type="submit"
onClick={handleRelaunch} onClick={handleRelaunch}
isDisabled={isLaunching}
> >
{i18n._(t`Relaunch`)} {i18n._(t`Relaunch`)}
</Button> </Button>

View File

@@ -144,21 +144,23 @@ const OutputToolbar = ({
> >
{job.status === 'failed' && job.type === 'job' ? ( {job.status === 'failed' && job.type === 'job' ? (
<LaunchButton resource={job}> <LaunchButton resource={job}>
{({ handleRelaunch }) => ( {({ handleRelaunch, isLaunching }) => (
<ReLaunchDropDown <ReLaunchDropDown
handleRelaunch={handleRelaunch} handleRelaunch={handleRelaunch}
ouiaId="job-output-relaunch-dropdown" ouiaId="job-output-relaunch-dropdown"
isLaunching={isLaunching}
/> />
)} )}
</LaunchButton> </LaunchButton>
) : ( ) : (
<LaunchButton resource={job}> <LaunchButton resource={job}>
{({ handleRelaunch }) => ( {({ handleRelaunch, isLaunching }) => (
<Button <Button
ouiaId="job-output-relaunch-button" ouiaId="job-output-relaunch-button"
variant="plain" variant="plain"
onClick={handleRelaunch} onClick={handleRelaunch}
aria-label={i18n._(t`Relaunch`)} aria-label={i18n._(t`Relaunch`)}
isDisabled={isLaunching}
> >
<RocketIcon /> <RocketIcon />
</Button> </Button>

View File

@@ -116,12 +116,13 @@ function ProjectJobTemplateListItem({
{canLaunch && template.type === 'job_template' && ( {canLaunch && template.type === 'job_template' && (
<Tooltip content={i18n._(t`Launch Template`)} position="top"> <Tooltip content={i18n._(t`Launch Template`)} position="top">
<LaunchButton resource={template}> <LaunchButton resource={template}>
{({ handleLaunch }) => ( {({ handleLaunch, isLaunching }) => (
<Button <Button
ouiaId={`${template.id}-launch-button`} ouiaId={`${template.id}-launch-button`}
css="grid-column: 1" css="grid-column: 1"
variant="plain" variant="plain"
onClick={handleLaunch} onClick={handleLaunch}
isDisabled={isLaunching}
> >
<RocketIcon /> <RocketIcon />
</Button> </Button>

View File

@@ -387,12 +387,13 @@ function JobTemplateDetail({ i18n, template }) {
)} )}
{canLaunch && ( {canLaunch && (
<LaunchButton resource={template} aria-label={i18n._(t`Launch`)}> <LaunchButton resource={template} aria-label={i18n._(t`Launch`)}>
{({ handleLaunch }) => ( {({ handleLaunch, isLaunching }) => (
<Button <Button
ouiaId="job-template-detail-launch-button" ouiaId="job-template-detail-launch-button"
variant="secondary" variant="secondary"
type="submit" type="submit"
onClick={handleLaunch} onClick={handleLaunch}
isDisabled={isLaunching}
> >
{i18n._(t`Launch`)} {i18n._(t`Launch`)}
</Button> </Button>

View File

@@ -227,12 +227,13 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
)} )}
{canLaunch && ( {canLaunch && (
<LaunchButton resource={template} aria-label={i18n._(t`Launch`)}> <LaunchButton resource={template} aria-label={i18n._(t`Launch`)}>
{({ handleLaunch }) => ( {({ handleLaunch, isLaunching }) => (
<Button <Button
ouiaId="workflow-job-template-detail-launch-button" ouiaId="workflow-job-template-detail-launch-button"
variant="secondary" variant="secondary"
type="submit" type="submit"
onClick={handleLaunch} onClick={handleLaunch}
isDisabled={isLaunching}
> >
{i18n._(t`Launch`)} {i18n._(t`Launch`)}
</Button> </Button>

View File

@@ -125,11 +125,13 @@ function VisualizerToolbar({
resource={template} resource={template}
aria-label={i18n._(t`Launch workflow`)} aria-label={i18n._(t`Launch workflow`)}
> >
{({ handleLaunch }) => ( {({ handleLaunch, isLaunching }) => (
<ActionButton <ActionButton
id="visualizer-launch" id="visualizer-launch"
variant="plain" variant="plain"
isDisabled={hasUnsavedChanges || totalNodes === 0} isDisabled={
hasUnsavedChanges || totalNodes === 0 || isLaunching
}
onClick={handleLaunch} onClick={handleLaunch}
> >
<RocketIcon /> <RocketIcon />