mobile panel navigation

pull/1144/head
gabrielburnworth 2019-04-09 18:45:59 -07:00
parent b6eff330ab
commit ab715c1bba
38 changed files with 557 additions and 122 deletions

View File

@ -796,15 +796,18 @@ export enum Actions {
SELECT_REGIMEN = "SELECT_REGIMEN",
SET_SEQUENCE = "SET_SEQUENCE",
SET_TIME_OFFSET = "SET_TIME_OFFSET",
SET_SCHEDULER_STATE = "SET_SCHEDULER_STATE",
// Sequences
SELECT_SEQUENCE = "SELECT_SEQUENCE",
SET_SEQUENCE_POPUP_STATE = "SET_SEQUENCE_POPUP_STATE",
SET_SEQUENCE_STEP_POSITION = "SET_SEQUENCE_STEP_POSITION",
// Farmware
SELECT_FARMWARE = "SELECT_FARMWARE",
SELECT_IMAGE = "SELECT_IMAGE",
FETCH_FIRST_PARTY_FARMWARE_NAMES_OK = "FETCH_FIRST_PARTY_FARMWARE_NAMES_OK",
SET_FARMWARE_INFO_STATE = "SET_FARMWARE_INFO_STATE",
// App
START_TOUR = "START_TOUR",

View File

@ -267,18 +267,20 @@ a {
}
.drag-drop-area {
margin: 0.75rem 0;
margin-right: 15px;
border-style: dashed;
border-width: 2px;
border-color: $light_gray;
color: $gray;
font-weight: bold;
padding: 1.25rem;
background: $off_white;
text-align: center;
color: $gray;
font-weight: bold;
&.visible {
margin: 0.75rem 0;
margin-right: 15px;
border-style: dashed;
border-width: 2px;
border-color: $light_gray;
color: $gray;
font-weight: bold;
padding: 1.25rem;
background: $off_white;
text-align: center;
color: $gray;
font-weight: bold;
}
}
.hardware-widget,

View File

@ -1,7 +1,13 @@
// Bulk Scheduler
.bulk-scheduler {
@media screen and (max-width: 768px) {
display: none;
margin-bottom: 3rem;
&.open {
&.inserting-item {
display: block;
}
}
}
.week-grid-meta-buttons {
margin-top: 1rem;
@ -61,4 +67,13 @@
margin-top: 2rem;
}
.open-bulk-scheduler-btn {
display: none;
@media screen and (max-width: 768px) {
display: block;
margin: auto;
float: none !important;
}
}
// Regimen List styles in sequences.scss

View File

@ -5,6 +5,15 @@
height: calc(100vh - 5rem);
background: $light_gray;
@media screen and (max-width: 768px) {
display: none;
&.open {
display: block;
&.farmware-info-open,
&.inserting-item,
&.inserting-step {
display: none;
}
}
margin-bottom: 3rem;
}
padding: 3rem 1.5rem 8rem;
@ -138,6 +147,11 @@
.farmware-info-panel,
.step-button-cluster-panel {
@media screen and (max-width: 974px) {
display: none;
&.farmware-info-open,
&.inserting-step {
display: block;
}
margin-left: 15px;
margin-right: 15px;
}
@ -185,6 +199,9 @@
margin-bottom: 3rem;
margin-right: 5px;
@media screen and (max-width: 974px) {
&.open {
display: none;
}
margin-left: 15px;
margin-right: 15px;
}
@ -217,3 +234,59 @@
.regimen-list-panel input {
margin-bottom: 1rem;
}
.back-to-farmware,
.back-to-regimens,
.back-to-sequences {
display: none;
&.open {
@media screen and (max-width: 768px) {
display: block;
margin: 4rem;
margin-top: 0;
margin-left: 2rem;
float: left !important;
i {
margin-right: 1rem;
}
&.inserting-step {
display: none;
}
}
}
}
.drag-drop-area {
@media screen and (max-width: 768px) {
display: none;
}
}
.add-command-button-container {
display: none;
@media screen and (max-width: 768px) {
display: block;
min-height: 3rem;
.add-command {
display: block;
margin: auto;
margin-top: 1rem;
float: none !important;
}
}
}
.farmware-info-button {
display: none;
@media screen and (max-width: 768px) {
&.open {
display: block;
margin: 4rem;
margin-top: 0;
margin-left: 2rem;
}
&.farmware-info-open {
display: none;
}
}
}

View File

@ -227,6 +227,7 @@ export interface FarmwareProps {
saveFarmwareEnv: SaveFarmwareEnv;
taggedFarmwareInstallations: TaggedFarmwareInstallation[];
imageJobs: JobProgress[];
infoOpen: boolean;
}
export interface HardwareSettingsProps {

View File

@ -26,9 +26,9 @@ export class DropArea extends React.Component<DropAreaProps, DropAreaState> {
render() {
const isVisible = this.props.isLocked || this.state.isHovered;
const klass = isVisible ? "drag-drop-area" : "";
const visible = isVisible ? "visible" : "";
return <div
className={klass}
className={`drag-drop-area ${visible}`}
onDragLeave={this.toggle}
onDragEnter={(e) => {
e.preventDefault();

View File

@ -16,6 +16,7 @@ import { FarmwarePage, BasicFarmwarePage } from "../index";
import { FarmwareProps } from "../../devices/interfaces";
import { fakeFarmware, fakeFarmwares } from "../../__test_support__/fake_farmwares";
import { clickButton } from "../../__test_support__/helpers";
import { Actions } from "../../constants";
describe("<FarmwarePage />", () => {
const fakeProps = (): FarmwareProps => {
@ -36,6 +37,7 @@ describe("<FarmwarePage />", () => {
saveFarmwareEnv: jest.fn(),
taggedFarmwareInstallations: [],
imageJobs: [],
infoOpen: false,
};
};
@ -102,9 +104,50 @@ describe("<FarmwarePage />", () => {
p.farmwares["My Fake Test Farmware"] = farmware;
p.currentFarmware = "My Fake Test Farmware";
const wrapper = mount(<FarmwarePage {...p} />);
clickButton(wrapper, 1, "Run");
clickButton(wrapper, 3, "Run");
expect(mockDevice.execScript).toHaveBeenCalledWith("My Fake Test Farmware");
});
it("shows Farmware info", () => {
const p = fakeProps();
p.botToMqttStatus = "up";
p.infoOpen = true;
const wrapper = mount(<FarmwarePage {...p} />);
expect(wrapper.html()).toContain("farmware-info-open");
});
it("opens Farmware list", () => {
const p = fakeProps();
p.botToMqttStatus = "up";
p.infoOpen = false;
const wrapper = mount(<FarmwarePage {...p} />);
clickButton(wrapper, 0, "farmware list");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_FARMWARE, payload: undefined
});
});
it("closes Farmware info", () => {
const p = fakeProps();
p.botToMqttStatus = "up";
p.infoOpen = true;
const wrapper = mount(<FarmwarePage {...p} />);
clickButton(wrapper, 0, "back");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_FARMWARE_INFO_STATE, payload: false
});
});
it("opens Farmware info", () => {
const p = fakeProps();
p.botToMqttStatus = "up";
p.infoOpen = false;
const wrapper = mount(<FarmwarePage {...p} />);
clickButton(wrapper, 1, "farmware info");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_FARMWARE_INFO_STATE, payload: true
});
});
});
describe("<BasicFarmwarePage />", () => {

View File

@ -1,15 +1,17 @@
import { farmwareReducer } from "../reducer";
import { FarmwareState } from "../interfaces";
import { Actions } from "../../constants";
import { fakeImage, fakeFarmwareInstallation } from "../../__test_support__/fake_state/resources";
import {
fakeImage, fakeFarmwareInstallation
} from "../../__test_support__/fake_state/resources";
describe("farmwareReducer", () => {
const fakeState = (): FarmwareState => {
return {
currentFarmware: undefined,
currentImage: undefined,
firstPartyFarmwareNames: []
firstPartyFarmwareNames: [],
infoOpen: false,
};
};
@ -82,4 +84,14 @@ describe("farmwareReducer", () => {
.not.toEqual(newState.firstPartyFarmwareNames);
expect(newState.firstPartyFarmwareNames).toEqual(FARMWARE_NAMES);
});
it("sets the farmware info panel state", () => {
const oldState = fakeState();
const newState = farmwareReducer(oldState, {
type: Actions.SET_FARMWARE_INFO_STATE,
payload: true
});
expect(oldState.infoOpen).toBeFalsy();
expect(newState.infoOpen).toBeTruthy();
});
});

View File

@ -1,7 +1,7 @@
import * as React from "react";
import { connect } from "react-redux";
import {
Page, Row, LeftPanel, CenterPanel, RightPanel, DocSlug
Page, Row, LeftPanel, CenterPanel, RightPanel, DocSlug, Col
} from "../ui/index";
import { mapStateToProps, isPendingInstallation } from "./state_to_props";
import { Photos } from "./images/photos";
@ -17,7 +17,7 @@ import {
} from "./farmware_forms";
import { urlFriendly } from "../util";
import { history } from "../history";
import { ToolTips } from "../constants";
import { ToolTips, Actions } from "../constants";
import { FarmwareInfo } from "./farmware_info";
import { Farmwares, FarmwareManifestInfo } from "./interfaces";
import { commandErr } from "../devices/actions";
@ -112,6 +112,10 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
}
componentWillMount() {
this.props.dispatch({
type: Actions.SELECT_FARMWARE,
payload: "Photos"
});
if (!this.current && Object.values(this.props.farmwares).length > 0) {
const farmwareNames = Object.values(this.props.farmwares).map(x => x.name);
setActiveFarmwareByName(farmwareNames);
@ -173,13 +177,54 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
}
}
FarmwareBackButton = (props: { className: string }) => {
const infoOpen = props.className.includes("farmware-info-open");
return <Row>
<button
className={`back-to-farmware fb-button gray ${props.className}`}
onClick={() => infoOpen
? this.props.dispatch({
type: Actions.SET_FARMWARE_INFO_STATE, payload: false
})
: this.props.dispatch({
type: Actions.SELECT_FARMWARE, payload: undefined
})}>
{infoOpen ? t("back") : t("farmware list")}
</button>
</Row>;
};
FarmwareInfoButton = (props: { className: string, online: boolean }) =>
<Row>
<button
className={`farmware-info-button fb-button gray ${props.className}`}
disabled={!props.online}
onClick={() => this.props.dispatch({
type: Actions.SET_FARMWARE_INFO_STATE, payload: true
})}>
{t("farmware info")}
</button>
</Row>;
render() {
const farmware = getFarmwareByName(
this.props.farmwares, this.current || "take-photo");
const farmwareOpen = this.current ? "open" : "";
const online = this.props.botToMqttStatus === "up";
const infoOpen = (this.props.infoOpen && online) ? "farmware-info-open" : "";
const activeClasses = [farmwareOpen, infoOpen].join(" ");
return <Page className="farmware-page">
<Row>
<Col xs={6}>
<this.FarmwareBackButton className={activeClasses} />
</Col>
<Col xs={6}>
<this.FarmwareInfoButton className={activeClasses} online={online} />
</Col>
</Row>
<Row>
<LeftPanel
className="farmware-list-panel"
className={`farmware-list-panel ${activeClasses}`}
title={t("Farmware")}
helpText={ToolTips.FARMWARE_LIST}>
<FarmwareList
@ -192,7 +237,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
showFirstParty={!!this.props.webAppConfig.show_first_party_farmware} />
</LeftPanel>
<CenterPanel
className="farmware-input-panel"
className={`farmware-input-panel ${activeClasses}`}
title={this.current || t("Photos")}
helpText={getToolTipByFarmware(this.props.farmwares, this.current)
|| ToolTips.PHOTOS}
@ -202,7 +247,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
</div>}
</CenterPanel>
<RightPanel
className="farmware-info-panel"
className={`farmware-info-panel ${activeClasses}`}
title={t("Information")}
helpText={ToolTips.FARMWARE_INFO}
show={!!farmware}>

View File

@ -21,6 +21,7 @@ export interface FarmwareState {
currentFarmware: string | undefined;
currentImage: string | undefined;
firstPartyFarmwareNames: string[];
infoOpen: boolean;
}
export type FarmwareManifestEntry = Record<"name" | "manifest", string>;

View File

@ -6,7 +6,8 @@ import { Actions } from "../constants";
export let farmwareState: FarmwareState = {
currentFarmware: undefined,
currentImage: undefined,
firstPartyFarmwareNames: []
firstPartyFarmwareNames: [],
infoOpen: false,
};
export let farmwareReducer = generateReducer<FarmwareState>(farmwareState)
@ -33,4 +34,8 @@ export let farmwareReducer = generateReducer<FarmwareState>(farmwareState)
const thisUUID = s.currentImage;
if (thisUUID === thatUUID) { s.currentImage = undefined; }
return s;
})
.add<boolean>(Actions.SET_FARMWARE_INFO_STATE, (s, { payload }) => {
s.infoOpen = payload;
return s;
});

View File

@ -63,7 +63,7 @@ export function mapStateToProps(props: Everything): FarmwareProps {
|| firstImage;
const botStateFarmwares = props.bot.hardware.process_info.farmwares;
const conf = getWebAppConfig(props.resources.index);
const { currentFarmware, firstPartyFarmwareNames } =
const { currentFarmware, firstPartyFarmwareNames, infoOpen } =
props.resources.consumers.farmware;
const installedOsVersion = determineInstalledOsVersion(
@ -129,5 +129,6 @@ export function mapStateToProps(props: Everything): FarmwareProps {
saveFarmwareEnv: saveOrEditFarmwareEnv(props.resources.index),
taggedFarmwareInstallations,
imageJobs,
infoOpen,
};
}

View File

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

View File

@ -1,4 +1,4 @@
import { editRegimen, selectRegimen } from "../actions";
import { editRegimen, selectRegimen, unselectRegimen } from "../actions";
import { fakeRegimen } from "../../__test_support__/fake_state/resources";
import { Actions } from "../../constants";
import { SpecialStatus } from "farmbot";
@ -40,3 +40,11 @@ describe("selectRegimen()", () => {
expect(() => selectRegimen("wrong")).toThrowError();
});
});
describe("unselectRegimen()", () => {
it("deselects regimen", () => {
expect(unselectRegimen()).toEqual({
type: Actions.SELECT_REGIMEN, payload: undefined
});
});
});

View File

@ -17,6 +17,8 @@ import { bot } from "../../__test_support__/fake_state/bot";
import { auth } from "../../__test_support__/fake_state/token";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeRegimen } from "../../__test_support__/fake_state/resources";
import { clickButton } from "../../__test_support__/helpers";
import { Actions } from "../../constants";
describe("<Regimens />", () => {
function fakeProps(): Props {
@ -35,6 +37,7 @@ describe("<Regimens />", () => {
regimenUsageStats: {},
shouldDisplay: () => false,
variableData: {},
schedulerOpen: false,
};
}
@ -50,4 +53,31 @@ describe("<Regimens />", () => {
const wrapper = mount(<Regimens {...p} />);
expect(wrapper.text()).not.toContain("Scheduler");
});
it("shows scheduler", () => {
const p = fakeProps();
p.schedulerOpen = true;
const wrapper = mount(<Regimens {...p} />);
expect(wrapper.html()).toContain("inserting-item");
});
it("returns to regimen", () => {
const p = fakeProps();
p.schedulerOpen = true;
const wrapper = mount(<Regimens {...p} />);
clickButton(wrapper, 0, "back to regimen");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SCHEDULER_STATE, payload: false
});
});
it("returns to regimen list", () => {
const p = fakeProps();
p.schedulerOpen = false;
const wrapper = mount(<Regimens {...p} />);
clickButton(wrapper, 0, "back to regimens");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_REGIMEN, payload: undefined
});
});
});

