Merge conflicts
commit
6583cd2708
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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" })
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -312,7 +312,7 @@
|
|||
}
|
||||
|
||||
.saved-garden-panel-content {
|
||||
margin-top: 3rem;
|
||||
margin-top: 5rem;
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
.row {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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)))));
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -42,6 +42,7 @@ describe("<FarmwarePage />", () => {
|
|||
shouldDisplay: () => false,
|
||||
saveFarmwareEnv: jest.fn(),
|
||||
taggedFarmwareInstallations: [],
|
||||
imageJobs: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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()", () => {
|
||||
|
|
|
@ -35,6 +35,7 @@ describe("<CameraCalibration/>", () => {
|
|||
syncStatus: "synced",
|
||||
shouldDisplay: () => false,
|
||||
saveFarmwareEnv: jest.fn(),
|
||||
timeOffset: 0,
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -21,4 +21,5 @@ export interface CameraCalibrationProps {
|
|||
syncStatus: SyncStatus | undefined;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
saveFarmwareEnv: SaveFarmwareEnv;
|
||||
timeOffset: number;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -42,6 +42,7 @@ describe("<WeedDetector />", () => {
|
|||
shouldDisplay: () => false,
|
||||
saveFarmwareEnv: jest.fn(),
|
||||
taggedFarmwareInstallations: [],
|
||||
imageJobs: [],
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)}
|
||||
|
|
Loading…
Reference in New Issue