From 02fbdfec36549e379e8580c494a710782e6219ad Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 2 Jun 2024 11:10:38 +0300 Subject: [PATCH] feat(edge/jobs): migrate create view to react [EE-2221] (#11867) --- app/edge/__module.js | 2 +- .../components/edge-job-form/edgeJobForm.html | 11 +- .../edge-job-form/edgeJobFormController.js | 10 +- app/edge/react/views/jobs.ts | 6 +- .../createEdgeJobView/createEdgeJobView.html | 20 -- .../createEdgeJobViewController.js | 86 -------- .../edge-jobs/createEdgeJobView/index.js | 7 - app/react/components/DateTimeField.tsx | 57 +++++ app/react/components/WebEditorForm.tsx | 3 + .../form-components/Input/Select.tsx | 2 +- app/react/edge/edge-jobs/CreateView/.keep | 0 .../CreateView/CreateEdgeJobForm.tsx | 203 ++++++++++++++++++ .../edge/edge-jobs/CreateView/CreateView.tsx | 28 +++ .../EdgeJobForm/AdvancedCronFieldset.tsx | 40 ++++ .../EdgeJobForm/BasicCronFieldset.tsx | 31 +++ .../EdgeJobForm/JobConfigurationFieldset.tsx | 37 ++++ .../components/EdgeJobForm/NameField.tsx | 46 ++++ .../EdgeJobForm/RecurringFieldset.tsx | 40 ++++ .../EdgeJobForm/ScheduledDateFieldset.tsx | 25 +++ .../components/EdgeJobForm/TimeTip.tsx | 9 + .../edge-jobs/components/EdgeJobForm/types.ts | 20 ++ .../components/EdgeJobForm/useValidation.ts | 72 +++++++ .../createJobFromFile.ts | 39 ++++ .../createJobFromFileContent.ts | 31 +++ .../useCreateEdgeJobMutation.ts | 62 ++++++ .../ListView/columns/scheduled-time.tsx | 2 +- .../common/ScheduledTimeField.tsx | 51 ++--- 27 files changed, 777 insertions(+), 163 deletions(-) delete mode 100644 app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html delete mode 100644 app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js delete mode 100644 app/edge/views/edge-jobs/createEdgeJobView/index.js create mode 100644 app/react/components/DateTimeField.tsx delete mode 100644 app/react/edge/edge-jobs/CreateView/.keep create mode 100644 app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx create mode 100644 app/react/edge/edge-jobs/CreateView/CreateView.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/AdvancedCronFieldset.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/NameField.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/RecurringFieldset.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/ScheduledDateFieldset.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/TimeTip.tsx create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/types.ts create mode 100644 app/react/edge/edge-jobs/components/EdgeJobForm/useValidation.ts create mode 100644 app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/createJobFromFile.ts create mode 100644 app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/createJobFromFileContent.ts create mode 100644 app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation.ts diff --git a/app/edge/__module.js b/app/edge/__module.js index 433dd6214..be1da9d61 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -122,7 +122,7 @@ angular url: '/new', views: { 'content@': { - component: 'createEdgeJobView', + component: 'edgeJobsCreateView', }, }, }; diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html index 5af164f2d..93fd17f9c 100644 --- a/app/edge/components/edge-job-form/edgeJobForm.html +++ b/app/edge/components/edge-job-form/edgeJobForm.html @@ -36,7 +36,7 @@
Edge job configuration
- + @@ -44,9 +44,10 @@
- +
@@ -135,7 +136,7 @@
Job content
- +
diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js index 0bc8acfaf..814eb19bb 100644 --- a/app/edge/components/edge-job-form/edgeJobFormController.js +++ b/app/edge/components/edge-job-form/edgeJobFormController.js @@ -9,8 +9,8 @@ export class EdgeJobFormController { this.$scope = $scope; this.$async = $async; - this.cronMethods = cronMethodOptions; - this.buildMethods = [editor, upload]; + this.cronMethods = cronMethodOptions.map((o) => ({ ...o, id: o.id + '-old' })); + this.buildMethods = [editor, upload].map((o) => ({ ...o, id: o.id + '-old' })); this.state = { formValidationError: '', @@ -70,10 +70,12 @@ export class EdgeJobFormController { onChangeModel(model) { const defaultTime = moment().add('hours', 1); + const scheduled = this.scheduleValues.find((v) => v.cron === model.CronExpression); + this.formValues = { datetime: model.CronExpression ? cronToDatetime(model.CronExpression, defaultTime) : defaultTime, - scheduleValue: this.formValues.scheduleValue, - cronMethod: model.Recurring ? 'advanced' : 'basic', + scheduleValue: scheduled || this.scheduleValues[0], + cronMethod: model.Recurring && !scheduled ? 'advanced' : 'basic', method: this.formValues.method, }; } diff --git a/app/edge/react/views/jobs.ts b/app/edge/react/views/jobs.ts index 6042aa7be..7718cffba 100644 --- a/app/edge/react/views/jobs.ts +++ b/app/edge/react/views/jobs.ts @@ -4,10 +4,12 @@ import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { ListView } from '@/react/edge/edge-jobs/ListView'; +import { CreateView } from '@/react/edge/edge-jobs/CreateView/CreateView'; export const jobsModule = angular .module('portainer.edge.react.views.jobs', []) + .component('edgeJobsView', r2a(withUIRouter(withCurrentUser(ListView)), [])) .component( - 'edgeJobsView', - r2a(withUIRouter(withCurrentUser(ListView)), []) + 'edgeJobsCreateView', + r2a(withUIRouter(withCurrentUser(CreateView)), []) ).name; diff --git a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html b/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html deleted file mode 100644 index ee1430ec1..000000000 --- a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html +++ /dev/null @@ -1,20 +0,0 @@ - - -
-
- - - - - -
-
diff --git a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js b/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js deleted file mode 100644 index 97baf8ea8..000000000 --- a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js +++ /dev/null @@ -1,86 +0,0 @@ -import { confirmWebEditorDiscard } from '@@/modals/confirm'; - -export class CreateEdgeJobViewController { - /* @ngInject */ - constructor($async, $q, $state, $window, EdgeJobService, GroupService, Notifications, TagService) { - this.state = { - actionInProgress: false, - isEditorDirty: false, - }; - - this.model = { - Name: '', - Recurring: false, - CronExpression: '', - Endpoints: [], - FileContent: '', - File: null, - EdgeGroups: [], - }; - - this.$async = $async; - this.$q = $q; - this.$state = $state; - this.$window = $window; - this.Notifications = Notifications; - this.GroupService = GroupService; - this.EdgeJobService = EdgeJobService; - this.TagService = TagService; - - this.create = this.create.bind(this); - this.createEdgeJob = this.createEdgeJob.bind(this); - this.createAsync = this.createAsync.bind(this); - } - - create(method) { - return this.$async(this.createAsync, method); - } - - async createAsync(method) { - this.state.actionInProgress = true; - - try { - await this.createEdgeJob(method, this.model); - this.Notifications.success('Success', 'Edge job successfully created'); - this.state.isEditorDirty = false; - this.$state.go('edge.jobs', {}, { reload: true }); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to create Edge job'); - } - - this.state.actionInProgress = false; - } - - createEdgeJob(method, model) { - if (method === 'editor') { - return this.EdgeJobService.createEdgeJobFromFileContent(model); - } - return this.EdgeJobService.createEdgeJobFromFileUpload(model); - } - - async uiCanExit() { - if (this.model.FileContent && this.state.isEditorDirty) { - return confirmWebEditorDiscard(); - } - } - - async $onInit() { - try { - const [groups, tags] = await Promise.all([this.GroupService.groups(), this.TagService.tags()]); - this.groups = groups; - this.tags = tags; - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve page data'); - } - - this.$window.onbeforeunload = () => { - if (this.model.FileContent && this.state.isEditorDirty) { - return ''; - } - }; - } - - $onDestroy() { - this.state.isEditorDirty = false; - } -} diff --git a/app/edge/views/edge-jobs/createEdgeJobView/index.js b/app/edge/views/edge-jobs/createEdgeJobView/index.js deleted file mode 100644 index ecddfb5ae..000000000 --- a/app/edge/views/edge-jobs/createEdgeJobView/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import angular from 'angular'; -import { CreateEdgeJobViewController } from './createEdgeJobViewController'; - -angular.module('portainer.edge').component('createEdgeJobView', { - templateUrl: './createEdgeJobView.html', - controller: CreateEdgeJobViewController, -}); diff --git a/app/react/components/DateTimeField.tsx b/app/react/components/DateTimeField.tsx new file mode 100644 index 000000000..d697f791e --- /dev/null +++ b/app/react/components/DateTimeField.tsx @@ -0,0 +1,57 @@ +import DateTimePicker from 'react-datetime-picker'; +import { Calendar, X } from 'lucide-react'; + +import { isoDate } from '@/portainer/filters/filters'; +import { AutomationTestingProps } from '@/types'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +import 'react-datetime-picker/dist/DateTimePicker.css'; +import 'react-calendar/dist/Calendar.css'; + +export const FORMAT = 'YYYY-MM-DD HH:mm'; + +export function DateTimeField({ + error, + label, + disabled, + name, + value, + onChange, + minDate, + 'data-cy': dataCy, +}: { + error?: string; + disabled?: boolean; + name: string; + value: Date | null; + onChange: (date: Date | null) => void; + label: string; + minDate?: Date; +} & AutomationTestingProps) { + return ( + + {!disabled ? ( + } + clearIcon={} + disableClock + data-cy={dataCy} + minDate={minDate} + /> + ) : ( + + )} + + ); +} diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index f8b150e11..36f05969e 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -59,6 +59,7 @@ interface Props extends AutomationTestingProps { id: string; placeholder?: string; yaml?: boolean; + shell?: boolean; readonly?: boolean; titleContent?: React.ReactNode; hideTitle?: boolean; @@ -77,6 +78,7 @@ export function WebEditorForm({ hideTitle, readonly, yaml, + shell, children, error, versions, @@ -108,6 +110,7 @@ export function WebEditorForm({ placeholder={placeholder} readonly={readonly} yaml={yaml} + shell={shell} value={value} onChange={onChange} versions={versions} diff --git a/app/react/components/form-components/Input/Select.tsx b/app/react/components/form-components/Input/Select.tsx index 561de3cd2..8c9d11b7b 100644 --- a/app/react/components/form-components/Input/Select.tsx +++ b/app/react/components/form-components/Input/Select.tsx @@ -11,7 +11,7 @@ export interface Option } interface Props extends AutomationTestingProps { - options: Option[]; + options: Array> | ReadonlyArray>; } export function Select({ diff --git a/app/react/edge/edge-jobs/CreateView/.keep b/app/react/edge/edge-jobs/CreateView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx new file mode 100644 index 000000000..882928e9c --- /dev/null +++ b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx @@ -0,0 +1,203 @@ +import { Form, Formik, useFormikContext } from 'formik'; +import { useRouter } from '@uirouter/react'; +import moment from 'moment'; + +import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; +import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { FormActions } from '@@/form-components/FormActions'; +import { FormSection } from '@@/form-components/FormSection'; +import { BoxSelector } from '@@/BoxSelector'; +import { editor, upload } from '@@/BoxSelector/common-options/build-methods'; +import { WebEditorForm } from '@@/WebEditorForm'; +import { FileUploadForm } from '@@/form-components/FileUpload'; + +import { NameField } from '../components/EdgeJobForm/NameField'; +import { FormValues } from '../components/EdgeJobForm/types'; +import { useValidation } from '../components/EdgeJobForm/useValidation'; +import { JobConfigurationFieldset } from '../components/EdgeJobForm/JobConfigurationFieldset'; +import { + BasePayload, + CreateEdgeJobPayload, + useCreateEdgeJobMutation, +} from '../queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation'; +import { defaultCronExpression } from '../components/EdgeJobForm/RecurringFieldset'; + +export function CreateEdgeJobForm() { + const mutation = useCreateEdgeJobMutation(); + const validation = useValidation(); + const router = useRouter(); + + return ( + + validationSchema={validation} + validateOnMount + initialValues={{ + name: '', + recurring: false, + cronExpression: '', + recurringOption: defaultCronExpression, + method: 'editor', + cronMethod: 'basic', + dateTime: new Date(), + edgeGroupIds: [], + environmentIds: [], + file: undefined, + fileContent: '', + }} + onSubmit={(values) => { + mutation.mutate(getPayload(values.method, values), { + onSuccess: () => { + notifySuccess('Success', 'Edge job successfully created'); + router.stateService.go('^'); + }, + }); + }} + > + + + ); +} + +const buildMethods = [editor, upload]; + +function InnerForm({ isLoading }: { isLoading: boolean }) { + const { values, setFieldValue, isValid, errors } = + useFormikContext(); + + return ( +
+ + + + + + setFieldValue('method', value)} + radioName="build-method" + /> + + + {values.method === 'editor' && ( + setFieldValue('fileContent', value)} + value={values.fileContent} + placeholder="Define or paste the content of your script file here" + shell + error={errors.fileContent} + /> + )} + + {values.method === 'upload' && ( + setFieldValue('file', value)} + value={values.file} + required + /> + )} + + setFieldValue('edgeGroupIds', value)} + value={values.edgeGroupIds} + error={errors.edgeGroupIds} + /> + + + setFieldValue('environmentIds', value)} + value={values.environmentIds} + /> + + + + + ); +} + +function getPayload( + method: 'upload' | 'editor', + values: FormValues +): CreateEdgeJobPayload { + switch (method) { + case 'upload': + if (!values.file) { + throw new Error('File is required'); + } + + return { + method: 'file', + payload: { + ...getBasePayload(values), + file: values.file, + }, + }; + case 'editor': + return { + method: 'string', + payload: { + ...getBasePayload(values), + fileContent: values.fileContent, + }, + }; + + default: + throw new Error(`Unknown method: ${method}`); + } + + function getBasePayload(values: FormValues): BasePayload { + return { + name: values.name, + edgeGroups: values.edgeGroupIds, + endpoints: values.environmentIds, + ...getRecurringConfig(values), + }; + } + + function getRecurringConfig(values: FormValues): { + recurring: boolean; + cronExpression: string; + } { + if (values.cronMethod !== 'basic') { + return { + recurring: true, + cronExpression: values.cronExpression, + }; + } + + if (values.recurring) { + return { + recurring: true, + cronExpression: values.recurringOption, + }; + } + + return { + recurring: false, + cronExpression: dateTimeToCron(values.dateTime), + }; + + function dateTimeToCron(datetime: Date) { + const date = moment(datetime); + return [ + date.minutes(), + date.hours(), + date.date(), + date.month() + 1, + '*', + ].join(' '); + } + } +} diff --git a/app/react/edge/edge-jobs/CreateView/CreateView.tsx b/app/react/edge/edge-jobs/CreateView/CreateView.tsx new file mode 100644 index 000000000..eb6911743 --- /dev/null +++ b/app/react/edge/edge-jobs/CreateView/CreateView.tsx @@ -0,0 +1,28 @@ +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; + +import { CreateEdgeJobForm } from './CreateEdgeJobForm'; + +export function CreateView() { + return ( + <> + + +
+
+ + + + + +
+
+ + ); +} diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/AdvancedCronFieldset.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/AdvancedCronFieldset.tsx new file mode 100644 index 000000000..ed8feaa97 --- /dev/null +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/AdvancedCronFieldset.tsx @@ -0,0 +1,40 @@ +import { useField } from 'formik'; +import { string } from 'yup'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +import { TimeTip } from './TimeTip'; + +export function AdvancedCronFieldset() { + const [{ value, onChange, name, onBlur }, { error }] = + useField('cronExpression'); + + return ( + <> + + + + + + + ); +} +/** https://regexr.com/573i2 */ +const cronRegex = + /(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/; + +export function cronValidation() { + return string() + .default('') + .matches(cronRegex, 'This field format is invalid.'); +} diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx new file mode 100644 index 000000000..5d5435420 --- /dev/null +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx @@ -0,0 +1,31 @@ +import { useFormikContext } from 'formik'; + +import { SwitchField } from '@@/form-components/SwitchField'; + +import { FormValues } from './types'; +import { RecurringFieldset, defaultCronExpression } from './RecurringFieldset'; +import { ScheduledDateFieldset } from './ScheduledDateFieldset'; + +export function BasicCronFieldset() { + const { values, setFieldValue } = useFormikContext(); + return ( + <> +
+
+ { + setFieldValue('recurring', value); + if (value) { + setFieldValue('recurringOption', defaultCronExpression); + } + }} + data-cy="edgeJobCreate-recurringSwitch" + /> +
+
+ {values.recurring ? : } + + ); +} diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx new file mode 100644 index 000000000..7e82d1670 --- /dev/null +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx @@ -0,0 +1,37 @@ +import { useFormikContext } from 'formik'; + +import { FormSection } from '@@/form-components/FormSection'; +import { BoxSelector } from '@@/BoxSelector'; + +import { cronMethodOptions } from '../../CreateView/cron-method-options'; + +import { FormValues } from './types'; +import { AdvancedCronFieldset } from './AdvancedCronFieldset'; +import { BasicCronFieldset } from './BasicCronFieldset'; + +export function JobConfigurationFieldset() { + const { values, setFieldValue } = useFormikContext(); + + return ( + <> + + { + setFieldValue('cronMethod', value); + setFieldValue('cronExpression', ''); + }} + /> + + + {values.cronMethod === 'basic' ? ( + + ) : ( + + )} + + ); +} diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/NameField.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/NameField.tsx new file mode 100644 index 000000000..3ca9e19ec --- /dev/null +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/NameField.tsx @@ -0,0 +1,46 @@ +import { Field, FormikErrors } from 'formik'; +import { string } from 'yup'; +import { useMemo } from 'react'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +import { useEdgeJobs } from '../../queries/useEdgeJobs'; +import { EdgeJob } from '../../types'; + +export function NameField({ errors }: { errors?: FormikErrors }) { + return ( + + + + ); +} + +export function useNameValidation(id?: EdgeJob['Id']) { + const edgeJobsQuery = useEdgeJobs(); + + return useMemo( + () => + string() + .required('Name is required') + .matches( + /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/, + 'Allowed characters are: [a-zA-Z0-9_.-]' + ) + .test({ + name: 'is-unique', + test: (value) => + !edgeJobsQuery.data?.find( + (job) => job.Name === value && job.Id !== id + ), + message: 'Name must be unique', + }), + [edgeJobsQuery.data, id] + ); +} diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/RecurringFieldset.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/RecurringFieldset.tsx new file mode 100644 index 000000000..807edb2ee --- /dev/null +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/RecurringFieldset.tsx @@ -0,0 +1,40 @@ +import { useField } from 'formik'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Select } from '@@/form-components/Input'; + +export const defaultCronExpression = '0 * * * *' as const; + +export const timeOptions = [ + { + label: 'Every hour', + value: defaultCronExpression, + }, + { + label: 'Every 2 hours', + value: '0 */2 * * *', + }, + { + label: 'Every day', + value: '0 0 * * *', + }, +] as const; + +export function RecurringFieldset() { + const [{ value, onChange, name, onBlur }, { error }] = + useField('recurringOption'); + + return ( + + - )} - + { + const dateToSave = date || new Date(Date.now() + 24 * 60 * 60 * 1000); + setValue(isoDate(dateToSave.valueOf(), FORMAT)); + }} + error={error} + disabled={disabled} + name={name} + value={dateValue} + data-cy="update-schedules-time-input" + /> {!disabled && value && ( If time zone is not set on edge agent then UTC+0 will be used.