Update UI to support setting "Continue on Error" for tasks

This commit is contained in:
Dane Everitt 2021-05-01 11:24:18 -07:00
parent 92cd659db3
commit ea057cb1cb
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
7 changed files with 149 additions and 124 deletions

View File

@ -5,15 +5,16 @@ interface Data {
action: string; action: string;
payload: string; payload: string;
timeOffset: string | number; timeOffset: string | number;
continueOnFailure: boolean;
} }
export default (uuid: string, schedule: number, task: number | undefined, { timeOffset, ...data }: Data): Promise<Task> => { export default async (uuid: string, schedule: number, task: number | undefined, data: Data): Promise<Task> => {
return new Promise((resolve, reject) => { const { data: response } = await http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, { action: data.action,
...data, payload: data.payload,
time_offset: timeOffset, continue_on_failure: data.continueOnFailure,
}) time_offset: data.timeOffset,
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))
.catch(reject);
}); });
return rawDataToServerTask(response.attributes);
}; };

View File

@ -104,15 +104,15 @@ const EditScheduleModal = ({ schedule }: Props) => {
</p> </p>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}> <div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch <FormikSwitch
name={'only_when_online'} name={'onlyWhenOnline'}
description={'If disabled this schedule will always run, regardless of the server\'s current power state.'} description={'Only execute this schedule when the server is in a running state.'}
label={'Only When Server Is Online'} label={'Only When Server Is Online'}
/> />
</div> </div>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}> <div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch <FormikSwitch
name={'enabled'} name={'enabled'}
description={'If disabled this schedule and it\'s associated tasks will not run.'} description={'This schedule will be executed automatically if enabled.'}
label={'Schedule Enabled'} label={'Schedule Enabled'}
/> />
</div> </div>

View File

@ -13,12 +13,7 @@ export default ({ schedule }: Props) => {
return ( return (
<> <>
{visible && <TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)}/>
<TaskDetailsModal
schedule={schedule}
onDismissed={() => setVisible(false)}
/>
}
<Button onClick={() => setVisible(true)} css={tw`flex-1`}> <Button onClick={() => setVisible(true)} css={tw`flex-1`}>
New Task New Task
</Button> </Button>

View File

@ -67,7 +67,7 @@ export default () => {
} }
<Can action={'schedule.create'}> <Can action={'schedule.create'}>
<div css={tw`mt-8 flex justify-end`}> <div css={tw`mt-8 flex justify-end`}>
{visible && <EditScheduleModal appear visible onDismissed={() => setVisible(false)}/>} <EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)}/>
<Button type={'button'} onClick={() => setVisible(true)}> <Button type={'button'} onClick={() => setVisible(true)}>
Create schedule Create schedule
</Button> </Button>

View File

@ -153,7 +153,7 @@ export default () => {
} }
</div> </div>
</div> </div>
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/> <EditScheduleModal visible={showEditModal} schedule={schedule} onModalDismissed={toggleEditModal}/>
<div css={tw`mt-6 flex sm:justify-end`}> <div css={tw`mt-6 flex sm:justify-end`}>
<Can action={'schedule.delete'}> <Can action={'schedule.delete'}>
<DeleteScheduleButton <DeleteScheduleButton

View File

