Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into new_steps

pull/1648/head
Rick Carlino 2019-12-30 12:10:53 -06:00
commit f2ca8fc6fa
235 changed files with 5675 additions and 1382 deletions

View File

@ -0,0 +1,35 @@
module Api
class FoldersController < Api::AbstractController
def create
mutate Folders::Create.run(raw_json, device: current_device)
end
def index
render json: folders
end
def show
render json: folder
end
def update
mutate Folders::Update.run(raw_json,
folder: folder,
device: current_device)
end
def destroy
mutate Folders::Destroy.run(folder: folder, device: current_device)
end
private
def folder
folders.find(params[:id])
end
def folders
current_device.folders
end
end
end

View File

@ -90,6 +90,7 @@ module CeleryScript
id: sequence.id,
created_at: sequence.created_at,
updated_at: sequence.updated_at,
folder_id: sequence.folder_id,
args: Sequence::DEFAULT_ARGS,
color: sequence.color,
name: sequence.name,

View File

@ -34,6 +34,7 @@ class Device < ApplicationRecord
has_many :in_use_tools
has_many :in_use_points
has_many :users
has_many :folders
validates_presence_of :name
validates :timezone, inclusion: {

View File

@ -12,6 +12,7 @@ class FbosConfig < ApplicationRecord
ARDUINO = "arduino",
FARMDUINO = "farmduino",
FARMDUINO_K14 = "farmduino_k14",
FARMDUINO_K15 = "farmduino_k15",
EXPRESS_K10 = "express_k10",
]

View File

@ -0,0 +1,8 @@
class Folder < ApplicationRecord
belongs_to :device
has_many :sub_folders, class_name: "Folder",
foreign_key: "parent_id"
belongs_to :parent, class_name: "Folder", optional: true
end

View File

@ -16,6 +16,7 @@ class Sequence < ApplicationRecord
include CeleryScriptSettingsBag
belongs_to :device
belongs_to :folder
belongs_to :fbos_config, foreign_key: :boot_sequence_id
has_one :sequence_usage_report
has_many :farm_events, as: :executable

View File

@ -0,0 +1,27 @@
module Folders
class Create < Mutations::Command
required do
model :device
string :color
string :name
end
optional do
integer :parent_id
end
def execute
Folder.create!(update_params)
end
private
def update_params
inputs.except(:parent_id).merge({ parent: parent })
end
def parent
@parent ||= device.folders.find_by(id: parent_id)
end
end
end

View File

@ -0,0 +1,44 @@
module Folders
class Destroy < Mutations::Command
IN_USE = "This folder still contains %s %s(s). " \
"They must be removed prior to deletion"
required do
model :device
model :folder
end
def validate
check_subfolders
check_sequences
end
def execute
folder.destroy! && ""
end
private
def sequences
@sequences ||= Sequence.where(folder: folder)
end
def subfolders
@subfolders ||= Folder.where(parent: folder)
end
def check_sequences
count = sequences.count
if count > 0
add_error :in_use, :in_use, IN_USE % [count, "sequence"]
end
end
def check_subfolders
count = subfolders.count
if count > 0
add_error :in_use, :in_use, IN_USE % [count, "subfolder"]
end
end
end
end

View File

@ -0,0 +1,28 @@
module Folders
class Update < Mutations::Command
required do
model :device
model :folder
integer :parent_id, nils: true, empty_is_nil: true
end
optional do
string :name
string :color
end
def execute
folder.update!(update_params) && folder
end
private
def update_params
inputs.except(:device, :folder).merge({ parent: parent })
end
def parent
@parent ||= parent_id && device.folders.find_by(id: parent_id)
end
end
end

View File

@ -12,6 +12,7 @@ module Sequences
optional do
color
args
integer :folder_id
end
def validate
@ -25,6 +26,7 @@ module Sequences
p = inputs
.merge(migrated_nodes: true)
.without(:body, :args, "body", "args")
.merge(folder: device.folders.find_by(id: folder_id))
seq = Sequence.create!(p)
x = CeleryScript::FirstPass.run!(sequence: seq,
args: args || {},

View File

@ -2,7 +2,12 @@ module Sequences
class Update < Mutations::Command
include CeleryScriptValidators
using CanonicalCeleryHelpers
BLACKLIST = [:sequence, :device, :args, :body]
BLACKLIST = [:sequence, :device, :args, :body, :folder_id]
BASE = "Can't add 'parent' to sequence because "
EXPL = {
FarmEvent => BASE + "it is in use by FarmEvents on these dates: %{items}",
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
}
required do
model :device, class: Device
@ -27,6 +32,7 @@ module Sequences
optional do
color
integer :folder_id
end
def validate
@ -40,7 +46,7 @@ module Sequences
Sequence.auto_sync_debounce do
ActiveRecord::Base.transaction do
sequence.migrated_nodes = true
sequence.update!(inputs.except(*BLACKLIST))
sequence.update!(inputs.except(*BLACKLIST).merge(folder_stuff))
CeleryScript::StoreCelery.run!(sequence: sequence,
args: args,
body: body)
@ -49,11 +55,12 @@ module Sequences
end
CeleryScript::FetchCelery.run!(sequence: sequence, args: args, body: body)
end
BASE = "Can't add 'parent' to sequence because "
EXPL = {
FarmEvent => BASE + "it is in use by FarmEvents on these dates: %{items}",
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
}
def folder_stuff
if folder_id
return { folder: device.folders.find_by(id: folder_id) }
else
return {}
end
end
end
end

View File

@ -0,0 +1,3 @@
class FolderSerializer < ApplicationSerializer
attributes :id, :parent_id, :color, :name
end

View File

@ -25,6 +25,7 @@ FarmBot::Application.routes.draw do
sequences: [:create, :destroy, :index, :show, :update],
tools: [:create, :destroy, :index, :show, :update],
webcam_feeds: [:create, :destroy, :index, :show, :update],
folders: [:create, :destroy, :index, :show, :update],
}.to_a.map { |(name, only)| resources name, only: only }
# Singular API Resources:

View File

@ -0,0 +1,21 @@
class AddFolderColumns < ActiveRecord::Migration[6.0]
def change
create_table :folders do |t|
t.references :device, null: false
t.timestamps
# https://twitter.com/wesbos/status/719678818831757313?lang=en
t.string :color, limit: 20, null: false
t.string :name, limit: 40, null: false
end
add_column :folders,
:parent_id,
:integer,
null: true,
index: true
add_foreign_key :folders,
:folders,
column: :parent_id
add_reference :sequences, :folder, index: true
end
end

View File

