photo page upload progress and refactoring

pull/1047/head
gabrielburnworth 2018-11-15 15:52:50 -08:00
parent 810785d12e
commit b28362ab82
13 changed files with 323 additions and 169 deletions

View File

@ -74,7 +74,7 @@
font-weight: bold;
}
}
.image-metadatas {
.image-metadata {
display: flex;
label {
margin-left: 1rem;
@ -89,6 +89,14 @@
margin-right: 0.5rem;
}
}
.farmware-button {
p {
float: right;
margin-top: 0.75rem;
margin-right: 1rem;
color: $medium_gray;
}
}
}
.index-indicator {

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

@ -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

@ -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>;
};
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}

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

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