Merge conflicts

pull/1048/head
Rick Carlino 2018-11-16 13:17:16 -06:00
commit 6583cd2708
36 changed files with 680 additions and 319 deletions

View File

@ -63,7 +63,7 @@ GEM
builder (3.2.3)
bunny (2.12.0)
amq-protocol (~> 2.3, >= 2.3.0)
capybara (3.10.1)
capybara (3.11.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -77,7 +77,7 @@ GEM
ffi (~> 1.0, >= 1.0.11)
choice (0.2.0)
climate_control (0.2.0)
codecov (0.1.13)
codecov (0.1.14)
json
simplecov
url
@ -266,7 +266,7 @@ GEM
thor (>= 0.19.0, < 2.0)
rake (12.3.1)
redis (4.0.3)
regexp_parser (1.2.0)
regexp_parser (1.3.0)
representable (3.0.4)
declarative (< 0.1.0)
declarative-option (< 0.2.0)

View File

@ -127,4 +127,44 @@ namespace :api do
rebuild_deps if user_typed?("build")
end
end
VERSION = "tag_name"
TIMESTAMP = "created_at"
desc "Update GlobalConfig to deprecate old FBOS versions"
task deprecate: :environment do
# Get current version
version_str = GlobalConfig.dump.fetch("FBOS_END_OF_LIFE_VERSION")
# Convert it to Gem::Version for easy comparisons (>, <, ==, etc)
current_version = Gem::Version::new(version_str)
# 60 days is the current policy.
cutoff = 60.days.ago
# Download release data from github
stringio = open("https://api.github.com/repos/farmbot/farmbot_os/releases")
string = stringio.read
data = JSON
.parse(string)
.map { |x| x.slice(VERSION, TIMESTAMP) } # Only grab keys that matter
.reject { |x| x.fetch(VERSION).include?("-") } # Remove RC/Beta releases
.map do |x|
# Convert string-y version/timestamps to Real ObjectsTM
version = Gem::Version::new(x.fetch(VERSION).gsub("v", ""))
time = DateTime.parse(x.fetch(TIMESTAMP))
Pair.new(version, time)
end
.select do |pair|
# Grab versions that are > current version and outside of cutoff window
(pair.head > current_version) && (pair.tail < cutoff)
end
.sort_by { |p| p.tail } # Sort by release date
.last(2) # Grab 2 latest versions (closest to cuttof)
.first # Give 'em some leeway, grabbing the 2nd most outdated version.
.try(:head) # We might already be up-to-date?
if data # ...or not
puts "Setting new support target to #{data.to_s}"
GlobalConfig # Set the new oldest support version.
.find_by(key: "FBOS_END_OF_LIFE_VERSION")
.update_attributes!(value: data.to_s)
end
end
end

View File

@ -39,8 +39,8 @@
"@types/lodash": "^4.14.118",
"@types/markdown-it": "0.0.7",
"@types/moxios": "^0.4.5",
"@types/node": "^10.12.6",
"@types/react": "^16.7.4",
"@types/node": "^10.12.8",
"@types/react": "^16.7.6",
"@types/react-color": "2.13.6",
"@types/react-dom": "^16.0.9",
"@types/react-joyride": "^2.0.1",

View File

@ -13,7 +13,7 @@ jest.mock("axios", () => {
};
});
import { destroy, saveAll, initSave } from "../crud";
import { destroy, saveAll, initSave, initSaveGetId } from "../crud";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { createStore, applyMiddleware } from "redux";
import { resourceReducer } from "../../resources/reducer";
@ -67,4 +67,14 @@ describe("AJAX data tracking", () => {
store.dispatch(action);
expect(maybeStartTracking).toHaveBeenCalled();
});
it("sets consistency when calling initSaveGetId()", () => {
// tslint:disable-next-line:no-any
const action: any = initSaveGetId("User", {
name: "tester123",
email: "test@test.com"
});
store.dispatch(action);
expect(maybeStartTracking).toHaveBeenCalled();
});
});

View File

@ -1,3 +1,4 @@
let mockPost = Promise.resolve({ data: { id: 1 } });
jest.mock("axios", () => ({
default: {
get: () => Promise.resolve({
@ -7,11 +8,12 @@ jest.mock("axios", () => ({
"timezone": "America/Chicago",
"last_saw_api": "2017-08-30T20:42:35.854Z"
}
})
}),
post: () => mockPost,
}
}));
import { refresh } from "../crud";
import { refresh, initSaveGetId } from "../crud";
import { TaggedDevice, SpecialStatus } from "farmbot";
import { API } from "../index";
import { Actions } from "../../constants";
@ -62,3 +64,33 @@ describe("successful refresh()", () => {
});
});
});
describe("initSaveGetId()", () => {
it("returns id", async () => {
const dispatch = jest.fn();
const result = await initSaveGetId("SavedGarden", {})(dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SAVE_RESOURCE_START,
payload: expect.objectContaining({ kind: "SavedGarden" })
});
expect(dispatch).toHaveBeenCalledWith({
type: Actions.INIT_RESOURCE,
payload: expect.objectContaining({ kind: "SavedGarden" })
});
await expect(result).toEqual(1);
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SAVE_RESOURCE_OK,
payload: expect.objectContaining({ kind: "SavedGarden" })
});
});
it("catches errors", async () => {
mockPost = Promise.reject("error");
const dispatch = jest.fn();
await initSaveGetId("SavedGarden", {})(dispatch).catch(() => { });
await expect(dispatch).toHaveBeenLastCalledWith({
type: Actions._RESOURCE_NO,
payload: expect.objectContaining({ err: "error" })
});
});
});

View File

