mirror of
https://github.com/ansible/awx.git
synced 2026-05-10 10:57:35 -02:30
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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user