View File

@ -5,10 +5,10 @@ import { popWeek, pushWeek, selectDays, deselectDays } from "../bulk_scheduler/a
import { defensiveClone } from "../../util";
const STATE: RegimenState = {
"dailyOffsetMs": 300000,
"selectedSequenceUUID": "Sequence.71.167",
"currentRegimen": "Regimen.4.56",
"weeks": [
dailyOffsetMs: 300000,
selectedSequenceUUID: "Sequence.71.167",
currentRegimen: "Regimen.4.56",
weeks: [
{
"days": {
"day1": true,
@ -20,7 +20,8 @@ const STATE: RegimenState = {
"day7": false
}
}
]
],
schedulerOpen: false,
};
describe("Regimens reducer", () => {
@ -123,3 +124,16 @@ describe("SET_TIME_OFFSET", () => {
expect(nextState.dailyOffsetMs).toBe(action.payload);
});
});
describe("SET_SCHEDULER_STATE", () => {
it("sets schedulerOpen", () => {
const state = defensiveClone(STATE);
state.schedulerOpen = false;
const action = {
type: Actions.SET_SCHEDULER_STATE,
payload: true
};
const nextState = regimensReducer(STATE, action);
expect(nextState.schedulerOpen).toBe(action.payload);
});
});

View File

@ -19,3 +19,7 @@ export function selectRegimen(payload: string): SelectRegimen {
throw new Error("Not a regimen.");
}
}
export const unselectRegimen = () => ({
type: Actions.SELECT_REGIMEN, payload: undefined
});