@ -77,10 +77,36 @@ export function init<T extends TaggedResource>(kind: T["kind"],
clean = false): ReduxAction<TaggedResource> {
const resource = arrayUnwrap(newTaggedResource(kind, body));
resource.specialStatus = SpecialStatus[clean ? "SAVED" : "DIRTY"];
// /** Don't touch this- very important! */
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) {

View File

@ -558,6 +558,11 @@ export namespace Content {
trim(`Drag a box around the plants you would like to select.
Press the back arrow to exit.`);
export const SAVED_GARDENS =
trim(`Create new gardens from scratch or by copying plants from the
current garden. View and edit saved gardens, and, when ready, apply them
to the main garden.`);
// Farm Events
export const REGIMEN_TODAY_SKIPPED_ITEM_RISK =
trim(`You are scheduling a regimen to run today. Be aware that

View File

@ -312,7 +312,7 @@
}
.saved-garden-panel-content {
margin-top: 3rem;
margin-top: 5rem;
margin-left: 1rem;
margin-right: 1rem;
.row {

View File

@ -57,37 +57,42 @@
}
}
.photos {
.photos-footer {
display: flex;
position: relative;
left: 2.5rem;
bottom: -1rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
width: 90%;
justify-content: space-between;
label {
font-weight: normal;
}
span {
font-weight: bold;
}
.photos-footer {
display: flex;
position: relative;
bottom: 1rem;
justify-content: space-between;
label {
font-weight: normal;
}
.image-metadatas {
display: flex;
label {
margin-left: 1rem;
margin-right: 0.5rem;
}
span {
font-weight: bold;
}
.image-created-at {
span {
white-space: nowrap;
}
label {
margin-right: 0.5rem;
}
}
.image-metadata {
display: flex;
label {
margin-left: 1rem;
margin-right: 0.5rem;
}
}
.image-created-at {
span {
white-space: nowrap;
}
label {
margin-right: 0.5rem;
}
}
.farmware-button {
p {
float: right;
margin-top: 0.75rem;
margin-right: 1rem;
color: $medium_gray;
}
}

View File

@ -37,7 +37,7 @@ const isWorking = (job: JobProgress | undefined) =>
job && (job.status == "working");
/** FBOS update download progress. */
function downloadProgress(job: JobProgress | undefined) {
export function downloadProgress(job: JobProgress | undefined) {
if (job && isWorking(job)) {
switch (job.unit) {
case "bytes":

View File

@ -8,7 +8,8 @@ import {
TaggedSensor,
TaggedDiagnosticDump,
TaggedUser,
TaggedFarmwareInstallation
TaggedFarmwareInstallation,
JobProgress,
} from "farmbot";
import { ResourceIndex } from "../resources/interfaces";
import { WD_ENV } from "../farmware/weed_detector/remote_env/interfaces";
@ -223,6 +224,7 @@ export interface FarmwareProps {
shouldDisplay: ShouldDisplay;
saveFarmwareEnv: SaveFarmwareEnv;
taggedFarmwareInstallations: TaggedFarmwareInstallation[];
imageJobs: JobProgress[];
}
export interface HardwareSettingsProps {

View File

@ -10,17 +10,22 @@ jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../../api/crud", () => ({
destroy: jest.fn(),
initSave: jest.fn(),
initSaveGetId: jest.fn(),
}));
import { API } from "../../../api";
import axios from "axios";
import {
snapshotGarden, applyGarden, destroySavedGarden, closeSavedGarden,
openSavedGarden, openOrCloseGarden, newSavedGarden
openSavedGarden, openOrCloseGarden, newSavedGarden, unselectSavedGarden,
copySavedGarden
} from "../actions";
import { history } from "../../../history";
import { Actions } from "../../../constants";
import { destroy, initSave } from "../../../api/crud";
import { destroy, initSave, initSaveGetId } from "../../../api/crud";
import {
fakeSavedGarden, fakePlantTemplate
} from "../../../__test_support__/fake_state/resources";
describe("snapshotGarden", () => {
it("calls the API and lets auto-sync do the rest", () => {
@ -43,10 +48,7 @@ describe("applyGarden", () => {
await applyGarden(4)(dispatch);
expect(axios.patch).toHaveBeenCalledWith(API.current.applyGardenPath(4));
expect(history.push).toHaveBeenCalledWith("/app/designer/plants");
expect(dispatch).toHaveBeenCalledWith({
type: Actions.CHOOSE_SAVED_GARDEN,
payload: undefined
});
expect(dispatch).toHaveBeenCalledWith(unselectSavedGarden);
});
});
@ -55,6 +57,7 @@ describe("destroySavedGarden", () => {
const dispatch = jest.fn(() => Promise.resolve());
destroySavedGarden("SavedGardenUuid")(dispatch);
expect(destroy).toHaveBeenCalledWith("SavedGardenUuid");
expect(dispatch).toHaveBeenLastCalledWith(unselectSavedGarden);
});
});
@ -63,10 +66,7 @@ describe("closeSavedGarden", () => {
const dispatch = jest.fn();
closeSavedGarden()(dispatch);
expect(history.push).toHaveBeenCalledWith("/app/designer/saved_gardens");
expect(dispatch).toHaveBeenCalledWith({
type: Actions.CHOOSE_SAVED_GARDEN,
payload: undefined
});
expect(dispatch).toHaveBeenCalledWith(unselectSavedGarden);
});
});
@ -111,4 +111,40 @@ describe("newSavedGarden", () => {
expect(initSave).toHaveBeenCalledWith(
"SavedGarden", { name: "my saved garden" });
});
it("creates a new saved garden with default name", () => {
newSavedGarden("")(jest.fn());
expect(initSave).toHaveBeenCalledWith(
"SavedGarden", { name: "Untitled Garden" });
});
});
describe("copySavedGarden", () => {
const fakeProps = () => {
const fakeSG = fakeSavedGarden();
fakeSG.body.id = 1;
const fakePT = fakePlantTemplate();
fakePT.body.saved_garden_id = fakeSG.body.id;
return {
newSGName: "",
savedGarden: fakeSG,
plantTemplates: [fakePT],
};
};
it("creates copy", async () => {
await copySavedGarden(fakeProps())(jest.fn(() => Promise.resolve(5)));
expect(initSaveGetId).toHaveBeenCalledWith("SavedGarden",
{ name: "Saved Garden 1 (copy)" });
await expect(initSave).toHaveBeenCalledWith("PlantTemplate",
expect.objectContaining({ saved_garden_id: 5 }));
});
it("creates copy with provided name", () => {
const p = fakeProps();
p.newSGName = "New copy";
copySavedGarden(p)(jest.fn(() => Promise.resolve()));
expect(initSaveGetId).toHaveBeenCalledWith("SavedGarden",
{ name: p.newSGName });
});
});

View File

@ -8,19 +8,20 @@ jest.mock("../../../api/crud", () => ({
}));
import * as React from "react";
import { shallow } from "enzyme";
import { shallow, mount } from "enzyme";
import {
fakeSavedGarden
fakeSavedGarden, fakePlantTemplate
} from "../../../__test_support__/fake_state/resources";
import { edit } from "../../../api/crud";
import { GardenInfo } from "../garden_list";
import { GardenInfo, SavedGardenList } from "../garden_list";
import { SavedGardenInfoProps, SavedGardensProps } from "../interfaces";
describe("<GardenInfo />", () => {
const fakeProps = () => ({
const fakeProps = (): SavedGardenInfoProps => ({
dispatch: jest.fn(),
savedGarden: fakeSavedGarden(),
gardenIsOpen: false,
plantCount: 1,
plantTemplateCount: 0,
});
it("edits garden name", () => {
@ -30,3 +31,28 @@ describe("<GardenInfo />", () => {
expect(edit).toHaveBeenCalledWith(expect.any(Object), { name: "new name" });
});
});
describe("<SavedGardenList />", () => {
const fakeProps = (): SavedGardensProps => {
const fakeSG = fakeSavedGarden();
return {
dispatch: jest.fn(),
plantPointerCount: 1,
savedGardens: [fakeSG],
plantTemplates: [fakePlantTemplate(), fakePlantTemplate()],
openedSavedGarden: fakeSG.uuid,
};
};
it("renders open garden", () => {
const wrapper = mount(<SavedGardenList {...fakeProps()} />);
expect(wrapper.text()).toContain("exit");
});
it("renders gardens closed", () => {
const p = fakeProps();
p.openedSavedGarden = undefined;
const wrapper = mount(<SavedGardenList {...p} />);
expect(wrapper.text()).not.toContain("exit");
});
});

View File

@ -5,19 +5,18 @@ jest.mock("axios", () => {
jest.mock("../actions", () => ({
snapshotGarden: jest.fn(),
newSavedGarden: jest.fn(),
copySavedGarden: jest.fn(),
}));
import * as React from "react";
import { mount, shallow } from "enzyme";
import { GardenSnapshotProps, GardenSnapshot } from "../garden_snapshot";
import { clickButton } from "../../../__test_support__/helpers";
import { error } from "farmbot-toastr";
import { snapshotGarden, newSavedGarden } from "../actions";
import { snapshotGarden, newSavedGarden, copySavedGarden } from "../actions";
import { fakeSavedGarden } from "../../../__test_support__/fake_state/resources";
describe("<GardenSnapshot />", () => {
const fakeProps = (): GardenSnapshotProps => ({
plantsInGarden: true,
currentSavedGarden: undefined,
plantTemplates: [],
dispatch: jest.fn(),
@ -29,24 +28,17 @@ describe("<GardenSnapshot />", () => {
expect(snapshotGarden).toHaveBeenCalledWith("");
});
it("doesn't snapshot saved garden", () => {
it("copies saved garden", () => {
const p = fakeProps();
p.currentSavedGarden = fakeSavedGarden();
const wrapper = mount(<GardenSnapshot {...p} />);
clickButton(wrapper, 0, "snapshot current garden");
expect(snapshotGarden).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("while saved garden is open"));
});
it("no garden to save", () => {
const p = fakeProps();
p.plantsInGarden = false;
const wrapper = mount(<GardenSnapshot {...p} />);
clickButton(wrapper, 0, "snapshot current garden");
expect(snapshotGarden).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(expect.stringContaining(
"No plants in garden"));
expect(copySavedGarden).toHaveBeenCalledWith({
newSGName: "",
plantTemplates: [],
savedGarden: p.currentSavedGarden
});
});
it("changes name", () => {

View File

@ -36,7 +36,7 @@ import { Actions } from "../../../constants";
describe("<SavedGardens />", () => {
const fakeProps = (): SavedGardensProps => ({
dispatch: jest.fn(),
plantsInGarden: true,
plantPointerCount: 1,
savedGardens: [fakeSavedGarden()],
plantTemplates: [fakePlantTemplate(), fakePlantTemplate()],
openedSavedGarden: undefined,
@ -51,7 +51,7 @@ describe("<SavedGardens />", () => {
it("applies garden", () => {
const p = fakeProps();
p.savedGardens[0].uuid = "SavedGarden.1.0";
p.plantsInGarden = false;
p.plantPointerCount = 0;
const wrapper = mount(<SavedGardens {...p} />);
clickButton(wrapper, 3, "apply");
expect(applyGarden).toHaveBeenCalledWith(1);
@ -91,12 +91,12 @@ describe("mapStateToProps()", () => {
const state = fakeState();
state.resources = buildResourceIndex([]);
const result = mapStateToProps(state);
expect(result.plantsInGarden).toEqual(false);
expect(result.plantPointerCount).toEqual(0);
});
it("has plants in garden", () => {
const result = mapStateToProps(fakeState());
expect(result.plantsInGarden).toEqual(true);
expect(result.plantPointerCount).toBeGreaterThan(0);
});
});

View File

@ -1,27 +1,31 @@
import axios from "axios";
import { API } from "../../api";
import { t } from "i18next";
import { success } from "farmbot-toastr";
import { success, info } from "farmbot-toastr";
import { history } from "../../history";
import { Actions } from "../../constants";
import { destroy, initSave } from "../../api/crud";
import { destroy, initSave, initSaveGetId } from "../../api/crud";
import { unpackUUID } from "../../util";
import { isString } from "lodash";
import { TaggedSavedGarden, TaggedPlantTemplate } from "farmbot";
/** Save all Plant to PlantTemplates in a new SavedGarden. */
export const snapshotGarden = (name?: string | undefined) =>
axios.post<void>(API.current.snapshotPath, name ? { name } : {})
.then(() => success(t("Garden Saved.")));
const unselectSavedGarden = {
export const unselectSavedGarden = {
type: Actions.CHOOSE_SAVED_GARDEN,
payload: undefined
};
/** Save a SavedGarden's PlantTemplates as Plants. */
export const applyGarden = (gardenId: number) => (dispatch: Function) => axios
.patch<void>(API.current.applyGardenPath(gardenId))
.then(() => {
history.push("/app/designer/plants");
dispatch(unselectSavedGarden);
info(t("while your garden is applied."), t("Please wait"), "blue");
});
export const destroySavedGarden = (uuid: string) => (dispatch: Function) => {
@ -42,6 +46,7 @@ export const openSavedGarden = (savedGarden: string) => {
dispatch({ type: Actions.CHOOSE_SAVED_GARDEN, payload: savedGarden });
};
/** Open a SavedGarden if it is closed, otherwise close it. */
export const openOrCloseGarden = (props: {
savedGarden: string | undefined,
gardenIsOpen: boolean,
@ -51,7 +56,36 @@ export const openOrCloseGarden = (props: {
? props.dispatch(openSavedGarden(props.savedGarden))
: props.dispatch(closeSavedGarden());
/** Create a new SavedGarden with the chosen name. */
export const newSavedGarden = (name: string) =>
(dispatch: Function) => {
dispatch(initSave("SavedGarden", { name: name || "Untitled Garden" }));
};
/** Create a copy of a PlantTemplate body and assign it a new SavedGarden. */
const newPTBody =
(source: TaggedPlantTemplate, newSGId: number): TaggedPlantTemplate["body"] =>
({
name: source.body.name,
openfarm_slug: source.body.openfarm_slug,
saved_garden_id: newSGId,
radius: source.body.radius,
x: source.body.x,
y: source.body.y,
z: source.body.z,
});
/** Copy a SavedGarden and all of its PlantTemplates. */
export const copySavedGarden = ({ newSGName, savedGarden, plantTemplates }: {
newSGName: string,
savedGarden: TaggedSavedGarden,
plantTemplates: TaggedPlantTemplate[]
}) =>
(dispatch: Function) => {
const sourceSavedGardenId = savedGarden.body.id;
const name = newSGName || `${savedGarden.body.name} (${t("copy")})`;
dispatch(initSaveGetId(savedGarden.kind, { name }))
.then((newSGId: number) => plantTemplates
.filter(x => x.body.saved_garden_id === sourceSavedGardenId)
.map(x => dispatch(initSave(x.kind, newPTBody(x, newSGId)))));
};

View File

@ -5,17 +5,13 @@ import { error } from "farmbot-toastr";
import { isNumber, isString } from "lodash";
import { openOrCloseGarden, applyGarden, destroySavedGarden } from "./actions";
import {
SavedGardensProps, GardenViewButtonProps, SavedGardenItemProps
SavedGardensProps, GardenViewButtonProps, SavedGardenItemProps,
SavedGardenInfoProps
} from "./interfaces";
import { TaggedSavedGarden } from "farmbot";
import { edit, save } from "../../api/crud";
export const GardenInfo = (props: {
savedGarden: TaggedSavedGarden,
gardenIsOpen: boolean,
plantCount: number,
dispatch: Function
}) => {
/** Name input and PlantTemplate count for a single SavedGarden. */
export const GardenInfo = (props: SavedGardenInfoProps) => {
const { savedGarden, gardenIsOpen, dispatch } = props;
return <div className="saved-garden-info"
onClick={openOrCloseGarden({
@ -30,11 +26,12 @@ export const GardenInfo = (props: {
}} />
</Col>
<Col xs={2}>
<p style={{ textAlign: "center" }}>{props.plantCount}</p>
<p style={{ textAlign: "center" }}>{props.plantTemplateCount}</p>
</Col>
</div>;
};
/** Open or close a SavedGarden. */
const GardenViewButton = (props: GardenViewButtonProps) => {
const { dispatch, savedGarden, gardenIsOpen } = props;
const onClick = openOrCloseGarden({ savedGarden, gardenIsOpen, dispatch });
@ -48,12 +45,14 @@ const GardenViewButton = (props: GardenViewButtonProps) => {
</button>;
};
/** Apply a SavedGarden after checking that the current garden is empty. */
const ApplyGardenButton =
(props: { plantsInGarden: boolean, gardenId: number, dispatch: Function }) =>
(props: { plantPointerCount: number, gardenId: number, dispatch: Function }) =>
<button
className="fb-button green"
onClick={() => props.plantsInGarden
? error(t("Please clear current garden first."))
onClick={() => props.plantPointerCount > 0
? error(t("Please clear current garden first. ({{plantCount}} plants)",
{ plantCount: props.plantPointerCount }))
: props.dispatch(applyGarden(props.gardenId))}>
{t("apply")}
</button>;
@ -66,6 +65,7 @@ const DestroyGardenButton =
<i className="fa fa-times" />
</button>;
/** Info and actions for a single SavedGarden. */
const SavedGardenItem = (props: SavedGardenItemProps) => {
return <div
className={`saved-garden-row ${props.gardenIsOpen ? "selected" : ""}`}>
@ -73,7 +73,7 @@ const SavedGardenItem = (props: SavedGardenItemProps) => {
<GardenInfo
savedGarden={props.savedGarden}
gardenIsOpen={props.gardenIsOpen}
plantCount={props.plantCount}
plantTemplateCount={props.plantTemplateCount}
dispatch={props.dispatch} />
<Col xs={6}>
<DestroyGardenButton
@ -81,7 +81,7 @@ const SavedGardenItem = (props: SavedGardenItemProps) => {
gardenUuid={props.savedGarden.uuid} />
<ApplyGardenButton
dispatch={props.dispatch}
plantsInGarden={props.plantsInGarden}
plantPointerCount={props.plantPointerCount}
gardenId={props.savedGarden.body.id || -1} />
<GardenViewButton
dispatch={props.dispatch}
@ -92,6 +92,7 @@ const SavedGardenItem = (props: SavedGardenItemProps) => {
</div>;
};
/** Info and action list for all SavedGardens. */
export const SavedGardenList = (props: SavedGardensProps) =>
<div className="saved-garden-list">
<Row>
@ -112,9 +113,9 @@ export const SavedGardenList = (props: SavedGardensProps) =>
savedGarden={sg}
gardenIsOpen={sg.uuid === props.openedSavedGarden}
dispatch={props.dispatch}
plantCount={props.plantTemplates.filter(pt =>
pt.body.saved_garden_id === sg.body.id).length}
plantsInGarden={props.plantsInGarden} />;
plantPointerCount={props.plantPointerCount}
plantTemplateCount={props.plantTemplates.filter(pt =>
pt.body.saved_garden_id === sg.body.id).length} />;
}
})}
</div>;

View File

@ -1,11 +1,9 @@
import * as React from "react";
import { t } from "i18next";
import { error } from "farmbot-toastr";
import { snapshotGarden, newSavedGarden } from "./actions";
import { snapshotGarden, newSavedGarden, copySavedGarden } from "./actions";
import { TaggedPlantTemplate, TaggedSavedGarden } from "farmbot";
export interface GardenSnapshotProps {
plantsInGarden: boolean;
currentSavedGarden: TaggedSavedGarden | undefined;
plantTemplates: TaggedPlantTemplate[];
dispatch: Function;
@ -15,23 +13,21 @@ interface GardenSnapshotState {
name: string;
}
const GARDEN_OPEN_ERROR = t("Can't snapshot while saved garden is open.");
const NO_PLANTS_ERROR = t("No plants in garden. Create some plants first.");
/** New SavedGarden name input and snapshot/create buttons. */
export class GardenSnapshot
extends React.Component<GardenSnapshotProps, GardenSnapshotState> {
state = { name: "" };
snapshot = () => {
const { currentSavedGarden } = this.props;
if (!currentSavedGarden) {
this.props.plantsInGarden
? snapshotGarden(this.state.name)
: error(NO_PLANTS_ERROR);
this.setState({ name: "" });
} else {
error(GARDEN_OPEN_ERROR);
}
const { currentSavedGarden, plantTemplates } = this.props;
!currentSavedGarden
? snapshotGarden(this.state.name)
: this.props.dispatch(copySavedGarden({
newSGName: this.state.name,
savedGarden: currentSavedGarden,
plantTemplates
}));
this.setState({ name: "" });
}
new = () => {
@ -40,18 +36,13 @@ export class GardenSnapshot
};
render() {
const disabledClassName =
this.props.currentSavedGarden ? "pseudo-disabled" : "";
return <div className="garden-snapshot">
<label>{t("garden name")}</label>
<label>{t("new garden name")}</label>
<input
onChange={e => this.setState({ name: e.currentTarget.value })}
value={this.state.name} />
<button
title={this.props.currentSavedGarden
? GARDEN_OPEN_ERROR
: ""}
className={`fb-button gray wide ${disabledClassName}`}
className={"fb-button gray wide"}
onClick={this.snapshot}>
{t("Snapshot current garden")}
</button>

View File

@ -4,7 +4,7 @@ export interface SavedGardensProps {
savedGardens: TaggedSavedGarden[];
plantTemplates: TaggedPlantTemplate[];
dispatch: Function;
plantsInGarden: boolean;
plantPointerCount: number;
openedSavedGarden: string | undefined;
}
@ -18,6 +18,13 @@ export interface SavedGardenItemProps {
savedGarden: TaggedSavedGarden;
gardenIsOpen: boolean;
dispatch: Function;
plantCount: number;
plantsInGarden: boolean;
plantPointerCount: number;
plantTemplateCount: number;
}
export interface SavedGardenInfoProps {
savedGarden: TaggedSavedGarden;
gardenIsOpen: boolean;
plantTemplateCount: number;
dispatch: Function;
}

View File

@ -12,16 +12,15 @@ import { SavedGardenList } from "./garden_list";
import { SavedGardensProps } from "./interfaces";
import { closeSavedGarden } from "./actions";
import { TaggedSavedGarden } from "farmbot";
import { Content } from "../../constants";
export function mapStateToProps(props: Everything): SavedGardensProps {
return {
savedGardens: selectAllSavedGardens(props.resources.index),
plantTemplates: selectAllPlantTemplates(props.resources.index),
dispatch: props.dispatch,
plantsInGarden: selectAllPlantPointers(props.resources.index).length > 0,
openedSavedGarden: props.resources.consumers.farm_designer.openedSavedGarden,
};
}
export const mapStateToProps = (props: Everything): SavedGardensProps => ({
savedGardens: selectAllSavedGardens(props.resources.index),
plantTemplates: selectAllPlantTemplates(props.resources.index),
dispatch: props.dispatch,
plantPointerCount: selectAllPlantPointers(props.resources.index).length,
openedSavedGarden: props.resources.consumers.farm_designer.openedSavedGarden,
});
@connect(mapStateToProps)
export class SavedGardens extends React.Component<SavedGardensProps, {}> {
@ -46,13 +45,12 @@ export class SavedGardens extends React.Component<SavedGardensProps, {}> {
</p>
<div className="panel-header-description">
{t("Save or load a garden.")}
{t(Content.SAVED_GARDENS)}
</div>
</div>
<div className="panel-content saved-garden-panel-content">
<GardenSnapshot
plantsInGarden={this.props.plantsInGarden}
currentSavedGarden={this.currentSavedGarden}
plantTemplates={this.props.plantTemplates}
dispatch={this.props.dispatch} />
@ -65,6 +63,7 @@ export class SavedGardens extends React.Component<SavedGardensProps, {}> {
}
}
/** Link to SavedGardens panel for garden map legend. */
export const SavedGardensLink = () =>
<button className="fb-button green"
hidden={!(localStorage.getItem("FUTURE_FEATURES"))}
@ -72,10 +71,12 @@ export const SavedGardensLink = () =>
{t("Saved Gardens")}
</button>;
/** Check if a SavedGarden is currently open (URL approach). */
export const savedGardenOpen = (pathArray: string[]) =>
pathArray[3] === "saved_gardens" && parseInt(pathArray[4]) > 0
? parseInt(pathArray[4]) : false;
/** Sticky an indicator and actions menu when a SavedGarden is open. */
export const SavedGardenHUD = (props: { dispatch: Function }) =>
<div className="saved-garden-indicator">
<label>{t("Viewing saved garden")}</label>

View File

@ -42,6 +42,7 @@ describe("<FarmwarePage />", () => {
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
taggedFarmwareInstallations: [],
imageJobs: [],
};
};

View File

@ -14,6 +14,7 @@ import {
} from "../../__test_support__/fake_state/resources";
import { edit, initSave, save } from "../../api/crud";
import { fakeFarmware } from "../../__test_support__/fake_farmwares";
import { JobProgress } from "farmbot";
describe("mapStateToProps()", () => {
@ -79,6 +80,44 @@ describe("mapStateToProps()", () => {
[botFarmwareName]: botFarmware
});
});
it("returns image upload job list", () => {
const state = fakeState();
state.bot.hardware.jobs = {
"img1.png": {
status: "working",
percent: 20,
unit: "percent",
time: "2018-11-15 18:13:21.167440Z",
} as JobProgress,
"FBOS_OTA": {
status: "working",
percent: 10,
unit: "percent",
time: "2018-11-15 17:13:21.167440Z",
} as JobProgress,
"img2.png": {
status: "working",
percent: 10,
unit: "percent",
time: "2018-11-15 19:13:21.167440Z",
} as JobProgress,
};
const props = mapStateToProps(state);
expect(props.imageJobs).toEqual([
{
status: "working",
percent: 10,
unit: "percent",
time: "2018-11-15 19:13:21.167440Z"
},
{
status: "working",
percent: 20,
unit: "percent",
time: "2018-11-15 18:13:21.167440Z"
}]);
});
});
describe("saveOrEditFarmwareEnv()", () => {

View File

@ -35,6 +35,7 @@ describe("<CameraCalibration/>", () => {
syncStatus: "synced",
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
timeOffset: 0,
});
it("renders", () => {

View File

@ -45,6 +45,7 @@ export class CameraCalibration extends
images={this.props.images}
currentImage={this.props.currentImage}
onChange={this.change}
timeOffset={this.props.timeOffset}
iteration={this.props.iteration}
morph={this.props.morph}
blur={this.props.blur}

View File

@ -21,4 +21,5 @@ export interface CameraCalibrationProps {
syncStatus: SyncStatus | undefined;
shouldDisplay: ShouldDisplay;
saveFarmwareEnv: SaveFarmwareEnv;
timeOffset: number;
}

View File

@ -1,10 +1,11 @@
import "../../../__test_support__/unmock_i18next";
import * as React from "react";
import { shallow, mount } from "enzyme";
import { ImageFlipper } from "../image_flipper";
import { ImageFlipper, PLACEHOLDER_FARMBOT } from "../image_flipper";
import { fakeImages } from "../../../__test_support__/fake_state/images";
import { TaggedImage } from "farmbot";
import { defensiveClone } from "../../../util";
import { ImageFlipperProps } from "../interfaces";
describe("<ImageFlipper/>", () => {
function prepareImages(data: TaggedImage[]): TaggedImage[] {
@ -17,114 +18,104 @@ describe("<ImageFlipper/>", () => {
return images;
}
const fakeProps = (): ImageFlipperProps => ({
images: prepareImages(fakeImages),
currentImage: undefined,
onFlip: jest.fn(),
});
it("defaults to index 0 and flips up", () => {
const onFlip = jest.fn();
const currentImage = undefined;
const images = prepareImages(fakeImages);
const props = { images, currentImage, onFlip };
const x = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
const x = shallow(<ImageFlipper {...p} />);
const up = (x.instance() as ImageFlipper).go(1);
up();
expect(onFlip).toHaveBeenCalledWith(images[1].uuid);
expect(p.onFlip).toHaveBeenCalledWith(p.images[1].uuid);
});
it("flips down", () => {
const onFlip = jest.fn();
const images = prepareImages(fakeImages);
const currentImage = images[1];
const props = { images, currentImage, onFlip };
const x = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
p.currentImage = p.images[1];
const x = shallow(<ImageFlipper {...p} />);
const down = (x.instance() as ImageFlipper).go(-1);
down();
expect(onFlip).toHaveBeenCalledWith(images[0].uuid);
expect(p.onFlip).toHaveBeenCalledWith(p.images[0].uuid);
});
it("stops at upper end", () => {
const onFlip = jest.fn();
const images = prepareImages(fakeImages);
const currentImage = images[2];
const props = { images, currentImage, onFlip };
const x = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
p.currentImage = p.images[2];
const x = shallow(<ImageFlipper {...p} />);
const up = (x.instance() as ImageFlipper).go(1);
up();
expect(onFlip).not.toHaveBeenCalled();
expect(p.onFlip).not.toHaveBeenCalled();
});
it("stops at lower end", () => {
const images = prepareImages(fakeImages);
const props = {
images,
currentImage: images[0],
onFlip: jest.fn()
};
const x = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
p.currentImage = p.images[0];
const x = shallow(<ImageFlipper {...p} />);
const down = (x.instance() as ImageFlipper).go(-1);
down();
expect(props.onFlip).not.toHaveBeenCalled();
expect(p.onFlip).not.toHaveBeenCalled();
});
it("disables flippers when no images", () => {
const onFlip = jest.fn();
const images = prepareImages([]);
const currentImage = undefined;
const props = { images, currentImage, onFlip };
const wrapper = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
p.images = prepareImages([]);
const wrapper = shallow(<ImageFlipper {...p} />);
expect(wrapper.find("button").first().props().disabled).toBeTruthy();
expect(wrapper.find("button").last().props().disabled).toBeTruthy();
});
it("disables flippers when only one image", () => {
const onFlip = jest.fn();
const images = prepareImages([fakeImages[0]]);
const currentImage = undefined;
const props = { images, currentImage, onFlip };
const wrapper = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
p.images = prepareImages([fakeImages[0]]);
const wrapper = shallow(<ImageFlipper {...p} />);
expect(wrapper.find("button").first().props().disabled).toBeTruthy();
expect(wrapper.find("button").last().props().disabled).toBeTruthy();
});
it("disables next flipper on load", () => {
const onFlip = jest.fn();
const images = prepareImages(fakeImages);
const currentImage = undefined;
const props = { images, currentImage, onFlip };
const wrapper = shallow(<ImageFlipper {...props} />);
const wrapper = shallow(<ImageFlipper {...fakeProps()} />);
wrapper.update();
expect(wrapper.find("button").first().props().disabled).toBeFalsy();
expect(wrapper.find("button").last().props().disabled).toBeTruthy();
});
it("disables flipper at lower end", () => {
const onFlip = jest.fn();
const images = prepareImages(fakeImages);
const currentImage = images[1];
const props = { images, currentImage, onFlip };
const wrapper = shallow(<ImageFlipper {...props} />);
const p = fakeProps();
p.currentImage = p.images[1];
const wrapper = shallow(<ImageFlipper {...p} />);
wrapper.setState({ disableNext: false });
const nextButton = wrapper.render().find("button").last();
expect(nextButton.text().toLowerCase()).toBe("next");
expect(nextButton.prop("disabled")).toBeFalsy();
wrapper.find("button").last().simulate("click");
expect(onFlip).toHaveBeenLastCalledWith(images[0].uuid);
expect(p.onFlip).toHaveBeenLastCalledWith(p.images[0].uuid);
expect(wrapper.find("button").last().render().prop("disabled")).toBeTruthy();
});
it("disables flipper at upper end", () => {
const onFlip = jest.fn();
const images = prepareImages(fakeImages);
const currentImage = images[1];
const props = { images, currentImage, onFlip };
const wrapper = mount(<ImageFlipper {...props} />);
const p = fakeProps();
p.currentImage = p.images[1];
const wrapper = mount(<ImageFlipper {...p} />);
const prevButton = wrapper.find("button").first();
expect(prevButton.text().toLowerCase()).toBe("prev");
expect(prevButton.props().disabled).toBeFalsy();
prevButton.simulate("click");
wrapper.update();
// FAILED
expect(onFlip).toHaveBeenCalledWith(images[2].uuid);
expect(p.onFlip).toHaveBeenCalledWith(p.images[2].uuid);
expect(wrapper.find("button").first().render().prop("disabled")).toBeTruthy();
prevButton.simulate("click");
expect(onFlip).toHaveBeenCalledTimes(1);
expect(p.onFlip).toHaveBeenCalledTimes(1);
});
it("renders placeholder", () => {
const p = fakeProps();
p.images[0].body.attachment_processed_at = undefined;
p.currentImage = p.images[0];
const wrapper = mount(<ImageFlipper {...p} />);
expect(wrapper.find("img").last().props().src).toEqual(PLACEHOLDER_FARMBOT);
});
});

View File

@ -1,60 +1,135 @@
jest.mock("../../../api/crud", () => ({
destroy: jest.fn(),
}));
const mockDevice = { takePhoto: jest.fn(() => Promise.resolve({})) };
jest.mock("../../../device", () => ({ getDevice: () => mockDevice }));
jest.mock("../../../api/crud", () => ({ destroy: jest.fn() }));
jest.mock("../actions", () => ({ selectImage: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import { Photos } from "../photos";
import { TaggedImage } from "farmbot";
import { JobProgress } from "farmbot";
import { fakeImages } from "../../../__test_support__/fake_state/images";
import { defensiveClone } from "../../../util";
import { destroy } from "../../../api/crud";
import { clickButton } from "../../../__test_support__/helpers";
import { PhotosProps } from "../interfaces";
import { success, error } from "farmbot-toastr";
import { selectImage } from "../actions";
describe("<Photos/>", () => {
function prepareImages(data: TaggedImage[]): TaggedImage[] {
const images: TaggedImage[] = [];
data.forEach((item, index) => {
const image = defensiveClone(item);
image.uuid = `Position ${index}`;
images.push(image);
});
return images;
}
const fakeProps = (): PhotosProps => ({
images: [],
currentImage: undefined,
dispatch: jest.fn(),
timeOffset: 0,
imageJobs: [],
});
it("shows photo", () => {
const dispatch = jest.fn();
const images = prepareImages(fakeImages);
const currentImage = images[1];
const props = { images, currentImage, dispatch, timeOffset: 0 };
const wrapper = mount(<Photos {...props} />);
const p = fakeProps();
const images = fakeImages;
p.currentImage = images[1];
const wrapper = mount(<Photos {...p} />);
expect(wrapper.text()).toContain("Created At:June 1st, 2017");
expect(wrapper.text()).toContain("X:632Y:347Z:164");
});
it("no photos", () => {
const props = {
images: [],
currentImage: undefined,
dispatch: jest.fn(),
timeOffset: 0
};
const wrapper = mount(<Photos {...props} />);
const wrapper = mount(<Photos {...fakeProps()} />);
expect(wrapper.text()).toContain("Image:No meta data.");
});
it("deletes photo", () => {
const dispatch = jest.fn(() => { return Promise.resolve(); });
const images = prepareImages(fakeImages);
const currentImage = images[1];
const props = {
images,
currentImage,
dispatch,
timeOffset: 0
};
const wrapper = mount(<Photos {...props} />);
it("takes photo", async () => {
const wrapper = mount(<Photos {...fakeProps()} />);
await clickButton(wrapper, 0, "take photo");
expect(mockDevice.takePhoto).toHaveBeenCalled();
await expect(success).toHaveBeenCalled();
});
it("fails to take photo", async () => {
mockDevice.takePhoto = jest.fn(() => Promise.reject());
const wrapper = mount(<Photos {...fakeProps()} />);
await clickButton(wrapper, 0, "take photo");
expect(mockDevice.takePhoto).toHaveBeenCalled();
await expect(error).toHaveBeenCalled();
});
it("deletes photo", async () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.resolve());
const images = fakeImages;
p.currentImage = images[1];
const wrapper = mount(<Photos {...p} />);
await clickButton(wrapper, 1, "delete photo");
expect(destroy).toHaveBeenCalledWith(p.currentImage.uuid);
await expect(success).toHaveBeenCalled();
});
it("fails to delete photo", async () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.reject("error"));
const images = fakeImages;
p.currentImage = images[1];
const wrapper = mount(<Photos {...p} />);
await clickButton(wrapper, 1, "delete photo");
await expect(destroy).toHaveBeenCalledWith(p.currentImage.uuid);
await expect(error).toHaveBeenCalled();
});
it("deletes most recent photo", async () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.resolve());
p.images = fakeImages;
const wrapper = mount(<Photos {...p} />);
await clickButton(wrapper, 1, "delete photo");
expect(destroy).toHaveBeenCalledWith(p.images[0].uuid);
await expect(success).toHaveBeenCalled();
});
it("no photos to delete", () => {
const wrapper = mount(<Photos {...fakeProps()} />);
clickButton(wrapper, 1, "delete photo");
expect(destroy).toHaveBeenCalledWith("Position 1");
expect(destroy).not.toHaveBeenCalledWith();
});
it("shows image download progress", () => {
const p = fakeProps();
p.imageJobs = [{
status: "working",
percent: 15,
unit: "percent",
time: "2018-11-15 19:13:21.167440Z"
} as JobProgress];
const wrapper = mount(<Photos {...p} />);
expect(wrapper.text()).toContain("uploading photo...15%");
});
it("doesn't show image download progress", () => {
const p = fakeProps();
p.imageJobs = [{
status: "complete",
percent: 15,
unit: "percent",
time: "2018-11-15 19:13:21.167440Z"
} as JobProgress];
const wrapper = mount(<Photos {...p} />);
expect(wrapper.text()).not.toContain("uploading");
});
it("can't find meta field data", () => {
const p = fakeProps();
p.images = fakeImages;
p.images[0].body.meta.x = undefined;
p.currentImage = p.images[0];
const wrapper = mount(<Photos {...p} />);
expect(wrapper.text()).toContain("X:unknown");
});
it("flips photo", () => {
const p = fakeProps();
p.images = fakeImages;
const wrapper = shallow(<Photos {...p} />);
wrapper.find("ImageFlipper").simulate("flip", 1);
expect(selectImage).toHaveBeenCalledWith(1);
});
});

View File

@ -5,6 +5,15 @@ import { Content } from "../../constants";
export const PLACEHOLDER_FARMBOT = "/placeholder_farmbot.jpg";
/** Placeholder image with text overlay. */
const PlaceholderImg = ({ textOverlay }: { textOverlay: string }) =>
<div className="no-flipper-image-container">
<p>{t(textOverlay)}</p>
<img
className="image-flipper-image"
src={PLACEHOLDER_FARMBOT} />
</div>;
export class ImageFlipper extends
React.Component<ImageFlipperProps, Partial<ImageFlipperState>> {
@ -16,30 +25,22 @@ export class ImageFlipper extends
imageJSX = () => {
if (this.props.images.length > 0) {
const i = this.props.currentImage || this.props.images[0];
let url: string;
url = (i.body.attachment_processed_at) ?
i.body.attachment_url : PLACEHOLDER_FARMBOT;
const image = this.props.currentImage || this.props.images[0];
const url = image.body.attachment_processed_at
? image.body.attachment_url
: PLACEHOLDER_FARMBOT;
return <div>
{!this.state.isLoaded && (
<div className="no-flipper-image-container">
<p>{t(`Image loading (try refreshing)`)}</p>
<img
className="image-flipper-image"
src={PLACEHOLDER_FARMBOT} />
</div>)}
{!this.state.isLoaded &&
<PlaceholderImg
textOverlay={t("Image loading (try refreshing)")} />}
<img
onLoad={() => this.setState({ isLoaded: true })}
className={`image-flipper-image is-loaded-${this.state.isLoaded}`}
src={url} />
</div>;
} else {
return <div className="no-flipper-image-container">
<p>{t(Content.NO_IMAGES_YET)}</p>
<img
className="image-flipper-image"
src={PLACEHOLDER_FARMBOT} />
</div>;
return <PlaceholderImg
textOverlay={Content.NO_IMAGES_YET} />;
}
}
@ -61,10 +62,9 @@ export class ImageFlipper extends
}
render() {
const image = this.imageJSX();
const multipleImages = this.props.images.length > 1;
return <div className="image-flipper">
{image}
<this.imageJSX />
<button
onClick={this.go(1)}
disabled={!multipleImages || this.state.disablePrev}

View File

@ -1,4 +1,4 @@
import { TaggedImage } from "farmbot";
import { TaggedImage, JobProgress } from "farmbot";
export interface ImageFlipperProps {
onFlip(uuid: string | undefined): void;
@ -17,4 +17,5 @@ export interface PhotosProps {
images: TaggedImage[];
currentImage: TaggedImage | undefined;
timeOffset: number;
imageJobs: JobProgress[];
}

View File

@ -10,6 +10,10 @@ import { Content } from "../../constants";
import { selectImage } from "./actions";
import { safeStringFetch } from "../../util";
import { destroy } from "../../api/crud";
import {
downloadProgress
} from "../../devices/components/fbos_settings/os_update_button";
import { JobProgress, TaggedImage } from "farmbot";
interface MetaInfoProps {
/** Default conversion is `attr_name ==> Attr Name`.
@ -30,6 +34,67 @@ function MetaInfo({ obj, attr, label }: MetaInfoProps) {
</div>;
}
const PhotoMetaData = ({ image }: { image: TaggedImage | undefined }) =>
<div className="image-metadata">
{image
? Object.keys(image.body.meta)
.filter(key => ["x", "y", "z"].includes(key))
.sort()
.map((key, index) =>
<MetaInfo key={index} attr={key} obj={image.body.meta} />)
: <MetaInfo
label={t("Image")}
attr={"image"}
obj={{ image: t("No meta data.") }} />}
</div>;
const PhotoButtons = (props: {
takePhoto: () => void,
deletePhoto: () => void,
imageJobs: JobProgress[]
}) => {
const imageUploadJobProgress = downloadProgress(props.imageJobs[0]);
return <div className="farmware-button">
<button
className="fb-button green"
onClick={props.takePhoto}>
{t("Take Photo")}
</button>
<button
className="fb-button red"
onClick={props.deletePhoto}>
{t("Delete Photo")}
</button>
<p>
{imageUploadJobProgress &&
`${t("uploading photo")}...${imageUploadJobProgress}`}
</p>
</div>;
};
export const PhotoFooter = ({ image, timeOffset }: {
image: TaggedImage | undefined,
timeOffset: number
}) => {
const created_at = image
? moment(image.body.created_at)
.utcOffset(timeOffset)
.format("MMMM Do, YYYY h:mma")
: "";
return <div className="photos-footer">
{/** Separated from <MetaInfo /> for stylistic purposes. */}
{image ?
<div className="image-created-at">
<label>{t("Created At:")}</label>
<span>
{created_at}
</span>
</div>
: ""}
<PhotoMetaData image={image} />
</div>;
};
export class Photos extends React.Component<PhotosProps, {}> {
takePhoto = () => {
@ -38,22 +103,7 @@ export class Photos extends React.Component<PhotosProps, {}> {
getDevice().takePhoto().then(ok, no);
}
metaDatas() {
const i = this.props.currentImage;
if (i) {
const { meta } = i.body;
return Object.keys(meta)
.filter(key => ["x", "y", "z"].includes(key))
.sort()
.map((key, index) => {
return <MetaInfo key={index} attr={key} obj={meta} />;
});
} else {
return <MetaInfo attr={t("image")} obj={{ image: t("No meta data.") }} />;
}
}
destroy = () => {
deletePhoto = () => {
const img = this.props.currentImage || this.props.images[0];
if (img && img.uuid) {
this.props.dispatch(destroy(img.uuid))
@ -63,43 +113,18 @@ export class Photos extends React.Component<PhotosProps, {}> {
}
render() {
const image = this.props.currentImage;
const created_at = image
? moment(image.body.created_at)
.utcOffset(this.props.timeOffset)
.format("MMMM Do, YYYY h:mma")
: "";
return <div className="photos">
<div className="farmware-button">
<button
className="fb-button green"
onClick={this.takePhoto}>
{t("Take Photo")}
</button>
<button
className="fb-button red"
onClick={() => this.destroy()}>
{t("Delete Photo")}
</button>
</div>
<PhotoButtons
takePhoto={this.takePhoto}
deletePhoto={this.deletePhoto}
imageJobs={this.props.imageJobs} />
<ImageFlipper
onFlip={id => { this.props.dispatch(selectImage(id)); }}
onFlip={id => this.props.dispatch(selectImage(id))}
currentImage={this.props.currentImage}
images={this.props.images} />
<div className="photos-footer">
{/** Separated from <MetaInfo /> for stylistic purposes. */}
{image ?
<div className="image-created-at">
<label>{t("Created At:")}</label>
<span>
{created_at}
</span>
</div>
: ""}
<div className="image-metadatas">
{this.metaDatas()}
</div>
</div>
<PhotoFooter
image={this.props.currentImage}
timeOffset={this.props.timeOffset} />
</div>;
}
}

View File

@ -122,7 +122,8 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
timeOffset={this.props.timeOffset}
dispatch={this.props.dispatch}
images={this.props.images}
currentImage={this.props.currentImage} />;
currentImage={this.props.currentImage}
imageJobs={this.props.imageJobs} />;
case "camera_calibration":
return <CameraCalibration
syncStatus={this.props.syncStatus}
@ -140,6 +141,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
H_HI={envGet("CAMERA_CALIBRATION_H_HI", this.props.env)}
S_HI={envGet("CAMERA_CALIBRATION_S_HI", this.props.env)}
V_HI={envGet("CAMERA_CALIBRATION_V_HI", this.props.env)}
timeOffset={this.props.timeOffset}
shouldDisplay={this.props.shouldDisplay}
botToMqttStatus={this.props.botToMqttStatus} />;
case "plant_detection":

View File

@ -13,10 +13,11 @@ import {
import {
determineInstalledOsVersion,
shouldDisplay as shouldDisplayFunc,
trim
trim,
betterCompact
} from "../util";
import { ResourceIndex } from "../resources/interfaces";
import { TaggedFarmwareEnv, FarmwareManifest } from "farmbot";
import { TaggedFarmwareEnv, FarmwareManifest, JobProgress } from "farmbot";
import { save, edit, initSave } from "../api/crud";
import { t } from "i18next";
@ -97,6 +98,14 @@ export function mapStateToProps(props: Everything): FarmwareProps {
}
});
const { jobs } = props.bot.hardware;
const imageJobNames = Object.keys(jobs).filter(x => x != "FBOS_OTA");
const imageJobs: JobProgress[] =
_(betterCompact(imageJobNames.map(x => jobs[x])))
.sortBy("time")
.reverse()
.value();
return {
timeOffset: maybeGetTimeOffset(props.resources.index),
currentFarmware,
@ -113,5 +122,6 @@ export function mapStateToProps(props: Everything): FarmwareProps {
shouldDisplay,
saveFarmwareEnv: saveOrEditFarmwareEnv(props.resources.index),
taggedFarmwareInstallations,
imageJobs,
};
}

View File

@ -1,26 +1,25 @@
import { ImageWorkspace } from "../image_workspace";
import { ImageWorkspace, ImageWorkspaceProps } from "../image_workspace";
import { fakeImage } from "../../../__test_support__/fake_state/resources";
import { TaggedImage } from "farmbot";
describe("<Body/>", () => {
function fakeProps() {
return {
onFlip: jest.fn(),
onProcessPhoto: jest.fn(),
onChange: jest.fn(),
currentImage: undefined as TaggedImage | undefined,
images: [] as TaggedImage[],
iteration: 9,
morph: 9,
blur: 9,
H_LO: 2,
S_LO: 4,
V_LO: 6,
H_HI: 8,
S_HI: 10,
V_HI: 12
};
}
const fakeProps = (): ImageWorkspaceProps => ({
onFlip: jest.fn(),
onProcessPhoto: jest.fn(),
onChange: jest.fn(),
currentImage: undefined as TaggedImage | undefined,
images: [] as TaggedImage[],
iteration: 9,
morph: 9,
blur: 9,
H_LO: 2,
S_LO: 4,
V_LO: 6,
H_HI: 8,
S_HI: 10,
V_HI: 12,
timeOffset: 0,
});
it("triggers onChange() event", () => {
const props = fakeProps();

View File

@ -42,6 +42,7 @@ describe("<WeedDetector />", () => {
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
taggedFarmwareInstallations: [],
imageJobs: [],
});
it("renders", () => {

View File

@ -6,6 +6,7 @@ import { HSV } from "./interfaces";
import { WeedDetectorSlider } from "./slider";
import { TaggedImage } from "farmbot";
import { t } from "i18next";
import { PhotoFooter } from "../images/photos";
const RANGES = {
H: { LOWEST: 0, HIGHEST: 179 },
@ -31,13 +32,14 @@ export interface NumericValues {
type NumericKeyName = keyof NumericValues;
interface Props extends NumericValues {
export interface ImageWorkspaceProps extends NumericValues {
onFlip(uuid: string | undefined): void;
onProcessPhoto(image_id: number): void;
currentImage: TaggedImage | undefined;
images: TaggedImage[];
onChange(key: NumericKeyName, value: number): void;
invertHue?: boolean;
timeOffset: number;
}
/** Mapping of HSV values to FBOS Env variables. */
@ -47,7 +49,7 @@ const CHANGE_MAP: Record<HSV, [NumericKeyName, NumericKeyName]> = {
V: ["V_LO", "V_HI"]
};
export class ImageWorkspace extends React.Component<Props, {}> {
export class ImageWorkspace extends React.Component<ImageWorkspaceProps, {}> {
/** Generates a function to handle changes to blur/morph/iteration. */
numericChange = (key: NumericKeyName) =>
(e: React.SyntheticEvent<HTMLInputElement>) => {
@ -160,6 +162,9 @@ export class ImageWorkspace extends React.Component<Props, {}> {
onFlip={this.props.onFlip}
images={this.props.images}
currentImage={this.props.currentImage} />
<PhotoFooter
image={this.props.currentImage}
timeOffset={this.props.timeOffset} />
</div>;
}
}

View File

@ -79,6 +79,7 @@ export class WeedDetector
currentImage={this.props.currentImage}
images={this.props.images}
onChange={this.change}
timeOffset={this.props.timeOffset}
iteration={envGet(this.namespace("iteration"), this.props.env)}
morph={envGet(this.namespace("morph"), this.props.env)}
blur={envGet(this.namespace("blur"), this.props.env)}