@ -1406,7 +1406,7 @@ CREATE VIEW public.resource_update_steps AS
edge_nodes.kind,
edge_nodes.value
FROM public.edge_nodes
WHERE (((edge_nodes.kind)::text = 'resource_type'::text) AND ((edge_nodes.value)::text = ANY (ARRAY[('"GenericPointer"'::character varying)::text, ('"ToolSlot"'::character varying)::text, ('"Plant"'::character varying)::text])))
WHERE (((edge_nodes.kind)::text = 'resource_type'::text) AND ((edge_nodes.value)::text = ANY ((ARRAY['"GenericPointer"'::character varying, '"ToolSlot"'::character varying, '"Plant"'::character varying])::text[])))
), resource_id AS (
SELECT edge_nodes.primary_node_id,
edge_nodes.kind,
@ -1686,8 +1686,7 @@ CREATE TABLE public.users (
agreed_to_terms_at timestamp without time zone,
confirmation_sent_at timestamp without time zone,
unconfirmed_email character varying,
inactivity_warning_sent_at timestamp without time zone,
inactivity_warning_count integer
inactivity_warning_sent_at timestamp without time zone
);

View File

@ -8,6 +8,7 @@ export const fakeDesignerState = (): DesignerState => ({
},
hoveredPoint: undefined,
hoveredPlantListItem: undefined,
hoveredToolSlot: undefined,
cropSearchQuery: "",
cropSearchResults: [],
cropSearchInProgress: false,

View File

@ -0,0 +1,17 @@
import { HardwareFlags, FarmwareData } from "../sequences/interfaces";
export const fakeHardwareFlags = (): HardwareFlags => ({
findHomeEnabled: { x: false, y: false, z: false },
stopAtHome: { x: false, y: false, z: false },
stopAtMax: { x: false, y: false, z: false },
negativeOnly: { x: false, y: false, z: false },
axisLength: { x: 0, y: 0, z: 0 },
});
export const fakeFarmwareData = (): FarmwareData => ({
farmwareNames: [],
firstPartyFarmwareNames: [],
showFirstPartyFarmware: false,
farmwareConfigs: {},
cameraDisabled: false,
});

View File

@ -1,6 +1,5 @@
import { noop } from "lodash";
import { Everything } from "../interfaces";
import { peripherals as Peripheral } from "./fake_state/peripherals";
import { auth } from "./fake_state/token";
import { bot } from "./fake_state/bot";
import { config } from "./fake_state/config";
@ -11,7 +10,6 @@ import { resources } from "./fake_state/resources";
export function fakeState(_: Function = noop): Everything {
return {
dispatch: jest.fn(),
Peripheral,
auth,
bot,
config,

View File

@ -1,6 +1,6 @@
import { Everything } from "../../interfaces";
export let bot: Everything["bot"] = {
export const bot: Everything["bot"] = {
"consistent": true,
"stepSize": 100,
"controlPanelState": {

View File

@ -1,6 +1,6 @@
import { Everything } from "../../interfaces";
export let config: Everything["config"] = {
export const config: Everything["config"] = {
"host": "localhost",
"port": "3000"
};

View File

@ -1,5 +1,5 @@
import { Everything } from "../../interfaces";
export let draggable: Everything["draggable"] = {
export const draggable: Everything["draggable"] = {
"dataTransfer": {}
};

View File

@ -1,6 +1,6 @@
import { TaggedImage, SpecialStatus } from "farmbot";
export let fakeImages: TaggedImage[] = [
export const fakeImages: TaggedImage[] = [
{
"kind": "Image",
"specialStatus": SpecialStatus.SAVED,

View File

@ -1,5 +0,0 @@
import { Everything } from "../../interfaces";
export let peripherals: Everything["Peripheral"] = {
"isEditing": true
};

View File

@ -26,29 +26,42 @@ import {
TaggedFarmwareInstallation,
TaggedAlert,
TaggedPointGroup,
TaggedFolder,
} from "farmbot";
import { fakeResource } from "../fake_resource";
import {
ExecutableType, PinBindingType
ExecutableType, PinBindingType, Folder
} from "farmbot/dist/resources/api_resources";
import { FirmwareConfig } from "farmbot/dist/resources/configs/firmware";
import { MessageType } from "../../sequences/interfaces";
export let resources: Everything["resources"] = buildResourceIndex();
export const resources: Everything["resources"] = buildResourceIndex();
let idCounter = 1;
export function fakeSequence(): TaggedSequence {
return fakeResource("Sequence", {
args: {
version: 4,
locals: { kind: "scope_declaration", args: {} },
},
export const fakeSequence =
(body: Partial<TaggedSequence["body"]> = {}): TaggedSequence => {
return fakeResource("Sequence", {
args: {
version: 4,
locals: { kind: "scope_declaration", args: {} },
},
id: idCounter++,
color: "red",
folder_id: undefined,
name: "fake",
kind: "sequence",
body: [],
...body
});
};
export function fakeFolder(input: Partial<Folder> = {}): TaggedFolder {
return fakeResource("Folder", {
id: idCounter++,
color: "red",
parent_id: undefined,
name: "fake",
kind: "sequence",
folder_id: undefined,
body: []
...input
});
}

View File

@ -1,6 +1,6 @@
import { AuthState } from "../../auth/interfaces";
export let auth: AuthState = {
export const auth: AuthState = {
"token": {
"unencoded": {
"jti": "xyz",

View File

@ -12,7 +12,7 @@ export const TIME = {
SATURDAY: moment("2017-06-24T06:30:00.000-05:00")
};
export let fakeFarmEventWithExecutable = (): FarmEventWithExecutable => {
export const fakeFarmEventWithExecutable = (): FarmEventWithExecutable => {
return {
id: 1,
start_time: "---",
@ -29,7 +29,7 @@ export let fakeFarmEventWithExecutable = (): FarmEventWithExecutable => {
};
};
export let calendarRows = [
export const calendarRows = [
{
"sortKey": 1500922800,
"year": 17,

View File

@ -328,7 +328,7 @@ const log: TaggedLog = {
uuid: "Log.1091396.70"
};
export let FAKE_RESOURCES: TaggedResource[] = [
export const FAKE_RESOURCES: TaggedResource[] = [
tr1,
fakeDevice(),
tr2,

View File

@ -1,11 +0,0 @@
import { HardwareFlags } from "../sequences/interfaces";
export const fakeHardwareFlags = (): HardwareFlags => {
return {
findHomeEnabled: { x: false, y: false, z: false },
stopAtHome: { x: false, y: false, z: false },
stopAtMax: { x: false, y: false, z: false },
negativeOnly: { x: false, y: false, z: false },
axisLength: { x: 0, y: 0, z: 0 },
};
};

View File

@ -1,7 +1,7 @@
import { User } from "farmbot/dist/resources/api_resources";
import { TaggedUser, SpecialStatus } from "farmbot";
export let user: User = {
export const user: User = {
created_at: "2016-10-05T03:02:58.000Z",
email: "farmbot1@farmbot.io",
id: 2,
@ -9,7 +9,7 @@ export let user: User = {
updated_at: "2017-08-04T19:53:29.724Z"
};
export let taggedUser: TaggedUser = {
export const taggedUser: TaggedUser = {
kind: "User",
uuid: "1234-5678",
specialStatus: SpecialStatus.SAVED,

View File

@ -0,0 +1,14 @@
jest.mock("../session", () => ({ Session: { clear: jest.fn() } }));
import * as React from "react";
import { mount } from "enzyme";
import { Apology } from "../apology";
import { Session } from "../session";
describe("<Apology />", () => {
it("clears session", () => {
const wrapper = mount(<Apology />);
wrapper.find("a").first().simulate("click");
expect(Session.clear).toHaveBeenCalled();
});
});

View File

@ -9,7 +9,7 @@ import { RawApp as App, AppProps, mapStateToProps } from "../app";
import { mount } from "enzyme";
import { bot } from "../__test_support__/fake_state/bot";
import {
fakeUser, fakeWebAppConfig
fakeUser, fakeWebAppConfig, fakeFbosConfig, fakeFarmwareEnv
} from "../__test_support__/fake_state/resources";
import { fakeState } from "../__test_support__/fake_state";
import {
@ -40,7 +40,8 @@ const fakeProps = (): AppProps => ({
resources: buildResourceIndex().index,
autoSync: false,
alertCount: 0,
pings: fakePings()
pings: fakePings(),
env: {},
});
describe("<App />: Controls Pop-Up", () => {
@ -145,7 +146,24 @@ describe("mapStateToProps()", () => {
const config = fakeWebAppConfig();
config.body.x_axis_inverted = true;
state.resources = buildResourceIndex([config]);
state.bot.hardware.user_env = { fake: "value" };
const result = mapStateToProps(state);
expect(result.axisInversion.x).toEqual(true);
expect(result.autoSync).toEqual(false);
expect(result.env).toEqual({ fake: "value" });
});
it("returns api props", () => {
const state = fakeState();
const config = fakeFbosConfig();
config.body.auto_sync = true;
config.body.api_migrated = true;
const fakeEnv = fakeFarmwareEnv();
state.resources = buildResourceIndex([config, fakeEnv]);
state.bot.minOsFeatureData = { api_farmware_env: "8.0.0" };
state.bot.hardware.informational_settings.controller_version = "8.0.0";
const result = mapStateToProps(state);
expect(result.autoSync).toEqual(true);
expect(result.env).toEqual({ [fakeEnv.body.key]: fakeEnv.body.value });
});
});

View File

@ -9,38 +9,37 @@ import { ControlsPopup } from "../controls_popup";
import { mount } from "enzyme";
import { bot } from "../__test_support__/fake_state/bot";
import { ControlsPopupProps } from "../controls/move/interfaces";
import { error } from "../toast/toast";
import { Content, ToolTips } from "../constants";
describe("<ControlsPopup />", () => {
const fakeProps = (): ControlsPopupProps => {
return {
dispatch: jest.fn(),
axisInversion: { x: false, y: false, z: false },
botPosition: { x: undefined, y: undefined, z: undefined },
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
arduinoBusy: false,
stepSize: 100,
botOnline: true,
};
};
const p = fakeProps();
p.axisInversion.x = true;
const wrapper = mount(<ControlsPopup {...p} />);
afterAll(wrapper.unmount);
const fakeProps = (): ControlsPopupProps => ({
dispatch: jest.fn(),
axisInversion: { x: true, y: false, z: false },
botPosition: { x: undefined, y: undefined, z: undefined },
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
arduinoBusy: false,
stepSize: 100,
botOnline: true,
env: {},
});
it("Has a false initial state", () => {
const wrapper = mount(<ControlsPopup {...fakeProps()} />);
expect(wrapper.state("isOpen")).toBeFalsy();
});
it("Toggles state", () => {
const wrapper = mount(<ControlsPopup {...fakeProps()} />);
const parent = wrapper.find("i").first();
parent.simulate("click");
expect(wrapper.state("isOpen")).toBeTruthy();
});
it("x axis is inverted", () => {
const wrapper = mount(<ControlsPopup {...fakeProps()} />);
wrapper.setState({ isOpen: true });
const button = wrapper.find("button").at(3);
expect(button.props().title).toBe("move x axis (100)");
button.simulate("click");
@ -49,6 +48,8 @@ describe("<ControlsPopup />", () => {
});
it("y axis is not inverted", () => {
const wrapper = mount(<ControlsPopup {...fakeProps()} />);
wrapper.setState({ isOpen: true });
const button = wrapper.find("button").at(1);
expect(button.props().title).toBe("move y axis (100)");
button.simulate("click");
@ -57,6 +58,7 @@ describe("<ControlsPopup />", () => {
});
it("disabled when closed", () => {
const wrapper = mount(<ControlsPopup {...fakeProps()} />);
wrapper.setState({ isOpen: false });
[0, 1, 2, 3].map((i) => wrapper.find("button").at(i).simulate("click"));
expect(mockDevice.moveRelative).not.toHaveBeenCalled();
@ -65,6 +67,7 @@ describe("<ControlsPopup />", () => {
it("swaps axes", () => {
const swappedProps = fakeProps();
swappedProps.xySwap = true;
swappedProps.axisInversion.x = false;
const swapped = mount(<ControlsPopup {...swappedProps} />);
swapped.setState({ isOpen: true });
expect(swapped.state("isOpen")).toBeTruthy();
@ -76,7 +79,25 @@ describe("<ControlsPopup />", () => {
});
it("takes photo", () => {
wrapper.find("button").at(4).simulate("click");
const wrapper = mount(<ControlsPopup {...fakeProps()} />);
wrapper.setState({ isOpen: true });
const btn = wrapper.find("button").at(4);
expect(btn.props().title).not.toEqual(Content.NO_CAMERA_SELECTED);
btn.simulate("click");
expect(mockDevice.takePhoto).toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("shows camera as disabled", () => {
const p = fakeProps();
p.env = { camera: "NONE" };
const wrapper = mount(<ControlsPopup {...p} />);
wrapper.setState({ isOpen: true });
const btn = wrapper.find("button").at(4);
expect(btn.props().title).toEqual(Content.NO_CAMERA_SELECTED);
btn.simulate("click");
expect(error).toHaveBeenCalledWith(
ToolTips.SELECT_A_CAMERA, Content.NO_CAMERA_SELECTED);
expect(mockDevice.takePhoto).not.toHaveBeenCalled();
});
});

View File

@ -164,5 +164,7 @@ export class API {
get alertPath() { return `${this.baseUrl}/api/alerts/`; }
/** /api/global_bulletins/:id */
get globalBulletinPath() { return `${this.baseUrl}/api/global_bulletins/`; }
/** /api/folders */
get foldersPath() { return `${this.baseUrl}/api/folders/`; }
// get syncPath() { return `${this.baseUrl}/api/device/sync/`; }
}

View File

@ -1,5 +1,8 @@
import {
TaggedResource, SpecialStatus, ResourceName, TaggedSequence
TaggedResource,
SpecialStatus,
ResourceName,
TaggedSequence,
} from "farmbot";
import {
isTaggedResource,
@ -8,7 +11,11 @@ import { GetState, ReduxAction } from "../redux/interfaces";
import { API } from "./index";
import axios from "axios";
import {
updateNO, destroyOK, destroyNO, GeneralizedError, saveOK
updateNO,
destroyOK,
destroyNO,
GeneralizedError,
saveOK,
} from "../resources/actions";
import { UnsafeError } from "../interfaces";
import { defensiveClone, unpackUUID } from "../util";
@ -274,6 +281,7 @@ export function urlFor(tag: ResourceName) {
User: API.current.usersPath,
WebAppConfig: API.current.webAppConfigPath,
WebcamFeed: API.current.webcamFeedPath,
Folder: API.current.foldersPath,
};
const url = OPTIONS[tag];
if (url) {

View File

@ -15,6 +15,7 @@ const BLACKLIST: ResourceName[] = [
"WebAppConfig",
"WebcamFeed",
"Alert",
"Folder",
];
export function maybeStartTracking(uuid: string) {

View File

@ -1,20 +1,29 @@
import * as React from "react";
import { Session } from "./session";
const STYLE: React.CSSProperties = {
border: "2px solid #434343",
background: "#a4c2f4",
fontSize: "18px",
const OUTER_STYLE: React.CSSProperties = {
borderRadius: "10px",
background: "repeating-linear-gradient(-45deg," +
"#ffff55, #ffff55 20px," +
"#ff5555 20px, #ff5555 40px)",
fontSize: "100%",
color: "black",
display: "block",
overflow: "auto",
padding: "1rem",
margin: "1rem",
};
const INNER_STYLE: React.CSSProperties = {
borderRadius: "10px",
background: "#ffffffdd",
padding: "1rem",
};
export function Apology(_: {}) {
return <div style={STYLE}>
<div>
<h1>Page Error</h1>
return <div style={OUTER_STYLE}>
<div style={INNER_STYLE}>
<h1 style={{ fontSize: "175%" }}>Page Error</h1>
<span>
We can't render this part of the page due to an unrecoverable error.
Here are some things you can try:

View File

@ -4,7 +4,7 @@ import { init, error } from "./toast/toast";
import { NavBar } from "./nav";
import { Everything, TimeSettings } from "./interfaces";
import { LoadingPlant } from "./loading_plant";
import { BotState, Xyz } from "./devices/interfaces";
import { BotState, Xyz, UserEnv } from "./devices/interfaces";
import { ResourceName, TaggedUser, TaggedLog } from "farmbot";
import {
maybeFetchUser,
@ -30,6 +30,7 @@ import { isBotOnline } from "./devices/must_be_online";
import { getStatus } from "./connectivity/reducer_support";
import { getAllAlerts } from "./messages/state_to_props";
import { PingDictionary } from "./devices/connectivity/qos";
import { getEnv, getShouldDisplayFn } from "./farmware/state_to_props";
/** For the logger module */
init();
@ -52,11 +53,14 @@ export interface AppProps {
autoSync: boolean;
alertCount: number;
pings: PingDictionary;
env: UserEnv;
}
export function mapStateToProps(props: Everything): AppProps {
const webAppConfigValue = getWebAppConfigValue(() => props);
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const shouldDisplay = getShouldDisplayFn(props.resources.index, props.bot);
const env = getEnv(props.resources.index, shouldDisplay, props.bot);
return {
timeSettings: maybeGetTimeSettings(props.resources.index),
dispatch: props.dispatch,
@ -78,7 +82,8 @@ export function mapStateToProps(props: Everything): AppProps {
resources: props.resources.index,
autoSync: !!(fbosConfig && fbosConfig.auto_sync),
alertCount: getAllAlerts(props.resources).length,
pings: props.bot.connectivity.pings
pings: props.bot.connectivity.pings,
env,
};
}
/** Time at which the app gives up and asks the user to refresh */
@ -147,6 +152,7 @@ export class RawApp extends React.Component<AppProps, {}> {
xySwap={this.props.xySwap}
arduinoBusy={!!this.props.bot.hardware.informational_settings.busy}
botOnline={isBotOnline(sync_status, getStatus(bot2mqtt))}
env={this.props.env}
stepSize={this.props.bot.stepSize} />}
</div>;
}

View File

@ -2,7 +2,7 @@ import { AuthState } from "./interfaces";
import { generateReducer } from "../redux/generate_reducer";
import { Actions } from "../constants";
export let authReducer = generateReducer<AuthState | undefined>(undefined)
export const authReducer = generateReducer<AuthState | undefined>(undefined)
.add<AuthState>(Actions.REPLACE_TOKEN, (_, { payload }) => {
return payload;
});

View File

@ -10,7 +10,7 @@ const initialState: ConfigState = {
port: API.inferPort()
};
export let configReducer = generateReducer<ConfigState>(initialState)
export const configReducer = generateReducer<ConfigState>(initialState)
.add<ChangeApiPort>(Actions.CHANGE_API_PORT, (s, { payload }) => {
s.port = payload.port.replace(/\D/g, "");
return s;

View File

@ -14,4 +14,4 @@ const change = (state: "up" | "down") =>
export const networkUp = change("up");
export let networkDown = change("down");
export const networkDown = change("down");

View File

@ -202,6 +202,6 @@ export const attachEventListeners =
};
/** Connect to MQTT and attach all relevant event handlers. */
export let connectDevice = (token: AuthState) =>
export const connectDevice = (token: AuthState) =>
(dispatch: Function, getState: GetState) => fetchNewDevice(token)
.then(bot => attachEventListeners(bot, dispatch, getState), onOffline);

View File

@ -37,13 +37,13 @@ export const dispatchQosStart = (id: string) => {
});
};
export let dispatchNetworkUp = (edge: Edge, at: number) => {
export const dispatchNetworkUp = (edge: Edge, at: number) => {
if (shouldThrottle(edge, at)) { return; }
store.dispatch(networkUp(edge, at));
bumpThrottle(edge, at);
};
export let dispatchNetworkDown = (edge: Edge, at: number) => {
export const dispatchNetworkDown = (edge: Edge, at: number) => {
if (shouldThrottle(edge, at)) { return; }
store.dispatch(networkDown(edge, at));
bumpThrottle(edge, at);

View File

@ -17,7 +17,7 @@ export const DEFAULT_STATE: ConnectionState = {
};
export type PingResultPayload = { id: string, at: number };
export let connectivityReducer =
export const connectivityReducer =
generateReducer<ConnectionState>(DEFAULT_STATE)
.add<{ id: string }>(Actions.PING_START, (s, { payload }) => {
return { ...s, pings: startPing(s.pings, payload.id) };

View File

@ -2,5 +2,5 @@ import { throttle } from "lodash";
/** Too many status updates === too many screen redraws. */
export const slowDown =
(fn: (...args: unknown[]) => unknown) =>
<Returns, Args, Fn extends (u: Args) => Returns>(fn: Fn) =>
throttle(fn, 600, { leading: false, trailing: true });

View File

@ -287,6 +287,9 @@ export namespace ToolTips {
export const EMERGENCY_LOCK =
trim(`Stops a device from moving until it is unlocked by a user.`);
export const SELECT_A_CAMERA =
trim(`Select a camera on the Device page to take photos.`);
export const MARK_AS =
trim(`The Mark As step allows FarmBot to programmatically edit the
properties of the UTM, plants, and weeds from within a sequence.
@ -787,6 +790,10 @@ export namespace Content {
export const NO_TOOLS =
trim(`Press "+" to add a new tool.`);
export const MOUNTED_TOOL =
trim(`The tool currently mounted to the UTM can be set here or by using
a MARK AS step in a sequence.`);
// Farm Events
export const NOTHING_SCHEDULED =
trim(`Press "+" to schedule an event.`);
@ -827,6 +834,9 @@ export namespace Content {
export const NOT_AVAILABLE_WHEN_OFFLINE =
trim(`Not available when device is offline.`);
export const NO_CAMERA_SELECTED =
trim(`No camera selected`);
}
export namespace TourContent {
@ -978,6 +988,7 @@ export enum Actions {
TOGGLE_HOVERED_PLANT = "TOGGLE_HOVERED_PLANT",
TOGGLE_HOVERED_POINT = "TOGGLE_HOVERED_POINT",
HOVER_PLANT_LIST_ITEM = "HOVER_PLANT_LIST_ITEM",
HOVER_TOOL_SLOT = "HOVER_TOOL_SLOT",
OF_SEARCH_RESULTS_START = "OF_SEARCH_RESULTS_START",
OF_SEARCH_RESULTS_OK = "OF_SEARCH_RESULTS_OK",
OF_SEARCH_RESULTS_NO = "OF_SEARCH_RESULTS_NO",
@ -1016,5 +1027,11 @@ export enum Actions {
SET_CONSISTENCY = "SET_CONSISTENCY",
PING_START = "PING_START",
PING_OK = "PING_OK",
PING_NO = "PING_NO"
PING_NO = "PING_NO",
// Sequence Folders
FOLDER_TOGGLE = "FOLDER_TOGGLE",
FOLDER_TOGGLE_ALL = "FOLDER_TOGGLE_ALL",
FOLDER_TOGGLE_EDIT = "FOLDER_TOGGLE_EDIT",
FOLDER_SEARCH = "FOLDER_SEARCH"
}

View File

@ -24,6 +24,7 @@ describe("<Controls />", () => {
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
sensorReadings: [],
timeSettings: fakeTimeSettings(),
env: {},
});
it("shows webcam widget", () => {

View File

@ -1,6 +1,6 @@
import { mapStateToProps } from "../state_to_props";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeUser } from "../../__test_support__/fake_state/resources";
import { fakeUser, fakeFarmwareEnv } from "../../__test_support__/fake_state/resources";
import { fakeState } from "../../__test_support__/fake_state";
describe("mapStateToProps()", () => {
@ -10,4 +10,14 @@ describe("mapStateToProps()", () => {
const result = mapStateToProps(state);
expect(result.timeSettings).toEqual({ utcOffset: 0, hour24: false });
});
it("returns api props", () => {
const state = fakeState();
const fakeEnv = fakeFarmwareEnv();
state.resources = buildResourceIndex([fakeEnv]);
state.bot.minOsFeatureData = { api_farmware_env: "8.0.0" };
state.bot.hardware.informational_settings.controller_version = "8.0.0";
const result = mapStateToProps(state);
expect(result.env).toEqual({ [fakeEnv.body.key]: fakeEnv.body.value });
});
});

View File

@ -8,7 +8,7 @@ const Axis = ({ val }: { val: number | undefined }) => <Col xs={3}>
<input disabled value={isNumber(val) ? val : "---"} />
</Col>;
export let AxisDisplayGroup = ({ position, label }: AxisDisplayGroupProps) => {
export const AxisDisplayGroup = ({ position, label }: AxisDisplayGroupProps) => {
const { x, y, z } = position;
return <Row>
<Axis val={x} />

View File

@ -3,7 +3,7 @@ import { AxisInputBoxProps } from "./interfaces";
import { Col, BlurableInput } from "../ui/index";
import { isUndefined } from "lodash";
export let AxisInputBox = ({ onChange, value, axis }: AxisInputBoxProps) => {
export const AxisInputBox = ({ onChange, value, axis }: AxisInputBoxProps) => {
return <Col xs={3}>
<BlurableInput
value={(isUndefined(value) ? "" : value)}

View File

@ -29,6 +29,7 @@ export class RawControls extends React.Component<Props, {}> {
move = () => <Move
bot={this.props.bot}
env={this.props.env}
dispatch={this.props.dispatch}
arduinoBusy={this.arduinoBusy}
botToMqttStatus={this.props.botToMqttStatus}

View File

@ -1,4 +1,6 @@
import { BotState, Xyz, BotPosition, ShouldDisplay } from "../devices/interfaces";
import {
BotState, Xyz, BotPosition, ShouldDisplay, UserEnv
} from "../devices/interfaces";
import { Vector3, McuParams } from "farmbot/dist";
import {
TaggedWebcamFeed,
@ -22,6 +24,7 @@ export interface Props {
getWebAppConfigVal: GetWebAppConfigValue;
sensorReadings: TaggedSensorReading[];
timeSettings: TimeSettings;
env: UserEnv;
}
export interface AxisDisplayGroupProps {

View File

@ -15,6 +15,8 @@ import { mount } from "enzyme";
import { JogButtons } from "../jog_buttons";
import { JogMovementControlsProps } from "../interfaces";
import { bot } from "../../../__test_support__/fake_state/bot";
import { error } from "../../../toast/toast";
import { Content, ToolTips } from "../../../constants";
describe("<JogButtons/>", function () {
const jogButtonProps = (): JogMovementControlsProps => {
@ -26,6 +28,7 @@ describe("<JogButtons/>", function () {
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
doFindHome: false,
env: {},
};
};
@ -60,8 +63,12 @@ describe("<JogButtons/>", function () {
it("takes photo", () => {
const jogButtons = mount(<JogButtons {...jogButtonProps()} />);
jogButtons.find("button").at(0).simulate("click");
const cameraBtn = jogButtons.find("button").at(0);
expect(cameraBtn.props().title).not.toEqual(Content.NO_CAMERA_SELECTED);
cameraBtn.simulate("click");
expect(mockDevice.takePhoto).toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("error taking photo", () => {
@ -71,6 +78,18 @@ describe("<JogButtons/>", function () {
expect(mockDevice.takePhoto).toHaveBeenCalled();
});
it("shows camera as disabled", () => {
const p = jogButtonProps();
p.env = { camera: "NONE" };
const jogButtons = mount(<JogButtons {...p} />);
const cameraBtn = jogButtons.find("button").at(0);
expect(cameraBtn.props().title).toEqual(Content.NO_CAMERA_SELECTED);
cameraBtn.simulate("click");
expect(error).toHaveBeenCalledWith(
ToolTips.SELECT_A_CAMERA, Content.NO_CAMERA_SELECTED);
expect(mockDevice.takePhoto).not.toHaveBeenCalled();
});
it("has unswapped xy jog buttons", () => {
const jogButtons = mount(<JogButtons {...jogButtonProps()} />);
const button = jogButtons.find("button").at(6);

View File

@ -1,16 +1,15 @@
const mockDevice = {
moveAbsolute: jest.fn(() => { return Promise.resolve(); }),
};
const mockDevice = { moveAbsolute: jest.fn(() => Promise.resolve()) };
jest.mock("../../../device", () => ({ getDevice: () => mockDevice }));
jest.mock("../../../device", () => ({
getDevice: () => (mockDevice)
jest.mock("../../../config_storage/actions", () => ({
toggleWebAppBool: jest.fn(),
}));
jest.mock("../../../config_storage/actions", () => {
return {
toggleWebAppBool: jest.fn()
};
});
jest.mock("../../../account/dev/dev_support", () => ({
DevSettings: {
futureFeaturesEnabled: () => false,
}
}));
import * as React from "react";
import { mount, shallow } from "enzyme";
@ -26,16 +25,15 @@ import { clickButton } from "../../../__test_support__/helpers";
describe("<Move />", () => {
const mockConfig: Dictionary<boolean> = {};
function fakeProps(): MoveProps {
return {
dispatch: jest.fn(),
bot: bot,
arduinoBusy: false,
botToMqttStatus: "up",
firmwareSettings: bot.hardware.mcu_params,
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
};
}
const fakeProps = (): MoveProps => ({
dispatch: jest.fn(),
bot: bot,
arduinoBusy: false,
botToMqttStatus: "up",
firmwareSettings: bot.hardware.mcu_params,
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
env: {},
});
it("has default elements", () => {
const wrapper = mount(<Move {...fakeProps()} />);

View File

@ -1,4 +1,4 @@
import { BotPosition, BotState } from "../../devices/interfaces";
import { BotPosition, BotState, UserEnv } from "../../devices/interfaces";
import { McuParams, Xyz } from "farmbot";
import { NetworkState } from "../../connectivity/interfaces";
import { GetWebAppConfigValue } from "../../config_storage/actions";
@ -14,6 +14,7 @@ export interface MoveProps {
botToMqttStatus: NetworkState;
firmwareSettings: McuParams;
getWebAppConfigVal: GetWebAppConfigValue;
env: UserEnv;
}
export interface DirectionButtonProps {
@ -47,6 +48,7 @@ interface JogMovementControlsBaseProps extends DirectionAxesProps {
stepSize: number;
arduinoBusy: boolean;
xySwap: boolean;
env: UserEnv;
}
export interface JogMovementControlsProps extends JogMovementControlsBaseProps {

View File

@ -5,6 +5,9 @@ import { JogMovementControlsProps } from "./interfaces";
import { getDevice } from "../../device";
import { buildDirectionProps } from "./direction_axes_props";
import { t } from "../../i18next_wrapper";
import {
cameraBtnProps
} from "../../devices/components/fbos_settings/camera_selection";
const DEFAULT_STEP_SIZE = 100;
/*
@ -20,35 +23,37 @@ export function JogButtons(props: JogMovementControlsProps) {
const directionAxesProps = buildDirectionProps(props);
const rightLeft = xySwap ? "y" : "x";
const upDown = xySwap ? "x" : "y";
const commonProps = {
steps: stepSize || DEFAULT_STEP_SIZE,
disabled: arduinoBusy
};
const camDisabled = cameraBtnProps(props.env);
return <table className="jog-table">
<tbody>
<tr>
<td>
<button
className="i fa fa-camera arrow-button fb-button"
title={t("Take a photo")}
onClick={() => getDevice().takePhoto().catch(() => { })} />
className={`fa fa-camera arrow-button fb-button ${
camDisabled.class}`}
title={camDisabled.title || t("Take a photo")}
onClick={camDisabled.click ||
(() => getDevice().takePhoto().catch(() => { }))} />
</td>
<td />
<td />
<td>
<DirectionButton
<DirectionButton {...commonProps}
axis={upDown}
direction="up"
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
directionAxisProps={directionAxesProps[upDown]} />
</td>
<td />
<td />
<td>
<DirectionButton
<DirectionButton {...commonProps}
axis="z"
direction="up"
directionAxisProps={directionAxesProps.z}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
directionAxisProps={directionAxesProps.z} />
</td>
</tr>
<tr>
@ -61,37 +66,29 @@ export function JogButtons(props: JogMovementControlsProps) {
</td>
<td />
<td>
<DirectionButton
<DirectionButton {...commonProps}
axis={rightLeft}
direction="left"
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
directionAxisProps={directionAxesProps[rightLeft]} />
</td>
<td>
<DirectionButton
<DirectionButton {...commonProps}
axis={upDown}
direction="down"
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
directionAxisProps={directionAxesProps[upDown]} />
</td>
<td>
<DirectionButton
<DirectionButton {...commonProps}
axis={rightLeft}
direction="right"
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
directionAxisProps={directionAxesProps[rightLeft]} />
</td>
<td />
<td>
<DirectionButton
<DirectionButton {...commonProps}
axis="z"
direction="down"
directionAxisProps={directionAxesProps.z}
steps={stepSize || DEFAULT_STEP_SIZE}
disabled={arduinoBusy} />
directionAxisProps={directionAxesProps.z} />
</td>
</tr>
<tr>

View File

@ -1,6 +1,6 @@
import * as React from "react";
import { McuParams } from "farmbot";
import { BotPosition } from "../../devices/interfaces";
import { BotPosition, UserEnv } from "../../devices/interfaces";
import { changeStepSize } from "../../devices/actions";
import { StepSizeSelector } from "./step_size_selector";
import { GetWebAppBool } from "./interfaces";
@ -15,6 +15,7 @@ interface JogControlsGroupProps {
getValue: GetWebAppBool;
arduinoBusy: boolean;
firmwareSettings: McuParams;
env: UserEnv;
}
export const JogControlsGroup = (props: JogControlsGroupProps) => {
@ -38,6 +39,7 @@ export const JogControlsGroup = (props: JogControlsGroupProps) => {
z: getValue(BooleanSetting.z_axis_inverted)
}}
arduinoBusy={arduinoBusy}
env={props.env}
firmwareSettings={firmwareSettings}
xySwap={getValue(BooleanSetting.xy_swap)}
doFindHome={getValue(BooleanSetting.home_button_homing)} />

View File

@ -47,6 +47,7 @@ export class Move extends React.Component<MoveProps, {}> {
botPosition={locationData.position}
getValue={this.getValue}
arduinoBusy={this.props.arduinoBusy}
env={this.props.env}
firmwareSettings={this.props.firmwareSettings} />
<BotPositionRows
locationData={locationData}

View File

@ -3,31 +3,23 @@ import {
selectAllPeripherals,
selectAllWebcamFeeds,
selectAllSensors,
maybeGetDevice,
selectAllSensorReadings,
maybeGetTimeSettings
} from "../resources/selectors";
import { Props } from "./interfaces";
import {
validFwConfig,
createShouldDisplayFn as shouldDisplayFunc,
determineInstalledOsVersion
} from "../util";
import { validFwConfig } from "../util";
import { getWebAppConfigValue } from "../config_storage/actions";
import { getFirmwareConfig } from "../resources/getters";
import { uniq } from "lodash";
import { getStatus } from "../connectivity/reducer_support";
import { DevSettings } from "../account/dev/dev_support";
import { getEnv, getShouldDisplayFn } from "../farmware/state_to_props";
export function mapStateToProps(props: Everything): Props {
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const { mcu_params } = props.bot.hardware;
const device = maybeGetDevice(props.resources.index);
const installedOsVersion = determineInstalledOsVersion(props.bot, device);
const fbosVersionOverride = DevSettings.overriddenFbosVersion();
const shouldDisplay = shouldDisplayFunc(
installedOsVersion, props.bot.minOsFeatureData, fbosVersionOverride);
const shouldDisplay = getShouldDisplayFn(props.resources.index, props.bot);
const env = getEnv(props.resources.index, shouldDisplay, props.bot);
return {
feeds: selectAllWebcamFeeds(props.resources.index),
@ -41,5 +33,6 @@ export function mapStateToProps(props: Everything): Props {
shouldDisplay,
sensorReadings: selectAllSensorReadings(props.resources.index),
timeSettings: maybeGetTimeSettings(props.resources.index),
env,
};
}

View File

@ -5,6 +5,10 @@ import { buildDirectionProps } from "./controls/move/direction_axes_props";
import { ControlsPopupProps } from "./controls/move/interfaces";
import { commandErr } from "./devices/actions";
import { mapPanelClassName } from "./farm_designer/map/util";
import {
cameraBtnProps
} from "./devices/components/fbos_settings/camera_selection";
import { t } from "./i18next_wrapper";
interface State {
isOpen: boolean;
@ -23,40 +27,38 @@ export class ControlsPopup
const directionAxesProps = buildDirectionProps(this.props);
const rightLeft = xySwap ? "y" : "x";
const upDown = xySwap ? "x" : "y";
const movementDisabled = !isOpen || arduinoBusy || !botOnline;
const commonProps = { steps: stepSize, disabled: movementDisabled };
const camDisabled = cameraBtnProps(this.props.env);
return <div
className={`controls-popup ${isOpen} ${mapPanelClassName()}`}>
<i className="fa fa-crosshairs"
onClick={this.toggle("isOpen")} />
<div className="controls-popup-menu-outer">
<div className="controls-popup-menu-inner">
<DirectionButton
<DirectionButton {...commonProps}
axis={rightLeft}
direction="right"
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize}
disabled={!isOpen || arduinoBusy || !botOnline} />
<DirectionButton
directionAxisProps={directionAxesProps[rightLeft]} />
<DirectionButton {...commonProps}
axis={upDown}
direction="up"
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize}
disabled={!isOpen || arduinoBusy || !botOnline} />
<DirectionButton
directionAxisProps={directionAxesProps[upDown]} />
<DirectionButton {...commonProps}
axis={upDown}
direction="down"
directionAxisProps={directionAxesProps[upDown]}
steps={stepSize}
disabled={!isOpen || arduinoBusy || !botOnline} />
<DirectionButton
directionAxisProps={directionAxesProps[upDown]} />
<DirectionButton {...commonProps}
axis={rightLeft}
direction="left"
directionAxisProps={directionAxesProps[rightLeft]}
steps={stepSize}
disabled={!isOpen || arduinoBusy || !botOnline} />
directionAxisProps={directionAxesProps[rightLeft]} />
<button
className="i fa fa-camera arrow-button fb-button brown"
disabled={!botOnline}
onClick={() => getDevice().takePhoto().catch(commandErr("Photo"))} />
className={`fa fa-camera arrow-button fb-button brown ${
camDisabled.class}`}
disabled={!isOpen || !botOnline}
title={camDisabled.title || t("Take a photo")}
onClick={camDisabled.click ||
(() => getDevice().takePhoto().catch(commandErr("Photo")))} />
</div>
</div>
</div>;

View File

@ -279,6 +279,7 @@
&.pseudo-disabled {
background: $medium_light_gray !important;
box-shadow: 0 2px 0px 0px lighten($medium_light_gray, 5%) !important;
border-bottom: none !important;
&:focus,
&:hover,
&.active {

View File

@ -3,6 +3,7 @@ $translucent: rgba(0, 0, 0, 0.2);
$translucent2: rgba(0, 0, 0, 0.6);
$white: #fff;
$off_white: #f4f4f4;
$lighter_gray: #eee;
$light_gray: #ddd;
$gray: #ccc;
$medium_light_gray: #bcbcbc;
@ -129,9 +130,36 @@ $panel_light_red: #fff7f6;
background: $blue !important;
}
.dark-blue,
.fun,
.saucer-fun {
background: $dark_blue !important;
}
.icon-saucer {
background: none !important;
&.blue {
color: $blue;
}
&.green {
color: $green;
}
&.yellow {
color: $yellow;
}
&.orange {
color: $orange;
}
&.purple {
color: $purple;
}
&.pink {
color: $pink;
}
&.gray {
color: $gray;
}
&.red {
color: $red;
}
}

View File

@ -394,7 +394,7 @@
a {
margin-top: 0.5rem;
}
i {
i:not(.fa-stack-2x) {
font-size: 1.5rem;
}
}
@ -509,9 +509,125 @@
}
}
.tools-panel-content {
.tool-slots-panel,
.tools-panel {
.panel-top {
display: flex;
margin-top: 5rem;
}
.tool-slots-panel-content,
.tools-panel-content {
.tool-search-item,
.tool-slot-search-item {
cursor: pointer;
margin-left: -15px;
margin-right: -15px;
.row {
margin-left: 0;
margin-right: 0;
}
p {
line-height: 3rem;
}
}
.mounted-tool-header {
display: flex;
margin-top: 1rem;
label {
margin: 0;
}
.help-icon {
margin-left: 1rem;
vertical-align: top;
font-size: 1.4rem;
}
}
.tool-slots-header {
display: flex;
margin-top: 1rem;
margin-bottom: 1rem;
label {
margin: 0;
line-height: 2.1rem;
}
a {
margin-left: auto;
}
.fa-plus {
font-size: 1.5rem;
}
}
button:not(.bp3-button) {
display: block;
margin-left: auto;
float: none;
margin-top: 1rem;
}
.tool-verification-status {
display: flex;
margin-top: 1rem;
margin-bottom: 2rem;
button {
margin-top: 0;
}
}
}
}
.add-tool-panel-content,
.edit-tool-panel-content {
button {
display: block;
margin-left: auto;
float: none;
margin-top: 1rem;
&.red {
float: left;
}
}
.add-stock-tools {
ul {
font-size: 1.2rem;
padding-left: 1rem;
}
button {
.fa-plus {
margin-right: 0.5rem;
}
}
}
}
.add-tool-slot-panel-content,
.edit-tool-slot-panel-content {
label {
margin-top: 0 !important;
}
.row, fieldset {
margin-top: 2rem;
}
fieldset button {
margin: 0;
}
.direction-icon {
margin-left: 1rem;
}
.use-current-location-input {
button {
margin: 0;
float: none;
margin-left: 1rem;
vertical-align: middle;
}
}
.gantry-mounted-input {
label {
margin-top: 0;
}
input[type="checkbox"] {
float: left;
margin-right: 1rem;
}
}
}

View File

@ -34,7 +34,10 @@ body {
width: 13rem;
background: $dark_gray;
}
div {
.bp3-popover-content,
.color-picker-cluster,
.color-picker-item-wrapper,
.saucer {
display: inline-block;
padding: 0.4rem;
}
@ -113,6 +116,21 @@ fieldset {
}
}
.icon-saucer {
position: relative;
z-index: 2;
height: 2rem;
width: 2rem;
color: $dark_gray;
cursor: pointer;
&.active {
border: 2px solid white;
}
&.hover {
border: 2px solid $dark_gray;
}
}
.saucer-connector {
position: absolute;
z-index: 1;

View File

@ -160,17 +160,17 @@
margin-left: 10px;
}
.step-button-cluster,
.sequence-list,
.regimen-list {
overflow-y: auto;
overflow-x: hidden;
max-height: calc(100vh - 21rem);
max-height: calc(100vh - 19rem);
}
.step-button-cluster,
.sequence-list {
.step-button-cluster {
margin-right: -15px;
overflow-y: auto;
overflow-x: hidden;
max-height: calc(100vh - 16rem);
}
.farmware-info-panel,
@ -229,15 +229,230 @@
}
}
.sequence-list-item {
margin-right: 15px;
.folders-panel {
margin-left: -30px;
margin-right: -20px;
@media screen and (max-width: 767px) {
margin-left: -15px;
}
.non-empty-state {
height: calc(100vh - 19rem);
overflow-y: auto;
overflow-x: hidden;
}
.panel-top {
margin-left: 1rem !important;
button {
margin-top: 0.7rem !important;
}
}
.panel-top,
.folder-button-cluster {
i {
font-size: 1.5rem;
}
.fa-stack {
font-size: 1rem;
transform: scale(0.8);
}
.fa-stack-2x {
font-size: 2rem;
}
.fa-stack-1x {
font-size: 1.5rem;
line-height: 1.5rem;
margin-left: 0.75rem;
filter: drop-shadow(0 0 0.2rem $dark_green);
text-align: center;
}
}
.folder-button-cluster {
display: flex;
i {
width: 1.5rem !important;
line-height: 2rem !important;
}
}
ul {
margin-bottom: 0;
}
.folder-drop-area {
height: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 2rem;
padding-right: 2rem;
transition: height 0.5s ease-out,
padding-top 0.5s ease-out,
padding-bottom 0.5s ease-out;
transition-delay: 0.4s;
color: $gray;
font-weight: bold;
background: $white;
text-align: center;
cursor: pointer;
&.visible {
transition: height 0.3s ease-in,
padding-top 0.3s ease-in,
padding-bottom 0.3s ease-in;
transition-delay: 0.2s;
height: 3rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
&:hover, &.hovered {
color: $medium_gray;
}
}
.folders {
.folder > div:not(:first-child), ul {
margin-left: 1rem;
}
}
.folder-list-item,
.sequence-list-item {
display: flex;
position: relative;
width: 100%;
height: 3.5rem;
border-bottom: 1px solid $light_gray;
cursor: pointer;
background: $lighter_gray;
border-left: 4px solid transparent;
&.active {
border-left: 4px solid $dark_gray;
}
.fa-chevron-down, .fa-chevron-right {
position: absolute;
z-index: 2;
width: 3rem;
font-size: 1.1rem;
}
.folder-settings-icon,
.fa-bars {
position: absolute;
right: 0;
}
.fa-bars, .fa-ellipsis-v {
display: none;
}
.fa-ellipsis-v {
&.open {
display: block;
}
}
&:hover {
.fa-bars, .fa-ellipsis-v {
display: block;
}
}
i {
margin: 0;
line-height: 3.5rem;
width: 3rem;
text-align: center;
}
.saucer, .icon-saucer {
position: absolute;
margin: 0.5rem;
width: 1.2rem;
height: 1.2rem;
}
a {
width: 100%;
}
p {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 1.2rem;
font-weight: bold;
width: 75%;
padding: 0.5rem;
padding-left: 0;
margin-left: 3rem;
line-height: 2.5rem;
}
.folder-name {
width: 100%;
margin-right: 3rem;
margin-left: 6rem;
p {
margin-left: 0;
}
.folder-name-input {
display: flex;
button {
top: 0.5rem;
width: auto;
height: 2rem;
i {
line-height: 0;
width: 1rem;
}
}
.input {
margin: 0.3rem;
width: 90%;
}
}
}
.sequence-list-item-icons {
display: flex;
position: absolute;
right: 0;
.fa-hdd-o {
margin-right: 3rem;
}
}
button {
margin-left: 0.5rem;
}
&.move-source {
border: 1px solid $dark_gray;
}
&.move-target {
background: $white;
}
}
.folder-list-item,
.sequence-list-item {
.bp3-popover-wrapper.color-picker {
position: absolute;
line-height: 0;
.bp3-popover-target {
width: 2rem;
height: 3.5rem;
}
}
padding-left: 3rem;
.saucer, .icon-saucer {
position: relative;
top: 0.55rem;
margin: auto;
margin-top: 0.6rem;
}
}
.folder-list-item {
padding-left: 0;
.bp3-popover-wrapper.color-picker {
margin-left: 3rem;
}
.color-picker {
.icon-saucer {
top: 0;
margin-top: 0;
margin-left: 0.4rem;
}
}
}
}
.farmware-list-panel,
.sequence-list-panel,
.regimen-list-panel {
padding-top: 0.4rem;
margin-bottom: 3rem;
margin-right: 5px;
@media screen and (max-width: 1075px) {
margin-left: 15px;
@ -268,7 +483,15 @@
}
.farmware-list-panel {
margin-bottom: 5rem;
.farmware-list-panel-contents {
height: calc(100vh - 15rem);
overflow-y: auto;
overflow-x: hidden;
margin-right: -20px;
margin-left: -15px;
padding-left: 1rem;
padding-right: 1rem;
}
label {
font-weight: bold;
font-size: 1.4rem;

View File

@ -1,5 +1,8 @@
import {
fakeFbosConfig, fakeImage, fakeFarmwareEnv, fakeWebAppConfig
fakeFbosConfig,
fakeImage,
fakeFarmwareEnv,
fakeWebAppConfig
} from "../../__test_support__/fake_state/resources";
let mockFbosConfig: TaggedFbosConfig | undefined = fakeFbosConfig();
@ -12,6 +15,8 @@ jest.mock("../../resources/selectors_by_kind", () => ({
selectAllRegimens: () => [],
selectAllLogs: () => [],
selectAllImages: () => [mockImages],
selectAllFolders: () => [],
selectAllSequences: () => [],
selectAllFarmwareEnvs: () => [fakeFarmwareEnv()]
}));

View File

@ -245,7 +245,7 @@ function validMinOsFeatureLookup(x: MinOsFeatureLookup): boolean {
* Fetch and save minimum FBOS version data for UI feature display.
* @param url location of data
*/
export let fetchMinOsFeatureData = (url: string) =>
export const fetchMinOsFeatureData = (url: string) =>
(dispatch: Function) => {
axios
.get<MinOsFeatureLookup>(url)

View File

@ -9,6 +9,10 @@ describe("boardType()", () => {
expect(boardType("5.0.3.G")).toEqual("farmduino_k14");
});
it("returns Farmduino k1.5", () => {
expect(boardType("5.0.3.H")).toEqual("farmduino_k15");
});
it("returns Farmduino Express k1.0", () => {
expect(boardType("5.0.3.E")).toEqual("express_k10");
});

View File

@ -81,6 +81,7 @@ describe("<BoardType/>", () => {
{ label: "Arduino/RAMPS (Genesis v1.2)", value: "arduino" },
{ label: "Farmduino (Genesis v1.3)", value: "farmduino" },
{ label: "Farmduino (Genesis v1.4)", value: "farmduino_k14" },
{ label: "Farmduino (Genesis v1.5)", value: "farmduino_k15" },
{ label: "Farmduino (Express v1.0)", value: "express_k10" },
{ label: "None", value: "none" },
]);

View File

@ -1,26 +1,20 @@
const mockDevice = {
setUserEnv: jest.fn(() => { return Promise.resolve(); }),
};
jest.mock("../../../../device", () => ({
getDevice: () => mockDevice
}));
const mockDevice = { setUserEnv: jest.fn(() => Promise.resolve()) };
jest.mock("../../../../device", () => ({ getDevice: () => mockDevice }));
import * as React from "react";
import { mount, shallow } from "enzyme";
import { CameraSelection } from "../camera_selection";
import { CameraSelection, cameraDisabled } from "../camera_selection";
import { CameraSelectionProps } from "../interfaces";
import { info, error } from "../../../../toast/toast";
describe("<CameraSelection/>", () => {
const fakeProps = (): CameraSelectionProps => {
return {
env: {},
botOnline: true,
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
dispatch: jest.fn(),
};
};
const fakeProps = (): CameraSelectionProps => ({
env: {},
botOnline: true,
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
dispatch: jest.fn(),
});
it("doesn't render camera", () => {
const cameraSelection = mount(<CameraSelection {...fakeProps()} />);
@ -66,3 +60,15 @@ describe("<CameraSelection/>", () => {
expect(p.saveFarmwareEnv).toHaveBeenCalledWith("camera", "\"mycamera\"");
});
});
describe("cameraDisabled()", () => {
it("returns enabled", () => {
expect(cameraDisabled({ camera: "USB" })).toEqual(false);
expect(cameraDisabled({ camera: "" })).toEqual(false);
});
it("returns disabled", () => {
expect(cameraDisabled({ camera: "none" })).toEqual(true);
expect(cameraDisabled({ camera: "\"NONE\"" })).toEqual(true);
});
});

View File

@ -6,25 +6,56 @@ import {
import { info, success, error } from "../../../toast/toast";
import { getDevice } from "../../../device";
import { ColWidth } from "../farmbot_os_settings";
import { Feature } from "../../interfaces";
import { Feature, UserEnv } from "../../interfaces";
import { t } from "../../../i18next_wrapper";
import { Content, ToolTips } from "../../../constants";
/** Check if the camera has been disabled. */
export const cameraDisabled = (env: UserEnv): boolean =>
parseCameraSelection(env) === Camera.NONE;
/** `disabled` and `title` props for buttons with actions that use the camera. */
export const cameraBtnProps = (env: UserEnv) => {
const disabled = cameraDisabled(env);
return disabled
? {
class: "pseudo-disabled",
click: () =>
error(t(ToolTips.SELECT_A_CAMERA), t(Content.NO_CAMERA_SELECTED)),
title: t(Content.NO_CAMERA_SELECTED)
}
: { class: "", click: undefined, title: "" };
};
enum Camera {
USB = "USB",
RPI = "RPI",
NONE = "NONE",
}
const parseCameraSelection = (env: UserEnv): Camera => {
const camera = env["camera"]?.toUpperCase();
if (camera?.includes(Camera.NONE)) {
return Camera.NONE;
} else if (camera?.includes(Camera.RPI)) {
return Camera.RPI;
} else {
return Camera.USB;
}
};
const CAMERA_CHOICES = () => ([
{ label: t("USB Camera"), value: "USB" },
{ label: t("Raspberry Pi Camera"), value: "RPI" }
{ label: t("USB Camera"), value: Camera.USB },
{ label: t("Raspberry Pi Camera"), value: Camera.RPI },
{ label: t("None"), value: Camera.NONE },
]);
const CAMERA_CHOICES_DDI = () => {
const CHOICES = CAMERA_CHOICES();
return {
[CHOICES[0].value]: {
label: CHOICES[0].label,
value: CHOICES[0].value
},
[CHOICES[1].value]: {
label: CHOICES[1].label,
value: CHOICES[1].value
}
[CHOICES[0].value]: { label: CHOICES[0].label, value: CHOICES[0].value },
[CHOICES[1].value]: { label: CHOICES[1].label, value: CHOICES[1].value },
[CHOICES[2].value]: { label: CHOICES[2].label, value: CHOICES[2].value },
};
};
@ -35,12 +66,8 @@ export class CameraSelection
cameraStatus: ""
};
selectedCamera(): DropDownItem {
const camera = this.props.env["camera"];
return camera
? CAMERA_CHOICES_DDI()[JSON.parse(camera)]
: CAMERA_CHOICES_DDI()["USB"];
}
selectedCamera = (): DropDownItem =>
CAMERA_CHOICES_DDI()[parseCameraSelection(this.props.env)]
sendOffConfig = (selectedCamera: DropDownItem) => {
const { props } = this;

View File

@ -6,6 +6,7 @@ import { ColWidth } from "../farmbot_os_settings";
import { FarmbotOsRowProps } from "./interfaces";
import { FbosDetails } from "./fbos_details";
import { t } from "../../../i18next_wrapper";
import { ErrorBoundary } from "../../../error_boundary";
const getVersionString =
(fbosVersion: string | undefined, onBeta: boolean | undefined): string => {
@ -30,14 +31,16 @@ export function FarmbotOsRow(props: FarmbotOsRowProps) {
<p>
{t("Version {{ version }}", { version })}
</p>
<FbosDetails
botInfoSettings={bot.hardware.informational_settings}
dispatch={dispatch}
shouldDisplay={props.shouldDisplay}
sourceFbosConfig={sourceFbosConfig}
botToMqttLastSeen={props.botToMqttLastSeen}
timeSettings={props.timeSettings}
deviceAccount={props.deviceAccount} />
<ErrorBoundary>
<FbosDetails
botInfoSettings={bot.hardware.informational_settings}
dispatch={dispatch}
shouldDisplay={props.shouldDisplay}
sourceFbosConfig={sourceFbosConfig}
botToMqttLastSeen={props.botToMqttLastSeen}
timeSettings={props.timeSettings}
deviceAccount={props.deviceAccount} />
</ErrorBoundary>
</Popover>
</Col>
<Col xs={3}>

View File

@ -2,8 +2,12 @@ import { FirmwareHardware } from "farmbot";
import { ShouldDisplay, Feature } from "../interfaces";
export const isFwHardwareValue = (x?: unknown): x is FirmwareHardware => {
const values: FirmwareHardware[] =
["arduino", "farmduino", "farmduino_k14", "express_k10", "none"];
const values: FirmwareHardware[] = [
"arduino",
"farmduino", "farmduino_k14", "farmduino_k15",
"express_k10",
"none"
];
return !!values.includes(x as FirmwareHardware);
};
@ -13,7 +17,7 @@ export const getBoardIdentifier =
export const isKnownBoard = (firmwareVersion: string | undefined): boolean => {
const boardIdentifier = getBoardIdentifier(firmwareVersion);
return ["R", "F", "G", "E"].includes(boardIdentifier);
return ["R", "F", "G", "H", "E"].includes(boardIdentifier);
};
export const getBoardCategory =
@ -33,6 +37,8 @@ export const boardType =
return "farmduino";
case "G":
return "farmduino_k14";
case "H":
return "farmduino_k15";
case "E":
return "express_k10";
default:
@ -45,6 +51,9 @@ const FARMDUINO = { label: "Farmduino (Genesis v1.3)", value: "farmduino" };
const FARMDUINO_K14 = {
label: "Farmduino (Genesis v1.4)", value: "farmduino_k14"
};
const FARMDUINO_K15 = {
label: "Farmduino (Genesis v1.5)", value: "farmduino_k15"
};
const EXPRESS_K10 = {
label: "Farmduino (Express v1.0)", value: "express_k10"
};
@ -54,6 +63,7 @@ export const FIRMWARE_CHOICES_DDI = {
[ARDUINO.value]: ARDUINO,
[FARMDUINO.value]: FARMDUINO,
[FARMDUINO_K14.value]: FARMDUINO_K14,
[FARMDUINO_K15.value]: FARMDUINO_K15,
[EXPRESS_K10.value]: EXPRESS_K10,
[NONE.value]: NONE
};
@ -63,6 +73,7 @@ export const getFirmwareChoices =
ARDUINO,
FARMDUINO,
FARMDUINO_K14,
...(shouldDisplay(Feature.farmduino_k15) ? [FARMDUINO_K15] : []),
...(shouldDisplay(Feature.express_k10) ? [EXPRESS_K10] : []),
...(shouldDisplay(Feature.none_firmware) ? [NONE] : []),
]);

View File

@ -10,7 +10,7 @@ interface Props {
expanded: boolean;
}
export let Header = (props: Props) => {
export const Header = (props: Props) => {
const { dispatch, name, title, expanded } = props;
return <ExpandableHeader
expanded={expanded}

View File

@ -78,6 +78,7 @@ export enum Feature {
endstop_invert = "endstop_invert",
express_k10 = "express_k10",
farmduino_k14 = "farmduino_k14",
farmduino_k15 = "farmduino_k15",
firmware_restart = "firmware_restart",
flash_firmware = "flash_firmware",
groups = "groups",
@ -209,8 +210,8 @@ export interface SensorsProps {
export interface FarmwareProps {
dispatch: Function;
env: Partial<WD_ENV>;
user_env: UserEnv;
wDEnv: Partial<WD_ENV>;
env: UserEnv;
images: TaggedImage[];
currentImage: TaggedImage | undefined;
botToMqttStatus: NetworkState;

View File

@ -1,6 +1,9 @@
import {
BotState, HardwareState, ControlPanelState, OsUpdateInfo,
MinOsFeatureLookup
BotState,
ControlPanelState,
HardwareState,
MinOsFeatureLookup,
OsUpdateInfo
} from "./interfaces";
import { generateReducer } from "../redux/generate_reducer";
import { Actions } from "../constants";
@ -18,7 +21,7 @@ const afterEach = (state: BotState, a: ReduxAction<{}>) => {
return state;
};
export let initialState = (): BotState => ({
export const initialState = (): BotState => ({
consistent: true,
stepSize: 100,
controlPanelState: {
@ -81,7 +84,7 @@ export let initialState = (): BotState => ({
}
});
export let botReducer = generateReducer<BotState>(initialState())
export const botReducer = generateReducer<BotState>(initialState())
.afterEach(afterEach)
.add<boolean>(Actions.SET_CONSISTENCY, (s, a) => {
s.consistent = a.payload;

View File

@ -1,38 +1,28 @@
import { Everything } from "../interfaces";
import { Props, Feature } from "./interfaces";
import { Props } from "./interfaces";
import {
selectAllImages,
getDeviceAccountSettings,
maybeGetDevice,
maybeGetTimeSettings,
} from "../resources/selectors";
import {
sourceFbosConfigValue, sourceFwConfigValue
} from "./components/source_config_value";
import { validFwConfig, validFbosConfig } from "../util";
import {
determineInstalledOsVersion, validFwConfig, validFbosConfig,
createShouldDisplayFn as shouldDisplayFunc
} from "../util";
import {
saveOrEditFarmwareEnv, reduceFarmwareEnv
saveOrEditFarmwareEnv, getEnv, getShouldDisplayFn
} from "../farmware/state_to_props";
import { getFbosConfig, getFirmwareConfig, getWebAppConfig } from "../resources/getters";
import { DevSettings } from "../account/dev/dev_support";
import {
getFbosConfig, getFirmwareConfig, getWebAppConfig
} from "../resources/getters";
import { getAllAlerts } from "../messages/state_to_props";
export function mapStateToProps(props: Everything): Props {
const { hardware } = props.bot;
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const firmwareConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const installedOsVersion = determineInstalledOsVersion(
props.bot, maybeGetDevice(props.resources.index));
const fbosVersionOverride = DevSettings.overriddenFbosVersion();
const shouldDisplay = shouldDisplayFunc(installedOsVersion,
props.bot.minOsFeatureData,
fbosVersionOverride);
const env = shouldDisplay(Feature.api_farmware_env)
? reduceFarmwareEnv(props.resources.index)
: props.bot.hardware.user_env;
const shouldDisplay = getShouldDisplayFn(props.resources.index, props.bot);
const env = getEnv(props.resources.index, shouldDisplay, props.bot);
const webAppConfig = getWebAppConfig(props.resources.index);
if (!webAppConfig) {
throw new Error("Missing web app config");

View File

@ -1,4 +1,4 @@
export let list = ["Africa/Abidjan",
export const list = ["Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",

View File

@ -4,6 +4,7 @@ import { Everything } from "../interfaces";
import { ReduxAction } from "../redux/interfaces";
import * as React from "react";
import { Actions } from "../constants";
import { UUID } from "../resources/interfaces";
export const STEP_DATATRANSFER_IDENTIFER = "farmbot/sequence-step";
/** SIDE EFFECT-Y!! Stores a step into store.draggable.dataTransfer and
@ -12,7 +13,8 @@ export const STEP_DATATRANSFER_IDENTIFER = "farmbot/sequence-step";
export function stepPut(value: Step,
ev: React.DragEvent<HTMLElement>,
intent: DataXferIntent,
draggerId: number):
draggerId: number,
resourceUuid?: UUID):
ReduxAction<DataXferBase> {
const uuid = id();
ev.dataTransfer.setData(STEP_DATATRANSFER_IDENTIFER, uuid);
@ -22,7 +24,8 @@ export function stepPut(value: Step,
intent,
uuid,
value,
draggerId
draggerId,
resourceUuid,
}
};
}

View File

@ -1,4 +1,5 @@
import { SequenceBodyItem as Step } from "farmbot";
import { UUID } from "../resources/interfaces";
/** An entry in the data transfer table. Used to transfer data from a "draggable"
* to a "dropable". For type safety, this is a "tagged union". See Typescript
@ -17,6 +18,8 @@ export interface DataXferBase {
/** "why" the drag/drop event took place (tagged union- See Typescript
* documentation for more information). */
intent: DataXferIntent;
/** Optional resource UUID. */
resourceUuid?: UUID;
}
/** Data transfer payload used when moving a *new* step into an existing step */
@ -51,4 +54,5 @@ export interface StepDraggerProps {
intent: DataXferIntent;
children?: React.ReactNode;
draggerId: number;
resourceUuid?: UUID;
}

View File

@ -6,7 +6,7 @@ const INITIAL_STATE: DraggableState = {
dataTransfer: {}
};
export let draggableReducer = generateReducer<DraggableState>(INITIAL_STATE)
export const draggableReducer = generateReducer<DraggableState>(INITIAL_STATE)
.add<DataXfer>(Actions.PUT_DATA_XFER, (s, { payload }) => {
s.dataTransfer[payload.uuid] = payload;
return s;

View File

@ -2,6 +2,7 @@ import * as React from "react";
import { stepPut } from "./actions";
import { SequenceBodyItem as Step } from "farmbot";
import { DataXferIntent, StepDraggerProps } from "./interfaces";
import { UUID } from "../resources/interfaces";
/** Magic number to indicate that the draggerId was not provided or can't be
* known. */
@ -18,25 +19,24 @@ export const NULL_DRAGGER_ID = 0xCAFEF00D;
* Drag this!
* </button>
* */
export let stepDragEventHandler = (dispatch: Function,
export const stepDragEventHandler = (dispatch: Function,
step: Step,
intent: DataXferIntent,
draggerId: number) => {
draggerId: number,
resourceUuid?: UUID) => {
return (ev: React.DragEvent<HTMLElement>) => {
dispatch(stepPut(step, ev, intent, draggerId));
dispatch(stepPut(step, ev, intent, draggerId, resourceUuid));
};
};
export function StepDragger({ dispatch,
step,
children,
intent,
draggerId }: StepDraggerProps) {
return <div
export function StepDragger(props: StepDraggerProps) {
const { dispatch, step, children, intent, draggerId, resourceUuid } = props;
return <div className="step-dragger"
onDragStart={stepDragEventHandler(dispatch,
step,
intent,
draggerId)}>
draggerId,
resourceUuid)}>
{children}
</div>;
}

View File

@ -1,7 +1,6 @@
/// <reference path="./hacks.d.ts" />
/**
* THIS IS THE ENTRY POINT FOR THE MAIN PORTION OF THE WEB APP.
*
* Try to keep this file light. */
import { detectLanguage } from "./i18n";
import { stopIE } from "./util/stop_ie";

View File

@ -3,7 +3,7 @@ import { catchErrors } from "./util";
import { Apology } from "./apology";
interface State { hasError?: boolean; }
interface Props { }
interface Props { fallback?: React.ReactElement }
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
@ -16,7 +16,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
this.setState({ hasError: true });
}
no = () => <Apology />;
no = () => this.props.fallback || <Apology />;
ok = () => this.props.children || <div />;

View File

@ -1,17 +1,17 @@
import * as React from "react";
import { mount } from "enzyme";
import { DesignerPanel, DesignerPanelHeader } from "../../designer_panel";
import { DesignerPanel, DesignerPanelHeader } from "../designer_panel";
describe("<DesignerPanel />", () => {
it("renders default panel", () => {
const wrapper = mount(<DesignerPanel panelName={"test-panel"} />);
expect(wrapper.find("div").hasClass("gray-panel")).toBeTruthy();
expect(wrapper.find("div").first().hasClass("gray-panel")).toBeTruthy();
});
});
describe("<DesignerPanelHeader />", () => {
it("renders default panel header", () => {
const wrapper = mount(<DesignerPanelHeader panelName={"test-panel"} />);
expect(wrapper.find("div").hasClass("gray-panel")).toBeTruthy();
expect(wrapper.find("div").first().hasClass("gray-panel")).toBeTruthy();
});
});

View File

@ -63,6 +63,15 @@ describe("designer reducer", () => {
expect(newState.hoveredPoint).toEqual("uuid");
});
it("sets hovered tool slot", () => {
const action: ReduxAction<string> = {
type: Actions.HOVER_TOOL_SLOT,
payload: "toolSlotUuid"
};
const newState = designer(oldState(), action);
expect(newState.hoveredToolSlot).toEqual("toolSlotUuid");
});
it("sets chosen location", () => {
const action: ReduxAction<BotPosition> = {
type: Actions.CHOOSE_LOCATION,

View File

@ -4,6 +4,7 @@ import { last, trim } from "lodash";
import { Link } from "../link";
import { Panel, TAB_COLOR, PanelColor } from "./panel_header";
import { t } from "../i18next_wrapper";
import { ErrorBoundary } from "../error_boundary";
interface DesignerPanelProps {
panelName: string;
@ -19,7 +20,9 @@ export const DesignerPanel = (props: DesignerPanelProps) => {
"panel-container",
`${color || PanelColor.gray}-panel`,
`${props.panelName}-panel`].join(" ")}>
{props.children}
<ErrorBoundary>
{props.children}
</ErrorBoundary>
</div>;
};
@ -86,7 +89,9 @@ export const DesignerPanelTop = (props: DesignerPanelTopProps) => {
<div className="text-input-wrapper">
{!props.noIcon &&
<i className="fa fa-search"></i>}
{props.children}
<ErrorBoundary>
{props.children}
</ErrorBoundary>
</div>
</div>
{props.linkTo &&
@ -105,8 +110,11 @@ interface DesignerPanelContentProps {
}
export const DesignerPanelContent = (props: DesignerPanelContentProps) =>
<div className={
`panel-content ${props.panelName}-panel-content ${props.className || ""}`
}>
{props.children}
<div className={[
"panel-content",
`${props.panelName}-panel-content`,
props.className || ""].join(" ")}>
<ErrorBoundary>
{props.children}
</ErrorBoundary>
</div>;

View File

@ -161,4 +161,4 @@ export class PureFarmEvents
* It avoids mocking `connect` in unit tests.
* See testing pattern noted here: https://github.com/airbnb/enzyme/issues/98
*/
export let FarmEvents = connect(mapStateToProps)(PureFarmEvents);
export const FarmEvents = connect(mapStateToProps)(PureFarmEvents);

View File

@ -65,7 +65,7 @@ export const nextRegItemTimes =
&& time.isSameOrAfter(moment(startTime)));
};
export let regimenCalendarAdder = (
export const regimenCalendarAdder = (
index: ResourceIndex, timeSettings: TimeSettings) =>
(f: FarmEventWithRegimen, c: Calendar, now = moment()) => {
const { regimen_items } = f.executable;
@ -96,7 +96,7 @@ export let regimenCalendarAdder = (
}
};
export let addSequenceToCalendar =
export const addSequenceToCalendar =
(f: FarmEventWithSequence, c: Calendar, timeSettings: TimeSettings,
now = moment()) => {
const schedule = scheduleForFarmEvent(f, now);

View File

@ -13,7 +13,6 @@ import {
findSequenceById,
findRegimenById,
getDeviceAccountSettings,
maybeGetDevice,
maybeGetTimeSettings
} from "../../resources/selectors";
import {
@ -22,11 +21,7 @@ import {
TaggedRegimen
} from "farmbot";
import { DropDownItem } from "../../ui/index";
import {
validFbosConfig,
createShouldDisplayFn as shouldDisplayFunc,
determineInstalledOsVersion
} from "../../util";
import { validFbosConfig } from "../../util";
import {
sourceFbosConfigValue
} from "../../devices/components/source_config_value";
@ -34,19 +29,19 @@ import { hasId } from "../../resources/util";
import { ExecutableType } from "farmbot/dist/resources/api_resources";
import { getFbosConfig } from "../../resources/getters";
import { t } from "../../i18next_wrapper";
import { DevSettings } from "../../account/dev/dev_support";
import { getShouldDisplayFn } from "../../farmware/state_to_props";
export let formatTime = (input: string, timeSettings: TimeSettings) => {
export const formatTime = (input: string, timeSettings: TimeSettings) => {
const iso = new Date(input).toISOString();
return moment(iso).utcOffset(timeSettings.utcOffset).format("HH:mm");
};
export let formatDate = (input: string, timeSettings: TimeSettings) => {
export const formatDate = (input: string, timeSettings: TimeSettings) => {
const iso = new Date(input).toISOString();
return moment(iso).utcOffset(timeSettings.utcOffset).format("YYYY-MM-DD");
};
export let repeatOptions = [
export const repeatOptions = [
{ label: t("Minutes"), value: "minutely", name: "time_unit" },
{ label: t("Hours"), value: "hourly", name: "time_unit" },
{ label: t("Days"), value: "daily", name: "time_unit" },
@ -143,12 +138,6 @@ export function mapStateToPropsAddEdit(props: Everything): AddEditFarmEventProps
const autoSyncEnabled =
!!sourceFbosConfigValue(fbosConfig, configuration)("auto_sync").value;
const installedOsVersion = determineInstalledOsVersion(
props.bot, maybeGetDevice(props.resources.index));
const fbosVersionOverride = DevSettings.overriddenFbosVersion();
const shouldDisplay = shouldDisplayFunc(
installedOsVersion, props.bot.minOsFeatureData, fbosVersionOverride);
return {
deviceTimezone: dev
.body
@ -167,6 +156,6 @@ export function mapStateToPropsAddEdit(props: Everything): AddEditFarmEventProps
timeSettings: maybeGetTimeSettings(props.resources.index),
autoSyncEnabled,
resources: props.resources.index,
shouldDisplay,
shouldDisplay: getShouldDisplayFn(props.resources.index, props.bot),
};
}

View File

@ -103,6 +103,7 @@ export interface DesignerState {
hoveredPlant: HoveredPlantPayl;
hoveredPoint: string | undefined;
hoveredPlantListItem: string | undefined;
hoveredToolSlot: string | undefined;
cropSearchQuery: string;
cropSearchResults: CropLiveSearchResult[];
cropSearchInProgress: boolean;

View File

@ -340,6 +340,8 @@ export class GardenMap extends
ToolSlotLayer = () => <ToolSlotLayer
mapTransformProps={this.mapTransformProps}
visible={!!this.props.showFarmbot}
dispatch={this.props.dispatch}
hoveredToolSlot={this.props.designer.hoveredToolSlot}
botPositionX={this.props.botLocationData.position.x}
slots={this.props.toolSlots} />
FarmBotLayer = () => <FarmBotLayer

View File

@ -5,6 +5,7 @@ import {
import { BotOriginQuadrant } from "../../../../interfaces";
import { Color } from "../../../../../ui";
import { svgMount } from "../../../../../__test_support__/svg_mount";
import { Actions } from "../../../../../constants";
describe("<ToolbaySlot />", () => {
const fakeProps = (): ToolSlotGraphicProps => ({
@ -61,7 +62,8 @@ describe("<Tool/>", () => {
x: 10,
y: 20,
hovered: false,
setHoverState: jest.fn(),
dispatch: jest.fn(),
uuid: "fakeUuid",
xySwap: false,
});
@ -75,9 +77,13 @@ describe("<Tool/>", () => {
p.tool = toolName;
const wrapper = svgMount(<Tool {...p} />);
wrapper.find("g").simulate("mouseOver");
expect(p.toolProps.setHoverState).toHaveBeenCalledWith(true);
expect(p.toolProps.dispatch).toHaveBeenCalledWith({
type: Actions.HOVER_TOOL_SLOT, payload: "fakeUuid"
});
wrapper.find("g").simulate("mouseLeave");
expect(p.toolProps.setHoverState).toHaveBeenCalledWith(false);
expect(p.toolProps.dispatch).toHaveBeenCalledWith({
type: Actions.HOVER_TOOL_SLOT, payload: undefined
});
};
it("renders standard tool styling", () => {

View File

@ -14,6 +14,7 @@ import { shallow } from "enzyme";
import { history } from "../../../../../history";
import { ToolSlotPointer } from "farmbot/dist/resources/api_resources";
import { TaggedToolSlotPointer } from "farmbot";
import { ToolSlotPoint } from "../tool_slot_point";
describe("<ToolSlotLayer/>", () => {
function fakeProps(): ToolSlotLayerProps {
@ -35,18 +36,20 @@ describe("<ToolSlotLayer/>", () => {
slots: [{ toolSlot, tool: undefined }],
botPositionX: undefined,
mapTransformProps: fakeMapTransformProps(),
dispatch: jest.fn(),
hoveredToolSlot: undefined,
};
}
it("toggles visibility off", () => {
const result = shallow(<ToolSlotLayer {...fakeProps()} />);
expect(result.find("ToolSlotPoint").length).toEqual(0);
expect(result.find(ToolSlotPoint).length).toEqual(0);
});
it("toggles visibility on", () => {
const p = fakeProps();
p.visible = true;
const result = shallow(<ToolSlotLayer {...p} />);
expect(result.find("ToolSlotPoint").length).toEqual(1);
expect(result.find(ToolSlotPoint).length).toEqual(1);
});
it("navigates to tools page", async () => {

View File

@ -1,3 +1,10 @@
let mockDev = false;
jest.mock("../../../../../account/dev/dev_support", () => ({
DevSettings: { futureFeaturesEnabled: () => mockDev, }
}));
jest.mock("../../../../../history", () => ({ history: { push: jest.fn() } }));
import * as React from "react";
import { ToolSlotPoint, TSPProps } from "../tool_slot_point";
import {
@ -7,12 +14,19 @@ import {
fakeMapTransformProps
} from "../../../../../__test_support__/map_transform_props";
import { svgMount } from "../../../../../__test_support__/svg_mount";
import { history } from "../../../../../history";
describe("<ToolSlotPoint/>", () => {
beforeEach(() => {
mockDev = false;
});
const fakeProps = (): TSPProps => ({
mapTransformProps: fakeMapTransformProps(),
botPositionX: undefined,
slot: { toolSlot: fakeToolSlot(), tool: fakeTool() }
slot: { toolSlot: fakeToolSlot(), tool: fakeTool() },
dispatch: jest.fn(),
hoveredToolSlot: undefined,
});
const testToolSlotGraphics = (tool: 0 | 1, slot: 0 | 1) => {
@ -31,11 +45,23 @@ describe("<ToolSlotPoint/>", () => {
testToolSlotGraphics(1, 0);
testToolSlotGraphics(1, 1);
it("opens tool info", () => {
const p = fakeProps();
p.slot.toolSlot.body.id = 1;
const wrapper = svgMount(<ToolSlotPoint {...p} />);
mockDev = false;
wrapper.find("g").first().simulate("click");
expect(history.push).not.toHaveBeenCalled();
mockDev = true;
wrapper.find("g").first().simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/tool-slots/1");
});
it("displays tool name", () => {
const p = fakeProps();
p.slot.toolSlot.body.pullout_direction = 2;
p.hoveredToolSlot = p.slot.toolSlot.uuid;
const wrapper = svgMount(<ToolSlotPoint {...p} />);
wrapper.find(ToolSlotPoint).setState({ hovered: true });
expect(wrapper.find("text").props().visibility).toEqual("visible");
expect(wrapper.find("text").text()).toEqual("Foo");
expect(wrapper.find("text").props().dx).toEqual(-40);
@ -44,8 +70,8 @@ describe("<ToolSlotPoint/>", () => {
it("displays 'no tool'", () => {
const p = fakeProps();
p.slot.tool = undefined;
p.hoveredToolSlot = p.slot.toolSlot.uuid;
const wrapper = svgMount(<ToolSlotPoint {...p} />);
wrapper.find(ToolSlotPoint).setState({ hovered: true });
expect(wrapper.find("text").text()).toEqual("no tool");
expect(wrapper.find("text").props().dx).toEqual(40);
});
@ -74,13 +100,21 @@ describe("<ToolSlotPoint/>", () => {
p.slot.toolSlot.body.gantry_mounted = true;
if (p.slot.tool) { p.slot.tool.body.name = "seed trough"; }
const wrapper = svgMount(<ToolSlotPoint {...p} />);
expect(wrapper.find("#seed-trough").length).toEqual(1);
expect(wrapper.find("#seed-trough").find("rect").props().width)
.toEqual(45);
expect(wrapper.find("#gantry-toolbay-slot").find("rect").props().width)
.toEqual(49);
});
it("sets hover", () => {
const wrapper = svgMount(<ToolSlotPoint {...fakeProps()} />);
expect(wrapper.find(ToolSlotPoint).state().hovered).toBeFalsy();
(wrapper.find(ToolSlotPoint).instance() as ToolSlotPoint).setHover(true);
expect(wrapper.find(ToolSlotPoint).state().hovered).toBeTruthy();
it("renders rotated trough", () => {
const p = fakeProps();
p.mapTransformProps.xySwap = true;
p.slot.toolSlot.body.gantry_mounted = true;
if (p.slot.tool) { p.slot.tool.body.name = "seed trough"; }
const wrapper = svgMount(<ToolSlotPoint {...p} />);
expect(wrapper.find("#seed-trough").find("rect").props().width)
.toEqual(20);
expect(wrapper.find("#gantry-toolbay-slot").find("rect").props().width)
.toEqual(24);
});
});

View File

@ -3,13 +3,16 @@ import { Color } from "../../../../ui/index";
import { trim } from "../../../../util";
import { BotOriginQuadrant } from "../../../interfaces";
import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources";
import { Actions } from "../../../../constants";
import { UUID } from "../../../../resources/interfaces";
export interface ToolGraphicProps {
x: number;
y: number;
hovered: boolean;
setHoverState(hoverState: boolean): void;
dispatch: Function;
xySwap: boolean;
uuid: UUID | undefined;
}
export interface ToolProps {
@ -95,11 +98,14 @@ export const Tool = (props: ToolProps) => {
}
};
export const setToolHover = (payload: string | undefined) =>
({ type: Actions.HOVER_TOOL_SLOT, payload });
const StandardTool = (props: ToolGraphicProps) => {
const { x, y, hovered, setHoverState } = props;
const { x, y, hovered, dispatch, uuid } = props;
return <g id={"tool"}
onMouseOver={() => setHoverState(true)}
onMouseLeave={() => setHoverState(false)}>
onMouseOver={() => dispatch(setToolHover(uuid))}
onMouseLeave={() => dispatch(setToolHover(undefined))}>
<circle
cx={x}
cy={y}
@ -116,10 +122,10 @@ const seedBinGradient =
</radialGradient>;
const SeedBin = (props: ToolGraphicProps) => {
const { x, y, hovered, setHoverState } = props;
const { x, y, hovered, dispatch, uuid } = props;
return <g id={"seed-bin"}
onMouseOver={() => setHoverState(true)}
onMouseLeave={() => setHoverState(false)}>
onMouseOver={() => dispatch(setToolHover(uuid))}
onMouseLeave={() => dispatch(setToolHover(undefined))}>
<defs>
{seedBinGradient}
@ -140,10 +146,10 @@ const SeedBin = (props: ToolGraphicProps) => {
};
const SeedTray = (props: ToolGraphicProps) => {
const { x, y, hovered, setHoverState } = props;
const { x, y, hovered, dispatch, uuid } = props;
return <g id={"seed-tray"}
onMouseOver={() => setHoverState(true)}
onMouseLeave={() => setHoverState(false)}>
onMouseOver={() => dispatch(setToolHover(uuid))}
onMouseLeave={() => dispatch(setToolHover(undefined))}>
<defs>
{seedBinGradient}
@ -188,12 +194,12 @@ export const GantryToolSlot = (props: GantryToolSlotGraphicProps) => {
};
const SeedTrough = (props: ToolGraphicProps) => {
const { x, y, hovered, setHoverState, xySwap } = props;
const { x, y, hovered, dispatch, uuid, xySwap } = props;
const slotLengthX = xySwap ? 20 : 45;
const slotLengthY = xySwap ? 45 : 20;
return <g id={"seed-trough"}
onMouseOver={() => setHoverState(true)}
onMouseLeave={() => setHoverState(false)}>
onMouseOver={() => dispatch(setToolHover(uuid))}
onMouseLeave={() => dispatch(setToolHover(undefined))}>
<rect
x={x - slotLengthX / 2} y={y - slotLengthY / 2}
width={slotLengthX} height={slotLengthY}

View File

@ -1,21 +1,25 @@
import * as React from "react";
import { SlotWithTool } from "../../../../resources/interfaces";
import { SlotWithTool, UUID } from "../../../../resources/interfaces";
import { ToolSlotPoint } from "./tool_slot_point";
import { MapTransformProps } from "../../interfaces";
import { history, getPathArray } from "../../../../history";
import { maybeNoPointer } from "../../util";
import { DevSettings } from "../../../../account/dev/dev_support";
export interface ToolSlotLayerProps {
visible: boolean;
slots: SlotWithTool[];
botPositionX: number | undefined;
mapTransformProps: MapTransformProps;
dispatch: Function;
hoveredToolSlot: UUID | undefined;
}
export function ToolSlotLayer(props: ToolSlotLayerProps) {
const pathArray = getPathArray();
const canClickTool = !(pathArray[3] === "plants" && pathArray.length > 4);
const goToToolsPage = () => canClickTool && history.push("/app/tools");
const goToToolsPage = () => canClickTool &&
!DevSettings.futureFeaturesEnabled() && history.push("/app/tools");
const { slots, visible, mapTransformProps } = props;
const cursor = canClickTool ? "pointer" : "default";
@ -28,6 +32,8 @@ export function ToolSlotLayer(props: ToolSlotLayerProps) {
<ToolSlotPoint
key={slot.toolSlot.uuid}
slot={slot}
hoveredToolSlot={props.hoveredToolSlot}
dispatch={props.dispatch}
botPositionX={props.botPositionX}
mapTransformProps={mapTransformProps} />)}
</g>;

View File

@ -1,78 +1,73 @@
import * as React from "react";
import { SlotWithTool } from "../../../../resources/interfaces";
import { SlotWithTool, UUID } from "../../../../resources/interfaces";
import { transformXY } from "../../util";
import { MapTransformProps } from "../../interfaces";
import { ToolbaySlot, ToolNames, Tool, GantryToolSlot } from "./tool_graphics";
import { ToolLabel } from "./tool_label";
import { includes } from "lodash";
import { DevSettings } from "../../../../account/dev/dev_support";
import { history } from "../../../../history";
export interface TSPProps {
slot: SlotWithTool;
botPositionX: number | undefined;
mapTransformProps: MapTransformProps;
dispatch: Function;
hoveredToolSlot: UUID | undefined;
}
interface TSPState {
hovered: boolean;
}
const reduceToolName = (raw: string | undefined) => {
const lower = (raw || "").toLowerCase();
if (includes(lower, "seed bin")) { return ToolNames.seedBin; }
if (includes(lower, "seed tray")) { return ToolNames.seedTray; }
if (includes(lower, "seed trough")) { return ToolNames.seedTrough; }
return ToolNames.tool;
};
export class ToolSlotPoint extends
React.Component<TSPProps, Partial<TSPState>> {
state: TSPState = { hovered: false };
setHover = (state: boolean) => this.setState({ hovered: state });
get slot() { return this.props.slot; }
reduceToolName = (raw: string | undefined) => {
const lower = (raw || "").toLowerCase();
if (includes(lower, "seed bin")) { return ToolNames.seedBin; }
if (includes(lower, "seed tray")) { return ToolNames.seedTray; }
if (includes(lower, "seed trough")) { return ToolNames.seedTrough; }
return ToolNames.tool;
}
render() {
const {
id, x, y, pullout_direction, gantry_mounted
} = this.slot.toolSlot.body;
const { mapTransformProps, botPositionX } = this.props;
const { quadrant, xySwap } = mapTransformProps;
const xPosition = gantry_mounted ? (botPositionX || 0) : x;
const { qx, qy } = transformXY(xPosition, y, this.props.mapTransformProps);
const toolName = this.slot.tool ? this.slot.tool.body.name : "no tool";
const toolProps = {
x: qx,
y: qy,
hovered: this.state.hovered,
setHoverState: this.setHover,
xySwap,
};
return <g id={"toolslot-" + id}>
{pullout_direction &&
<ToolbaySlot
id={id}
x={qx}
y={qy}
pulloutDirection={pullout_direction}
quadrant={quadrant}
xySwap={xySwap} />}
{gantry_mounted && <GantryToolSlot x={qx} y={qy} xySwap={xySwap} />}
{(this.slot.tool || (!pullout_direction && !gantry_mounted)) &&
<Tool
tool={this.reduceToolName(toolName)}
toolProps={toolProps} />}
<ToolLabel
toolName={toolName}
hovered={this.state.hovered}
export const ToolSlotPoint = (props: TSPProps) => {
const {
id, x, y, pullout_direction, gantry_mounted
} = props.slot.toolSlot.body;
const { mapTransformProps, botPositionX } = props;
const { quadrant, xySwap } = mapTransformProps;
const xPosition = gantry_mounted ? (botPositionX || 0) : x;
const { qx, qy } = transformXY(xPosition, y, props.mapTransformProps);
const toolName = props.slot.tool ? props.slot.tool.body.name : "no tool";
const hovered = props.slot.toolSlot.uuid === props.hoveredToolSlot;
const toolProps = {
x: qx,
y: qy,
hovered,
dispatch: props.dispatch,
uuid: props.slot.toolSlot.uuid,
xySwap,
};
return <g id={"toolslot-" + id}
onClick={() => DevSettings.futureFeaturesEnabled() &&
history.push(`/app/designer/tool-slots/${id}`)}>
{pullout_direction &&
<ToolbaySlot
id={id}
x={qx}
y={qy}
pulloutDirection={pullout_direction}
quadrant={quadrant}
xySwap={xySwap} />
</g>;
}
}
xySwap={xySwap} />}
{gantry_mounted && <GantryToolSlot x={qx} y={qy} xySwap={xySwap} />}
{(props.slot.tool || (!pullout_direction && !gantry_mounted)) &&
<Tool
tool={reduceToolName(toolName)}
toolProps={toolProps} />}
<ToolLabel
toolName={toolName}
hovered={hovered}
x={qx}
y={qy}
pulloutDirection={pullout_direction}
quadrant={quadrant}
xySwap={xySwap} />
</g>;
};

View File

@ -68,8 +68,8 @@ export namespace OpenFarm {
attributes: ImageAttrs;
}
export let cropUrl = "https://openfarm.cc/api/v1/crops";
export let browsingCropUrl = "https://openfarm.cc/crops/";
export const cropUrl = "https://openfarm.cc/api/v1/crops";
export const browsingCropUrl = "https://openfarm.cc/crops/";
}
/** Returned by https://openfarm.cc/api/v1/crops?filter=q */
export interface CropSearchResult {

View File

@ -47,8 +47,7 @@ export class RawPlantInfo extends React.Component<EditPlantInfoProps, {}> {
panel={Panel.Plants}
title={`${t("Edit")} ${info.name}`}
backTo={"/app/designer/plants"}
onBack={unselectPlant(this.props.dispatch)}>
</DesignerPanelHeader>
onBack={unselectPlant(this.props.dispatch)} />
<PlantPanel
info={info}
onDestroy={this.destroy}

View File

@ -76,8 +76,7 @@ export class GroupDetailActive
panelName={Panel.Groups}
panel={Panel.Groups}
title={t("Edit Group")}
backTo={"/app/designer/groups"}>
</DesignerPanelHeader>
backTo={"/app/designer/groups"} />
<DesignerPanelContent
panelName={"groups"}>
<label>{t("GROUP NAME")}{this.saved ? "" : "*"}</label>

Some files were not shown because too many files have changed in this diff Show More