View File

@ -12,6 +12,8 @@ import {
} from "../../../__test_support__/resource_index_builder";
import { overwrite } from "../../../api/crud";
import { VariableDeclaration } from "farmbot";
import { clickButton } from "../../../__test_support__/helpers";
import { Actions } from "../../../constants";
describe("<ActiveEditor />", () => {
const fakeProps = (): ActiveEditorProps => ({
@ -53,6 +55,15 @@ describe("<ActiveEditor />", () => {
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
expect.objectContaining({ regimen_items: [keptItem] }));
});
it("opens scheduler", () => {
const p = fakeProps();
const wrapper = mount(<ActiveEditor {...p} />);
clickButton(wrapper, 3, "Schedule item");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SCHEDULER_STATE, payload: true
});
});
});
describe("editRegimenVariables()", () => {

View File

@ -1,7 +1,6 @@
import * as React from "react";
import { RegimenNameInput } from "./regimen_name_input";
import { ActiveEditorProps } from "./interfaces";
import { push } from "../../history";
import {
RegimenItem, CalendarRow, RegimenItemCalendarRow, RegimenProps
@ -17,6 +16,7 @@ import {
} from "../../sequences/locals_list/locals_list_support";
import { addOrEditBodyVariables } from "../../sequences/locals_list/handle_select";
import { t } from "../../i18next_wrapper";
import { Actions } from "../../constants";
/**
* The bottom half of the regimen editor panel (when there's something to
@ -39,10 +39,19 @@ export function ActiveEditor(props: ActiveEditorProps) {
shouldDisplay={props.shouldDisplay} />
<hr />
</div>
<OpenSchedulerButton dispatch={props.dispatch} />
<RegimenRows calendar={props.calendar} dispatch={props.dispatch} />
</div>;
}
export const OpenSchedulerButton = (props: { dispatch: Function }) =>
<button className="open-bulk-scheduler-btn fb-button gray"
onClick={() => props.dispatch({
type: Actions.SET_SCHEDULER_STATE, payload: true
})}>
{t("Schedule item")}
</button>;
export const editRegimenVariables = (props: RegimenProps) =>
(bodyVariables: VariableNode[]) =>
(variable: ScopeDeclarationBodyItem) => {

View File

@ -8,9 +8,23 @@ import { Page, Row, LeftPanel, CenterPanel, RightPanel } from "../ui/index";
import { mapStateToProps } from "./state_to_props";
import { isTaggedRegimen } from "../resources/tagged_resources";
import { setActiveRegimenByName } from "./set_active_regimen_by_name";
import { ToolTips } from "../constants";
import { t } from "../i18next_wrapper";
import { ToolTips, Actions } from "../constants";
import { unselectRegimen } from "./actions";
const RegimenBackButton = (props: { dispatch: Function, className: string }) => {
const schedulerOpen = props.className.includes("inserting-item");
return <Row>
<button
className={`back-to-regimens fb-button gray ${props.className}`}
onClick={() => schedulerOpen
? props.dispatch({ type: Actions.SET_SCHEDULER_STATE, payload: false })
: props.dispatch(unselectRegimen())}>
<i className="fa fa-arrow-left" />
{schedulerOpen ? t("back to regimen") : t("back to regimens")}
</button>
</Row>;
};
@connect(mapStateToProps)
export class Regimens extends React.Component<Props, {}> {
@ -21,10 +35,14 @@ export class Regimens extends React.Component<Props, {}> {
render() {
const { current, calendar } = this.props;
const regimenSelected = current && isTaggedRegimen(current) && calendar;
const regimenOpen = regimenSelected ? "open" : "";
const insertingItem = this.props.schedulerOpen ? "inserting-item" : "";
const activeClasses = [regimenOpen, insertingItem].join(" ");
return <Page className="regimen-page">
<RegimenBackButton className={activeClasses} dispatch={this.props.dispatch} />
<Row>
<LeftPanel
className="regimen-list-panel"
className={`regimen-list-panel ${activeClasses}`}
title={t("Regimens")}
helpText={t(ToolTips.REGIMEN_LIST)}>
<RegimensList
@ -34,7 +52,7 @@ export class Regimens extends React.Component<Props, {}> {
regimen={this.props.current} />
</LeftPanel>
<CenterPanel
className="regimen-editor-panel"
className={`regimen-editor-panel ${activeClasses}`}
title={t("Regimen Editor")}
helpText={t(ToolTips.REGIMEN_EDITOR)}
width={5}>
@ -47,7 +65,7 @@ export class Regimens extends React.Component<Props, {}> {
shouldDisplay={this.props.shouldDisplay} />
</CenterPanel>
<RightPanel
className="bulk-scheduler"
className={`bulk-scheduler ${activeClasses}`}
title={t("Scheduler")}
helpText={t(ToolTips.BULK_SCHEDULER)}
show={!!regimenSelected} width={4}>

View File

@ -25,6 +25,7 @@ export interface Props {
calendar: CalendarRow[];
regimenUsageStats: Record<UUID, boolean | undefined>;
shouldDisplay: ShouldDisplay;
schedulerOpen: boolean;
}
export interface RegimenItemCalendarRow {

View File

@ -9,6 +9,7 @@ export interface RegimenState {
weeks: Week[];
selectedSequenceUUID?: string | undefined;
currentRegimen?: string | undefined;
schedulerOpen: boolean;
}
function newWeek() {
@ -30,7 +31,8 @@ function newState(): RegimenState {
dailyOffsetMs: 300000,
weeks: times(10, newWeek),
selectedSequenceUUID: undefined,
currentRegimen: undefined
currentRegimen: undefined,
schedulerOpen: false,
};
}
@ -80,4 +82,8 @@ export let regimensReducer = generateReducer<RegimenState>(initialState)
.add<number>(Actions.SET_TIME_OFFSET, (s, { payload }) => {
s.dailyOffsetMs = payload;
return s;
})
.add<boolean>(Actions.SET_SCHEDULER_STATE, (s, { payload }) => {
s.schedulerOpen = payload;
return s;
});

View File

@ -25,8 +25,9 @@ import { DevSettings } from "../account/dev/dev_support";
export function mapStateToProps(props: Everything): Props {
const { resources, dispatch, bot } = props;
const { weeks, dailyOffsetMs, selectedSequenceUUID, currentRegimen } =
resources.consumers.regimens;
const {
weeks, dailyOffsetMs, selectedSequenceUUID, currentRegimen, schedulerOpen
} = resources.consumers.regimens;
const { index } = resources;
const current = maybeGetRegimen(index, currentRegimen);
const calendar = current ?
@ -67,6 +68,7 @@ export function mapStateToProps(props: Everything): Props {
calendar,
regimenUsageStats: resourceUsageList(props.resources.index.inUse),
shouldDisplay,
schedulerOpen,
};
}

View File

@ -2,7 +2,8 @@ jest.mock("../../history", () => ({ push: jest.fn() }));
jest.mock("../../api/crud", () => ({
init: jest.fn(),
edit: jest.fn()
edit: jest.fn(),
overwrite: jest.fn(),
}));
jest.mock("../set_active_sequence_by_name", () => ({
@ -10,13 +11,14 @@ jest.mock("../set_active_sequence_by_name", () => ({
}));
import {
copySequence, editCurrentSequence, selectSequence
copySequence, editCurrentSequence, selectSequence, pushStep
} from "../actions";
import { fakeSequence } from "../../__test_support__/fake_state/resources";
import { init, edit } from "../../api/crud";
import { init, edit, overwrite } from "../../api/crud";
import { push } from "../../history";
import { Actions } from "../../constants";
import { setActiveSequenceByName } from "../set_active_sequence_by_name";
import { TakePhoto, Wait } from "farmbot";
describe("copySequence()", () => {
it("copies sequence", () => {
@ -57,3 +59,44 @@ describe("selectSequence()", () => {
});
});
});
describe("pushStep()", () => {
const step = (n: number): Wait => ({ kind: "wait", args: { milliseconds: n } });
const NEW_STEP: TakePhoto = { kind: "take_photo", args: {} };
it("adds step at 2", () => {
const sequence = fakeSequence();
sequence.body.body = [
step(1),
step(2),
step(3),
];
pushStep(NEW_STEP, jest.fn(), sequence, 2);
expect(overwrite).toHaveBeenCalledWith(sequence, expect.objectContaining({
body: [
step(1),
step(2),
NEW_STEP,
step(3),
]
}));
});
it("adds step at end", () => {
const sequence = fakeSequence();
sequence.body.body = [
step(1),
step(2),
step(3),
];
pushStep(NEW_STEP, jest.fn(), sequence);
expect(overwrite).toHaveBeenCalledWith(sequence, expect.objectContaining({
body: [
step(1),
step(2),
step(3),
NEW_STEP,
]
}));
});
});

View File

@ -10,7 +10,9 @@ describe("sequence reducer", () => {
after: string | undefined) {
const sequence = fakeSequence();
sequence.uuid = "sequence";
const state: SequenceReducerState = { current: before, menuOpen: false };
const state: SequenceReducerState = {
current: before, menuOpen: false, stepIndex: undefined
};
const action = { type: actionType, payload: sequence };
const stateAfter = sequenceReducer(state, action);
expect(stateAfter.current).toBe(after);
@ -21,9 +23,20 @@ describe("sequence reducer", () => {
});
it("sets current sequence with string", () => {
const state: SequenceReducerState = { current: undefined, menuOpen: false };
const state: SequenceReducerState = {
current: undefined, menuOpen: false, stepIndex: undefined
};
const action = { type: Actions.SELECT_SEQUENCE, payload: "sequence" };
const stateAfter = sequenceReducer(state, action);
expect(stateAfter.current).toBe("sequence");
});
it("sets step position", () => {
const state: SequenceReducerState = {
current: undefined, menuOpen: false, stepIndex: undefined
};
const action = { type: Actions.SET_SEQUENCE_STEP_POSITION, payload: 1 };
const stateAfter = sequenceReducer(state, action);
expect(stateAfter.stepIndex).toBe(1);
});
});

View File

@ -26,7 +26,7 @@ jest.mock("../locals_list/locals_list", () => ({
import * as React from "react";
import {
SequenceEditorMiddleActive, onDrop, SequenceNameAndColor
SequenceEditorMiddleActive, onDrop, SequenceNameAndColor, AddCommandButton
} from "../sequence_editor_middle_active";
import { mount, shallow } from "enzyme";
import { ActiveMiddleProps, SequenceHeaderProps } from "../interfaces";
@ -45,6 +45,7 @@ import { execSequence } from "../../devices/actions";
import { clickButton } from "../../__test_support__/helpers";
import { fakeVariableNameSet } from "../../__test_support__/fake_variables";
import { DropAreaProps } from "../../draggable/interfaces";
import { Actions } from "../../constants";
describe("<SequenceEditorMiddleActive/>", () => {
const fakeProps = (): ActiveMiddleProps => {
@ -228,3 +229,15 @@ describe("<SequenceNameAndColor />", () => {
{ color: "red" });
});
});
describe("<AddCommandButton />", () => {
it("dispatches new step position", () => {
const dispatch = jest.fn();
const wrapper = shallow(<AddCommandButton dispatch={dispatch} index={1} />);
wrapper.find("button").simulate("click");
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SEQUENCE_STEP_POSITION,
payload: 1,
});
});
});

View File

@ -2,39 +2,44 @@ jest.mock("react-redux", () => ({
connect: jest.fn()
}));
jest.mock("../../history", () => ({
push: jest.fn(),
history: { getCurrentLocation: () => "" },
}));
import * as React from "react";
import { Sequences } from "../sequences";
import { shallow } from "enzyme";
import { shallow, mount } from "enzyme";
import { Props } from "../interfaces";
import {
FAKE_RESOURCES, buildResourceIndex
} from "../../__test_support__/resource_index_builder";
import { fakeSequence } from "../../__test_support__/fake_state/resources";
import { ToolTips } from "../../constants";
import { ToolTips, Actions } from "../../constants";
import {
fakeHardwareFlags
} from "../../__test_support__/sequence_hardware_settings";
import { push } from "../../history";
describe("<Sequences/>", () => {
function fakeProps(): Props {
return {
dispatch: jest.fn(),
sequence: fakeSequence(),
sequences: [],
resources: buildResourceIndex(FAKE_RESOURCES).index,
syncStatus: "synced",
hardwareFlags: fakeHardwareFlags(),
farmwareInfo: {
farmwareNames: [],
firstPartyFarmwareNames: [],
showFirstPartyFarmware: false,
farmwareConfigs: {},
},
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
menuOpen: false,
};
}
const fakeProps = (): Props => ({
dispatch: jest.fn(),
sequence: fakeSequence(),
sequences: [],
resources: buildResourceIndex(FAKE_RESOURCES).index,
syncStatus: "synced",
hardwareFlags: fakeHardwareFlags(),
farmwareInfo: {
farmwareNames: [],
firstPartyFarmwareNames: [],
showFirstPartyFarmware: false,
farmwareConfigs: {},
},
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
menuOpen: false,
stepIndex: undefined,
});
it("renders", () => {
const wrapper = shallow(<Sequences {...fakeProps()} />);
@ -50,4 +55,21 @@ describe("<Sequences/>", () => {
const wrapper = shallow(<Sequences {...p} />);
expect(wrapper.text()).not.toContain("Commands");
});
it("makes inserting step mode active", () => {
const p = fakeProps();
p.stepIndex = 2;
const wrapper = shallow(<Sequences {...p} />);
expect(wrapper.html()).toContain("inserting-step");
});
it("goes back", () => {
const p = fakeProps();
const wrapper = mount(<Sequences {...p} />);
wrapper.find("button").first().simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_SEQUENCE, payload: undefined
});
expect(push).toHaveBeenCalledWith("/app/sequences");
});
});

View File

@ -12,6 +12,7 @@ describe("<StepButtonCluster />", () => {
dispatch: jest.fn(),
current: undefined,
shouldDisplay: () => false,
stepIndex: undefined,
});
it("renders sequence commands", () => {

View File

@ -5,16 +5,17 @@ import { defensiveClone } from "../util";
import { push } from "../history";
import { urlFriendly } from "../util";
import { Actions } from "../constants";
import { setActiveSequenceByName } from "./set_active_sequence_by_name";
import { t } from "../i18next_wrapper";
import { isNumber } from "lodash";
export function pushStep(step: SequenceBodyItem,
dispatch: Function,
sequence: TaggedSequence) {
sequence: TaggedSequence,
index?: number | undefined) {
const next = defensiveClone(sequence);
next.body.body = next.body.body || [];
next.body.body.push(defensiveClone(step));
next.body.body.splice(isNumber(index) ? index : Infinity, 0, defensiveClone(step));
dispatch(overwrite(sequence, next.body));
}
@ -41,3 +42,8 @@ export function selectSequence(uuid: string): SelectSequence {
payload: uuid
};
}
export const unselectSequence = () => {
push("/app/sequences");
return { type: Actions.SELECT_SEQUENCE, payload: undefined };
};

View File

@ -8,6 +8,7 @@ import { ResourceIndex } from "../resources/interfaces";
import { getStepTag } from "../resources/sequence_tagging";
import { HardwareFlags, FarmwareInfo } from "./interfaces";
import { ShouldDisplay } from "../devices/interfaces";
import { AddCommandButton } from "./sequence_editor_middle_active";
interface AllStepsProps {
sequence: TaggedSequence;
@ -35,6 +36,7 @@ export class AllSteps extends React.Component<AllStepsProps, {}> {
const readThatCommentAbove = getStepTag(currentStep);
return <div className="sequence-steps"
key={readThatCommentAbove}>
<AddCommandButton dispatch={dispatch} index={index} />
<DropArea callback={(key) => onDrop(index, key)} />
<StepDragger
dispatch={dispatch}

View File

@ -46,6 +46,7 @@ export interface Props {
shouldDisplay: ShouldDisplay;
confirmStepDeletion: boolean;
menuOpen: boolean;
stepIndex: number | undefined;
}
export interface SequenceEditorMiddleProps {
@ -109,6 +110,7 @@ export interface Sequence extends CeleryScriptSequence {
export interface SequenceReducerState {
current: string | undefined;
menuOpen: boolean;
stepIndex: number | undefined;
}
export interface SequencesListProps {
@ -141,6 +143,7 @@ export interface StepButtonParams {
| "purple"
| "pink"
| "gray";
index?: number | undefined;
}
export interface CopyParams {

View File

@ -6,6 +6,7 @@ import { Actions } from "../constants";
export const initialState: SequenceReducerState = {
current: undefined,
menuOpen: false,
stepIndex: undefined,
};
export let sequenceReducer = generateReducer<SequenceReducerState>(initialState)
@ -24,4 +25,8 @@ export let sequenceReducer = generateReducer<SequenceReducerState>(initialState)
.add<boolean>(Actions.SET_SEQUENCE_POPUP_STATE, function (s, { payload }) {
s.menuOpen = payload;
return s;
})
.add<number | undefined>(Actions.SET_SEQUENCE_STEP_POSITION, function (s, { payload }) {
s.stepIndex = payload;
return s;
});

View File

@ -2,7 +2,6 @@ import * as React from "react";
import { ActiveMiddleProps, SequenceHeaderProps } from "./interfaces";
import { editCurrentSequence } from "./actions";
import { splice, move } from "./step_tiles";
import { push } from "../history";
import { BlurableInput, Row, Col, SaveBtn, ColorPicker } from "../ui";
import { DropArea } from "../draggable/drop_area";
@ -19,6 +18,7 @@ import { ResourceIndex } from "../resources/interfaces";
import { ShouldDisplay } from "../devices/interfaces";
import { isScopeDeclarationBodyItem } from "./locals_list/handle_select";
import { t } from "../i18next_wrapper";
import { Actions } from "../constants";
export const onDrop =
(dispatch1: Function, sequence: TaggedSequence) =>
@ -168,9 +168,22 @@ export class SequenceEditorMiddleActive extends
callback={key => onDrop(dispatch, sequence)(Infinity, key)}>
{t("DRAG COMMAND HERE")}
</DropArea>
<AddCommandButton dispatch={dispatch} index={99999999} />
</Col>
</Row>
</div>
</div>;
}
}
export const AddCommandButton = (props: { dispatch: Function, index: number }) =>
<div className="add-command-button-container">
<button
className="add-command fb-button gray"
onClick={() => props.dispatch({
type: Actions.SET_SEQUENCE_STEP_POSITION,
payload: props.index,
})}>
{t("Add command")}
</button>
</div>;

View File

@ -1,5 +1,4 @@
import * as React from "react";
import { connect } from "react-redux";
import { SequencesList } from "./sequences_list";
import { StepButtonCluster } from "./step_button_cluster";
@ -13,6 +12,18 @@ import { setActiveSequenceByName } from "./set_active_sequence_by_name";
import { LeftPanel, CenterPanel, RightPanel } from "../ui";
import { resourceUsageList } from "../resources/in_use";
import { t } from "../i18next_wrapper";
import { unselectSequence } from "./actions";
import { isNumber } from "lodash";
const SequenceBackButton = (props: { dispatch: Function, className: string }) =>
<Row>
<button
className={`back-to-sequences fb-button gray ${props.className}`}
onClick={() => props.dispatch(unselectSequence())}>
<i className="fa fa-arrow-left" />
{t("back to sequences")}
</button>
</Row>;
@connect(mapStateToProps)
export class Sequences extends React.Component<Props, {}> {
@ -23,10 +34,14 @@ export class Sequences extends React.Component<Props, {}> {
render() {
const { sequence } = this.props;
const sequenceSelected = sequence && isTaggedSequence(sequence);
const sequenceOpen = sequenceSelected ? "open" : "";
const insertingStep = isNumber(this.props.stepIndex) ? "inserting-step" : "";
const activeClasses = [sequenceOpen, insertingStep].join(" ");
return <Page className="sequence-page">
<SequenceBackButton className={activeClasses} dispatch={this.props.dispatch} />
<Row>
<LeftPanel
className="sequence-list-panel"
className={`sequence-list-panel ${activeClasses}`}
title={t("Sequences")}
helpText={t(ToolTips.SEQUENCE_LIST)}>
<SequencesList
@ -37,7 +52,7 @@ export class Sequences extends React.Component<Props, {}> {
sequences={this.props.sequences} />
</LeftPanel>
<CenterPanel
className="sequence-editor-panel"
className={`sequence-editor-panel ${activeClasses}`}
title={t("Sequence Editor")}
helpText={t(ToolTips.SEQUENCE_EDITOR)}>
<SequenceEditorMiddle
@ -52,14 +67,15 @@ export class Sequences extends React.Component<Props, {}> {
menuOpen={this.props.menuOpen} />
</CenterPanel>
<RightPanel
className="step-button-cluster-panel"
className={`step-button-cluster-panel ${activeClasses}`}
title={t("Commands")}
helpText={t(ToolTips.SEQUENCE_COMMANDS)}
show={sequenceSelected}>
<StepButtonCluster
current={this.props.sequence}
dispatch={this.props.dispatch}
shouldDisplay={this.props.shouldDisplay} />
shouldDisplay={this.props.shouldDisplay}
stepIndex={this.props.stepIndex} />
</RightPanel>
</Row>
</Page>;

View File

@ -96,5 +96,6 @@ export function mapStateToProps(props: Everything): Props {
shouldDisplay,
confirmStepDeletion,
menuOpen: props.resources.consumers.sequences.menuOpen,
stepIndex: props.resources.consumers.sequences.stepIndex,
};
}

View File

@ -1,6 +1,5 @@
import * as React from "react";
import { StepButton } from "./step_buttons/index";
import { scrollToBottom } from "../util";
import { Row } from "../ui/index";
import { TaggedSequence } from "farmbot";
@ -13,36 +12,26 @@ export interface StepButtonProps {
dispatch: Function;
current: TaggedSequence | undefined;
shouldDisplay: ShouldDisplay;
stepIndex: number | undefined;
}
export function StepButtonCluster(props: StepButtonProps) {
const { dispatch, current, shouldDisplay } = props;
const { dispatch, current, shouldDisplay, stepIndex } = props;
const commonStepProps = { dispatch, current, index: stepIndex };
const ALL_THE_BUTTONS = [
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "move_absolute",
args: {
location: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 }
},
offset: {
kind: "coordinate",
args: {
x: 0,
y: 0,
z: 0
},
},
location: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } },
offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } },
speed: CONFIG_DEFAULTS.speed
}
}}
color="blue">
{t("MOVE ABSOLUTE")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "move_relative",
args: { x: 0, y: 0, z: 0, speed: CONFIG_DEFAULTS.speed }
@ -50,8 +39,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="green">
{t("MOVE RELATIVE")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "write_pin",
args: { pin_number: 0, pin_value: 0, pin_mode: 0 }
@ -59,8 +47,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="orange">
{t("WRITE PIN")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "read_pin",
args: {
@ -72,8 +59,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="yellow">
{t("READ PIN")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "wait",
args: { milliseconds: 0 }
@ -81,8 +67,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="brown">
{t("WAIT")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "send_message",
args: {
@ -93,8 +78,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="red">
{t("SEND MESSAGE")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton{...commonStepProps}
step={{
kind: "find_home",
args: {
@ -105,8 +89,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="blue">
{t("Find Home")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "_if",
args: {
@ -120,8 +103,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="purple">
{t("IF STATEMENT")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "execute",
args: { sequence_id: 0 }
@ -129,8 +111,7 @@ export function StepButtonCluster(props: StepButtonProps) {
color="gray">
{t("EXECUTE SEQUENCE")}
</StepButton>,
<StepButton dispatch={dispatch}
current={current}
<StepButton {...commonStepProps}
step={{
kind: "execute_script",
args: { label: "plant-detection" }
@ -139,8 +120,7 @@ export function StepButtonCluster(props: StepButtonProps) {
{t("Run Farmware")}
</StepButton>,
<StepButton
dispatch={dispatch}
current={current}
{...commonStepProps}
color="brown"
step={{ kind: "take_photo", args: {} }} >
{t("TAKE PHOTO")}
@ -148,8 +128,7 @@ export function StepButtonCluster(props: StepButtonProps) {
];
shouldDisplay(Feature.mark_as_step) && ALL_THE_BUTTONS.push(<StepButton
dispatch={dispatch}
current={current}
{...commonStepProps}
step={{
kind: "resource_update",
args: {

View File

@ -16,7 +16,8 @@ function props(): StepButtonParams {
},
},
dispatch: jest.fn(),
color: "blue"
color: "blue",
index: 1,
};
}
@ -24,7 +25,7 @@ describe("<StepButton/>", () => {
it("clicks it", () => {
const p = props();
const el = shallow(<StepButton {...p } />);
const el = shallow(<StepButton {...p} />);
el.find("button").simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
payload: expect.objectContaining({
@ -34,6 +35,10 @@ describe("<StepButton/>", () => {
}),
type: Actions.OVERWRITE_RESOURCE
});
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SEQUENCE_STEP_POSITION,
payload: undefined,
});
});
});

View File

@ -1,5 +1,4 @@
import * as React from "react";
import { SequenceBodyItem as Step, TaggedSequence } from "farmbot";
import { error } from "farmbot-toastr";
import { StepDragger, NULL_DRAGGER_ID } from "../../draggable/step_dragger";
@ -7,15 +6,24 @@ import { pushStep } from "../actions";
import { StepButtonParams } from "../interfaces";
import { Col } from "../../ui/index";
import { t } from "../../i18next_wrapper";
import { Actions } from "../../constants";
export const stepClick =
(dispatch: Function, step: Step, seq: TaggedSequence | undefined) =>
(dispatch: Function,
step: Step,
seq: TaggedSequence | undefined,
index?: number | undefined) =>
() => {
(seq) ?
pushStep(step, dispatch, seq) : error(t("Select a sequence first"));
seq
? pushStep(step, dispatch, seq, index)
: error(t("Select a sequence first"));
dispatch({
type: Actions.SET_SEQUENCE_STEP_POSITION,
payload: undefined,
});
};
export function StepButton({ children, step, color, dispatch, current }:
export function StepButton({ children, step, color, dispatch, current, index }:
StepButtonParams) {
return <Col xs={6} sm={12}>
<div className="block">
@ -26,7 +34,7 @@ export function StepButton({ children, step, color, dispatch, current }:
draggerId={NULL_DRAGGER_ID} >
<button draggable={true}
className={`fb-button full-width block ${color}`}
onClick={stepClick(dispatch, step, current)} >
onClick={stepClick(dispatch, step, current, index)} >
{children}
<i className="fa fa-arrows block-control" />
</button>