@ -1,7 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import {
faArrowCircleDown,
faClock,
faCode,
faFileArchive,
faPencilAlt,
faToggleOn,
faTrashAlt,
} from '@fortawesome/free-solid-svg-icons';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask'; import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -59,11 +67,12 @@ export default ({ schedule, task }: Props) => {
return ( return (
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}> <div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
<SpinnerOverlay visible={isLoading} fixed size={'large'}/> <SpinnerOverlay visible={isLoading} fixed size={'large'}/>
{isEditing && <TaskDetailsModal <TaskDetailsModal
schedule={schedule} schedule={schedule}
task={task} task={task}
onDismissed={() => setIsEditing(false)} visible={isEditing}
/>} onModalDismissed={() => setIsEditing(false)}
/>
<ConfirmationModal <ConfirmationModal
title={'Confirm task deletion'} title={'Confirm task deletion'}
buttonText={'Delete Task'} buttonText={'Delete Task'}
@ -89,6 +98,14 @@ export default ({ schedule, task }: Props) => {
} }
</div> </div>
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}> <div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
{task.continueOnFailure &&
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
<Icon icon={faArrowCircleDown} css={tw`w-3 h-3 mr-2`}/>
Continues on Failure
</div>
</div>
}
{task.sequenceId > 1 && task.timeOffset > 0 && {task.sequenceId > 1 && task.timeOffset > 0 &&
<div css={tw`mr-6`}> <div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}> <div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>

View File

@ -1,13 +1,12 @@
import React, { useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import Modal from '@/components/elements/Modal';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik'; import { Field as FormikField, Form, Formik, FormikHelpers, useField } from 'formik';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask'; import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { number, object, string } from 'yup'; import { boolean, number, object, string } from 'yup';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import tw from 'twin.macro'; import tw from 'twin.macro';
@ -15,40 +14,106 @@ import Label from '@/components/elements/Label';
import { Textarea } from '@/components/elements/Input'; import { Textarea } from '@/components/elements/Input';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select'; import Select from '@/components/elements/Select';
import ModalContext from '@/context/ModalContext';
import asModal from '@/hoc/asModal';
import FormikSwitch from '@/components/elements/FormikSwitch';
interface Props { interface Props {
schedule: Schedule; schedule: Schedule;
// If a task is provided we can assume we're editing it. If not provided, // If a task is provided we can assume we're editing it. If not provided,
// we are creating a new one. // we are creating a new one.
task?: Task; task?: Task;
onDismissed: () => void;
} }
interface Values { interface Values {
action: string; action: string;
payload: string; payload: string;
timeOffset: string; timeOffset: string;
continueOnFailure: boolean;
} }
const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { const schema = object().shape({
const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext<Values>(); action: string().required().oneOf([ 'command', 'power', 'backup' ]),
payload: string().when('action', {
is: v => v !== 'backup',
then: string().required('A task payload must be provided.'),
otherwise: string(),
}),
continueOnFailure: boolean(),
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
.required('A time offset value must be provided.')
.min(0, 'The time offset must be at least 0 seconds.')
.max(900, 'The time offset must be less than 900 seconds.'),
});
const ActionListener = () => {
const [ { value }, { initialValue: initialAction } ] = useField<string>('action');
const [ , { initialValue: initialPayload }, { setValue, setTouched } ] = useField<string>('payload');
useEffect(() => { useEffect(() => {
if (action !== initialValues.action) { if (value !== initialAction) {
setFieldValue('payload', action === 'power' ? 'start' : ''); setValue(value === 'power' ? 'start' : '');
setFieldTouched('payload', false); setTouched(false);
} else { } else {
setFieldValue('payload', initialValues.payload); setValue(initialPayload || '');
setFieldTouched('payload', false); setTouched(false);
} }
}, [ action ]); }, [ value ]);
return null;
};
const TaskDetailsModal = ({ schedule, task }: Props) => {
const { dismiss } = useContext(ModalContext);
const { clearFlashes, addError } = useFlash();
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
return () => {
clearFlashes('schedule:task');
};
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:task');
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
appendSchedule({ ...schedule, tasks });
dismiss();
})
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
};
return ( return (
<Formik
onSubmit={submit}
validationSchema={schema}
initialValues={{
action: task?.action || 'command',
payload: task?.payload || '',
timeOffset: task?.timeOffset.toString() || '0',
continueOnFailure: task?.continueOnFailure || false,
}}
>
{({ isSubmitting, values }) => (
<Form css={tw`m-0`}> <Form css={tw`m-0`}>
<h2 css={tw`text-2xl mb-6`}>{isEditingTask ? 'Edit Task' : 'Create Task'}</h2> <FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>{task ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}> <div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}> <div css={tw`mr-2 w-1/3`}>
<Label>Action</Label> <Label>Action</Label>
<ActionListener/>
<FormikFieldWrapper name={'action'}> <FormikFieldWrapper name={'action'}>
<FormikField as={Select} name={'action'}> <FormikField as={Select} name={'action'}>
<option value={'command'}>Send command</option> <option value={'command'}>Send command</option>
@ -66,7 +131,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
</div> </div>
</div> </div>
<div css={tw`mt-6`}> <div css={tw`mt-6`}>
{action === 'command' ? {values.action === 'command' ?
<div> <div>
<Label>Payload</Label> <Label>Payload</Label>
<FormikFieldWrapper name={'payload'}> <FormikFieldWrapper name={'payload'}>
@ -74,7 +139,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
</FormikFieldWrapper> </FormikFieldWrapper>
</div> </div>
: :
action === 'power' ? values.action === 'power' ?
<div> <div>
<Label>Payload</Label> <Label>Payload</Label>
<FormikFieldWrapper name={'payload'}> <FormikFieldWrapper name={'payload'}>
@ -98,75 +163,22 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
</div> </div>
} }
</div> </div>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'continueOnFailure'}
description={'Future tasks will be run when this task fails.'}
label={'Continue on Failure'}
/>
</div>
<div css={tw`flex justify-end mt-6`}> <div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}> <Button type={'submit'} disabled={isSubmitting}>
{isEditingTask ? 'Save Changes' : 'Create Task'} {task ? 'Save Changes' : 'Create Task'}
</Button> </Button>
</div> </div>
</Form> </Form>
);
};
export default ({ task, schedule, onDismissed }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash();
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
clearFlashes('schedule:task');
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:task');
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
appendSchedule({ ...schedule, tasks });
onDismissed();
})
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
};
return (
<Formik
onSubmit={submit}
initialValues={{
action: task?.action || 'command',
payload: task?.payload || '',
timeOffset: task?.timeOffset.toString() || '0',
}}
validationSchema={object().shape({
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
payload: string().when('action', {
is: v => v !== 'backup',
then: string().required('A task payload must be provided.'),
otherwise: string(),
}),
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
.required('A time offset value must be provided.')
.min(0, 'The time offset must be at least 0 seconds.')
.max(900, 'The time offset must be less than 900 seconds.'),
})}
>
{({ isSubmitting }) => (
<Modal
visible
appear
onDismissed={() => onDismissed()}
showSpinnerOverlay={isSubmitting}
>
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`} />
<TaskDetailsForm isEditingTask={typeof task !== 'undefined'} />
</Modal>
)} )}
</Formik> </Formik>
); );
}; };
export default asModal<Props>()(TaskDetailsModal);