diff --git a/src/dashboard/Data/Jobs/EditScheduledJobModal.react.js b/src/dashboard/Data/Jobs/EditScheduledJobModal.react.js new file mode 100644 index 0000000000..8ae97f2d92 --- /dev/null +++ b/src/dashboard/Data/Jobs/EditScheduledJobModal.react.js @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from 'react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import B4aToggle from 'components/Toggle/B4aToggle.react'; +import DateTimeInput from 'components/DateTimeInput/DateTimeInput.react'; +import Dropdown from 'components/Dropdown/Dropdown.react'; +import Field from 'components/Field/Field.react'; +import FormNote from 'components/FormNote/FormNote.react'; +import IntervalInput from 'components/IntervalInput/IntervalInput.react'; +import Label from 'components/Label/Label.react'; +import Option from 'components/Dropdown/Option.react'; +import TextInput from 'components/TextInput/TextInput.react'; +import TimeInput from 'components/TimeInput/TimeInput.react'; +import { hoursFrom } from 'lib/DateUtils'; + +function parseInitialState(job) { + if (!job) { + return { + name: '', + cloudCodeFunction: '', + parameter: '', + startAt: hoursFrom(new Date(), 1), + repeat: false, + repeatType: 'Every day', + intervalCount: 15, + intervalUnit: 'minute', + repeatHour: '12', + repeatMinute: '00', + }; + } + + const startAfter = job.startAfter ? new Date(job.startAfter) : hoursFrom(new Date(), 1); + let repeat = false; + let repeatType = 'Every day'; + let intervalCount = 15; + let intervalUnit = 'minute'; + let repeatHour = '12'; + let repeatMinute = '00'; + + if (job.repeatMinutes) { + repeat = true; + if (job.repeatMinutes === 1440) { + repeatType = 'Every day'; + } else { + repeatType = 'On an interval'; + if (job.repeatMinutes > 60) { + intervalCount = (job.repeatMinutes / 60) | 0; + intervalUnit = 'hour'; + } else { + intervalCount = job.repeatMinutes; + intervalUnit = 'minute'; + } + } + } + + if (job.timeOfDay) { + const parts = job.timeOfDay.split(':'); + repeatHour = parts[0] ? parts[0].replace(/^0/, '') || '0' : '12'; + repeatMinute = parts[1] || '00'; + } + + return { + name: job.description || '', + cloudCodeFunction: job.jobName || '', + parameter: job.params || '', + startAt: startAfter, + repeat, + repeatType, + intervalCount, + intervalUnit, + repeatHour, + repeatMinute, + }; +} + +const FIELD_PADDING = { padding: '0 1rem', width: '100%' }; + +const EditScheduledJobModal = ({ job, context, onCancel, onSuccess }) => { + const isCreate = !job; + const initial = parseInitialState(job); + + const [name, setName] = useState(initial.name); + const [cloudCodeFunction, setCloudCodeFunction] = useState(initial.cloudCodeFunction); + const [parameter, setParameter] = useState(initial.parameter); + const [startAt, setStartAt] = useState(initial.startAt); + const [repeat, setRepeat] = useState(initial.repeat); + const [repeatType, setRepeatType] = useState(initial.repeatType); + const [intervalCount, setIntervalCount] = useState(initial.intervalCount); + const [intervalUnit, setIntervalUnit] = useState(initial.intervalUnit); + const [repeatHour, setRepeatHour] = useState(initial.repeatHour); + const [repeatMinute, setRepeatMinute] = useState(initial.repeatMinute); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [paramError, setParamError] = useState(null); + const [availableJobs, setAvailableJobs] = useState([]); + const [loadingJobs, setLoadingJobs] = useState(isCreate); + + useEffect(() => { + if (!isCreate) { + return; + } + context.getAvailableJobs() + .then(result => { + const jobs = (result && result.jobs) || []; + setAvailableJobs(jobs); + if (jobs.length > 0) { + setCloudCodeFunction(jobs[0]); + } + }) + .catch(() => setAvailableJobs([])) + .finally(() => setLoadingJobs(false)); + }, []); + + function buildPayload() { + let parsedParam = null; + if (parameter && parameter.trim() !== '') { + parsedParam = JSON.parse(parameter); + } + + let dailyRun = null; + let intervalRun = null; + + if (repeat) { + const hour = repeatHour.length < 2 ? '0' + repeatHour : repeatHour; + if (repeatType === 'Every day') { + dailyRun = `${hour}:${repeatMinute}`; + } else { + intervalRun = intervalCount * (intervalUnit === 'hour' ? 60 : 1); + dailyRun = `${hour}:${repeatMinute}`; + } + } + + return { + job: { + name, + cloudCodeFunction, + parameter: parsedParam, + schedule: { + start: startAt.toISOString(), + dailyRun, + intervalRun, + }, + }, + }; + } + + function validateParameter(value) { + if (!value || value.trim() === '') { + return null; + } + let parsed; + try { + parsed = JSON.parse(value); + } catch (e) { + return 'Parameters must be valid JSON.'; + } + if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { + return 'Parameters must be a JSON object, e.g. { "key": "value" }.'; + } + return null; + } + + function validate() { + if (!name.trim()) { + return 'A name is required.'; + } + if (!cloudCodeFunction) { + return 'A cloud code function is required.'; + } + const paramErr = validateParameter(parameter); + if (paramErr) { + return paramErr; + } + return null; + } + + function handleConfirm() { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + setSaving(true); + setError(null); + const payload = buildPayload(); + const request = isCreate + ? context.createScheduledJob(payload) + : context.updateScheduledJob(job.objectId, payload); + request + .then(() => { + setSaving(false); + onSuccess(); + }) + .catch(err => { + setSaving(false); + setError((err && (err.error || err.message)) || `Failed to ${isCreate ? 'create' : 'update'} job. Please try again.`); + }); + } + + return ( + +
+ + + } + input={ + { setName(val); setError(null); }} + /> + } + /> + + } + input={ + isCreate ? ( + loadingJobs ? ( + + ) : ( + { setCloudCodeFunction(val); setError(null); }} + > + {availableJobs.map(jobName => ( + + ))} + + ) + ) : ( + + ) + } + /> + + + } + input={ +
+ { + setParameter(val); + setParamError(validateParameter(val)); + setError(null); + }} + /> + {paramError && ( +
+ {paramError} +
+ )} +
+ } + /> + + } + input={ +
+ +
+ } + /> + + + } + input={ +
+ +
+ } + /> + + {repeat && ( + } + input={ +
+ +
+ } + /> + )} + + {repeat && repeatType === 'On an interval' && ( + } + input={ +
+ { setIntervalCount(count); setIntervalUnit(unit); }} + /> +
+ } + /> + )} + + {repeat && ( + + } + input={ +
+ { setRepeatHour(h); setRepeatMinute(m); }} + /> +
+ } + /> + )} + +
+ {error && ( + {error} + )} +
+ ); +}; + +export default EditScheduledJobModal; diff --git a/src/dashboard/Data/Jobs/Jobs.react.js b/src/dashboard/Data/Jobs/Jobs.react.js index 3abdd59a0d..7288f6433b 100644 --- a/src/dashboard/Data/Jobs/Jobs.react.js +++ b/src/dashboard/Data/Jobs/Jobs.react.js @@ -12,8 +12,9 @@ import CategoryList from 'components/CategoryList/CategoryList.react'; import EmptyGhostState from 'components/EmptyGhostState/EmptyGhostState.react'; import ChromeDropdown from 'components/ChromeDropdown/ChromeDropdown.react'; import Icon from 'components/Icon/Icon.react'; -import JobScheduleReminder from 'dashboard/Data/Jobs/JobScheduleReminder.react'; -import Modal from 'components/Modal/Modal.react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import EditScheduledJobModal from 'dashboard/Data/Jobs/EditScheduledJobModal.react'; +import FormNote from 'components/FormNote/FormNote.react'; import Popover from 'components/Popover/Popover.react'; import Position from 'lib/Position'; import React from 'react'; @@ -34,7 +35,8 @@ import { withRouter } from 'lib/withRouter'; const subsections = { all: 'All Jobs', - // scheduled: 'Scheduled Jobs', + scheduled: 'Scheduled Jobs', + 'scheduled-jobs': 'Scheduled Jobs', status: 'Job Status', }; @@ -44,7 +46,22 @@ const statusColors = { running: 'blue', }; +const MONTH_ABBREVS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function formatScheduleDate(date) { + const month = MONTH_ABBREVS[date.getUTCMonth()]; + const day = date.getUTCDate(); + const hours = (date.getUTCHours() < 10 ? '0' : '') + date.getUTCHours(); + const minutes = (date.getUTCMinutes() < 10 ? '0' : '') + date.getUTCMinutes(); + return `${month} ${day} at ${hours}:${minutes}`; +} + function scheduleString(data) { + const runAt = new Date(data.startAfter); + if (Number.isNaN(runAt.getTime())) { + return
-
; + } + let schedule = ''; if (data.repeatMinutes) { if (data.repeatMinutes === 1440) { @@ -54,22 +71,12 @@ function scheduleString(data) { } else { schedule += 'Each day, every ' + data.repeatMinutes + ' minutes, '; } - schedule += 'after ' + data.timeOfDay.substr(0, 5) + ', '; + if (data.timeOfDay) { + schedule += 'after ' + data.timeOfDay.substr(0, 5) + ', '; + } schedule += 'starting '; - } else { - schedule = 'On '; } - const runAt = new Date(data.startAfter); - schedule += - runAt.getUTCMonth() + '/' + runAt.getUTCDate() + '/' + String(runAt.getUTCFullYear()).substr(2); - schedule += - ' at ' + - (runAt.getUTCHours() < 10 ? '0' : '') + - runAt.getUTCHours() + - ':' + - (runAt.getUTCMinutes() < 10 ? '0' : '') + - runAt.getUTCMinutes() + - '.'; + schedule += formatScheduleDate(runAt); return
{schedule}
; } @@ -84,6 +91,10 @@ class Jobs extends TableView { this.state = { toDelete: null, + deleteInProgress: false, + deleteError: null, + toEdit: null, + toCreate: false, jobStatus: undefined, loading: true, // Properties used to control data access @@ -98,11 +109,29 @@ class Jobs extends TableView { // Job Status pagination (botão "Carregar mais") jobStatusHasMore: true, jobStatusLoadingMore: false, + // Job limit enforcement + jobLimitReached: false, + maxJobAmount: undefined, }; this.filterWrapRef = React.createRef(); this.JOB_STATUS_PAGE_SIZE = 100; } + handleScheduleClick() { + const { maxJobAmount } = this.state; + if (maxJobAmount === undefined) { + // Plan data not yet loaded — proceed and let the backend enforce the limit + this.setState({ toCreate: true }); + return; + } + const currentCount = (this.tableData() || []).length; + if (currentCount >= maxJobAmount) { + this.setState({ jobLimitReached: true }); + } else { + this.setState({ toCreate: true }); + } + } + componentWillMount() { this.loadData(); } @@ -111,19 +140,28 @@ class Jobs extends TableView { if (nextProps.availableJobs) { if (nextProps.availableJobs.length > 0) { this.action = new SidebarAction(Schedule job, this.navigateToNew.bind(this)); - return; } + } else { + this.action = null; } - // check if the changes are in currentApp serverInfo status - // if not return without making any request + + const sectionChanged = nextProps.params.section !== this.props.params.section; + const appChanged = nextProps.params.appId !== this.props.params.appId; + let appStatusChanged = false; + if (this.props.apps !== nextProps.apps) { - const updatedCurrentApp = nextProps.apps.find(ap => ap.slug === this.props.params.appId); + const updatedCurrentApp = nextProps.apps.find(ap => ap.slug === nextProps.params.appId); const prevCurrentApp = this.props.apps.find(ap => ap.slug === this.props.params.appId); - const shouldUpdate = updatedCurrentApp.serverInfo.status !== prevCurrentApp.serverInfo.status; - if (!shouldUpdate) {return;} + const updatedStatus = updatedCurrentApp && updatedCurrentApp.serverInfo && updatedCurrentApp.serverInfo.status; + const prevStatus = prevCurrentApp && prevCurrentApp.serverInfo && prevCurrentApp.serverInfo.status; + appStatusChanged = updatedStatus !== prevStatus; } - this.action = null; - this.loadData(); + + if (!sectionChanged && !appChanged && !appStatusChanged) { + return; + } + + this.loadData(nextProps.params.section); } navigateToNew() { @@ -134,8 +172,37 @@ class Jobs extends TableView { this.props.navigate(generatePath(this.context, `jobs/edit/${jobId}`)); } - loadData() { - this.props.jobs.dispatch(ActionTypes.FETCH).finally(() => { + getCurrentSection() { + return this.props.params.section || 'all'; + } + + isScheduledSection(section) { + const s = section || this.getCurrentSection(); + return s === 'scheduled' || s === 'scheduled-jobs'; + } + + loadData(section) { + const currentSection = section || this.getCurrentSection(); + this.setState({ + loading: true, + errorMessage: '', + hasPermission: true, + jobLimitReached: false, + ...(this.isScheduledSection(currentSection) ? { maxJobAmount: undefined } : {}), + ...(currentSection === 'status' ? { jobStatus: undefined } : {}), + }); + + if (this.isScheduledSection(currentSection)) { + this.context.getAppPlanData() + .then(planData => { + this.setState({ maxJobAmount: (planData && planData.maxJobAmount) || null }); + }) + .catch(() => { + this.setState({ maxJobAmount: null }); + }); + } + + this.props.jobs.dispatch(ActionTypes.FETCH, { section: currentSection }).finally(() => { const err = this.props.jobs.data && this.props.jobs.data.get('err') // Verify error message, used to control collaborators permissions if (err && err.code === 403) @@ -153,13 +220,14 @@ class Jobs extends TableView { } // If is a unexpected error just finish loading state else {this.setState({ loading: false });} - this.renderEmpty() - }); - this.context.getJobStatus(0, this.JOB_STATUS_PAGE_SIZE).then(status => { - this.setState({ jobStatus: status, jobStatusHasMore: status.length === this.JOB_STATUS_PAGE_SIZE }); - }).catch(() => { - this.setState({ jobStatus: [], jobStatusHasMore: false }); }); + if (currentSection === 'status') { + this.context.getJobStatus(0, this.JOB_STATUS_PAGE_SIZE).then(status => { + this.setState({ jobStatus: status, jobStatusHasMore: status.length === this.JOB_STATUS_PAGE_SIZE }); + }).catch(() => { + this.setState({ jobStatus: [], jobStatusHasMore: false }); + }); + } } loadMoreJobStatus() { @@ -191,7 +259,7 @@ class Jobs extends TableView { linkPrefix={'jobs/'} categories={[ { name: 'All Jobs', id: 'all' }, - // { name: 'Scheduled Jobs', id: 'scheduled' }, + { name: 'Scheduled Jobs', id: 'scheduled-jobs' }, { name: 'Job Status', id: 'status' }, ]} /> @@ -210,21 +278,23 @@ class Jobs extends TableView { ); - } else if (this.props.params.section === 'scheduled') { + } else if (this.isScheduledSection()) { + const openEdit = () => this.setState({ toEdit: data }); + const openDelete = () => this.setState({ toDelete: data, deleteError: null }); return ( - {data.description} - {data.jobName} - {scheduleString(data)} - - -