import * as React from "react"; import moment from "moment"; import { success, error } from "../../toast/toast"; import { TaggedFarmEvent, SpecialStatus, TaggedSequence, TaggedRegimen, ParameterApplication, } from "farmbot"; import { ExecutableQuery } from "../interfaces"; import { formatTime, formatDate } from "./map_state_to_props_add_edit"; import { BlurableInput, Col, Row, SaveBtn, FBSelect, DropDownItem, Help, } from "../../ui"; import { destroy, save, overwrite } from "../../api/crud"; import { history } from "../../history"; import { betterMerge, parseIntInput } from "../../util"; import { maybeWarnAboutMissedTasks } from "./util"; import { FarmEventRepeatForm } from "./farm_event_repeat_form"; import { scheduleForFarmEvent } from "./calendar/scheduler"; import { executableType } from "../util"; import { Content } from "../../constants"; import { EventTimePicker } from "./event_time_picker"; import { TzWarning } from "./tz_warning"; import { nextRegItemTimes } from "./map_state_to_props"; import { first } from "lodash"; import { TimeUnit, ExecutableType, FarmEvent } from "farmbot/dist/resources/api_resources"; import { LocalsList } from "../../sequences/locals_list/locals_list"; import { ResourceIndex } from "../../resources/interfaces"; import { ShouldDisplay } from "../../devices/interfaces"; import { addOrEditParamApps, variableList, getRegimenVariableData } from "../../sequences/locals_list/variable_support"; import { AllowedVariableNodes, } from "../../sequences/locals_list/locals_list_support"; import { t } from "../../i18next_wrapper"; import { TimeSettings } from "../../interfaces"; import { ErrorBoundary } from "../../error_boundary"; export const NEVER: TimeUnit = "never"; /** Separate each of the form fields into their own interface. Recombined later * on save. */ export interface FarmEventViewModel { id: number | undefined; startDate: string; startTime: string; endDate: string; endTime: string; repeat: string; timeUnit: string; executable_type: string; executable_id: string; timeSettings: TimeSettings; body?: ParameterApplication[]; } export type FarmEventViewModelKey = keyof FarmEventViewModel; /** Breaks up a TaggedFarmEvent into a structure that can easily be used * by the edit form. * USE CASE EXAMPLE: We have a "date" and "time" field that are created from * a single "start_time" FarmEvent field. */ export function destructureFarmEvent( fe: TaggedFarmEvent, timeSettings: TimeSettings): FarmEventViewModel { return { id: fe.body.id, startDate: formatDate((fe.body.start_time).toString(), timeSettings), startTime: formatTime((fe.body.start_time).toString(), timeSettings), endDate: formatDate((fe.body.end_time || new Date()).toString(), timeSettings), endTime: formatTime((fe.body.end_time || new Date()).toString(), timeSettings), repeat: (fe.body.repeat || 1).toString(), timeUnit: fe.body.time_unit, executable_type: fe.body.executable_type, executable_id: (fe.body.executable_id || "").toString(), timeSettings, body: fe.body.body, }; } const startTimeWarning = () => { const message = t("Event start time needs to be in the future, not the past."); const title = t("Unable to save event."); error(message, title); }; const nothingToRunWarning = () => { const message = t("All items scheduled before the start time. Nothing to run."); const title = t("Unable to save event."); error(message, title); }; type RecombineOptions = { forceRegimensToMidnight: boolean }; /** Take a FormViewModel and recombine the fields into a FarmEvent * that can be used to apply updates (such as a PUT request to the API). */ export function recombine(vm: FarmEventViewModel, options: RecombineOptions): TaggedFarmEvent["body"] { // Make sure that `repeat` is set to `never` when dealing with regimens. const isReg = vm.executable_type === "Regimen"; const startTime = isReg && options.forceRegimensToMidnight ? "00:00" : vm.startTime; return { id: vm.id, start_time: offsetTime(vm.startDate, startTime, vm.timeSettings), end_time: offsetTime(vm.endDate, vm.endTime, vm.timeSettings), repeat: parseInt(vm.repeat, 10) || 1, time_unit: (isReg ? "never" : vm.timeUnit) as TimeUnit, executable_id: parseIntInput(vm.executable_id), executable_type: vm.executable_type as ("Sequence" | "Regimen"), body: vm.body, }; } export function offsetTime( date: string, time: string, timeSettings: TimeSettings): string { const out = moment(date).utcOffset(timeSettings.utcOffset); const [hrs, min] = time.split(":").map(x => parseInt(x)); out.hours(hrs); out.minutes(min); return out.toISOString(); } export interface EditFEProps { deviceTimezone: string | undefined; executableOptions: DropDownItem[]; repeatOptions: DropDownItem[]; farmEvent: TaggedFarmEvent; dispatch: Function; findExecutable: ExecutableQuery; title: string; deleteBtn?: boolean; timeSettings: TimeSettings; autoSyncEnabled: boolean; resources: ResourceIndex; shouldDisplay: ShouldDisplay; } export interface EditFEFormState { /** * Hold a partial FarmEvent locally containing only updates made by the form. */ fe: Partial; /** * This form has local state and does not cause any global state changes when * editing. * * Example: Navigating away from the page while editing will discard changes. */ specialStatusLocal: SpecialStatus; } export class EditFEForm extends React.Component { state: EditFEFormState = { fe: {}, specialStatusLocal: SpecialStatus.SAVED }; /** API data for the FarmEvent to which form updates can be applied. */ get viewModel() { return destructureFarmEvent(this.props.farmEvent, this.props.timeSettings); } get executable() { const et = this.fieldGet("executable_type"); const id = parseInt(this.fieldGet("executable_id")); if (et === "Sequence" || et === "Regimen") { return this.props.findExecutable(et, id); } else { throw new Error(`${et} is not a valid executable_type`); } } get isReg() { return this.fieldGet("executable_type") === "Regimen"; } /** Executable requires variables or a user has manually added bodyVariables. */ get needsVariables() { const varData = this.props.resources.sequenceMetas[this.executable.uuid]; return Object.keys(varData || {}).length > 0; } get variableData() { if (this.executable.kind === "Regimen") { const regimenVariables = this.executable.body.body; return getRegimenVariableData(regimenVariables, this.props.resources); } return this.props.resources.sequenceMetas[this.executable.uuid]; } get bodyVariables(): ParameterApplication[] { return this.state.fe.body || this.props.farmEvent.body.body || []; } overwriteStateFEBody = (newBody: ParameterApplication[]) => { const state = this.state; state.fe.body = newBody; this.setState(state); } editBodyVariables = (bodyVariables: ParameterApplication[]) => (variable: ParameterApplication) => { const body = addOrEditParamApps(bodyVariables, variable); this.overwriteStateFEBody(body); this.setState({ specialStatusLocal: SpecialStatus.DIRTY }); } LocalsList = () => executableSet = (ddi: DropDownItem) => { if (ddi.value) { const { id, executable_type } = this.props.farmEvent.body; const prev_executable_type = executable_type; const next_executable_type = executableType(ddi.headingId); if (id && prev_executable_type !== next_executable_type) { error(t("Cannot change between Sequences and Regimens.")); history.push("/app/designer/events"); } else { const { uuid } = this.props.findExecutable( next_executable_type, parseInt("" + ddi.value)); const varData = this.props.resources.sequenceMetas[uuid]; const update: EditFEFormState = { fe: { executable_type: next_executable_type, executable_id: (ddi.value || "").toString(), }, specialStatusLocal: SpecialStatus.DIRTY }; this.overwriteStateFEBody(variableList(varData) || []); this.setState(betterMerge(this.state, update)); } } } executableGet = (): DropDownItem => { const headingId: ExecutableType = (this.executable.kind === "Sequence") ? "Sequence" : "Regimen"; return { value: this.executable.body.id || 0, label: this.executable.body.name, headingId }; } fieldSet = (key: FarmEventViewModelKey, value: string) => // A merge is required to not overwrite `fe`. this.setState(betterMerge(this.state, { fe: { [key]: value }, specialStatusLocal: SpecialStatus.DIRTY })) fieldGet = (key: FarmEventViewModelKey): string => (this.state.fe[key] || this.viewModel[key] || "").toString() nextItemTime = (fe: FarmEvent, now: moment.Moment ): moment.Moment | undefined => { const { timeSettings } = this.props; const kind = fe.executable_type; const start = fe.start_time; const isRegimen = (x: TaggedSequence | TaggedRegimen): x is TaggedRegimen => !!(x.kind === "Regimen"); switch (kind) { case "Sequence": return first(scheduleForFarmEvent(fe, now).items); case "Regimen": const r = this.props.findExecutable(kind, fe.executable_id); const nextItem = isRegimen(r) ? first(nextRegItemTimes(r.body.regimen_items, start, now, timeSettings)) : undefined; const futureStartTimeFallback = moment(start) > now ? moment(start) : undefined; return nextItem || futureStartTimeFallback; default: throw new Error(`${kind} is not a valid executable_type`); } } /** Rejects save of Farm Event if: */ maybeRejectStartTime = (f: FarmEvent, now = moment()) => { /** adding a new event (editing repeats for ongoing events is allowed) */ const newEvent = this.props.title.toLowerCase().includes("add"); /** start time is in the past */ const inThePast = moment(f.start_time) < now; /** is a sequence event */ const sequenceEvent = !this.isReg; return newEvent && (inThePast && sequenceEvent); } /** Merge and recombine FarmEvent form updates into and updated FarmEvent. */ get updatedFarmEvent() { /** ViewModel with INVALID `body` (must be replaced). */ const vm = betterMerge(this.viewModel, this.state.fe); const oldBodyData = this.needsVariables ? this.viewModel.body : []; vm.body = this.state.fe.body || oldBodyData; const opts: RecombineOptions = { forceRegimensToMidnight: true }; return recombine(vm, opts); } /** Use the next item run time to display toast messages. */ nextRunTimeActions = (now = moment()) => { const nextRun = this.nextItemTime(this.props.farmEvent.body, now); if (nextRun) { const nextRunText = this.props.autoSyncEnabled ? t(`The next item in this event will run {{timeFromNow}}.`, { timeFromNow: nextRun.from(now) }) : t(`The next item in this event will run {{timeFromNow}}, but you must first SYNC YOUR DEVICE. If you do not sync, the event will not run.`.replace(/\s+/g, " "), { timeFromNow: nextRun.from(now) }); success(nextRunText); } } /** Once saved, if * - Regimen Farm Event: * * If scheduled for today, warn about the possibility of missing tasks. * * Display the start time difference from now and maybe prompt to sync. * * Return to calendar view. * - Sequence Farm Event: * * Determine the time for the next item to be run. * * If auto-sync is disabled, prompt the user to sync. * * Return to calendar view. */ commitViewModel = (now = moment()) => { if (this.maybeRejectStartTime(this.updatedFarmEvent)) { return startTimeWarning(); } if (!this.nextItemTime(this.updatedFarmEvent, now)) { return nothingToRunWarning(); } const { dispatch } = this.props; dispatch(overwrite(this.props.farmEvent, this.updatedFarmEvent)); dispatch(save(this.props.farmEvent.uuid)) .then(() => { this.setState({ specialStatusLocal: SpecialStatus.SAVED }); dispatch(maybeWarnAboutMissedTasks(this.props.farmEvent, () => alert(t(Content.REGIMEN_TODAY_SKIPPED_ITEM_RISK)), now)); this.nextRunTimeActions(now); history.push("/app/designer/events"); }) .catch(() => { error(t("Unable to save event.")); this.setState({ specialStatusLocal: SpecialStatus.DIRTY }); }); } render() { const { farmEvent } = this.props; return
this.commitViewModel()}>
; } } export interface StartTimeFormProps { isRegimen: boolean; fieldGet(key: FarmEventViewModelKey): string; fieldSet(key: FarmEventViewModelKey, value: string): void; timeSettings: TimeSettings; } export const StartTimeForm = (props: StartTimeFormProps) => { const forceMidnight = props.isRegimen; return
props.fieldSet("startDate", e.currentTarget.value)} /> props.fieldSet("startTime", e.currentTarget.value)} disabled={forceMidnight} hidden={forceMidnight} />
; }; export interface RepeatFormProps { isRegimen: boolean; fieldGet(key: FarmEventViewModelKey): string; fieldSet(key: FarmEventViewModelKey, value: string): void; timeSettings: TimeSettings; } export const RepeatForm = (props: RepeatFormProps) => { const allowRepeat = !props.isRegimen && props.fieldGet("timeUnit") !== NEVER; return
{!props.isRegimen ? :
}
; }; export const dateCheck = ( fieldGet: (key: FarmEventViewModelKey) => string ): string | undefined => { const startDate = fieldGet("startDate"); const endDate = fieldGet("endDate"); if (!moment(endDate).isSameOrAfter(moment(startDate))) { return t("End date must not be before start date."); } }; export const timeCheck = ( fieldGet: (key: FarmEventViewModelKey) => string, timeSettings: TimeSettings ): string | undefined => { const startDate = fieldGet("startDate"); const startTime = fieldGet("startTime"); const endDate = fieldGet("endDate"); const endTime = fieldGet("endTime"); const start = offsetTime(startDate, startTime, timeSettings); const end = offsetTime(endDate, endTime, timeSettings); if (moment(start).isSameOrAfter(moment(end))) { return t("End time must be after start time."); } }; export interface FarmEventDeleteButtonProps { hidden: boolean; farmEvent: TaggedFarmEvent; dispatch: Function; } export const FarmEventDeleteButton = (props: FarmEventDeleteButtonProps) => ; export interface FarmEventFormProps { isRegimen: boolean; fieldGet(key: FarmEventViewModelKey): string; fieldSet(key: FarmEventViewModelKey, value: string): void; timeSettings: TimeSettings; executableOptions: DropDownItem[]; executableSet(ddi: DropDownItem): void; executableGet(): DropDownItem | undefined; dispatch: Function; specialStatus: SpecialStatus; onSave(): void; children?: React.ReactChild; } export const FarmEventForm = (props: FarmEventFormProps) => { const { isRegimen, fieldGet, fieldSet, timeSettings } = props; return
{props.executableOptions.length < 1 && } {props.children}
; };