frontend/api/crud.ts
import {
TaggedResource,
SpecialStatus,
ResourceName,
TaggedSequence,
SequenceBodyItem,
} from "farmbot";
import {
isTaggedResource,
} from "../resources/tagged_resources";
import { GetState, ReduxAction } from "../redux/interfaces";
import { API } from "./index";
import axios from "axios";
import {
updateNO,
destroyOK,
destroyNO,
GeneralizedError,
saveOK,
} from "../resources/actions";
import { UnsafeError } from "../interfaces";
import { defensiveClone, unpackUUID } from "../util";
import { EditResourceParams } from "./interfaces";
import { ResourceIndex } from "../resources/interfaces";
import { Actions } from "../constants";
import { maybeStartTracking } from "./maybe_start_tracking";
import { newTaggedResource } from "../sync/actions";
import { arrayUnwrap } from "../resources/util";
import { findByUuid } from "../resources/reducer_support";
import { assign, noop } from "lodash";
import { t } from "../i18next_wrapper";
import { appIsReadonly } from "../read_only_mode/app_is_read_only";
export function edit(tr: TaggedResource, changes: Partial<typeof tr.body>):
ReduxAction<EditResourceParams> {
return {
type: Actions.EDIT_RESOURCE,
payload: {
uuid: tr.uuid,
update: changes,
specialStatus: SpecialStatus.DIRTY
}
};
}
/** Rather than update (patch) a TaggedResource, this method will overwrite
* everything within the `.body` property. */
export function overwrite<T extends TaggedResource>(tr: T,
changeset: T["body"],
specialStatus = SpecialStatus.DIRTY):
ReduxAction<EditResourceParams> {
return {
type: Actions.OVERWRITE_RESOURCE,
payload: { uuid: tr.uuid, update: changeset, specialStatus }
};
}
interface EditStepProps {
step: Readonly<SequenceBodyItem>;
sequence: Readonly<TaggedSequence>;
index: number;
/** Callback provides a fresh, defensively cloned copy of the
* original step. Perform modifications to the resource within this
* callback */
executor(stepCopy: SequenceBodyItem): void;
}
/** Editing sequence steps is a tedious process. Use this function in place
* of `edit()` or `overwrite`. */
export function editStep({ step, sequence, index, executor }: EditStepProps) {
// https://en.wikipedia.org/wiki/NeXTSTEP
const nextStep = defensiveClone(step);
const nextSeq = defensiveClone(sequence);
// Let the developer safely perform mutations here:
executor(nextStep);
nextSeq.body.body = nextSeq.body.body || [];
nextSeq.body.body[index] = nextStep;
return overwrite(sequence, nextSeq.body);
}
/** Initialize (but don't save) an indexed / tagged resource. */
export function init<T extends TaggedResource>(kind: T["kind"],
body: T["body"],
/** Set to "true" when you want an `undefined` SpecialStatus. */
clean = false): ReduxAction<TaggedResource> {
const resource = arrayUnwrap(newTaggedResource(kind, body));
resource.specialStatus = SpecialStatus[clean ? "SAVED" : "DIRTY"];
return { type: Actions.INIT_RESOURCE, payload: resource };
}
/** Initialize and save a new resource, returning the `id`.
* If you don't need the `id` returned, use `initSave` instead.
*/
export const initSaveGetId =
<T extends TaggedResource>(kind: T["kind"], body: T["body"]) =>
(dispatch: Function) => {
const resource = arrayUnwrap(newTaggedResource(kind, body));
resource.specialStatus = SpecialStatus.DIRTY;
dispatch({ type: Actions.INIT_RESOURCE, payload: resource });
dispatch({ type: Actions.SAVE_RESOURCE_START, payload: resource });
maybeStartTracking(resource.uuid);
return axios.post<typeof resource.body>(
urlFor(resource.kind), resource.body)
.then(resp => {
dispatch(saveOK(resource));
return resp.data.id;
})
.catch((err: UnsafeError) => {
dispatch(updateNO({
err,
uuid: resource.uuid,
statusBeforeError: resource.specialStatus
}));
return Promise.reject(err);
});
};
export function initSave<T extends TaggedResource>(kind: T["kind"],
body: T["body"]) {
return function (dispatch: Function) {
const action = init(kind, body);
dispatch(action);
return dispatch(save(action.payload.uuid));
};
}
export function save(uuid: string) {
return function (dispatch: Function, getState: GetState) {
const resource = findByUuid(getState().resources.index, uuid);
const oldStatus = resource.specialStatus;
dispatch({ type: Actions.SAVE_RESOURCE_START, payload: resource });
return dispatch(update(uuid, oldStatus));
};
}
export function refresh(resource: TaggedResource, urlNeedsId = false) {
return function (dispatch: Function) {
dispatch(refreshStart(resource.uuid));
const endPart = "" + urlNeedsId ? resource.body.id : "";
const statusBeforeError = resource.specialStatus;
axios
.get<typeof resource.body>(urlFor(resource.kind) + endPart)
.then(resp => {
const r1 = defensiveClone(resource);
const r2 = { body: defensiveClone(resp.data) };
const newTR = assign({}, r1, r2);
if (isTaggedResource(newTR)) {
dispatch(refreshOK(newTR));
} else {
const action = refreshNO({
err: { message: "Unable to refresh" },
uuid: resource.uuid,
statusBeforeError
});
dispatch(action);
}
});
};
}
export function refreshStart(uuid: string): ReduxAction<string> {
return { type: Actions.REFRESH_RESOURCE_START, payload: uuid };
}
export function refreshOK(payload: TaggedResource): ReduxAction<TaggedResource> {
return { type: Actions.REFRESH_RESOURCE_OK, payload };
}
export function refreshNO(payload: GeneralizedError):
ReduxAction<GeneralizedError> {
return { type: Actions.REFRESH_RESOURCE_NO, payload };
}
interface AjaxUpdatePayload {
index: ResourceIndex;
uuid: string;
dispatch: Function;
statusBeforeError: SpecialStatus;
}
function update(uuid: string, statusBeforeError: SpecialStatus) {
return function (dispatch: Function, getState: GetState) {
const { index } = getState().resources;
const payl: AjaxUpdatePayload = { index, uuid, dispatch, statusBeforeError };
return updateViaAjax(payl);
};
}
interface DestroyNoProps {
uuid: string;
statusBeforeError: SpecialStatus;
dispatch: Function;
}
export const destroyCatch = (p: DestroyNoProps) => (err: UnsafeError) => {
p.dispatch(destroyNO({
err,
uuid: p.uuid,
statusBeforeError: p.statusBeforeError
}));
return Promise.reject(err);
};
/** We need this to detect read-only deletion attempts */
function destroyStart() {
return { type: Actions.DESTROY_RESOURCE_START, payload: {} };
}
export function destroy(uuid: string, force = false) {
return function (dispatch: Function, getState: GetState) {
dispatch(destroyStart());
/** Stop user from deleting resources if app is read only. */
if (appIsReadonly(getState().resources.index)) {
return Promise.reject("Application is in read-only mode.");
}
const resource = findByUuid(getState().resources.index, uuid);
const maybeProceed = confirmationChecker(resource.kind, force);
return maybeProceed(() => {
const statusBeforeError = resource.specialStatus;
if (resource.body.id) {
maybeStartTracking(uuid);
return axios
.delete(urlFor(resource.kind) + resource.body.id)
.then(function () {
dispatch(destroyOK(resource));
})
.catch(destroyCatch({ dispatch, uuid, statusBeforeError }));
} else {
dispatch(destroyOK(resource));
return Promise.resolve("");
}
}) || Promise.reject("User pressed cancel");
};
}
export function destroyAll(resourceName: ResourceName, force = false,
customConfirmation?: string) {
if (force || confirm(
customConfirmation || t("Are you sure you want to delete all items?"))) {
return axios.delete(urlFor(resourceName) + "all");
} else {
return Promise.reject("User pressed cancel");
}
}
export function saveAll(input: TaggedResource[],
callback: () => void = noop,
errBack: (err: UnsafeError) => void = noop) {
return function (dispatch: Function) {
const p = input
.filter(x => x.specialStatus === SpecialStatus.DIRTY)
.map(tts => tts.uuid)
.map(uuid => {
maybeStartTracking(uuid);
return dispatch(save(uuid));
});
return Promise.all(p).then(callback, errBack);
};
}
export function urlFor(tag: ResourceName) {
const OPTIONS: Partial<Record<ResourceName, string>> = {
Alert: API.current.alertPath,
Curve: API.current.curvesPath,
Device: API.current.devicePath,
FarmEvent: API.current.farmEventsPath,
FarmwareEnv: API.current.farmwareEnvPath,
FarmwareInstallation: API.current.farmwareInstallationPath,
FbosConfig: API.current.fbosConfigPath,
FirmwareConfig: API.current.firmwareConfigPath,
Image: API.current.imagesPath,
Log: API.current.logsPath,
Peripheral: API.current.peripheralsPath,
PinBinding: API.current.pinBindingPath,
PlantTemplate: API.current.plantTemplatePath,
Point: API.current.pointsPath,
PointGroup: API.current.pointGroupsPath,
Regimen: API.current.regimensPath,
SavedGarden: API.current.savedGardensPath,
Sensor: API.current.sensorPath,
SensorReading: API.current.sensorReadingPath,
Sequence: API.current.sequencesPath,
Telemetry: API.current.telemetryPath,
Tool: API.current.toolsPath,
User: API.current.usersPath,
WebAppConfig: API.current.webAppConfigPath,
WebcamFeed: API.current.webcamFeedPath,
WizardStepResult: API.current.wizardStepResultsPath,
Folder: API.current.foldersPath,
};
const url = OPTIONS[tag];
if (url) {
return url;
} else {
throw new Error(`No resource/URL handler for ${tag} yet.
Consider adding one to crud.ts`);
}
}
const SINGULAR_RESOURCE: ResourceName[] =
["WebAppConfig", "FbosConfig", "FirmwareConfig"];
/** Shared functionality in create() and update(). */
export function updateViaAjax(payl: AjaxUpdatePayload) {
const { uuid, statusBeforeError, dispatch, index } = payl;
const resource = findByUuid(index, uuid);
const { body, kind } = resource;
let verb: "post" | "put";
let url = urlFor(kind);
if (body.id) {
verb = "put";
if (!SINGULAR_RESOURCE.includes(unpackUUID(payl.uuid).kind)) {
url += body.id;
}
} else {
verb = "post";
}
maybeStartTracking(uuid);
return axios[verb]<typeof resource.body>(url, body)
.then(function (resp) {
const r1 = defensiveClone(resource);
const r2 = { body: defensiveClone(resp.data) };
const newTR = assign({}, r1, r2);
if (isTaggedResource(newTR)) {
dispatch(saveOK(newTR));
} else {
throw new Error("Just saved a malformed TR.");
}
})
.catch(function (err: UnsafeError) {
dispatch(updateNO({ err, uuid, statusBeforeError }));
return Promise.reject(err);
});
}
const MUST_CONFIRM_LIST: ResourceName[] = [
"FarmEvent",
"Point",
"Sequence",
"Regimen",
"Image",
"SavedGarden",
"PointGroup",
];
const confirmationChecker = (resourceName: ResourceName, force: boolean) =>
<T>(proceed: () => T): T | undefined => {
if (MUST_CONFIRM_LIST.includes(resourceName)) {
if (force || confirm(t("Are you sure you want to delete this item?"))) {
return proceed();
} else {
return undefined;
}
}
return proceed();
};