Compare commits

...

59 Commits

Author SHA1 Message Date
server 53bc33fc3e Example Debian Buster install script 2020-05-08 15:34:21 -06:00
Rick Carlino 1a7ee04d0b
Merge pull request #1778 from FarmBot/dep_updates
FBJS updates
2020-05-08 16:15:39 -05:00
gabrielburnworth 4a7a683ba7 dep updates (fe) 2020-05-08 13:27:15 -07:00
Rick Carlino 8700d50c81
Merge pull request #1777 from FarmBot/plant_z
Add plant z input
2020-05-07 15:39:35 -05:00
gabrielburnworth 3bab5694b8 add plant z input 2020-05-07 12:15:49 -07:00
Rick Carlino e205214ba4
Merge pull request #1776 from FarmBot/dep_updates
Dependency upgrades
2020-05-07 08:24:47 -05:00
gabrielburnworth 73e9daed05 dep updates (fe) 2020-05-06 15:56:04 -07:00
gabrielburnworth 426f97ddc2 minor step changes 2020-05-06 15:55:59 -07:00
Rick Carlino 88e526cce3
Merge pull request #1775 from FarmBot/point_meta_updates
Merge meta attrs, dont overwrite
2020-05-06 16:40:08 -05:00
Rick Carlino 9e14c2125d
Merge branch 'staging' into point_meta_updates 2020-05-06 15:10:17 -05:00
Rick Carlino 889c78c77a Merge meta attrs, dont overwrite 2020-05-06 15:07:27 -05:00
Rick Carlino 3b1dbe2209
Merge pull request #1774 from FarmBot/mark_as
New Mark As UI
2020-05-01 14:29:18 -05:00
gabrielburnworth 980d39f70d new update_resource ui 2020-05-01 09:00:21 -07:00
Rick Carlino 461f4c2509
Merge pull request #1772 from FarmBot/shutdown_step
Add shutdown sequence step
2020-04-28 10:28:39 -05:00
gabrielburnworth d2176fd6ea dep updates (fe) 2020-04-28 07:20:21 -07:00
gabrielburnworth c9511593a3 add shutdown sequence command 2020-04-28 07:18:04 -07:00
Rick Carlino 66553d143d
Merge pull request #1771 from FarmBot/planted_at_updates
Override `created_at` value with `planted_at` value when available
2020-04-27 21:50:03 -05:00
Rick Carlino 696350343b Override `created_at` value with `planted_at` value when available 2020-04-27 16:09:12 -05:00
Rick Carlino 1f773c44fc
Merge pull request #1770 from FarmBot/planted_at_updates
Set default `planted_at` value
2020-04-27 15:45:50 -05:00
Rick Carlino 87c22d4a96 Set default `planted_at` value 2020-04-27 15:05:53 -05:00
Rick Carlino 9f35dd9992
Merge pull request #1769 from FarmBot/dep_updates
Dependency upgrades
2020-04-24 11:10:19 -05:00
gabrielburnworth 14bf5216e0 dep updates (fe) 2020-04-24 08:22:24 -07:00
gabrielburnworth 1d196d633a Merge branch 'master' of https://github.com/FarmBot/Farmbot-Web-App into staging 2020-04-24 06:49:58 -07:00
Rick Carlino de607e3e3a
Merge pull request #1768 from FarmBot/master-hotfix/update-fallback
Minor hotfix
2020-04-23 20:07:29 -05:00
gabrielburnworth d931cd1b84 update coverage task 2020-04-23 16:55:52 -07:00
gabrielburnworth b3f93dd678 update fallback 2020-04-23 15:58:33 -07:00
Rick Carlino 7f9ecd450d
Merge pull request #1767 from FarmBot/fe_updates
Misc updates
2020-04-23 15:04:05 -05:00
gabrielburnworth 69462e4b60 weeks refactor 2020-04-23 12:12:04 -07:00
gabrielburnworth d3732aed20 version updates 2020-04-23 12:11:25 -07:00
Rick Carlino 6213028f0f
Merge pull request #1765 from FarmBot/mark_as
Mark As step updates
2020-04-21 20:38:10 -05:00
gabrielburnworth 281813369e resource_update -> update_resource (fe) 2020-04-21 14:39:55 -07:00
Rick Carlino 1014eece5f
Merge pull request #1764 from FarmBot/minor-fixes
Minor fixes
2020-04-20 18:06:23 -05:00
gabrielburnworth 6f484ab2e3 minor fixes 2020-04-20 14:07:40 -07:00
Rick Carlino 0bd6d9a967
Merge pull request #1763 from FarmBot/mark_as
Add weed status for Mark As step
2020-04-20 09:26:28 -05:00
gabrielburnworth 25b2f18c4c add weed status for mark as 2020-04-17 16:13:26 -07:00
Rick Carlino 1556084dbd
Merge branch 'staging' into always_upgrade 2020-04-17 13:22:18 -05:00
Rick Carlino 0571100229 Remind custoemrs to upgrade FBOS before troubleshooting 2020-04-17 13:13:32 -05:00
Rick Carlino d6909f439c
Merge pull request #1760 from FarmBot/mark_as
Delete dump_info node
2020-04-17 11:54:09 -05:00
Rick Carlino 36b5c90b65 Merge remote-tracking branch 'origin/mark_as' into mark_as 2020-04-17 11:09:29 -05:00
Rick Carlino f3ac957485 Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into mark_as 2020-04-17 11:08:31 -05:00
Rick Carlino 6f834517ca More dump_info removal 2020-04-17 11:08:17 -05:00
Rick Carlino 44c3f7dc4e
Merge branch 'staging' into mark_as 2020-04-17 11:01:53 -05:00
Rick Carlino 5bb77c1c14 Delete dump_info node 2020-04-17 10:58:36 -05:00
Rick Carlino 3ee1478a58
Merge pull request #1759 from FarmBot/mark_as
Plant stage updates.
2020-04-17 10:58:02 -05:00
Rick Carlino df9e0ef26b Update PLANT_STAGES 2020-04-17 09:56:53 -05:00
Rick Carlino 0e02ca06ee Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into mark_as 2020-04-16 15:57:29 -05:00
Rick Carlino 643bcb1a37
Merge pull request #1758 from FarmBot/mark_as
Phase 0: Ability to pass variables to MARK AS step
2020-04-16 13:27:52 -05:00
Rick Carlino 88b20a73ea Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into mark_as 2020-04-16 13:26:29 -05:00
Rick Carlino e8a8165635
Merge branch 'staging' into mark_as 2020-04-16 13:04:15 -05:00
Rick Carlino 588d4eb36e
Merge pull request #1757 from FarmBot/settings_updates
Settings and dependency updates
2020-04-16 13:04:03 -05:00
Rick Carlino efea80b593 Deprecate `resource_update`. Add `update_resource`. 2020-04-15 13:46:46 -05:00
gabrielburnworth c75d93f3c4 dep updates (fe) 2020-04-14 16:03:48 -07:00
gabrielburnworth bee1e0e074 settings panel updates 2020-04-14 16:03:38 -07:00
Rick Carlino 4375a935f0
Merge pull request #1756 from FarmBot/recovery_release
v9.3.0 - Jolly Juniper
2020-04-14 09:33:24 -05:00
Rick Carlino 7d5fe7c9f6 Deploy fixes 2020-04-14 09:09:07 -05:00
Rick Carlino e801d53d51 Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into staging 2020-04-14 08:47:43 -05:00
Rick Carlino 046035ab9e
Merge pull request #1753 from FarmBot/staging
v9.2.6 - Jolly Juniper
2020-04-13 17:27:06 -05:00
Rick Carlino 52b481e831 Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into staging 2020-04-13 16:43:16 -05:00
Rick Carlino 1e1b405c32
Merge pull request #1751 from FarmBot/staging
v9.2.6 release
2020-04-12 11:49:39 -05:00
173 changed files with 4197 additions and 1919 deletions

View File

@ -16,9 +16,9 @@ module CeleryScriptSettingsBag
end
PIN_TYPE_MAP = { "Peripheral" => Peripheral,
"Sensor" => Sensor,
"BoxLed3" => BoxLed,
"BoxLed4" => BoxLed }
"Sensor" => Sensor,
"BoxLed3" => BoxLed,
"BoxLed4" => BoxLed }
ALLOWED_AXIS = %w(x y z all)
ALLOWED_ASSERTION_TYPES = %w(abort recover abort_recover continue)
ALLOWED_CHANGES = %w(add remove update)
@ -33,16 +33,16 @@ module CeleryScriptSettingsBag
ALLOWED_POINTER_TYPE = %w(GenericPointer ToolSlot Plant Weed)
ALLOWED_RESOURCE_TYPE = %w(Device Point Plant ToolSlot Weed GenericPointer)
ALLOWED_RPC_NODES = %w(assertion calibrate change_ownership
check_updates dump_info emergency_lock
check_updates emergency_lock
emergency_unlock execute execute_script
factory_reset find_home flash_firmware home
install_farmware install_first_party_farmware _if
move_absolute move_relative power_off read_pin
read_status reboot remove_farmware resource_update
read_status reboot remove_farmware update_resource
send_message set_servo_angle set_user_env sync
take_photo toggle_pin update_farmware wait
write_pin zero)
ALLOWED_SPEC_ACTION = %w(dump_info emergency_lock emergency_unlock power_off
ALLOWED_SPEC_ACTION = %w(emergency_lock emergency_unlock power_off
read_status reboot sync take_photo)
ANY_VARIABLE = %i(tool coordinate point identifier)
BAD_ALLOWED_PIN_MODES = '"%s" is not a valid pin_mode. Allowed values: %s'
@ -73,8 +73,7 @@ module CeleryScriptSettingsBag
ONLY_ONE_COORD = "Move Absolute does not accept a group of locations " \
"as input. Please change your selection to a single" \
" location."
PLANT_STAGES = %w(planned planted harvested sprouted)
RESOURCE_UPDATE_ARGS = [:resource_type, :resource_id, :label, :value]
PLANT_STAGES = %w(planned planted harvested sprouted removed)
SCOPE_DECLARATIONS = [:variable_declaration, :parameter_declaration]
MISC_ENUM_ERR = '"%s" is not valid. Allowed values: %s'
MAX_WAIT_MS = 1000 * 60 * 3 # Three Minutes
@ -82,6 +81,13 @@ module CeleryScriptSettingsBag
"A single wait node cannot exceed #{MAX_WAIT_MS / 1000 / 60} minutes. " +
"Consider lowering the wait time or using multiple WAIT blocks."
Corpus = CeleryScript::Corpus.new
THIS_IS_DEPRECATED = {
args: [:resource_type, :resource_id, :label, :value],
tags: [:function, :api_writer, :network_user],
blk: ->(n) do
n.invalidate!("Deprecated `mark_as` detected. Delete it and re-add")
end,
}
CORPUS_VALUES = {
boolean: [TrueClass, FalseClass],
@ -278,6 +284,9 @@ module CeleryScriptSettingsBag
lua: {
defn: [v(:string)],
},
resource: {
defn: [n(:identifier), n(:resource)],
},
}.map do |(name, conf)|
blk = conf[:blk]
defn = conf.fetch(:defn)
@ -317,10 +326,6 @@ module CeleryScriptSettingsBag
args: [:x, :y, :z],
tags: [:data, :location_like],
},
dump_info: {
tags: [:function, :network_user, :disk_user, :api_writer],
docs: "Sends an info dump to server administrators for troubleshooting.",
},
emergency_lock: {
tags: [:function, :firmware_user, :control_flow],
},
@ -513,15 +518,22 @@ module CeleryScriptSettingsBag
tags: [:function, :firmware_user, :rpi_user],
blk: ->(n) { no_rpi_analog(n) },
},
resource_update: {
args: RESOURCE_UPDATE_ARGS,
tags: [:function, :api_writer, :network_user],
# DEPRECATED- Get rid of this node ASAP -RC 15 APR 2020
resource_update: THIS_IS_DEPRECATED,
resource: {
args: [:resource_type, :resource_id],
tags: [:network_user],
blk: ->(n) do
resource_type = n.args.fetch(:resource_type).value
resource_id = n.args.fetch(:resource_id).value
check_resource_type(n, resource_type, resource_id, Device.current)
end,
},
update_resource: {
args: [:resource],
body: [:pair],
tags: [:function, :api_writer, :network_user],
},
point_group: {
args: [:point_group_id],
tags: [:data, :list_like],
@ -529,7 +541,7 @@ module CeleryScriptSettingsBag
resource_id = n.args.fetch(:point_group_id).value
check_resource_type(n, "PointGroup", resource_id, Device.current)
end,
}
},
}.map { |(name, list)| Corpus.node(name, **list) }
HASH = Corpus.as_json

View File

@ -1,20 +1,18 @@
class PinBinding < ApplicationRecord
OFF_LIMITS = [
2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 19, 21, 23, 24, 25, 27
OFF_LIMITS = [
2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 19, 21, 23, 24, 25, 27,
]
BAD_PIN_NUM = \
"The following pin numbers cannot be used: %s" % OFF_LIMITS.join(", ")
BAD_PIN_NUM = "The following pin numbers cannot be used: %s" % OFF_LIMITS.join(", ")
belongs_to :device
belongs_to :sequence
enum special_action: { dump_info: "dump_info",
emergency_lock: "emergency_lock",
enum special_action: { emergency_lock: "emergency_lock",
emergency_unlock: "emergency_unlock",
power_off: "power_off",
read_status: "read_status",
reboot: "reboot",
sync: "sync",
take_photo: "take_photo" }
power_off: "power_off",
read_status: "read_status",
reboot: "reboot",
sync: "sync",
take_photo: "take_photo" }
validates :pin_num, uniqueness: { scope: :device }
def fancy_name

View File

@ -27,11 +27,19 @@ module Points
end
def execute
Point.transaction { point.update!(inputs.except(:point)) && point }
Point.transaction { point.update!(update_params) && point }
end
private
def merged_meta_fields
@merged_meta_fields ||= (point.meta || {}).merge(meta || {})
end
def update_params
@update_params ||= inputs.except(:point).merge(meta: merged_meta_fields)
end
def new_tool_id?
raw_inputs.key?("tool_id")
end

View File

@ -1,6 +1,24 @@
class BasePointSerializer < ApplicationSerializer
attributes :device_id, :name, :pointer_type, :meta, :x, :y, :z
# PROBLEM:
# * Users need a mutable way to mark a plant's creation time => `planted_at`
# * DB Admin needs to know the _real_ created_at time.
# * We can't change field names (or destroy data) that is in use by legacy devices
#
# SOLUTION:
# * Don't allow users to modify `created_at`
# * Provide `planted_at` if possible.
# * Always provide `planted_at` if it is available
# * Provide a read-only view of `created_at` if `planted_at` is `nil`
def planted_at
object.planted_at || object.created_at
end
def created_at
planted_at
end
def meta
object.meta || {}
end

View File

@ -1,3 +1,3 @@
class WeedSerializer < BasePointSerializer
attributes :radius, :discarded_at
attributes :radius, :discarded_at, :plant_stage
end

90
debian_example.sh 100644
View File

@ -0,0 +1,90 @@
# How to install FarmBot Web API on a Debian Buster (10) Machine
# IMPORTANT NOTE: Resources are limited and Farmbot, inc. cannot provide
# longterm support to self-hosted users. If you have never administered a
# Ruby on Rails application, we highly advise stopping now. this presents an
# extremely high risk of data loss. Free hosting is provided at
# https://my.farm.bot and eliminates the risks and troubles of self-hosting.
#
# You are highly encouraged to use the my.farm.bot servers. Self hosted
# documentation is provided with the assumption that you have experience with
# Ruby/Javascript development.
#
# Self-hosting a Farmbot server is not a simple task.
# Remove old (possibly broke) docker versions
sudo apt-get remove docker docker-engine docker.io
# Install docker
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common gnupg2 --yes
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian buster stable" --yes
sudo apt-get update --yes
sudo apt-get install docker-ce --yes
sudo docker run hello-world # Should run!
# Install docker-compose
sudo curl -L "https://github.com/docker/compose/releases/download/1.22.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# Install FarmBot Web App
# âš  SKIP THIS STEP IF UPGRADING!
git clone https://github.com/FarmBot/Farmbot-Web-App --depth=5 --branch=master
cd Farmbot-Web-App
#snap install micro --classic # Don't like `micro`? vim, nano, etc are fine, too.
cp example.env .env # âš  SKIP THIS STEP IF UPGRADING!
# == This is a very important step!!! ==
#
# Open `.env` in a text editor and change all the values.
#
# == Nothing will work if you skip this step!!! ==
vim .env # âš  SKIP THIS STEP IF UPGRADING!
# ^ This is the most important step
# READ NOTE ABOVE. Very important!
# Install the correct version of bundler for the project
sudo docker-compose run web gem install bundler:2.1.4
# Install application specific Ruby dependencies
sudo docker-compose run web bundle install
# Install application specific Javascript deps
sudo docker-compose run web npm install
# Create a database in PostgreSQL
sudo docker-compose run web bundle exec rails db:create db:migrate
# Generate a set of *.pem files for data encryption
sudo docker-compose run web rake keys:generate # âš  SKIP THIS STEP IF UPGRADING!
# Build the UI assets via ParcelJS
sudo docker-compose run web rake assets:precompile
# Run the server! ٩(^‿^)۶
# NOTE: DONT TRY TO LOGIN until you see a message similar to this:
# "✨ Built in 44.92s"
# THIS MAY TAKE A VERY LONG TIME ON SLOW MACHINES (~3 minutes on DigitalOcean)
# You will just get an empty screen otherwise.
# This only happens during initialization
sudo docker-compose up
# At this point, setup is complete. Content should be visible at ===============
# http://YOUR_HOST:3000/.
# You can optionally verify installation by running unit tests.
# Create the database for the app to use:
sudo docker-compose run -e RAILS_ENV=test web bundle exec rails db:setup
# Run the tests in the "test" RAILS_ENV:
sudo docker-compose run -e RAILS_ENV=test web rspec spec
# Run user-interface unit tests REQUIRES AT LEAST 4 GB OF RAM:
sudo docker-compose run web npm run test
# === BEGIN OPTIONAL UPGRADES
# To update to later versions of FarmBot,
# shut down the server, create a database backup
# and run commands below.
git pull https://github.com/FarmBot/Farmbot-Web-App.git master
sudo docker-compose build
sudo docker-compose run web bundle install # <== âš  UPGRADE USERS ONLY
sudo docker-compose run web npm install # <== âš  UPGRADE USERS ONLY
sudo docker-compose run web rails db:migrate # <== âš  UPGRADE USERS ONLY
# === END OPTIONAL UPGRADES ^

View File

@ -19,4 +19,5 @@ export const fakeDesignerState = (): DesignerState => ({
openedSavedGarden: undefined,
tryGroupSortType: undefined,
editGroupAreaInMap: false,
settingsSearchTerm: "",
});

View File

@ -134,7 +134,6 @@ export function fakeToolSlot(): TaggedToolSlotPointer {
x: 0,
y: 0,
z: 0,
radius: 25,
pointer_type: "ToolSlot",
meta: {},
tool_id: undefined,
@ -181,6 +180,7 @@ export function fakeWeed(): TaggedWeedPointer {
y: 400,
z: 0,
radius: 100,
plant_stage: "planned",
meta: { created_by: "plant-detection", color: "red" }
});
}

View File

@ -7,4 +7,5 @@ jest.mock("../toast/toast", () => ({
error: jest.fn(),
warning: jest.fn(),
busy: jest.fn(),
removeToast: jest.fn(),
}));

View File

@ -265,7 +265,6 @@ const tr11: TaggedPoint = {
"pointer_type": "ToolSlot",
"pullout_direction": 0,
"gantry_mounted": false,
"radius": 25,
"x": 10,
"y": 10,
"z": 10,
@ -329,6 +328,7 @@ const tr16: TaggedPoint = {
},
name: "untitled",
pointer_type: "Weed",
plant_stage: "planned",
radius: 10,
x: 490,
y: 421,

View File

@ -37,7 +37,9 @@ import { getDevice } from "../../../device";
import { talk } from "browser-speech";
import { MessageType } from "../../../sequences/interfaces";
import { FbjsEventName } from "farmbot/dist/constants";
import { info, error, success, warning, fun, busy } from "../../../toast/toast";
import {
info, error, success, warning, fun, busy, removeToast,
} from "../../../toast/toast";
import { onLogs } from "../../log_handlers";
import { fakeState } from "../../../__test_support__/fake_state";
import { globalQueue } from "../../batch_queue";
@ -177,7 +179,8 @@ describe("onOffline", () => {
jest.resetAllMocks();
onOffline();
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(error).toHaveBeenCalledWith(Content.MQTT_DISCONNECTED);
expect(error).toHaveBeenCalledWith(
Content.MQTT_DISCONNECTED, "Error", "red", "offline");
});
});
@ -186,13 +189,17 @@ describe("onOnline", () => {
jest.resetAllMocks();
onOnline();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(removeToast).toHaveBeenCalledWith("offline");
});
});
describe("onReconnect", () => {
onReconnect();
expect(warning).toHaveBeenCalledWith(
"Attempting to reconnect to the message broker", "Offline", "yellow");
describe("onReconnect()", () => {
it("sends reconnect toast", () => {
onReconnect();
expect(warning).toHaveBeenCalledWith(
"Attempting to reconnect to the message broker",
"Offline", "yellow", "offline");
});
});
describe("changeLastClientConnected", () => {
@ -268,7 +275,8 @@ describe("onPublicBroadcast", () => {
console.log = jest.fn();
onPublicBroadcast({});
expectBroadcastLog();
expect(window.alert).toHaveBeenCalledWith(Content.FORCE_REFRESH_CANCEL_WARNING);
expect(window.alert).toHaveBeenCalledWith(
Content.FORCE_REFRESH_CANCEL_WARNING);
expect(location.assign).not.toHaveBeenCalled();
});
});

View File

@ -4,7 +4,9 @@ import { Log } from "farmbot/dist/resources/api_resources";
import { Farmbot, BotStateTree, TaggedResource } from "farmbot";
import { FbjsEventName } from "farmbot/dist/constants";
import { noop } from "lodash";
import { success, error, info, warning, fun, busy } from "../toast/toast";
import {
success, error, info, warning, fun, busy, removeToast,
} from "../toast/toast";
import { HardwareState } from "../devices/interfaces";
import { GetState, ReduxAction } from "../redux/interfaces";
import { Content, Actions } from "../constants";
@ -102,11 +104,6 @@ export function readStatus() {
.then(() => { commandOK(noun); }, commandErr(noun));
}
export const onOffline = () => {
dispatchNetworkDown("user.mqtt", now());
error(t(Content.MQTT_DISCONNECTED));
};
export const changeLastClientConnected = (bot: Farmbot) => () => {
bot.setUserEnv({
"LAST_CLIENT_CONNECTED": JSON.stringify(new Date())
@ -157,14 +154,20 @@ export function onMalformed() {
}
}
export const onOnline =
() => {
success(t("Reconnected to the message broker."), t("Online"));
dispatchNetworkUp("user.mqtt", now());
};
export const onReconnect =
() => warning(t("Attempting to reconnect to the message broker"),
t("Offline"), "yellow");
export const onOnline = () => {
removeToast("offline");
success(t("Reconnected to the message broker."), t("Online"));
dispatchNetworkUp("user.mqtt", now());
};
export const onReconnect = () =>
warning(t("Attempting to reconnect to the message broker"),
t("Offline"), "yellow", "offline");
export const onOffline = () => {
dispatchNetworkDown("user.mqtt", now());
error(t(Content.MQTT_DISCONNECTED), t("Error"), "red", "offline");
};
export function onPublicBroadcast(payl: unknown) {
console.log(FbjsEventName.publicBroadcast, payl);

View File

@ -346,10 +346,7 @@ export namespace ToolTips {
trim(`The Mark As step allows FarmBot to programmatically edit the
properties of the UTM, plants, and weeds from within a sequence.
For example, you can mark a plant as "planted" during a seeding
sequence or delete a weed after removing it.`);
export const REBOOT =
trim(`Power cycle FarmBot's onboard computer.`);
sequence or mark a weed as "removed" after removing it.`);
export const SET_SERVO_ANGLE =
trim(`Move a servo to the provided angle. An angle of 90 degrees
@ -362,6 +359,9 @@ export namespace ToolTips {
export const MOVE_TO_HOME =
trim(`Move FarmBot to home for the provided axis.`);
export const ASSERTION =
trim(`Evaluate Lua commands. For power users and software developers.`);
export const FIRMWARE_ACTION =
trim(`FarmBot OS or micro-controller firmware action.`);
@ -740,6 +740,15 @@ export namespace Content {
encoders, stall detection, or endstops enabled for the chosen axis.
Enable endstops, encoders, or stall detection from the Device page for: `);
export const REBOOT_STEP =
trim(`Power cycle FarmBot's onboard computer.`);
export const SHUTDOWN_STEP =
trim(`Power down FarmBot's onboard computer.`);
export const ESTOP_STEP =
trim(`Unlocking a device requires user intervention.`);
export const IN_USE =
trim(`Used in another resource. Protected from deletion.`);
@ -923,6 +932,8 @@ export namespace TourContent {
}
export enum DeviceSetting {
axisHeadingLabels = ``,
// Homing and calibration
homingAndCalibration = `Homing and Calibration`,
homing = `Homing`,
@ -974,6 +985,11 @@ export enum DeviceSetting {
// Pin Guard
pinGuard = `Pin Guard`,
pinGuard1 = `Pin Guard 1`,
pinGuard2 = `Pin Guard 2`,
pinGuard3 = `Pin Guard 3`,
pinGuard4 = `Pin Guard 4`,
pinGuard5 = `Pin Guard 5`,
// Danger Zone
dangerZone = `Danger Zone`,
@ -981,6 +997,8 @@ export enum DeviceSetting {
// Pin Bindings
pinBindings = `Pin Bindings`,
savedPinBindings = `Saved pin bindings`,
addNewPinBinding = `Add new pin binding`,
// FarmBot OS
farmbot = `FarmBot`,
@ -1131,6 +1149,7 @@ export enum Actions {
SET_DRAWN_WEED_DATA = "SET_DRAWN_WEED_DATA",
CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN",
TRY_SORT_TYPE = "TRY_SORT_TYPE",
SET_SETTINGS_SEARCH_TERM = "SET_SETTINGS_SEARCH_TERM",
EDIT_GROUP_AREA_IN_MAP = "EDIT_GROUP_AREA_IN_MAP",
// Regimens

View File

@ -38,6 +38,7 @@ $pink: #ebb;
$light_red: #e99;
$red: #e66;
$dark_red: #f00;
$medium_dark_red: #c00;
$darkest_red: #900;
$panel_green: #35761b;
$panel_light_green: #f3f9f1;

View File

@ -158,6 +158,17 @@
left: 1rem;
cursor: default !important;
}
.fa-times {
position: absolute;
bottom: 0;
right: 0;
padding: 0.5rem;
color: $darkest_red;
font-size: 1.3rem;
&:hover {
color: $medium_dark_red;
}
}
}
input {
background: transparent;
@ -553,10 +564,10 @@
cursor: pointer;
}
.more-bugs,
.select-mode,
.move-to-mode {
button {
margin-right: 1rem;
}
margin: auto;
margin-top: 1rem;
p {
text-align: center;
padding-top: 2rem;

View File

@ -291,10 +291,19 @@
.panel-action-buttons {
position: absolute;
z-index: 9;
height: 19rem;
height: 16rem;
width: 100%;
background: $panel_medium_light_gray;
padding: 0.5rem;
&.status {
height: 20rem;
}
&.more {
height: 23rem;
}
&.more.status {
height: 26rem;
}
button {
margin: 0.5rem;
float: left;
@ -303,10 +312,12 @@
min-width: -webkit-fill-available;
margin-bottom: 0px;
margin-left: .5rem;
margin-top: 0;
}
.button-row {
float: left;
width: 100%;
margin-bottom: 1rem;
}
.filter-search {
padding-right: 1rem;
@ -323,15 +334,35 @@
line-height: 4.1rem;
}
}
.more {
float: right;
cursor: pointer;
margin-right: 1rem;
line-height: 2.5rem;
p {
display: inline;
font-size: 1.4rem;
margin-right: 1rem;
}
}
}
.panel-content {
padding-top: 19rem;
padding-top: 16rem;
padding-right: 0;
padding-left: 0;
padding-bottom: 5rem;
max-height: calc(100vh - 13rem);
overflow-y: auto;
overflow-x: hidden;
&.status {
padding-top: 20rem;
}
&.more {
padding-top: 23rem;
}
&.more.status {
padding-top: 26rem;
}
}
}
@ -773,19 +804,101 @@
}
}
.no-pad {
padding: 0;
}
.settings-panel-content {
max-height: calc(100vh - 15rem);
overflow-y: auto;
overflow-x: hidden;
margin-top: 5rem;
padding: 0;
margin-top: 6rem;
padding-bottom: 5rem;
button {
margin-top: 1.75rem;
.section {
margin-bottom: 2rem;
}
p {
padding: 0.5rem;
.bulk-expand-controls {
margin-left: 1rem;
}
.row:first-child {
margin-right: 0;
margin-top: 1rem;
}
.row:nth-child(2) {
padding-left: 1.5rem;
padding-right: 3rem;
}
.label-headings {
margin-right: 2rem;
label {
line-height: 1rem;
}
}
.release-notes-wrapper {
float: right !important;
}
.network-not-found-timer {
margin-top: 1rem;
}
.pin-guard-input-row {
.row {
margin-left: -15px;
margin-right: -15px;
padding-left: 0;
padding-right: 1rem;
margin-bottom: 1rem;
}
}
.pin-bindings {
margin-right: 1rem;
.row {
padding-left: 0;
padding-right: 0;
margin-left: 1rem;
margin-right: 0;
margin-top: 1rem;
}
div[class*=col-] {
padding: 0;
padding-right: 1rem;
}
.bindings-list {
margin-left: -5px;
.binding-action {
font-weight: bold;
font-size: 1.2rem;
}
}
.pin-binding-input-rows {
margin-right: 1rem;
margin-left: -15px;
label {
margin-left: 1rem !important;
}
.green {
float: left;
margin-left: 1rem;
}
.row:last-child {
margin-top: 0;
}
}
.stock-pin-bindings-button {
display: inline;
button {
margin: 0;
margin-top: 0.5rem;
}
}
}
.fb-button {
margin-top: 0.5rem;
}
label {
margin: 0 !important;
line-height: 3rem;
}
.bp3-popover-wrapper {
display: inline;
float: none;
}
.map-size-inputs {
.row {
@ -795,6 +908,31 @@
margin-top: 0.5rem;
}
}
.help-icon {
margin-left: 1rem;
}
.all-settings-content {
max-height: calc(100vh - 22rem);
overflow-y: auto;
overflow-x: hidden;
margin-top: 1rem;
padding-left: 1rem;
.expandable-header {
margin-top: 1.5rem;
margin-bottom: 0;
}
.section {
margin-bottom: 0;
}
}
.designer-settings {
max-height: calc(100vh - 14rem);
overflow-y: auto;
overflow-x: hidden;
margin-right: -10px;
padding-right: 1rem;
padding-left: 1rem;
}
.designer-setting {
&.disabled {
input {

View File

@ -1314,6 +1314,50 @@ ul {
}
}
.update-resource-step {
.update-resource-step-resource {
margin-bottom: 1rem;
}
.update-resource-pair {
margin-top: 0;
margin-right: -2rem;
div[class*=col-] {
padding: 0;
padding-right: 2rem;
}
.custom-meta-field {
position: relative;
input {
height: 3rem;
}
.fa-undo {
position: absolute;
top: 0.65rem;
right: 0.5rem;
color: $medium_light_gray;
&:hover {
color: $dark_gray;
}
}
}
.custom-field-warning {
display: inline-block;
margin-top: 0.5rem;
i,
p {
display: inline;
cursor: default !important;
margin-right: 0.5rem;
color: $darkest_red;
}
.did-you-mean {
cursor: pointer !important;
font-weight: bold;
}
}
}
}
.farmware-name-manual-input {
margin-top: 1rem;
}

View File

@ -106,7 +106,7 @@
&.take-photo-step {
background: $brown;
}
&.resource-update-step {
&.update-resource-step {
background: $brown;
}
&.set-servo-angle-step {
@ -136,6 +136,9 @@
&.reboot-step {
background: $brown;
}
&.shutdown-step {
background: $brown;
}
&.unknown-step {
background: $gray;
}
@ -226,7 +229,7 @@
&.take-photo-step a {
color: $dark_brown;
}
&.resource-update-step {
&.update-resource-step {
background: $light_brown;
}
&.set-servo-angle-step {
@ -253,6 +256,9 @@
&.emergency-stop-step {
background: $light_red;
}
&.shutdown-step {
background: $light_brown;
}
&.reboot-step {
background: $light_brown;
}

View File

@ -1,3 +1,5 @@
jest.mock("../../redux/store", () => ({ store: jest.fn() }));
import { botReducer, initialState } from "../reducer";
import { Actions } from "../../constants";
import { ControlPanelState, BotState } from "../interfaces";

View File

@ -1,5 +1,12 @@
jest.mock("../../actions", () => ({
toggleControlPanel: jest.fn(),
bulkToggleControlPanel: jest.fn(),
}));
import { fakeState } from "../../../__test_support__/fake_state";
const mockState = fakeState();
jest.mock("../../../redux/store", () => ({
store: { getState: () => mockState },
}));
import * as React from "react";
@ -9,7 +16,7 @@ import {
} from "../maybe_highlight";
import { DeviceSetting } from "../../../constants";
import { panelState } from "../../../__test_support__/control_panel_state";
import { toggleControlPanel } from "../../actions";
import { toggleControlPanel, bulkToggleControlPanel } from "../../actions";
describe("<Highlight />", () => {
const fakeProps = (): HighlightProps => ({
@ -25,6 +32,24 @@ describe("<Highlight />", () => {
wrapper.instance().componentDidMount();
expect(wrapper.state().className).toEqual("unhighlight");
});
it("doesn't hide: no search term", () => {
mockState.resources.consumers.farm_designer.settingsSearchTerm = "";
const wrapper = mount(<Highlight {...fakeProps()} />);
expect(wrapper.find("div").first().props().hidden).toEqual(false);
});
it("doesn't hide: matches search term", () => {
mockState.resources.consumers.farm_designer.settingsSearchTerm = "motor";
const wrapper = mount(<Highlight {...fakeProps()} />);
expect(wrapper.find("div").first().props().hidden).toEqual(false);
});
it("hides", () => {
mockState.resources.consumers.farm_designer.settingsSearchTerm = "encoder";
const wrapper = mount(<Highlight {...fakeProps()} />);
expect(wrapper.find("div").first().props().hidden).toEqual(true);
});
});
describe("maybeHighlight()", () => {
@ -78,4 +103,11 @@ describe("maybeOpenPanel()", () => {
maybeOpenPanel(panelState())(jest.fn());
expect(toggleControlPanel).not.toHaveBeenCalled();
});
it("closes other panels", () => {
location.search = "?highlight=motors";
maybeOpenPanel(panelState(), true)(jest.fn());
expect(toggleControlPanel).toHaveBeenCalledWith("motors");
expect(bulkToggleControlPanel).toHaveBeenCalledWith(false, true);
});
});

View File

@ -9,11 +9,12 @@ import { settingToggle } from "../../actions";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
import { DeviceSetting } from "../../../constants";
describe("<PinGuardMCUInputGroup/>", () => {
const fakeProps = (): PinGuardMCUInputGroupProps => {
return {
label: "Pin Guard 1",
label: DeviceSetting.pinGuard1,
pinNumKey: "pin_guard_1_pin_nr",
timeoutKey: "pin_guard_1_time_out",
activeStateKey: "pin_guard_1_active_state",

View File

@ -13,11 +13,12 @@ import {
import { TaggedFirmwareConfig } from "farmbot";
import { FBSelect } from "../../../ui";
import { updateMCU } from "../../actions";
import { DeviceSetting } from "../../../constants";
describe("<PinNumberDropdown />", () => {
const fakeProps =
(firmwareConfig?: TaggedFirmwareConfig): PinGuardMCUInputGroupProps => ({
label: "Pin Guard 1",
label: DeviceSetting.pinGuard1,
pinNumKey: "pin_guard_1_pin_nr",
timeoutKey: "pin_guard_1_time_out",
activeStateKey: "pin_guard_1_active_state",

View File

@ -43,16 +43,6 @@ describe("<BoardType/>", () => {
expect(wrapper.text()).toContain("Farmduino");
});
it("sets sending status", () => {
const wrapper = mount<BoardType>(<BoardType {...fakeProps()} />);
expect(wrapper.state().sending).toBeFalsy();
const p = fakeProps();
p.sourceFbosConfig = () => ({ value: true, consistent: false });
wrapper.setProps(p);
wrapper.mount();
expect(wrapper.state().sending).toBeTruthy();
});
it("calls updateConfig", () => {
const p = fakeProps();
const wrapper = mount<BoardType>(<BoardType {...p} />);

View File

@ -11,12 +11,15 @@ import { OsUpdateButton } from "../os_update_button";
import { OsUpdateButtonProps } from "../interfaces";
import { ShouldDisplay } from "../../../interfaces";
import { Content } from "../../../../constants";
import { ConfigurationName } from "farmbot";
const UPDATE_CHANNEL = "update_channel" as ConfigurationName;
describe("<OsUpdateButton/>", () => {
beforeEach(() => {
bot.currentOSVersion = "6.1.6";
bot.hardware.informational_settings.controller_version = "6.1.6";
bot.hardware.configuration.beta_opt_in = false;
(bot.hardware.configuration[UPDATE_CHANNEL] as string) = "stable";
});
const fakeProps = (): OsUpdateButtonProps => ({
@ -33,7 +36,6 @@ describe("<OsUpdateButton/>", () => {
availableVersion: string | undefined;
availableBetaVersion: string | undefined;
availableBetaCommit: string | undefined;
betaOptIn: boolean | undefined;
onBeta: boolean | undefined;
update_available?: boolean | undefined;
shouldDisplay: ShouldDisplay;
@ -46,7 +48,6 @@ describe("<OsUpdateButton/>", () => {
availableVersion: "6.1.6",
availableBetaVersion: undefined,
availableBetaCommit: undefined,
betaOptIn: false,
onBeta: false,
shouldDisplay: () => false,
update_channel: "stable",
@ -104,7 +105,7 @@ describe("<OsUpdateButton/>", () => {
expected: Results) => {
const {
installedVersion, installedCommit, onBeta, update_available,
availableVersion, availableBetaVersion, availableBetaCommit, betaOptIn,
availableVersion, availableBetaVersion, availableBetaCommit,
shouldDisplay, update_channel,
} = testProps;
bot.hardware.informational_settings.controller_version = installedVersion;
@ -115,9 +116,7 @@ describe("<OsUpdateButton/>", () => {
bot.currentOSVersion = availableVersion;
bot.currentBetaOSVersion = availableBetaVersion;
bot.currentBetaOSCommit = availableBetaCommit;
bot.hardware.configuration.beta_opt_in = betaOptIn;
// tslint:disable-next-line:no-any
(bot.hardware.configuration as any).update_channel = update_channel;
(bot.hardware.configuration[UPDATE_CHANNEL] as string) = update_channel;
const p = fakeProps();
p.shouldDisplay = shouldDisplay;
@ -156,7 +155,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.6";
testProps.availableVersion = undefined;
testProps.betaOptIn = true;
testProps.update_channel = "beta";
const expectedResults = cantConnect("release server");
testButtonState(testProps, expectedResults);
});
@ -166,7 +165,7 @@ describe("<OsUpdateButton/>", () => {
testProps.installedVersion = "6.1.6";
testProps.availableVersion = undefined;
testProps.availableBetaVersion = "6.1.7-beta";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
const expectedResults = updateNeeded("6.1.7-beta");
testButtonState(testProps, expectedResults);
});
@ -175,7 +174,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.6";
testProps.availableBetaVersion = undefined;
testProps.betaOptIn = true;
testProps.update_channel = "beta";
const expectedResults = upToDate("6.1.6");
testButtonState(testProps, expectedResults);
});
@ -205,7 +204,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.5";
testProps.availableBetaVersion = "7.0.0-beta";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
const expectedResults = updateNeeded("7.0.0-beta");
testButtonState(testProps, expectedResults);
});
@ -214,7 +213,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.6";
testProps.availableBetaVersion = "6.1.6-beta";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
const expectedResults = upToDate("6.1.6");
testButtonState(testProps, expectedResults);
});
@ -223,7 +222,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.6";
testProps.availableBetaVersion = "6.1.6-beta";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
testProps.onBeta = true;
const expectedResults = updateNeeded("6.1.6");
testButtonState(testProps, expectedResults);
@ -233,7 +232,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.6";
testProps.availableBetaVersion = "6.1.6-beta";
testProps.betaOptIn = false;
testProps.update_channel = "stable";
testProps.onBeta = true;
const expectedResults = updateNeeded("6.1.6");
testButtonState(testProps, expectedResults);
@ -243,7 +242,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.7";
testProps.availableBetaVersion = "6.1.7-beta";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
testProps.onBeta = true;
const expectedResults = upToDate("6.1.7-beta");
testButtonState(testProps, expectedResults);
@ -253,7 +252,7 @@ describe("<OsUpdateButton/>", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.7-beta";
testProps.availableBetaVersion = "6.1.7-beta";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
const expectedResults = upToDate("6.1.7-beta");
testButtonState(testProps, expectedResults);
});
@ -264,7 +263,7 @@ describe("<OsUpdateButton/>", () => {
testProps.installedCommit = "old commit";
testProps.availableBetaVersion = "7.0.0-beta";
testProps.availableBetaCommit = "new commit";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
testProps.onBeta = true;
const expectedResults = updateNeeded("7.0.0-beta");
testButtonState(testProps, expectedResults);
@ -273,7 +272,7 @@ describe("<OsUpdateButton/>", () => {
it("handles installed version newer than available (beta enabled)", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.7";
testProps.betaOptIn = true;
testProps.update_channel = "beta";
testProps.onBeta = false;
testProps.availableBetaVersion = "6.1.7-beta";
const expectedResults = upToDate("6.1.7-beta");
@ -308,16 +307,6 @@ describe("<OsUpdateButton/>", () => {
testButtonState(testProps, expectedResults);
});
it("doesn't use update_channel value", () => {
const testProps = defaultTestProps();
testProps.installedVersion = "6.1.6";
testProps.shouldDisplay = () => false;
testProps.update_channel = "beta";
testProps.availableBetaVersion = "6.1.7-beta";
const expectedResults = upToDate("6.1.6");
testButtonState(testProps, expectedResults);
});
it("compares release candidates: newer", () => {
const testProps = defaultTestProps();
testProps.availableVersion = "6.1.5";

View File

@ -13,17 +13,7 @@ import { Highlight } from "../maybe_highlight";
import { DeviceSetting } from "../../../constants";
import { DevSettings } from "../../../account/dev/dev_support";
interface BoardTypeState { sending: boolean }
export class BoardType extends React.Component<BoardTypeProps, BoardTypeState> {
state = {
sending: this.sending
};
UNSAFE_componentWillReceiveProps() {
this.setState({ sending: this.sending });
}
export class BoardType extends React.Component<BoardTypeProps, {}> {
get sending() {
return !this.props.sourceFbosConfig("firmware_hardware").consistent;
}
@ -39,15 +29,14 @@ export class BoardType extends React.Component<BoardTypeProps, BoardTypeState> {
if (selectedItem && isFwHardwareValue(firmware_hardware)) {
info(t("Sending firmware configuration..."), t("Sending"));
this.props.dispatch(updateConfig({ firmware_hardware }));
this.setState({ sending: true });
this.forceUpdate();
}
}
FirmwareSelection = () =>
<FBSelect
key={this.props.firmwareHardware}
extraClass={this.state.sending ? "dim" : ""}
key={this.props.firmwareHardware + "" + this.sending}
extraClass={this.sending ? "dim" : ""}
list={getFirmwareChoices()}
selectedItem={this.selectedBoard}
onChange={this.sendOffConfig} />

View File

@ -263,7 +263,7 @@ export function FbosDetails(props: FbosDetailsProps) {
soc_temp, wifi_level, uptime, memory_usage, disk_usage, throttled,
wifi_level_percent, cpu_usage, private_ip,
} = props.botInfoSettings;
const { last_ota, last_ota_checkup } = props.deviceAccount.body;
const { last_ota, last_ota_checkup, fbos_version } = props.deviceAccount.body;
const infoFwCommit = firmware_version?.includes(".") ? firmware_commit : "---";
const firmwareCommit = firmware_version?.split("-")[1] || infoFwCommit;
@ -273,6 +273,7 @@ export function FbosDetails(props: FbosDetailsProps) {
botToMqttLastSeen={props.botToMqttLastSeen}
timeSettings={props.timeSettings}
device={props.deviceAccount} />
<p><b>{t("Version last seen")}: </b>{fbos_version}</p>
<p><b>{t("Environment")}: </b>{env}</p>
<CommitDisplay title={t("Commit")}
repo={FarmBotRepo.FarmBotOS} commit={commit} />

View File

@ -4,7 +4,7 @@ import { SemverResult, semverCompare } from "../../../util";
import { OsUpdateButtonProps } from "./interfaces";
import { checkControllerUpdates } from "../../actions";
import { isString } from "lodash";
import { BotState, Feature } from "../../interfaces";
import { BotState } from "../../interfaces";
import { Content } from "../../../constants";
import { t } from "../../../i18next_wrapper";
@ -154,9 +154,8 @@ export const OsUpdateButton = (props: OsUpdateButtonProps) => {
const { controller_version } = bot.hardware.informational_settings;
/** FBOS beta release opt-in setting. */
const betaOptIn = props.shouldDisplay(Feature.use_update_channel)
? sourceFbosConfig("update_channel" as ConfigurationName).value !== "stable"
: !!sourceFbosConfig("beta_opt_in").value;
const betaOptIn =
sourceFbosConfig("update_channel" as ConfigurationName).value !== "stable";
/** FBOS update availability. */
const buttonStatusProps = buttonVersionStatus({ bot, betaOptIn });

View File

@ -24,7 +24,7 @@ export function DangerZone(props: DangerZoneProps) {
<Highlight settingName={DeviceSetting.resetHardwareParams}>
<Row>
<Col xs={newFormat ? 8 : 4}>
<label>
<label style={{ lineHeight: "1.5rem" }}>
{t(DeviceSetting.resetHardwareParams)}
</label>
</Col>

View File

@ -44,7 +44,7 @@ export function PinGuard(props: PinGuardProps) {
</Col>
</Row>}
<PinGuardMCUInputGroup
label={t("Pin Guard {{ num }}", { num: 1 })}
label={DeviceSetting.pinGuard1}
pinNumKey={"pin_guard_1_pin_nr"}
timeoutKey={"pin_guard_1_time_out"}
activeStateKey={"pin_guard_1_active_state"}
@ -52,7 +52,7 @@ export function PinGuard(props: PinGuardProps) {
resources={resources}
sourceFwConfig={sourceFwConfig} />
<PinGuardMCUInputGroup
label={t("Pin Guard {{ num }}", { num: 2 })}
label={DeviceSetting.pinGuard2}
pinNumKey={"pin_guard_2_pin_nr"}
timeoutKey={"pin_guard_2_time_out"}
activeStateKey={"pin_guard_2_active_state"}
@ -60,7 +60,7 @@ export function PinGuard(props: PinGuardProps) {
resources={resources}
sourceFwConfig={sourceFwConfig} />
<PinGuardMCUInputGroup
label={t("Pin Guard {{ num }}", { num: 3 })}
label={DeviceSetting.pinGuard3}
pinNumKey={"pin_guard_3_pin_nr"}
timeoutKey={"pin_guard_3_time_out"}
activeStateKey={"pin_guard_3_active_state"}
@ -68,7 +68,7 @@ export function PinGuard(props: PinGuardProps) {
resources={resources}
sourceFwConfig={sourceFwConfig} />
<PinGuardMCUInputGroup
label={t("Pin Guard {{ num }}", { num: 4 })}
label={DeviceSetting.pinGuard4}
pinNumKey={"pin_guard_4_pin_nr"}
timeoutKey={"pin_guard_4_time_out"}
activeStateKey={"pin_guard_4_active_state"}
@ -76,7 +76,7 @@ export function PinGuard(props: PinGuardProps) {
resources={resources}
sourceFwConfig={sourceFwConfig} />
<PinGuardMCUInputGroup
label={t("Pin Guard {{ num }}", { num: 5 })}
label={DeviceSetting.pinGuard5}
pinNumKey={"pin_guard_5_pin_nr"}
timeoutKey={"pin_guard_5_time_out"}
activeStateKey={"pin_guard_5_active_state"}

View File

@ -2,28 +2,32 @@ import * as React from "react";
import { Row, Col } from "../../../ui/index";
import { t } from "../../../i18next_wrapper";
import { DevSettings } from "../../../account/dev/dev_support";
import { Highlight } from "../maybe_highlight";
import { DeviceSetting } from "../../../constants";
export function SpacePanelHeader() {
const newFormat = DevSettings.futureFeaturesEnabled();
const width = newFormat ? 4 : 2;
const offset = newFormat ? 0 : 6;
return <div className="label-headings">
<Row>
<Col xs={width} xsOffset={offset} className={"centered-button-div"}>
<label>
{t("X AXIS")}
</label>
</Col>
<Col xs={width} className={"centered-button-div"}>
<label>
{t("Y AXIS")}
</label>
</Col>
<Col xs={width} className={"centered-button-div"}>
<label>
{t("Z AXIS")}
</label>
</Col>
</Row>
<Highlight settingName={DeviceSetting.axisHeadingLabels}>
<Row>
<Col xs={width} xsOffset={offset} className={"centered-button-div"}>
<label>
{t("X AXIS")}
</label>
</Col>
<Col xs={width} className={"centered-button-div"}>
<label>
{t("Y AXIS")}
</label>
</Col>
<Col xs={width} className={"centered-button-div"}>
<label>
{t("Z AXIS")}
</label>
</Col>
</Row>
</Highlight>
</div>;
}

View File

@ -65,7 +65,7 @@ export interface NumericMCUInputGroupProps {
export interface PinGuardMCUInputGroupProps {
sourceFwConfig: SourceFwConfig;
dispatch: Function;
label: string;
label: DeviceSetting;
pinNumKey: McuParamName;
timeoutKey: McuParamName;
activeStateKey: McuParamName;

View File

@ -1,4 +1,5 @@
import * as React from "react";
import { store } from "../../redux/store";
import { ControlPanelState } from "../interfaces";
import { toggleControlPanel, bulkToggleControlPanel } from "../actions";
import { urlFriendly } from "../../util";
@ -56,6 +57,11 @@ const ERROR_HANDLING_PANEL = [
];
const PIN_GUARD_PANEL = [
DeviceSetting.pinGuard,
DeviceSetting.pinGuard1,
DeviceSetting.pinGuard2,
DeviceSetting.pinGuard3,
DeviceSetting.pinGuard4,
DeviceSetting.pinGuard5,
];
const DANGER_ZONE_PANEL = [
DeviceSetting.dangerZone,
@ -63,6 +69,8 @@ const DANGER_ZONE_PANEL = [
];
const PIN_BINDINGS_PANEL = [
DeviceSetting.pinBindings,
DeviceSetting.savedPinBindings,
DeviceSetting.addNewPinBinding,
];
const POWER_AND_RESET_PANEL = [
DeviceSetting.powerAndReset,
@ -183,6 +191,7 @@ export interface HighlightProps {
| (React.ReactChild | false)[]
| (React.ReactChild | React.ReactChild[])[];
className?: string;
searchTerm?: string;
}
interface HighlightState {
@ -200,11 +209,19 @@ export class Highlight extends React.Component<HighlightProps, HighlightState> {
}
}
get searchTerm() {
const { resources } = store.getState();
return resources.consumers.farm_designer.settingsSearchTerm;
}
render() {
const show = !this.searchTerm ||
this.props.settingName.toLowerCase().includes(this.searchTerm);
return <div className={[
this.props.className,
this.state.className,
].join(" ")}>
].join(" ")}
hidden={!show}>
{this.props.children}
</div>;
}

View File

@ -10,6 +10,7 @@ import { PinNumberDropdown } from "./pin_number_dropdown";
import { DevSettings } from "../../account/dev/dev_support";
import { ToolTips } from "../../constants";
import { Position } from "@blueprintjs/core";
import { Highlight } from "./maybe_highlight";
export class PinGuardMCUInputGroup
extends React.Component<PinGuardMCUInputGroupProps> {
@ -50,7 +51,7 @@ export class PinGuardMCUInputGroup
? <Row>
<Col xs={3}>
<label>
{label}
{t(label)}
</label>
</Col>
<Col xs={3}>
@ -63,46 +64,48 @@ export class PinGuardMCUInputGroup
<this.State />
</Col>
</Row>
: <div className={"pin-guard-input-row"}>
<Row>
<Col xs={12}>
<label>
{label}
</label>
</Col>
</Row>
<Row>
<Col xs={5} xsOffset={1} className="no-pad">
<label>
{t("Pin Number")}
</label>
<Help text={ToolTips.PIN_GUARD_PIN_NUMBER}
position={Position.TOP_RIGHT} />
</Col>
<Col xs={5} className="no-pad">
<this.Number />
</Col>
</Row>
<Row>
<Col xs={5} xsOffset={1} className="no-pad">
<label>
{t("Timeout (sec)")}
</label>
</Col>
<Col xs={5} className="no-pad">
<this.Timeout />
</Col>
</Row>
<Row>
<Col xs={5} xsOffset={1} className="no-pad">
<label>
{t("To State")}
</label>
</Col>
<Col xs={5} className="no-pad">
<this.State />
</Col>
</Row>
</div>;
: <Highlight settingName={label}>
<div className={"pin-guard-input-row"}>
<Row>
<Col xs={12}>
<label>
{t(label)}
</label>
</Col>
</Row>
<Row>
<Col xs={5} xsOffset={1} className="no-pad">
<label>
{t("Pin Number")}
</label>
<Help text={ToolTips.PIN_GUARD_PIN_NUMBER}
position={Position.TOP_RIGHT} />
</Col>
<Col xs={5} className="no-pad">
<this.Number />
</Col>
</Row>
<Row>
<Col xs={5} xsOffset={1} className="no-pad">
<label>
{t("Timeout (sec)")}
</label>
</Col>
<Col xs={5} className="no-pad">
<this.Timeout />
</Col>
</Row>
<Row>
<Col xs={5} xsOffset={1} className="no-pad">
<label>
{t("To State")}
</label>
</Col>
<Col xs={5} className="no-pad">
<this.State />
</Col>
</Row>
</div>
</Highlight>;
}
}

View File

@ -41,6 +41,13 @@ export function Diagnosis(props: DiagnosisProps) {
<div className={"saucer-connector last " + diagnosisColor} />
</Col>
<Col xs={10} className={"connectivity-diagnosis"}>
<p className="blinking">
{t("Always")}&nbsp;
<a className="blinking" href="/app/device?highlight=farmbot_os">
<u>{t("upgrade FarmBot OS")}</u>
</a>
&nbsp;{t("before troubleshooting.")}
</p>
<p>
{diagnose(props)}
</p>

View File

@ -81,6 +81,7 @@ export enum Feature {
ota_update_hour = "ota_update_hour",
rpi_led_control = "rpi_led_control",
sensors = "sensors",
update_resource = "update_resource",
use_update_channel = "use_update_channel",
variables = "variables",
}

View File

@ -32,7 +32,6 @@ export const specialActionLabelLookup: { [x: string]: string } = {
export const specialActionList: DropDownItem[] =
Object.values(PinBindingSpecialAction)
.filter(action => action != PinBindingSpecialAction.dump_info)
.map((action: PinBindingSpecialAction) =>
({ label: specialActionLabelLookup[action], value: action }));

View File

@ -25,6 +25,7 @@ import {
} from "farmbot/dist/resources/api_resources";
import { t } from "../../i18next_wrapper";
import { DevSettings } from "../../account/dev/dev_support";
import { DeviceSetting } from "../../constants";
export class PinBindingInputGroup
extends React.Component<PinBindingInputGroupProps, PinBindingInputGroupState> {
@ -129,7 +130,7 @@ export class PinBindingInputGroup
render() {
const newFormat = DevSettings.futureFeaturesEnabled();
return <div className="pin-binding-input-rows">
{newFormat && <Row><label>{t("add new pin binding")}</label></Row>}
{newFormat && <Row><label>{t(DeviceSetting.addNewPinBinding)}</label></Row>}
{newFormat && <this.Number />}
{newFormat && <Row>
<Col xs={5}>

View File

@ -1,6 +1,6 @@
import * as React from "react";
import { Row, Col, Help } from "../../ui";
import { ToolTips } from "../../constants";
import { ToolTips, DeviceSetting } from "../../constants";
import { selectAllPinBindings } from "../../resources/selectors";
import { PinBindingsContentProps, PinBindingListItems } from "./interfaces";
import { PinBindingsList } from "./pin_bindings_list";
@ -17,6 +17,7 @@ import {
} from "farmbot/dist/resources/api_resources";
import { t } from "../../i18next_wrapper";
import { DevSettings } from "../../account/dev/dev_support";
import { Highlight } from "../components/maybe_highlight";
/** Width of UI columns in Pin Bindings widget. */
export enum PinBindingColWidth {
@ -73,32 +74,38 @@ export const PinBindingsContent = (props: PinBindingsContentProps) => {
const pinBindings = apiPinBindings(resources);
const newFormat = DevSettings.futureFeaturesEnabled();
return <div className="pin-bindings">
<Row>
{newFormat && <Help text={ToolTips.PIN_BINDINGS}
position={Position.TOP_RIGHT} />}
<StockPinBindingsButton
dispatch={dispatch} firmwareHardware={firmwareHardware} />
<Popover
position={Position.TOP_RIGHT}
interactionKind={PopoverInteractionKind.HOVER}
portalClassName={"bindings-warning-icon"}
popoverClassName={"help"}>
<i className="fa fa-exclamation-triangle" />
<div className={"pin-binding-warning"}>
{t(ToolTips.PIN_BINDING_WARNING)}
</div>
</Popover>
</Row>
<Highlight settingName={DeviceSetting.pinBindings}>
<Row>
{newFormat && <Help text={ToolTips.PIN_BINDINGS}
position={Position.TOP_RIGHT} />}
<StockPinBindingsButton
dispatch={dispatch} firmwareHardware={firmwareHardware} />
<Popover
position={Position.TOP_RIGHT}
interactionKind={PopoverInteractionKind.HOVER}
portalClassName={"bindings-warning-icon"}
popoverClassName={"help"}>
<i className="fa fa-exclamation-triangle" />
<div className={"pin-binding-warning"}>
{t(ToolTips.PIN_BINDING_WARNING)}
</div>
</Popover>
</Row>
</Highlight>
<div className={"pin-bindings-list-and-input"}>
{!newFormat && <PinBindingsListHeader />}
<PinBindingsList
pinBindings={pinBindings}
dispatch={dispatch}
resources={resources} />
<PinBindingInputGroup
pinBindings={pinBindings}
dispatch={dispatch}
resources={resources} />
<Highlight settingName={DeviceSetting.savedPinBindings}>
<PinBindingsList
pinBindings={pinBindings}
dispatch={dispatch}
resources={resources} />
</Highlight>
<Highlight settingName={DeviceSetting.addNewPinBinding}>
<PinBindingInputGroup
pinBindings={pinBindings}
dispatch={dispatch}
resources={resources} />
</Highlight>
</div>
</div>;
};

View File

@ -15,6 +15,7 @@ import { DevSettings } from "../../account/dev/dev_support";
import {
PinBindingType, PinBindingSpecialAction,
} from "farmbot/dist/resources/api_resources";
import { DeviceSetting } from "../../constants";
export const PinBindingsList = (props: PinBindingsListProps) => {
const { pinBindings, resources, dispatch } = props;
@ -41,7 +42,7 @@ export const PinBindingsList = (props: PinBindingsListProps) => {
const newFormat = DevSettings.futureFeaturesEnabled();
return <div className={"bindings-list"}>
{newFormat && <Row><label>{t("saved pin bindings")}</label></Row>}
{newFormat && <Row><label>{t(DeviceSetting.savedPinBindings)}</label></Row>}
{pinBindings
.sort((a, b) => sortByNameAndPin(a.pin_number, b.pin_number))
.map(x => {

View File

@ -33,7 +33,7 @@ describe("<MoveTo />", () => {
it("moves to location: bot's current z value", () => {
const wrapper = mount(<MoveTo {...fakeProps()} />);
wrapper.find("button").simulate("click");
expect(mockDevice.moveAbsolute).toHaveBeenCalledWith({ x: 1, y: 2, z: 30 });
expect(mockDevice.moveAbsolute).toHaveBeenCalledWith({ x: 1, y: 2, z: 3 });
});
it("goes back", () => {

View File

@ -191,6 +191,16 @@ describe("designer reducer", () => {
expect(newState.tryGroupSortType).toEqual("random");
});
it("sets settings search term", () => {
const state = oldState();
state.settingsSearchTerm = "";
const action: ReduxAction<string> = {
type: Actions.SET_SETTINGS_SEARCH_TERM, payload: "random"
};
const newState = designer(state, action);
expect(newState.settingsSearchTerm).toEqual("random");
});
it("enables edit group area in map mode", () => {
const state = oldState();
state.editGroupAreaInMap = false;

View File

@ -1,70 +0,0 @@
jest.mock("../../config_storage/actions", () => ({
getWebAppConfigValue: jest.fn(x => { x(); return jest.fn(() => true); }),
setWebAppConfigValue: jest.fn(),
}));
import * as React from "react";
import { mount, ReactWrapper } from "enzyme";
import {
RawDesignerSettings as DesignerSettings, DesignerSettingsProps,
mapStateToProps,
} from "../settings";
import { fakeState } from "../../__test_support__/fake_state";
import { BooleanSetting, NumericSetting } from "../../session_keys";
import { setWebAppConfigValue } from "../../config_storage/actions";
const getSetting =
(wrapper: ReactWrapper, position: number, containsString: string) => {
const setting = wrapper.find(".designer-setting").at(position);
expect(setting.text().toLowerCase())
.toContain(containsString.toLowerCase());
return setting;
};
describe("<DesignerSettings />", () => {
const fakeProps = (): DesignerSettingsProps => ({
dispatch: jest.fn(),
getConfigValue: jest.fn(),
});
it("renders settings", () => {
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
expect(wrapper.text()).toContain("size");
const settings = wrapper.find(".designer-setting");
expect(settings.length).toEqual(7);
});
it("renders defaultOn setting", () => {
const p = fakeProps();
p.getConfigValue = () => undefined;
const wrapper = mount(<DesignerSettings {...p} />);
const confirmDeletion = getSetting(wrapper, 6, "confirm plant");
expect(confirmDeletion.find("button").text()).toEqual("on");
});
it("toggles setting", () => {
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
const trailSetting = getSetting(wrapper, 1, "trail");
trailSetting.find("button").simulate("click");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(BooleanSetting.display_trail, true);
});
it("changes origin", () => {
const p = fakeProps();
p.getConfigValue = () => 2;
const wrapper = mount(<DesignerSettings {...p} />);
const originSetting = getSetting(wrapper, 5, "origin");
originSetting.find("div").last().simulate("click");
expect(setWebAppConfigValue).toHaveBeenCalledWith(
NumericSetting.bot_origin_quadrant, 4);
});
});
describe("mapStateToProps()", () => {
it("returns props", () => {
const props = mapStateToProps(fakeState());
const value = props.getConfigValue(BooleanSetting.show_plants);
expect(value).toEqual(true);
});
});

View File

@ -18,8 +18,6 @@ import { generateUuid } from "../../resources/util";
import { DevSettings } from "../../account/dev/dev_support";
describe("mapStateToProps()", () => {
const DISCARDED_AT = "2018-01-01T00:00:00.000Z";
it("hovered plantUUID is undefined", () => {
const state = fakeState();
state.resources.consumers.farm_designer.hoveredPlant = {
@ -55,11 +53,8 @@ describe("mapStateToProps()", () => {
const webAppConfig = fakeWebAppConfig();
(webAppConfig.body as WebAppConfig).show_historic_points = true;
const point1 = fakePoint();
point1.body.discarded_at = undefined;
const point2 = fakePoint();
point2.body.discarded_at = DISCARDED_AT;
const point3 = fakePoint();
point3.body.discarded_at = DISCARDED_AT;
state.resources = buildResourceIndex([
webAppConfig, point1, point2, point3, fakeDevice(),
]);
@ -71,15 +66,12 @@ describe("mapStateToProps()", () => {
const webAppConfig = fakeWebAppConfig();
(webAppConfig.body as WebAppConfig).show_historic_points = false;
const point1 = fakePoint();
point1.body.discarded_at = undefined;
const point2 = fakePoint();
point2.body.discarded_at = DISCARDED_AT;
const point3 = fakePoint();
point3.body.discarded_at = DISCARDED_AT;
state.resources = buildResourceIndex([
webAppConfig, point1, point2, point3, fakeDevice(),
]);
expect(mapStateToProps(state).genericPoints.length).toEqual(1);
expect(mapStateToProps(state).genericPoints.length).toEqual(3);
});
it("returns sensor readings", () => {

View File

@ -545,7 +545,10 @@ describe("<RepeatForm />", () => {
const fakeProps = (): RepeatFormProps => ({
isRegimen: false,
fieldGet: jest.fn(key =>
"" + ({ endDate: "2017-07-26" } as FarmEventViewModel)[key]),
"" + ({
endDate: "2017-07-26", endTime: "08:57",
startDate: "2017-07-25", startTime: "08:57"
} as FarmEventViewModel)[key]),
fieldSet: jest.fn(),
timeSettings: fakeTimeSettings(),
});

View File

@ -125,6 +125,7 @@ export interface DesignerState {
openedSavedGarden: string | undefined;
tryGroupSortType: PointGroupSortType | "nn" | undefined;
editGroupAreaInMap: boolean;
settingsSearchTerm: string;
}
export type TaggedExecutable = TaggedSequence | TaggedRegimen;

View File

@ -1,24 +1,30 @@
import * as React from "react";
import { ImageFilterMenu, ImageFilterMenuProps } from "../image_filter_menu";
import { shallow, mount } from "enzyme";
jest.mock("../../../../../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
import {
fakeWebAppConfig,
} from "../../../../../__test_support__/fake_state/resources";
import { StringConfigKey } from "farmbot/dist/resources/configs/web_app";
import { setWebAppConfigValue } from "../../../../../config_storage/actions";
import {
fakeTimeSettings,
} from "../../../../../__test_support__/fake_time_settings";
const mockConfig = fakeWebAppConfig();
jest.mock("../../../../../resources/selectors", () => ({
getWebAppConfig: () => mockConfig,
assertUuid: jest.fn(),
}));
jest.mock("../../../../../config_storage/actions", () => ({
setWebAppConfigValue: jest.fn(),
}));
import * as React from "react";
import { ImageFilterMenu, ImageFilterMenuProps } from "../image_filter_menu";
import { shallow, mount } from "enzyme";
import { StringConfigKey } from "farmbot/dist/resources/configs/web_app";
import {
fakeTimeSettings,
} from "../../../../../__test_support__/fake_time_settings";
import { edit, save } from "../../../../../api/crud";
import { fakeState } from "../../../../../__test_support__/fake_state";
import {
buildResourceIndex,
} from "../../../../../__test_support__/resource_index_builder";
describe("<ImageFilterMenu />", () => {
mockConfig.body.photo_filter_begin = "";
@ -45,13 +51,19 @@ describe("<ImageFilterMenu />", () => {
["endDate", "photo_filter_end", 2],
])("sets filter: %s", (filter, key, i) => {
const p = fakeProps();
const state = fakeState();
const config = fakeWebAppConfig();
state.resources = buildResourceIndex([config]);
p.dispatch = jest.fn(x => x(jest.fn(), () => state));
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "2001-01-03" }
});
expect(wrapper.instance().state[filter]).toEqual("2001-01-03");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T00:00:00.000Z");
expect(edit).toHaveBeenCalledWith(config, {
[key]: "2001-01-03T00:00:00.000Z"
});
expect(save).toHaveBeenCalledWith(config.uuid);
});
it.each<[
@ -61,14 +73,64 @@ describe("<ImageFilterMenu />", () => {
["endTime", "photo_filter_end", 3],
])("sets filter: %s", (filter, key, i) => {
const p = fakeProps();
const state = fakeState();
const config = fakeWebAppConfig();
state.resources = buildResourceIndex([config]);
p.dispatch = jest.fn(x => x(jest.fn(), () => state));
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.setState({ beginDate: "2001-01-03", endDate: "2001-01-03" });
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "05:00" }
});
expect(wrapper.instance().state[filter]).toEqual("05:00");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T05:00:00.000Z");
expect(edit).toHaveBeenCalledWith(config, {
[key]: "2001-01-03T05:00:00.000Z"
});
expect(save).toHaveBeenCalledWith(config.uuid);
});
it.each<[
"beginDate" | "endDate",
"photo_filter_begin" | "photo_filter_end",
number
]>([
["beginDate", "photo_filter_begin", 0],
["endDate", "photo_filter_end", 2],
])("unsets filter: %s", (filter, key, i) => {
const p = fakeProps();
const state = fakeState();
const config = fakeWebAppConfig();
state.resources = buildResourceIndex([config]);
p.dispatch = jest.fn(x => x(jest.fn(), () => state));
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.setState({ beginDate: "2001-01-03", endDate: "2001-01-03" });
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "" }
});
expect(wrapper.instance().state[filter]).toEqual(undefined);
// tslint:disable-next-line:no-null-keyword
expect(edit).toHaveBeenCalledWith(config, { [key]: null });
expect(save).toHaveBeenCalledWith(config.uuid);
});
it.each<[
"beginTime" | "endTime", number
]>([
["beginTime", 1],
["endTime", 3],
])("doesn't set filter: %s", (filter, i) => {
const p = fakeProps();
const state = fakeState();
const config = fakeWebAppConfig();
state.resources = buildResourceIndex([config]);
p.dispatch = jest.fn(x => x(jest.fn(), () => state));
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "05:00" }
});
expect(wrapper.instance().state[filter]).toEqual("05:00");
expect(edit).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("loads values from config", () => {
@ -83,14 +145,34 @@ describe("<ImageFilterMenu />", () => {
it("changes slider", () => {
const p = fakeProps();
const state = fakeState();
const config = fakeWebAppConfig();
state.resources = buildResourceIndex([config]);
p.dispatch = jest.fn(x => x(jest.fn(), () => state));
p.getConfigValue = () => undefined;
p.imageAgeInfo.newestDate = "2001-01-03T05:00:00.000Z";
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.instance().sliderChange(1);
expect(wrapper.instance().state.slider).toEqual(1);
expect(setWebAppConfigValue)
.toHaveBeenCalledWith("photo_filter_begin", "2001-01-02T00:00:00.000Z");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith("photo_filter_end", "2001-01-03T00:00:00.000Z");
expect(edit).toHaveBeenCalledWith(config, {
photo_filter_begin: "2001-01-02T00:00:00.000Z",
photo_filter_end: "2001-01-03T00:00:00.000Z",
});
expect(save).toHaveBeenCalledWith(config.uuid);
});
it("doesn't update config", () => {
const p = fakeProps();
const state = fakeState();
state.resources = buildResourceIndex([]);
p.dispatch = jest.fn(x => x(jest.fn(), () => state));
p.getConfigValue = () => 1;
p.imageAgeInfo.newestDate = "2001-01-03T05:00:00.000Z";
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.instance().sliderChange(1);
expect(wrapper.instance().state.slider).toEqual(1);
expect(edit).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("displays slider labels", () => {

View File

@ -1,9 +1,7 @@
import * as React from "react";
import { BlurableInput } from "../../../../ui/index";
import { offsetTime } from "../../../farm_events/edit_fe_form";
import {
setWebAppConfigValue, GetWebAppConfigValue,
} from "../../../../config_storage/actions";
import { GetWebAppConfigValue } from "../../../../config_storage/actions";
import moment from "moment";
import {
formatDate, formatTime,
@ -11,8 +9,13 @@ import {
import { Slider } from "@blueprintjs/core";
import { t } from "../../../../i18next_wrapper";
import { TimeSettings } from "../../../../interfaces";
import { StringConfigKey } from "farmbot/dist/resources/configs/web_app";
import { GetState } from "../../../../redux/interfaces";
import { getWebAppConfig } from "../../../../resources/getters";
import { edit, save } from "../../../../api/crud";
import { isString, isUndefined } from "lodash";
interface ImageFilterMenuState {
interface FullImageFilterMenuState {
beginDate: string | undefined;
beginTime: string | undefined;
endDate: string | undefined;
@ -20,6 +23,8 @@ interface ImageFilterMenuState {
slider: number;
}
type ImageFilterMenuState = Partial<FullImageFilterMenuState>;
export interface ImageFilterMenuProps {
timeSettings: TimeSettings;
dispatch: Function;
@ -28,26 +33,48 @@ export interface ImageFilterMenuProps {
}
export class ImageFilterMenu
extends React.Component<ImageFilterMenuProps, Partial<ImageFilterMenuState>> {
constructor(props: ImageFilterMenuProps) {
super(props);
this.state = {};
}
extends React.Component<ImageFilterMenuProps, ImageFilterMenuState> {
state: ImageFilterMenuState = {};
UNSAFE_componentWillMount() {
const { newestDate, toOldest } = this.props.imageAgeInfo;
componentDidMount() {
const beginDatetime = this.props.getConfigValue("photo_filter_begin");
this.setState({
slider: toOldest + 1 - (beginDatetime
? Math.abs(moment(beginDatetime.toString())
.diff(moment(newestDate).clone(), "days")) : 0)
});
if (isString(beginDatetime) || isUndefined(beginDatetime)) {
this.updateSliderState(beginDatetime);
}
this.updateState();
}
UNSAFE_componentWillReceiveProps() {
this.updateState();
}
updateSliderState = (begin: string | undefined) => {
const { newestDate, toOldest } = this.props.imageAgeInfo;
const offset = begin ? Math.abs(moment(begin.toString())
.diff(moment(newestDate).clone(), "days")) : 0;
this.setState({ slider: toOldest + 1 - offset });
};
setValues = (update: StringValueUpdate) => {
Object.entries(update).map(([key, value]) => {
switch (key) {
case "photo_filter_begin":
this.updateSliderState(value);
value
? this.setState({
beginDate: formatDate(value.toString(), this.props.timeSettings),
beginTime: formatTime(value.toString(), this.props.timeSettings),
})
: this.setState({ beginDate: undefined, beginTime: undefined });
break;
case "photo_filter_end":
value
? this.setState({
endDate: formatDate(value.toString(), this.props.timeSettings),
endTime: formatTime(value.toString(), this.props.timeSettings),
})
: this.setState({ endDate: undefined, endTime: undefined });
break;
}
});
this.props.dispatch(setWebAppConfigValues(update));
};
updateState = () => {
const beginDatetime = this.props.getConfigValue("photo_filter_begin");
@ -70,27 +97,27 @@ export class ImageFilterMenu
const input = e.currentTarget.value;
this.setState({ [datetime]: input });
const { beginDate, beginTime, endDate, endTime } = this.state;
const { dispatch, timeSettings } = this.props;
const { timeSettings } = this.props;
let value = undefined;
switch (datetime) {
case "beginDate":
value = offsetTime(input, beginTime || "00:00", timeSettings);
dispatch(setWebAppConfigValue("photo_filter_begin", value));
this.setValues({ photo_filter_begin: value });
break;
case "beginTime":
if (beginDate) {
value = offsetTime(beginDate, input, timeSettings);
dispatch(setWebAppConfigValue("photo_filter_begin", value));
this.setValues({ photo_filter_begin: value });
}
break;
case "endDate":
value = offsetTime(input, endTime || "00:00", timeSettings);
dispatch(setWebAppConfigValue("photo_filter_end", value));
this.setValues({ photo_filter_end: value });
break;
case "endTime":
if (endDate) {
value = offsetTime(endDate, input, timeSettings);
dispatch(setWebAppConfigValue("photo_filter_end", value));
this.setValues({ photo_filter_end: value });
}
break;
}
@ -100,13 +127,12 @@ export class ImageFilterMenu
sliderChange = (slider: number) => {
const { newestDate, toOldest } = this.props.imageAgeInfo;
this.setState({ slider });
const { dispatch, timeSettings } = this.props;
const { timeSettings } = this.props;
const calcDate = (day: number) =>
moment(newestDate).subtract(toOldest - day, "days").toISOString();
const begin = offsetTime(calcDate(slider - 1), "00:00", timeSettings);
const end = offsetTime(calcDate(slider), "00:00", timeSettings);
dispatch(setWebAppConfigValue("photo_filter_begin", begin));
dispatch(setWebAppConfigValue("photo_filter_end", end));
this.setValues({ photo_filter_begin: begin, photo_filter_end: end });
}
renderLabel = (day: number) => {
@ -191,3 +217,14 @@ export class ImageFilterMenu
</div>;
}
}
type StringValueUpdate = Partial<Record<StringConfigKey, string | undefined>>;
const setWebAppConfigValues = (update: StringValueUpdate) =>
(dispatch: Function, getState: GetState) => {
const webAppConfig = getWebAppConfig(getState().resources.index);
if (webAppConfig) {
dispatch(edit(webAppConfig, update));
dispatch(save(webAppConfig.uuid));
}
};

View File

@ -73,7 +73,7 @@ export class SpreadCircle extends
React.Component<SpreadCircleProps, SpreadCircleState> {
state: SpreadCircleState = { spread: undefined };
UNSAFE_componentWillMount = () => {
componentDidMount = () => {
cachedCrop(this.props.plant.body.openfarm_slug)
.then(({ spread }) => this.setState({ spread }));
}

View File

@ -22,7 +22,6 @@ describe("<ToolSlotLayer/>", () => {
pointer_type: "ToolSlot",
tool_id: undefined,
name: "Name",
radius: 50,
x: 1,
y: 2,
z: 3,

View File

@ -11,6 +11,7 @@ import { BooleanSetting } from "../../../session_keys";
import { DevSettings } from "../../../account/dev/dev_support";
import { t } from "../../../i18next_wrapper";
import { Feature } from "../../../devices/interfaces";
import { SelectModeLink } from "../../plants/select_plants";
export const ZoomControls = ({ zoom, getConfigValue }: {
zoom: (value: number) => () => void,
@ -109,6 +110,7 @@ export function GardenMapLegend(props: GardenMapLegendProps) {
<ZoomControls zoom={props.zoom} getConfigValue={props.getConfigValue} />
<LayerToggles {...props} />
<MoveModeLink />
<SelectModeLink />
<BugsControls />
</div>
</div>;

View File

@ -43,7 +43,7 @@ interface MoveToFormState {
}
export class MoveToForm extends React.Component<MoveToFormProps, MoveToFormState> {
state = { z: undefined };
state = { z: this.props.chosenLocation.z };
get vector(): { x: number, y: number, z: number } {
const { chosenLocation } = this.props;

View File

@ -0,0 +1,121 @@
jest.mock("../../../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
import * as React from "react";
import { EditPlantStatusProps } from "../plant_panel";
import { shallow } from "enzyme";
import {
fakePlant, fakeWeed,
} from "../../../__test_support__/fake_state/resources";
import { edit } from "../../../api/crud";
import {
EditPlantStatus, PlantStatusBulkUpdateProps, PlantStatusBulkUpdate,
EditWeedStatus, EditWeedStatusProps,
} from "../edit_plant_status";
describe("<EditPlantStatus />", () => {
const fakeProps = (): EditPlantStatusProps => ({
uuid: "Plant.0.0",
plantStatus: "planned",
updatePlant: jest.fn(),
});
it("changes stage to planted", () => {
const p = fakeProps();
const wrapper = shallow(<EditPlantStatus {...p} />);
wrapper.find("FBSelect").simulate("change", { value: "planted" });
expect(p.updatePlant).toHaveBeenCalledWith("Plant.0.0", {
plant_stage: "planted",
planted_at: expect.stringContaining("Z")
});
});
it("changes stage to planned", () => {
const p = fakeProps();
const wrapper = shallow(<EditPlantStatus {...p} />);
wrapper.find("FBSelect").simulate("change", { value: "planned" });
expect(p.updatePlant).toHaveBeenCalledWith("Plant.0.0", {
plant_stage: "planned",
planted_at: undefined
});
});
});
describe("<PlantStatusBulkUpdate />", () => {
const fakeProps = (): PlantStatusBulkUpdateProps => ({
allPoints: [],
selected: [],
dispatch: jest.fn(),
pointerType: "Plant",
});
it("doesn't update plant statuses", () => {
const p = fakeProps();
const plant1 = fakePlant();
const plant2 = fakePlant();
p.allPoints = [plant1, plant2];
p.selected = [plant1.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => false);
wrapper.find("FBSelect").simulate("change", { label: "", value: "planted" });
expect(window.confirm).toHaveBeenCalled();
expect(edit).not.toHaveBeenCalled();
});
it("updates plant statuses", () => {
const p = fakeProps();
const plant1 = fakePlant();
const plant2 = fakePlant();
const plant3 = fakePlant();
p.allPoints = [plant1, plant2, plant3];
p.selected = [plant1.uuid, plant2.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => true);
wrapper.find("FBSelect").simulate("change", { label: "", value: "planted" });
expect(window.confirm).toHaveBeenCalledWith(
"Change status to 'planted' for 2 items?");
expect(edit).toHaveBeenCalledTimes(2);
expect(edit).toHaveBeenCalledWith(plant1, {
plant_stage: "planted",
planted_at: expect.stringContaining("Z"),
});
expect(edit).toHaveBeenCalledWith(plant2, {
plant_stage: "planted",
planted_at: expect.stringContaining("Z"),
});
});
it("updates weed statuses", () => {
const p = fakeProps();
p.pointerType = "Weed";
const weed1 = fakeWeed();
const weed2 = fakeWeed();
const weed3 = fakeWeed();
p.allPoints = [weed1, weed2, weed3];
p.selected = [weed1.uuid, weed2.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => true);
wrapper.find("FBSelect").simulate("change", { label: "", value: "removed" });
expect(window.confirm).toHaveBeenCalledWith(
"Change status to 'removed' for 2 items?");
expect(edit).toHaveBeenCalledTimes(2);
expect(edit).toHaveBeenCalledWith(weed1, { plant_stage: "removed" });
expect(edit).toHaveBeenCalledWith(weed2, { plant_stage: "removed" });
});
});
describe("<EditWeedStatus />", () => {
const fakeProps = (): EditWeedStatusProps => ({
weed: fakeWeed(),
updateWeed: jest.fn(),
});
it("updates weed status", () => {
const p = fakeProps();
const wrapper = shallow(<EditWeedStatus {...p} />);
wrapper.find("FBSelect").simulate("change", { label: "", value: "removed" });
expect(p.updateWeed).toHaveBeenCalledWith({ plant_stage: "removed" });
});
});

View File

@ -1,13 +1,8 @@
jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
import * as React from "react";
import {
PlantPanel, PlantPanelProps, EditPlantStatusProps,
PlantPanel, PlantPanelProps,
EditDatePlantedProps, EditDatePlanted, EditPlantLocationProps,
EditPlantLocation,
} from "../plant_panel";
@ -18,16 +13,12 @@ import { clickButton } from "../../../__test_support__/helpers";
import { history } from "../../../history";
import moment from "moment";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { edit } from "../../../api/crud";
import {
EditPlantStatus, PlantStatusBulkUpdateProps, PlantStatusBulkUpdate,
} from "../edit_plant_status";
describe("<PlantPanel/>", () => {
const info: FormattedPlantInfo = {
x: 12,
y: 34,
z: 0,
id: undefined,
name: "tomato",
uuid: "Plant.0.0",
@ -101,75 +92,11 @@ describe("<PlantPanel/>", () => {
expect(history.push).toHaveBeenCalledWith("/app/designer/move_to");
expect(innerDispatch).toHaveBeenLastCalledWith({
type: Actions.CHOOSE_LOCATION,
payload: { x: 12, y: 34, z: undefined }
payload: { x: 12, y: 34, z: 0 }
});
});
});
describe("<EditPlantStatus />", () => {
const fakeProps = (): EditPlantStatusProps => ({
uuid: "Plant.0.0",
plantStatus: "planned",
updatePlant: jest.fn(),
});
it("changes stage to planted", () => {
const p = fakeProps();
const wrapper = shallow(<EditPlantStatus {...p} />);
wrapper.find("FBSelect").simulate("change", { value: "planted" });
expect(p.updatePlant).toHaveBeenCalledWith("Plant.0.0", {
plant_stage: "planted",
planted_at: expect.stringContaining("Z")
});
});
it("changes stage to planned", () => {
const p = fakeProps();
const wrapper = shallow(<EditPlantStatus {...p} />);
wrapper.find("FBSelect").simulate("change", { value: "planned" });
expect(p.updatePlant).toHaveBeenCalledWith("Plant.0.0", {
plant_stage: "planned",
planted_at: undefined
});
});
});
describe("<PlantStatusBulkUpdate />", () => {
const fakeProps = (): PlantStatusBulkUpdateProps => ({
plants: [],
selected: [],
dispatch: jest.fn(),
});
it("doesn't update plant statuses", () => {
const p = fakeProps();
const plant1 = fakePlant();
const plant2 = fakePlant();
p.plants = [plant1, plant2];
p.selected = [plant1.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => false);
wrapper.find("FBSelect").simulate("change", { label: "", value: "planted" });
expect(window.confirm).toHaveBeenCalled();
expect(edit).not.toHaveBeenCalled();
});
it("updates plant statuses", () => {
const p = fakeProps();
const plant1 = fakePlant();
const plant2 = fakePlant();
const plant3 = fakePlant();
p.plants = [plant1, plant2, plant3];
p.selected = [plant1.uuid, plant2.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => true);
wrapper.find("FBSelect").simulate("change", { label: "", value: "planted" });
expect(window.confirm).toHaveBeenCalledWith(
"Change the plant status to 'planted' for 2 plants?");
expect(edit).toHaveBeenCalledTimes(2);
});
});
describe("<EditDatePlanted />", () => {
const fakeProps = (): EditDatePlantedProps => ({
uuid: "Plant.0.0",
@ -193,7 +120,7 @@ describe("<EditDatePlanted />", () => {
describe("<EditPlantLocation />", () => {
const fakeProps = (): EditPlantLocationProps => ({
uuid: "Plant.0.0",
xyLocation: { x: 1, y: 2 },
plantLocation: { x: 1, y: 2, z: 0 },
updatePlant: jest.fn(),
});

View File

@ -18,12 +18,13 @@ import * as React from "react";
import { mount, shallow } from "enzyme";
import {
RawSelectPlants as SelectPlants, SelectPlantsProps, mapStateToProps,
getFilteredPoints, GetFilteredPointsProps, validPointTypes,
getFilteredPoints, GetFilteredPointsProps, validPointTypes, SelectModeLink,
} from "../select_plants";
import {
fakePlant, fakePoint, fakeWeed, fakeToolSlot, fakeTool,
fakePlantTemplate,
fakeWebAppConfig,
fakePointGroup,
} from "../../../__test_support__/fake_state/resources";
import { Actions, Content } from "../../../constants";
import { clickButton } from "../../../__test_support__/helpers";
@ -35,6 +36,8 @@ import { mockDispatch } from "../../../__test_support__/fake_dispatch";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
import { history } from "../../../history";
import { POINTER_TYPES } from "../../point_groups/criteria/interfaces";
describe("<SelectPlants />", () => {
beforeEach(function () {
@ -60,6 +63,7 @@ describe("<SelectPlants />", () => {
quadrant: 2,
isActive: () => false,
tools: [],
groups: [],
};
}
@ -211,7 +215,93 @@ describe("<SelectPlants />", () => {
{ payload: undefined, type: Actions.SELECT_POINT });
});
const DELETE_BTN_INDEX = 3;
it("toggles more", () => {
const p = fakeProps();
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
expect(wrapper.state().more).toEqual(false);
expect(wrapper.find(".select-more").props().hidden).toBeTruthy();
expect(wrapper.html()).not.toContain(" more status");
wrapper.find(".more").simulate("click");
expect(wrapper.state().more).toEqual(true);
expect(wrapper.find(".select-more").props().hidden).toBeFalsy();
expect(wrapper.html()).toContain(" more status");
});
it("selects group items", () => {
const p = fakeProps();
p.selected = undefined;
const group = fakePointGroup();
group.body.id = 1;
const plant = fakePlant();
plant.body.id = 1;
group.body.point_ids = [1];
p.groups = [group];
p.allPoints = [plant];
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
expect(wrapper.state().group_id).toEqual(undefined);
actionsWrapper.find("FBSelect").at(1).simulate("change", {
label: "", value: 1
});
expect(wrapper.state().group_id).toEqual(1);
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant"],
});
expect(p.dispatch).toHaveBeenLastCalledWith({
type: Actions.SELECT_POINT,
payload: [plant.uuid],
});
});
it("selects selection type", () => {
const p = fakeProps();
const group0 = fakePointGroup();
group0.body.id = 0;
const group1 = fakePointGroup();
group1.body.id = 1;
group1.body.criteria.string_eq = { pointer_type: ["Plant"] };
p.groups = [group0, group1];
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
actionsWrapper.find("FBSelect").at(1).simulate("change", {
label: "", value: 1
});
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant"],
});
});
it("selects selection type without criteria", () => {
const p = fakeProps();
const group = fakePointGroup();
group.body.id = 1;
group.body.criteria.string_eq = {};
const plant = fakePlant();
plant.body.id = 1;
const weed = fakeWeed();
weed.body.id = 2;
group.body.point_ids = [1, 2];
p.groups = [group];
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
actionsWrapper.find("FBSelect").at(1).simulate("change", {
label: "", value: 1
});
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: POINTER_TYPES,
});
});
const DELETE_BTN_INDEX = 4;
it("confirms deletion of selected plants", () => {
const p = fakeProps();
@ -344,3 +434,11 @@ describe("validPointTypes()", () => {
expect(validPointTypes(["nope"])).toEqual(undefined);
});
});
describe("<SelectModeLink />", () => {
it("navigates to panel", () => {
const wrapper = shallow(<SelectModeLink />);
wrapper.find("button").simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/plants/select");
});
});

View File

@ -1,10 +1,9 @@
import * as React from "react";
import { FBSelect, DropDownItem } from "../../ui";
import { PlantOptions } from "../interfaces";
import { PlantStage } from "farmbot";
import { PlantStage, TaggedWeedPointer, PointType, TaggedPoint } from "farmbot";
import moment from "moment";
import { t } from "../../i18next_wrapper";
import { TaggedPlant } from "../map/interfaces";
import { UUID } from "../../resources/interfaces";
import { edit, save } from "../../api/crud";
import { EditPlantStatusProps } from "./plant_panel";
@ -14,14 +13,21 @@ export const PLANT_STAGE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
planted: { label: t("Planted"), value: "planted" },
sprouted: { label: t("Sprouted"), value: "sprouted" },
harvested: { label: t("Harvested"), value: "harvested" },
removed: { label: t("Removed"), value: "removed" },
});
export const PLANT_STAGE_LIST = () => [
PLANT_STAGE_DDI_LOOKUP().planned,
PLANT_STAGE_DDI_LOOKUP().planted,
PLANT_STAGE_DDI_LOOKUP().sprouted,
PLANT_STAGE_DDI_LOOKUP().harvested,
PLANT_STAGE_DDI_LOOKUP().removed,
];
export const WEED_STATUSES = ["removed"];
const WEED_STAGE_DDI_LOOKUP = (): Record<string, DropDownItem> => ({
removed: PLANT_STAGE_DDI_LOOKUP().removed,
});
/** Change `planted_at` value based on `plant_stage` update. */
const getUpdateByPlantStage = (plant_stage: PlantStage): PlantOptions => {
const update: PlantOptions = { plant_stage };
@ -46,33 +52,52 @@ export function EditPlantStatus(props: EditPlantStatusProps) {
}
export interface PlantStatusBulkUpdateProps {
plants: TaggedPlant[];
allPoints: TaggedPoint[];
selected: UUID[];
dispatch: Function;
pointerType: PointType;
}
/** Update `plant_stage` for multiple plants at once. */
export const PlantStatusBulkUpdate = (props: PlantStatusBulkUpdateProps) =>
<div className="plant-status-bulk-update">
<p>{t("update plant status to")}</p>
<p>{t("update status to")}</p>
<FBSelect
key={JSON.stringify(props.selected)}
list={PLANT_STAGE_LIST()}
list={PLANT_STAGE_LIST().filter(ddi =>
props.pointerType == "Plant" || WEED_STATUSES.includes("" + ddi.value))}
selectedItem={undefined}
customNullLabel={t("Select a status")}
onChange={ddi => {
const plant_stage = ddi.value as PlantStage;
const update = getUpdateByPlantStage(plant_stage);
const plants = props.plants.filter(plant =>
props.selected.includes(plant.uuid)
&& plant.kind === "Point"
&& plant.body.plant_stage != plant_stage);
plants.length > 0 && confirm(
t("Change the plant status to '{{ status }}' for {{ num }} plants?",
{ status: plant_stage, num: plants.length }))
&& plants.map(plant => {
props.dispatch(edit(plant, update));
props.dispatch(save(plant.uuid));
const update = props.pointerType == "Plant"
? getUpdateByPlantStage(plant_stage)
: { plant_stage };
const points = props.allPoints.filter(point =>
props.selected.includes(point.uuid)
&& point.kind === "Point"
&& (point.body.pointer_type == "Plant"
|| point.body.pointer_type == "Weed")
&& point.body.plant_stage != plant_stage);
points.length > 0 && confirm(
t("Change status to '{{ status }}' for {{ num }} items?",
{ status: plant_stage, num: points.length }))
&& points.map(point => {
props.dispatch(edit(point, update));
props.dispatch(save(point.uuid));
});
}} />
</div>;
export interface EditWeedStatusProps {
weed: TaggedWeedPointer;
updateWeed(update: Partial<TaggedWeedPointer["body"]>): void;
}
/** Select a `plant_stage` for a weed. */
export const EditWeedStatus = (props: EditWeedStatusProps) =>
<FBSelect
list={PLANT_STAGE_LIST().filter(ddi => WEED_STATUSES.includes("" + ddi.value))}
selectedItem={WEED_STAGE_DDI_LOOKUP()[props.weed.body.plant_stage]}
onChange={ddi =>
props.updateWeed({ plant_stage: ddi.value as PlantStage })} />;

View File

@ -40,6 +40,7 @@ export function mapStateToProps(props: Everything): EditPlantInfoProps {
export interface FormattedPlantInfo {
x: number;
y: number;
z: number;
id: number | undefined;
name: string;
uuid: string;
@ -72,6 +73,7 @@ export function formatPlantInfo(plant: TaggedPlant): FormattedPlantInfo {
daysOld: plantAge(plant),
x: plant.body.x,
y: plant.body.y,
z: plant.body.z,
uuid: plant.uuid,
plantedAt: plantDate(plant),
plantStatus: get(plant, "body.plant_stage", "planned"),

View File

@ -4,7 +4,7 @@ import { round } from "../map/util";
import { history } from "../../history";
import { BlurableInput, Row, Col } from "../../ui";
import { PlantOptions } from "../interfaces";
import { PlantStage } from "farmbot";
import { PlantStage, Xyz } from "farmbot";
import { Moment } from "moment";
import moment from "moment";
import { Actions } from "../../constants";
@ -51,19 +51,19 @@ export const EditDatePlanted = (props: EditDatePlantedProps) => {
};
export interface EditPlantLocationProps extends EditPlantProperty {
xyLocation: Record<"x" | "y", number>;
plantLocation: Record<Xyz, number>;
}
export const EditPlantLocation = (props: EditPlantLocationProps) => {
const { xyLocation, updatePlant, uuid } = props;
const { plantLocation, updatePlant, uuid } = props;
return <Row>
{["x", "y"].map((axis: "x" | "y") =>
<Col xs={6} key={axis}>
{["x", "y", "z"].map((axis: Xyz) =>
<Col xs={4} key={axis}>
<label style={{ marginTop: 0 }}>{t("{{axis}} (mm)", { axis })}</label>
<BlurableInput
type="number"
value={xyLocation[axis]}
min={0}
value={plantLocation[axis]}
min={axis == "z" ? undefined : 0}
onCommit={e => updatePlant(uuid, {
[axis]: round(parseIntInput(e.currentTarget.value))
})} />
@ -71,11 +71,11 @@ export const EditPlantLocation = (props: EditPlantLocationProps) => {
</Row>;
};
const chooseLocation = (to: Record<"x" | "y", number | undefined>) =>
const chooseLocation = (to: Record<Xyz, number | undefined>) =>
(dispatch: Function): Promise<void> => {
dispatch({
type: Actions.CHOOSE_LOCATION,
payload: { x: to.x, y: to.y, z: undefined }
payload: { x: to.x, y: to.y, z: to.z }
});
return Promise.resolve();
};
@ -83,6 +83,7 @@ const chooseLocation = (to: Record<"x" | "y", number | undefined>) =>
interface MoveToPlantProps {
x: number;
y: number;
z: number;
dispatch: Function;
}
@ -90,8 +91,9 @@ const MoveToPlant = (props: MoveToPlantProps) =>
<button className="fb-button gray no-float"
style={{ marginTop: "1rem" }}
title={t("Move to this plant")}
onClick={() => props.dispatch(chooseLocation({ x: props.x, y: props.y }))
.then(() => history.push("/app/designer/move_to"))}>
onClick={() =>
props.dispatch(chooseLocation({ x: props.x, y: props.y, z: props.z }))
.then(() => history.push("/app/designer/move_to"))}>
{t("Move FarmBot to this plant")}
</button>;
@ -141,7 +143,7 @@ export function PlantPanel(props: PlantPanelProps) {
info, onDestroy, updatePlant, dispatch, inSavedGarden, timeSettings
} = props;
const { slug, plantedAt, daysOld, uuid, plantStatus } = info;
const { x, y } = info;
const { x, y, z } = info;
const destroy = () => onDestroy(uuid);
return <DesignerPanelContent panelName={"plants"}>
<label>
@ -174,10 +176,10 @@ export function PlantPanel(props: PlantPanelProps) {
</Row>}
<ListItem name={t("Location")}>
<EditPlantLocation uuid={uuid}
xyLocation={{ x, y }}
plantLocation={{ x, y, z }}
updatePlant={updatePlant} />
</ListItem>
<MoveToPlant x={x} y={y} dispatch={dispatch} />
<MoveToPlant x={x} y={y} z={z} dispatch={dispatch} />
<ListItem name={t("Status")}>
{(!inSavedGarden)
? <EditPlantStatus

View File

@ -21,10 +21,12 @@ import {
PointType, TaggedPoint, TaggedGenericPointer, TaggedToolSlotPointer,
TaggedTool,
TaggedWeedPointer,
TaggedPointGroup,
} from "farmbot";
import { UUID } from "../../resources/interfaces";
import {
selectAllActivePoints, selectAllToolSlotPointers, selectAllTools,
selectAllPointGroups,
} from "../../resources/selectors";
import { PointInventoryItem } from "../points/point_inventory_item";
import { ToolSlotInventoryItem } from "../tools";
@ -37,6 +39,7 @@ import { isActive } from "../tools/edit_tool";
import { uniq } from "lodash";
import { POINTER_TYPES } from "../point_groups/criteria/interfaces";
import { WeedInventoryItem } from "../weeds/weed_inventory_item";
import { pointsSelectedByGroup } from "../point_groups/criteria";
// tslint:disable-next-line:no-any
export const isPointType = (x: any): x is PointType => POINTER_TYPES.includes(x);
@ -82,6 +85,7 @@ export const mapStateToProps = (props: Everything): SelectPlantsProps => {
dispatch: props.dispatch,
gardenOpen: props.resources.consumers.farm_designer.openedSavedGarden,
tools: selectAllTools(props.resources.index),
groups: selectAllPointGroups(props.resources.index),
isActive: isActive(selectAllToolSlotPointers(props.resources.index)),
xySwap,
quadrant,
@ -100,9 +104,18 @@ export interface SelectPlantsProps {
quadrant: BotOriginQuadrant;
isActive(id: number | undefined): boolean;
tools: TaggedTool[];
groups: TaggedPointGroup[];
}
export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
interface SelectPlantsState {
group_id: number | undefined;
more: boolean;
}
export class RawSelectPlants
extends React.Component<SelectPlantsProps, SelectPlantsState> {
state: SelectPlantsState = { group_id: undefined, more: false };
componentDidMount() {
const { dispatch, selected } = this.props;
if (selected && selected.length == 1) {
@ -135,27 +148,83 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
return selectionPointTypes.length > 1 ? "All" : selectionPointTypes[0];
}
get groupDDILookup(): Record<number, DropDownItem> {
const lookup: Record<number, DropDownItem> = {};
this.props.groups.map(group => {
const { id } = group.body;
const groupName = group.body.name;
const count = pointsSelectedByGroup(group, this.props.allPoints).length;
const label = `${groupName} (${t("{{count}} items", { count })})`;
id && (lookup[id] = { label, value: id });
});
return lookup;
}
selectGroup = (ddi: DropDownItem) => {
const group_id = parseInt("" + ddi.value);
this.setState({ group_id });
const group = this.props.groups
.filter(pg => pg.body.id == group_id)[0];
const points = pointsSelectedByGroup(group, this.props.allPoints);
const pointUuids = points.map(p => p.uuid);
const pointerTypes =
group.body.criteria.string_eq.pointer_type as PointType[] | undefined;
const uniqPointTypes = uniq(points.map(p => p.body.pointer_type));
const pointTypes =
uniqPointTypes.length == 1 ? [uniqPointTypes[0]] : undefined;
this.props.dispatch(setSelectionPointType(
pointerTypes || pointTypes || POINTER_TYPES));
this.props.dispatch(selectPoint(pointUuids));
}
ActionButtons = () =>
<div className="panel-action-buttons">
<FBSelect
<div className={["panel-action-buttons",
this.state.more ? "more" : "",
["Plant", "Weed"].includes(this.selectionPointType) ? "status" : "",
].join(" ")}>
<label>{t("selection type")}</label>
<FBSelect key={this.selectionPointType}
list={POINTER_TYPE_LIST()}
selectedItem={POINTER_TYPE_DDI_LOOKUP()[this.selectionPointType]}
onChange={ddi => {
this.props.dispatch(selectPoint(undefined));
this.setState({ group_id: undefined });
this.props.dispatch(setSelectionPointType(
ddi.value == "All" ? POINTER_TYPES : validPointTypes([ddi.value])));
}} />
<div className="button-row">
<button className="fb-button gray"
title={t("Select none")}
onClick={() => this.props.dispatch(selectPoint(undefined))}>
onClick={() => {
this.setState({ group_id: undefined });
this.props.dispatch(selectPoint(undefined));
}}>
{t("Select none")}
</button>
<button className="fb-button gray"
title={t("Select all")}
onClick={() => this.props.dispatch(selectPoint(this.allPointUuids))}>
onClick={() => {
this.setState({ group_id: undefined });
this.props.dispatch(selectPoint(this.allPointUuids));
}}>
{t("Select all")}
</button>
<div className="more"
onClick={() => this.setState({ more: !this.state.more })}>
<p>{this.state.more ? t("Less") : t("More")}</p>
<i className={`fa fa-caret-${this.state.more ? "up" : "down"}`}
title={this.state.more ? t("less") : t("more")} />
</div>
<div className={"select-more"} hidden={!this.state.more}>
<label>{t("select all in group")}</label>
<FBSelect key={`${this.selectionPointType}-${this.state.group_id}`}
list={Object.values(this.groupDDILookup)}
selectedItem={this.state.group_id
? this.groupDDILookup[this.state.group_id]
: undefined}
customNullLabel={t("Select a group")}
onChange={this.selectGroup} />
</div>
</div>
<label>{t("SELECTION ACTIONS")}</label>
<div className="button-row">
@ -171,9 +240,10 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
: error(t(Content.ERROR_PLANT_TEMPLATE_GROUP))}>
{t("Create group")}
</button>
{this.selectionPointType == "Plant" &&
{(this.selectionPointType == "Plant" || this.selectionPointType == "Weed") &&
<PlantStatusBulkUpdate
plants={this.props.plants}
pointerType={this.selectionPointType}
allPoints={this.props.allPoints}
selected={this.selected}
dispatch={this.props.dispatch} />}
</div>
@ -227,7 +297,11 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
description={Content.BOX_SELECT_DESCRIPTION} />
<this.ActionButtons />
<DesignerPanelContent panelName={"plant-selection"}>
<DesignerPanelContent panelName={"plant-selection"}
className={[
this.state.more ? "more" : "",
["Plant", "Weed"].includes(this.selectionPointType) ? "status" : "",
].join(" ")}>
{this.selectedPointData.map(p => {
if (p.kind == "PlantTemplate" || p.body.pointer_type == "Plant") {
return <PlantInventoryItem
@ -320,3 +394,13 @@ const getVisibleLayers = (getConfigValue: GetWebAppConfigValue): PointType[] =>
...(showFarmbot ? [PointerType.ToolSlot] : []),
];
};
export const SelectModeLink = () =>
<div className="select-mode">
<button
className="fb-button gray"
title={t("open point select panel")}
onClick={() => history.push("/app/designer/plants/select")}>
{t("select")}
</button>
</div>;

View File

@ -199,7 +199,7 @@ describe("togglePointTypeCriteria()", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = {
pointer_type: ["Plant", "ToolSlot"],
"plant_stage": ["planned"],
"openfarm_slug": ["mint"],
};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { pointer_type: ["Weed"] };

View File

@ -107,7 +107,7 @@ describe("typeDisabled()", () => {
it("isn't disabled", () => {
const criteria = fakeCriteria();
criteria.string_eq = { plant_stage: ["planted"] };
criteria.string_eq = { openfarm_slug: ["mint"] };
const result = typeDisabled(criteria, "Plant");
expect(result).toBeFalsy();
});

View File

@ -161,7 +161,6 @@ describe("<NumberLtGtInput />", () => {
p.group,
"number_gt",
"x",
undefined,
);
});
@ -175,7 +174,6 @@ describe("<NumberLtGtInput />", () => {
p.group,
"number_lt",
"x",
undefined,
);
});
});

View File

@ -81,6 +81,6 @@ describe("<CheckboxList />", () => {
expect(wrapper.text()).toContain("label");
wrapper.find("input").first().simulate("change");
expect(toggleAndEditEqCriteria).toHaveBeenCalledWith(
p.group, "openfarm_slug", "value", "Plant");
p.group, "openfarm_slug", "value");
});
});

View File

@ -60,7 +60,7 @@ export const toggleAndEditEqCriteria = <T extends string | number>(
const wasOff = !tempEqCriteria[key]?.includes(value);
toggleEqCriteria<T>(tempEqCriteria)(key, value);
pointerType && wasOff && clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
POINTER_TYPES.filter(x => x != pointerType), tempCriteria, key);
dispatch(editCriteria(group, tempCriteria));
};
@ -68,16 +68,17 @@ export const toggleAndEditEqCriteria = <T extends string | number>(
export const clearSubCriteria = (
pointerTypes: PointerType[],
tempCriteria: PointGroupCriteria,
keepKey: string,
) => {
const toggleStrEq = toggleEqCriteria<string>(tempCriteria.string_eq, "off");
const toggleNumEq = toggleEqCriteria<number>(tempCriteria.number_eq, "off");
const toggleStrEqMapper = (key: string) =>
const toggleStrEqMapper = (key: string) => key != keepKey &&
tempCriteria.string_eq[key]?.map(value => toggleStrEq(key, value));
if (pointerTypes.includes("Plant")) {
["openfarm_slug", "plant_stage"].map(toggleStrEqMapper);
}
if (pointerTypes.includes("Weed")) {
["meta.created_by"].map(toggleStrEqMapper);
["meta.created_by", "plant_stage"].map(toggleStrEqMapper);
}
if (pointerTypes.includes("GenericPointer") && pointerTypes.includes("Weed")) {
["meta.color"].map(toggleStrEqMapper);
@ -101,8 +102,8 @@ export const togglePointTypeCriteria =
const toggle = toggleEqCriteria<string>(tempCriteria.string_eq);
clear && (tempCriteria.string_eq.pointer_type = []);
toggle("pointer_type", pointerType);
clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
clearSubCriteria(POINTER_TYPES.filter(x => x != pointerType),
tempCriteria, "pointer_type");
dispatch(editCriteria(group, tempCriteria));
};
@ -164,7 +165,7 @@ export const editGtLtCriteriaField = (
(dispatch: Function) => {
const tempCriteria = cloneDeep(group.body.criteria);
pointerType && clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
POINTER_TYPES.filter(x => x != pointerType), tempCriteria, criteriaKey);
const value = e.currentTarget.value != ""
? parseInt(e.currentTarget.value)
: undefined;

View File

@ -62,6 +62,7 @@ export const hasSubCriteria = (criteria: PointGroupCriteria) =>
case "Weed":
return !!(
selected("meta.created_by")
|| selected("plant_stage")
|| selected("meta.color")
|| numSelected("radius"));
case "Plant":

View File

@ -153,7 +153,7 @@ export const DaySelection = (props: DaySelectionProps) => {
/** Edit number < and > criteria. */
export const NumberLtGtInput = (props: NumberLtGtInputProps) => {
const { group, dispatch, criteriaKey, pointerType } = props;
const { group, dispatch, criteriaKey } = props;
const gtCriteria = props.group.body.criteria.number_gt;
const ltCriteria = props.group.body.criteria.number_lt;
return <Row>
@ -164,7 +164,7 @@ export const NumberLtGtInput = (props: NumberLtGtInputProps) => {
defaultValue={gtCriteria[criteriaKey]}
disabled={props.disabled}
onBlur={e => dispatch(editGtLtCriteriaField(
group, "number_gt", criteriaKey, pointerType)(e))} />
group, "number_gt", criteriaKey)(e))} />
</Col>
<Col xs={1}>
<p>{"<"}</p>
@ -182,7 +182,7 @@ export const NumberLtGtInput = (props: NumberLtGtInputProps) => {
defaultValue={ltCriteria[criteriaKey]}
disabled={props.disabled}
onBlur={e => dispatch(editGtLtCriteriaField(
group, "number_lt", criteriaKey, pointerType)(e))} />
group, "number_lt", criteriaKey)(e))} />
</Col>
</Row>;
};

View File

@ -17,7 +17,7 @@ import {
SubCriteriaSectionProps,
CheckboxListItem,
} from "./interfaces";
import { PLANT_STAGE_LIST } from "../../plants/edit_plant_status";
import { PLANT_STAGE_LIST, WEED_STATUSES } from "../../plants/edit_plant_status";
import { DIRECTION_CHOICES } from "../../tools/tool_slot_edit_components";
import { Checkbox } from "../../../ui";
import { PointType } from "farmbot";
@ -80,7 +80,7 @@ export const CheckboxList =
<div className="criteria-checkbox-list-item" key={index}>
<Checkbox
onChange={() => props.dispatch(toggle<T>(
props.group, props.criteriaKey, value, props.pointerType))}
props.group, props.criteriaKey, value))}
checked={selected(props.criteriaKey, value)}
title={t(label)}
color={color}
@ -95,14 +95,16 @@ export const PlantCriteria = (props: PlantSubCriteriaProps) => {
const { group, dispatch, disabled } = props;
const commonProps = { group, dispatch, disabled };
return <div className={"plant-criteria-options"}>
<PlantStage {...commonProps} />
<PlantStage {...commonProps} pointerType={"Plant"} />
<PlantType {...commonProps} slugs={props.slugs} />
</div>;
};
const PlantStage = (props: SubCriteriaProps) =>
const PlantStage = (props: PointSubCriteriaProps) =>
<div className={"plant-stage-criteria"}>
<p className={"category"}>{t("Stage")}</p>
<p className={"category"}>
{props.pointerType == "Plant" ? t("Stage") : t("Status")}
</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
@ -110,12 +112,15 @@ const PlantStage = (props: SubCriteriaProps) =>
dispatch={props.dispatch} />
<CheckboxList<string>
disabled={props.disabled}
pointerType={"Plant"}
pointerType={props.pointerType}
criteriaKey={"plant_stage"}
group={props.group}
dispatch={props.dispatch}
list={PLANT_STAGE_LIST().map(ddi =>
({ label: ddi.label, value: "" + ddi.value }))} />
list={PLANT_STAGE_LIST().filter(ddi =>
props.pointerType == "Plant" || WEED_STATUSES.includes("" + ddi.value))
.map(ddi => ({ label: ddi.label, value: "" + ddi.value }))
.concat(props.pointerType == "Weed"
? [{ label: t("Remaining"), value: "planned" }] : [])} />
</div>;
const PlantType = (props: PlantSubCriteriaProps) =>
@ -145,6 +150,7 @@ export const WeedCriteria = (props: SubCriteriaProps) => {
const commonProps = { group, dispatch, disabled, pointerType };
return <div className={"weed-criteria-options"}>
<PointSource {...commonProps} />
<PlantStage {...commonProps} />
<Color {...commonProps} />
<Radius {...commonProps} />
</div>;

View File

@ -78,12 +78,12 @@ describe("<CreatePoints />", () => {
it("updates specific fields", () => {
const p = fakeProps();
p.drawnPoint = FAKE_POINT;
const i = new CreatePoints(p);
i.updateValue("color")(inputEvent("cheerful hue"));
expect(i.props.drawnPoint).toBeTruthy();
const wrapper = mount<CreatePoints>(<CreatePoints {...p} />);
wrapper.instance().updateValue("color")(inputEvent("cheerful hue"));
expect(wrapper.instance().props.drawnPoint).toBeTruthy();
const expected = cloneDeep(FAKE_POINT);
expected.color = "cheerful hue";
expect(i.props.dispatch).toHaveBeenCalledWith({
expect(wrapper.instance().props.dispatch).toHaveBeenCalledWith({
type: "SET_DRAWN_POINT_DATA",
payload: expected,
});
@ -133,6 +133,7 @@ describe("<CreatePoints />", () => {
meta: { color: "green", created_by: "farm-designer", type: "point" },
name: "Created Point",
pointer_type: "GenericPointer",
plant_stage: "planned",
radius: 30, x: 10, y: 20, z: 0,
});
});
@ -146,6 +147,7 @@ describe("<CreatePoints />", () => {
meta: { color: "red", created_by: "farm-designer", type: "weed" },
name: "Created Weed",
pointer_type: "Weed",
plant_stage: "planned",
radius: 30, x: 10, y: 20, z: 0,
});
});

View File

@ -11,9 +11,11 @@ import {
EditPointColor, EditPointColorProps, updatePoint, EditPointName,
EditPointNameProps,
AdditionalWeedProperties,
EditPointPropertiesProps,
AdditionalWeedPropertiesProps,
} from "../point_edit_actions";
import { fakePoint } from "../../../__test_support__/fake_state/resources";
import {
fakePoint, fakeWeed,
} from "../../../__test_support__/fake_state/resources";
import { edit, save } from "../../../api/crud";
describe("updatePoint()", () => {
@ -94,8 +96,8 @@ describe("<EditPointColor />", () => {
});
describe("<AdditionalWeedProperties />", () => {
const fakeProps = (): EditPointPropertiesProps => ({
point: fakePoint(),
const fakeProps = (): AdditionalWeedPropertiesProps => ({
point: fakeWeed(),
updatePoint: jest.fn(),
});

View File

@ -69,9 +69,8 @@ describe("mapStateToProps()", () => {
const state = fakeState();
const point = fakePoint();
const discarded = fakePoint();
discarded.body.discarded_at = "2016-05-22T05:00:00.000Z";
state.resources = buildResourceIndex([point, discarded]);
const props = mapStateToProps(state);
expect(props.genericPoints).toEqual([point]);
expect(props.genericPoints).toEqual([point, discarded]);
});
});

View File

@ -122,7 +122,7 @@ export class RawCreatePoints
});
}
UNSAFE_componentWillMount() {
componentDidMount() {
this.loadDefaultPoint();
}
@ -183,6 +183,7 @@ export class RawCreatePoints
x: this.attr("cx"),
y: this.attr("cy"),
z: 0,
plant_stage: "planned",
radius: this.attr("r"),
};
this.props.dispatch(initSave("Point", body));

View File

@ -10,6 +10,7 @@ import { Row, Col, BlurableInput, ColorPicker } from "../../ui";
import { parseIntInput } from "../../util";
import { UUID } from "../../resources/interfaces";
import { plantAge } from "../plants/map_state_to_props";
import { EditWeedStatus } from "../plants/edit_plant_status";
type PointUpdate =
Partial<TaggedGenericPointer["body"] | TaggedWeedPointer["body"]>;
@ -29,6 +30,11 @@ export interface EditPointPropertiesProps {
updatePoint(update: PointUpdate): void;
}
export interface AdditionalWeedPropertiesProps {
point: TaggedWeedPointer;
updatePoint(update: PointUpdate): void;
}
export const EditPointProperties = (props: EditPointPropertiesProps) =>
<ul>
<li>
@ -53,22 +59,25 @@ export const EditPointProperties = (props: EditPointPropertiesProps) =>
</ListItem>
</ul>;
export const AdditionalWeedProperties = (props: EditPointPropertiesProps) =>
export const AdditionalWeedProperties = (props: AdditionalWeedPropertiesProps) =>
<ul className="additional-weed-properties">
<ListItem name={t("Age")}>
{`${plantAge(props.point)} ${t("days old")}`}
</ListItem>
<ListItem name={t("Status")}>
<EditWeedStatus weed={props.point} updateWeed={props.updatePoint} />
</ListItem>
{Object.entries(props.point.body.meta).map(([key, value]) => {
switch (key) {
case "color":
case "type": return <div key={key}
className={`meta-${key}-not-displayed`} />;
case "created_by":
return <ListItem name={t("Source")}>
return <ListItem name={t("Source")} key={key}>
{SOURCE_LOOKUP()[value || ""] || t("unknown")}
</ListItem>;
case "removal_method":
return <ListItem name={t("Removal method")}>
return <ListItem name={t("Removal method")} key={key}>
<div className="weed-removal-method-section">
{REMOVAL_METHODS.map(method =>
<div className={"weed-removal-method"} key={method}>
@ -84,7 +93,7 @@ export const AdditionalWeedProperties = (props: EditPointPropertiesProps) =>
</div>
</ListItem>;
default:
return <ListItem name={key}>
return <ListItem name={key} key={key}>
{value || ""}
</ListItem>;
}

View File

@ -29,7 +29,7 @@ export function mapStateToProps(props: Everything): PointsProps {
const { hoveredPoint } = props.resources.consumers.farm_designer;
return {
genericPoints: selectAllGenericPointers(props.resources.index)
.filter(x => !x.body.discarded_at),
.filter(x => x),
dispatch: props.dispatch,
hoveredPoint,
};

View File

@ -27,6 +27,7 @@ export const initialState: DesignerState = {
openedSavedGarden: undefined,
tryGroupSortType: undefined,
editGroupAreaInMap: false,
settingsSearchTerm: "",
};
export const designer = generateReducer<DesignerState>(initialState)
@ -107,6 +108,10 @@ export const designer = generateReducer<DesignerState>(initialState)
s.tryGroupSortType = payload;
return s;
})
.add<string>(Actions.SET_SETTINGS_SEARCH_TERM, (s, { payload }) => {
s.settingsSearchTerm = payload;
return s;
})
.add<boolean>(Actions.EDIT_GROUP_AREA_IN_MAP, (s, { payload }) => {
s.editGroupAreaInMap = payload;
return s;

View File

@ -1,144 +0,0 @@
import * as React from "react";
import { Everything } from "../interfaces";
import { connect } from "react-redux";
import { Content } from "../constants";
import { DesignerPanel, DesignerPanelContent } from "./designer_panel";
import { t } from "../i18next_wrapper";
import {
GetWebAppConfigValue, getWebAppConfigValue, setWebAppConfigValue,
} from "../config_storage/actions";
import { Row, Col } from "../ui";
import { ToggleButton } from "../controls/toggle_button";
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
import { BooleanSetting, NumericSetting } from "../session_keys";
import { resetVirtualTrail } from "./map/layers/farmbot/bot_trail";
import { MapSizeInputs } from "./map_size_setting";
import { DesignerNavTabs, Panel } from "./panel_header";
import { isUndefined } from "lodash";
export const mapStateToProps = (props: Everything): DesignerSettingsProps => ({
dispatch: props.dispatch,
getConfigValue: getWebAppConfigValue(() => props),
});
export interface DesignerSettingsProps {
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
}
export class RawDesignerSettings
extends React.Component<DesignerSettingsProps, {}> {
render() {
const { getConfigValue, dispatch } = this.props;
const settingsProps = { getConfigValue, dispatch };
return <DesignerPanel panelName={"settings"} panel={Panel.Settings}>
<DesignerNavTabs />
<DesignerPanelContent panelName={"settings"}>
{DESIGNER_SETTINGS(settingsProps).map(setting =>
<Setting key={setting.title} {...setting} {...settingsProps} />)}
</DesignerPanelContent>
</DesignerPanel>;
}
}
interface SettingDescriptionProps {
setting?: BooleanConfigKey;
title: string;
description: string;
invert?: boolean;
callback?: () => void;
children?: React.ReactChild;
defaultOn?: boolean;
disabled?: boolean;
}
interface SettingProps
extends DesignerSettingsProps, SettingDescriptionProps { }
const Setting = (props: SettingProps) => {
const { title, setting, callback, defaultOn } = props;
const raw_value = setting ? props.getConfigValue(setting) : undefined;
const value = (defaultOn && isUndefined(raw_value)) ? true : !!raw_value;
return <div
className={`designer-setting ${props.disabled ? "disabled" : ""}`}>
<Row>
<Col xs={9}>
<label>{t(title)}</label>
</Col>
<Col xs={3}>
{setting && <ToggleButton
toggleValue={props.invert ? !value : value}
toggleAction={() => {
props.dispatch(setWebAppConfigValue(setting, !value));
callback?.();
}}
title={`${t("toggle")} ${title}`}
customText={{ textFalse: t("off"), textTrue: t("on") }} />}
</Col>
</Row>
<Row>
<p>{t(props.description)}</p>
</Row>
{props.children}
</div>;
};
const DESIGNER_SETTINGS =
(settingsProps: DesignerSettingsProps): SettingDescriptionProps[] => ([
{
title: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
setting: BooleanSetting.disable_animations,
invert: true
},
{
title: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
setting: BooleanSetting.display_trail,
callback: resetVirtualTrail,
},
{
title: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
setting: BooleanSetting.dynamic_map,
},
{
title: t("Map size"),
description: t(Content.MAP_SIZE),
children: <MapSizeInputs {...settingsProps} />,
disabled: !!settingsProps.getConfigValue(BooleanSetting.dynamic_map),
},
{
title: t("Rotate map"),
description: t(Content.MAP_SWAP_XY),
setting: BooleanSetting.xy_swap,
},
{
title: t("Map origin"),
description: t(Content.MAP_ORIGIN),
children: <OriginSelector {...settingsProps} />
},
{
title: t("Confirm plant deletion"),
description: t(Content.CONFIRM_PLANT_DELETION),
setting: BooleanSetting.confirm_plant_deletion,
defaultOn: true,
},
]);
const OriginSelector = (props: DesignerSettingsProps) => {
const quadrant = props.getConfigValue(NumericSetting.bot_origin_quadrant);
const update = (value: number) => () => props.dispatch(setWebAppConfigValue(
NumericSetting.bot_origin_quadrant, value));
return <div className="farmbot-origin">
<div className="quadrants">
{[2, 1, 3, 4].map(q =>
<div key={"quadrant_" + q}
className={`quadrant ${quadrant === q ? "selected" : ""}`}
onClick={update(q)} />)}
</div>
</div>;
};
export const DesignerSettings = connect(mapStateToProps)(RawDesignerSettings);

View File

@ -0,0 +1,35 @@
jest.mock("../../map/layers/farmbot/bot_trail", () => ({
resetVirtualTrail: jest.fn(),
}));
import * as React from "react";
import { mount } from "enzyme";
import { PlainDesignerSettings } from "../farm_designer_settings";
import { DesignerSettingsPropsBase } from "../interfaces";
import { resetVirtualTrail } from "../../map/layers/farmbot/bot_trail";
describe("<PlainDesignerSettings />", () => {
const fakeProps = (): DesignerSettingsPropsBase => ({
dispatch: jest.fn(),
getConfigValue: jest.fn(),
});
it("renders", () => {
const wrapper = mount(<div>{PlainDesignerSettings(fakeProps())}</div>);
expect(wrapper.text().toLowerCase()).toContain("plant animations");
});
it("doesn't call callback", () => {
const wrapper = mount(<div>{PlainDesignerSettings(fakeProps())}</div>);
expect(wrapper.find("label").at(0).text()).toContain("animations");
wrapper.find("button").at(0).simulate("click");
expect(resetVirtualTrail).not.toHaveBeenCalled();
});
it("calls callback", () => {
const wrapper = mount(<div>{PlainDesignerSettings(fakeProps())}</div>);
expect(wrapper.find("label").at(1).text()).toContain("trail");
wrapper.find("button").at(1).simulate("click");
expect(resetVirtualTrail).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,172 @@
jest.mock("../../../config_storage/actions", () => ({
getWebAppConfigValue: jest.fn(x => { x(); return jest.fn(() => true); }),
setWebAppConfigValue: jest.fn(),
}));
let mockDev = false;
jest.mock("../../../account/dev/dev_support", () => ({
DevSettings: {
futureFeaturesEnabled: () => mockDev,
overriddenFbosVersion: jest.fn(),
}
}));
jest.mock("../../../devices/components/maybe_highlight", () => ({
maybeOpenPanel: jest.fn(),
Highlight: (p: { children: React.ReactChild }) => <div>{p.children}</div>,
}));
import * as React from "react";
import { mount, ReactWrapper, shallow } from "enzyme";
import { RawDesignerSettings as DesignerSettings } from "..";
import { DesignerSettingsProps } from "../interfaces";
import { BooleanSetting, NumericSetting } from "../../../session_keys";
import { setWebAppConfigValue } from "../../../config_storage/actions";
import {
buildResourceIndex, fakeDevice,
} from "../../../__test_support__/resource_index_builder";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { bot } from "../../../__test_support__/fake_state/bot";
import { clickButton } from "../../../__test_support__/helpers";
import { Actions } from "../../../constants";
import { Motors } from "../hardware_settings";
import { SearchField } from "../../../ui/search_field";
import { maybeOpenPanel } from "../../../devices/components/maybe_highlight";
const getSetting =
(wrapper: ReactWrapper, position: number, containsString: string) => {
const setting = wrapper.find(".designer-setting").at(position);
expect(setting.text().toLowerCase())
.toContain(containsString.toLowerCase());
return setting;
};
describe("<DesignerSettings />", () => {
beforeEach(() => {
mockDev = false;
});
const fakeProps = (): DesignerSettingsProps => ({
dispatch: jest.fn(),
getConfigValue: jest.fn(),
firmwareConfig: undefined,
sourceFwConfig: () => ({ value: 10, consistent: true }),
sourceFbosConfig: () => ({ value: 10, consistent: true }),
resources: buildResourceIndex().index,
deviceAccount: fakeDevice(),
env: {},
alerts: [],
shouldDisplay: jest.fn(),
saveFarmwareEnv: jest.fn(),
timeSettings: fakeTimeSettings(),
bot: bot,
searchTerm: "",
});
it("renders settings", () => {
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
expect(wrapper.text()).toContain("size");
expect(wrapper.text().toLowerCase()).not.toContain("pin");
const settings = wrapper.find(".designer-setting");
expect(settings.length).toEqual(7);
});
it("renders all settings", () => {
mockDev = true;
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
expect(wrapper.text().toLowerCase()).toContain("pin");
});
it("mounts", () => {
mount(<DesignerSettings {...fakeProps()} />);
expect(maybeOpenPanel).toHaveBeenCalled();
});
it("unmounts", () => {
const p = fakeProps();
const wrapper = mount(<DesignerSettings {...p} />);
wrapper.unmount();
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.BULK_TOGGLE_CONTROL_PANEL,
payload: { open: false, all: true },
});
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_CONTROL_PANEL_OPTION,
payload: "farmbot_os",
});
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SETTINGS_SEARCH_TERM,
payload: "",
});
});
it("changes search term", () => {
const p = fakeProps();
const wrapper = shallow(<DesignerSettings {...p} />);
wrapper.find(SearchField).simulate("change", "setting");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.BULK_TOGGLE_CONTROL_PANEL,
payload: { open: true, all: true },
});
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SETTINGS_SEARCH_TERM,
payload: "setting",
});
});
it("fetches firmware_hardware", () => {
mockDev = true;
const p = fakeProps();
p.sourceFbosConfig = () => ({ value: "arduino", consistent: true });
const wrapper = mount(<DesignerSettings {...p} />);
expect(wrapper.find(Motors).props().firmwareHardware).toEqual("arduino");
});
it("expands all", () => {
mockDev = true;
const p = fakeProps();
const wrapper = mount(<DesignerSettings {...p} />);
clickButton(wrapper, 0, "expand all");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.BULK_TOGGLE_CONTROL_PANEL,
payload: { open: true, all: true },
});
});
it("collapses all", () => {
mockDev = true;
const p = fakeProps();
const wrapper = mount(<DesignerSettings {...p} />);
clickButton(wrapper, 1, "collapse all");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.BULK_TOGGLE_CONTROL_PANEL,
payload: { open: false, all: true },
});
});
it("renders defaultOn setting", () => {
const p = fakeProps();
p.getConfigValue = () => undefined;
const wrapper = mount(<DesignerSettings {...p} />);
const confirmDeletion = getSetting(wrapper, 6, "confirm plant");
expect(confirmDeletion.find("button").text()).toEqual("on");
});
it("toggles setting", () => {
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
const trailSetting = getSetting(wrapper, 1, "trail");
trailSetting.find("button").simulate("click");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(BooleanSetting.display_trail, true);
});
it("changes origin", () => {
const p = fakeProps();
p.getConfigValue = () => 2;
const wrapper = mount(<DesignerSettings {...p} />);
const originSetting = getSetting(wrapper, 5, "origin");
originSetting.find("div").last().simulate("click");
expect(setWebAppConfigValue).toHaveBeenCalledWith(
NumericSetting.bot_origin_quadrant, 4);
});
});

View File

@ -0,0 +1,16 @@
jest.mock("../../../config_storage/actions", () => ({
getWebAppConfigValue: jest.fn(x => { x(); return jest.fn(() => true); }),
setWebAppConfigValue: jest.fn(),
}));
import { mapStateToProps } from "../state_to_props";
import { fakeState } from "../../../__test_support__/fake_state";
import { BooleanSetting } from "../../../session_keys";
describe("mapStateToProps()", () => {
it("returns props", () => {
const props = mapStateToProps(fakeState());
const value = props.getConfigValue(BooleanSetting.show_plants);
expect(value).toEqual(true);
});
});

View File

@ -0,0 +1,125 @@
import * as React from "react";
import { Content, DeviceSetting } from "../../constants";
import { t } from "../../i18next_wrapper";
import { setWebAppConfigValue } from "../../config_storage/actions";
import { Row, Col } from "../../ui";
import { ToggleButton } from "../../controls/toggle_button";
import { BooleanSetting, NumericSetting } from "../../session_keys";
import { resetVirtualTrail } from "../map/layers/farmbot/bot_trail";
import { MapSizeInputs } from "../map_size_setting";
import { isUndefined } from "lodash";
import { Collapse } from "@blueprintjs/core";
import { Header } from "../../devices/components/hardware_settings/header";
import { Highlight } from "../../devices/components/maybe_highlight";
import {
DesignerSettingsSectionProps, SettingProps,
DesignerSettingsPropsBase, SettingDescriptionProps,
} from "./interfaces";
export const Designer = (props: DesignerSettingsSectionProps) => {
const { getConfigValue, dispatch, controlPanelState } = props;
const settingsProps = { getConfigValue, dispatch };
return <Highlight className={"section"}
settingName={DeviceSetting.farmDesigner}>
<Header
title={DeviceSetting.farmDesigner}
panel={"farm_designer"}
dispatch={dispatch}
expanded={controlPanelState.farm_designer} />
<Collapse isOpen={!!controlPanelState.farm_designer}>
{PlainDesignerSettings(settingsProps)}
</Collapse>
</Highlight>;
};
export const PlainDesignerSettings =
(settingsProps: DesignerSettingsPropsBase) =>
DESIGNER_SETTINGS(settingsProps).map(setting =>
<Setting key={setting.title} {...setting} {...settingsProps} />);
const Setting = (props: SettingProps) => {
const { title, setting, callback, defaultOn } = props;
const raw_value = setting ? props.getConfigValue(setting) : undefined;
const value = (defaultOn && isUndefined(raw_value)) ? true : !!raw_value;
return <Highlight settingName={title}>
<div
className={`designer-setting ${props.disabled ? "disabled" : ""}`}>
<Row>
<Col xs={9}>
<label>{t(title)}</label>
</Col>
<Col xs={3}>
{setting && <ToggleButton
toggleValue={props.invert ? !value : value}
toggleAction={() => {
props.dispatch(setWebAppConfigValue(setting, !value));
callback?.();
}}
title={`${t("toggle")} ${title}`}
customText={{ textFalse: t("off"), textTrue: t("on") }} />}
</Col>
</Row>
<Row>
<p>{t(props.description)}</p>
</Row>
{props.children}
</div>
</Highlight>;
};
const DESIGNER_SETTINGS =
(settingsProps: DesignerSettingsPropsBase): SettingDescriptionProps[] => ([
{
title: DeviceSetting.animations,
description: t(Content.PLANT_ANIMATIONS),
setting: BooleanSetting.disable_animations,
invert: true
},
{
title: DeviceSetting.trail,
description: t(Content.VIRTUAL_TRAIL),
setting: BooleanSetting.display_trail,
callback: resetVirtualTrail,
},
{
title: DeviceSetting.dynamicMap,
description: t(Content.DYNAMIC_MAP_SIZE),
setting: BooleanSetting.dynamic_map,
},
{
title: DeviceSetting.mapSize,
description: t(Content.MAP_SIZE),
children: <MapSizeInputs {...settingsProps} />,
disabled: !!settingsProps.getConfigValue(BooleanSetting.dynamic_map),
},
{
title: DeviceSetting.rotateMap,
description: t(Content.MAP_SWAP_XY),
setting: BooleanSetting.xy_swap,
},
{
title: DeviceSetting.mapOrigin,
description: t(Content.MAP_ORIGIN),
children: <OriginSelector {...settingsProps} />
},
{
title: DeviceSetting.confirmPlantDeletion,
description: t(Content.CONFIRM_PLANT_DELETION),
setting: BooleanSetting.confirm_plant_deletion,
defaultOn: true,
},
]);
const OriginSelector = (props: DesignerSettingsPropsBase) => {
const quadrant = props.getConfigValue(NumericSetting.bot_origin_quadrant);
const update = (value: number) => () => props.dispatch(setWebAppConfigValue(
NumericSetting.bot_origin_quadrant, value));
return <div className="farmbot-origin">
<div className="quadrants">
{[2, 1, 3, 4].map(q =>
<div key={"quadrant_" + q}
className={`quadrant ${quadrant === q ? "selected" : ""}`}
onClick={update(q)} />)}
</div>
</div>;
};

View File

@ -0,0 +1,3 @@
export * from "../../devices/components/fbos_settings/power_and_reset";
export * from "../../devices/components/fbos_settings/firmware";
export * from "../../devices/components/farmbot_os_settings";

View File

@ -0,0 +1,9 @@
export * from "../../devices/components/hardware_settings/homing_and_calibration";
export * from "../../devices/components/hardware_settings/motors";
export * from "../../devices/components/hardware_settings/encoders";
export * from "../../devices/components/hardware_settings/endstops";
export * from "../../devices/components/hardware_settings/error_handling";
export * from "../../devices/components/hardware_settings/pin_bindings";
export * from "../../devices/components/hardware_settings/pin_guard";
export * from "../../devices/components/hardware_settings/danger_zone";
export * from "../../devices/components/firmware_hardware_support";

View File

@ -0,0 +1,135 @@
import * as React from "react";
import { connect } from "react-redux";
import { DesignerPanel, DesignerPanelContent } from "../designer_panel";
import { t } from "../../i18next_wrapper";
import { DesignerNavTabs, Panel } from "../panel_header";
import {
bulkToggleControlPanel, MCUFactoryReset, toggleControlPanel,
} from "../../devices/actions";
import { FarmBotSettings, Firmware, PowerAndReset } from "./fbos_settings";
import {
HomingAndCalibration, Motors, Encoders, EndStops, ErrorHandling,
PinGuard, DangerZone, PinBindings, isFwHardwareValue,
} from "./hardware_settings";
import { DevSettings } from "../../account/dev/dev_support";
import { maybeOpenPanel } from "../../devices/components/maybe_highlight";
import { isBotOnlineFromState } from "../../devices/must_be_online";
import { DesignerSettingsProps } from "./interfaces";
import { Designer, PlainDesignerSettings } from "./farm_designer_settings";
import { SearchField } from "../../ui/search_field";
import { mapStateToProps } from "./state_to_props";
import { Actions } from "../../constants";
export class RawDesignerSettings
extends React.Component<DesignerSettingsProps, {}> {
componentDidMount = () =>
this.props.dispatch(maybeOpenPanel(this.props.bot.controlPanelState, true));
componentWillUnmount = () => {
this.props.dispatch(bulkToggleControlPanel(false, true));
this.props.dispatch(toggleControlPanel("farmbot_os"));
this.props.dispatch({
type: Actions.SET_SETTINGS_SEARCH_TERM,
payload: ""
});
}
render() {
const { getConfigValue, dispatch, firmwareConfig,
sourceFwConfig, sourceFbosConfig, resources,
} = this.props;
const { controlPanelState } = this.props.bot;
const settingsProps = { getConfigValue, dispatch };
const commonProps = { dispatch, controlPanelState };
const { value } = this.props.sourceFbosConfig("firmware_hardware");
const firmwareHardware = isFwHardwareValue(value) ? value : undefined;
const botOnline = isBotOnlineFromState(this.props.bot);
return <DesignerPanel panelName={"settings"} panel={Panel.Settings}>
<DesignerNavTabs />
<DesignerPanelContent panelName={"settings"}>
<SearchField
placeholder={t("Search settings...")}
searchTerm={this.props.searchTerm}
onChange={searchTerm => {
dispatch(bulkToggleControlPanel(true, true));
dispatch({
type: Actions.SET_SETTINGS_SEARCH_TERM,
payload: searchTerm
});
}} />
{DevSettings.futureFeaturesEnabled() ?
<div className="all-settings">
<div className="bulk-expand-controls">
<button
className={"fb-button gray no-float"}
onClick={() => dispatch(bulkToggleControlPanel(true, true))}>
{t("Expand All")}
</button>
<button
className={"fb-button gray no-float"}
onClick={() => dispatch(bulkToggleControlPanel(false, true))}>
{t("Collapse All")}
</button>
</div>
<div className="all-settings-content">
<FarmBotSettings
bot={this.props.bot}
env={this.props.env}
alerts={this.props.alerts}
saveFarmwareEnv={this.props.saveFarmwareEnv}
dispatch={this.props.dispatch}
sourceFbosConfig={sourceFbosConfig}
shouldDisplay={this.props.shouldDisplay}
botOnline={botOnline}
timeSettings={this.props.timeSettings}
device={this.props.deviceAccount} />
<Firmware
bot={this.props.bot}
alerts={this.props.alerts}
dispatch={this.props.dispatch}
sourceFbosConfig={sourceFbosConfig}
shouldDisplay={this.props.shouldDisplay}
botOnline={botOnline}
timeSettings={this.props.timeSettings} />
<PowerAndReset {...commonProps}
sourceFbosConfig={sourceFbosConfig}
botOnline={botOnline} />
<HomingAndCalibration {...commonProps}
bot={this.props.bot}
sourceFwConfig={sourceFwConfig}
firmwareConfig={firmwareConfig}
firmwareHardware={firmwareHardware}
botOnline={botOnline} />
<Motors {...commonProps}
sourceFwConfig={sourceFwConfig}
firmwareHardware={firmwareHardware} />
<Encoders {...commonProps}
sourceFwConfig={sourceFwConfig}
firmwareHardware={firmwareHardware} />
<EndStops {...commonProps}
sourceFwConfig={sourceFwConfig} />
<ErrorHandling {...commonProps}
sourceFwConfig={sourceFwConfig} />
<PinBindings {...commonProps}
resources={resources}
firmwareHardware={firmwareHardware} />
<PinGuard {...commonProps}
resources={resources}
sourceFwConfig={sourceFwConfig} />
<DangerZone {...commonProps}
onReset={MCUFactoryReset}
botOnline={botOnline} />
<Designer {...commonProps}
getConfigValue={getConfigValue} />
</div>
</div>
: <div className="designer-settings">
{PlainDesignerSettings(settingsProps)}
</div>}
</DesignerPanelContent>
</DesignerPanel>;
}
}
export const DesignerSettings = connect(mapStateToProps)(RawDesignerSettings);

View File

@ -0,0 +1,53 @@
import { GetWebAppConfigValue } from "../../config_storage/actions";
import { FirmwareConfig } from "farmbot/dist/resources/configs/firmware";
import {
SourceFwConfig, SourceFbosConfig, UserEnv, ShouldDisplay,
SaveFarmwareEnv, BotState, ControlPanelState,
} from "../../devices/interfaces";
import { ResourceIndex } from "../../resources/interfaces";
import { TaggedDevice, Alert } from "farmbot";
import { TimeSettings } from "../../interfaces";
import { DeviceSetting } from "../../constants";
import {
BooleanConfigKey as WebAppBooleanConfigKey,
} from "farmbot/dist/resources/configs/web_app";
export interface DesignerSettingsPropsBase {
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
}
export interface DesignerSettingsProps extends DesignerSettingsPropsBase {
firmwareConfig: FirmwareConfig | undefined;
sourceFwConfig: SourceFwConfig;
sourceFbosConfig: SourceFbosConfig;
resources: ResourceIndex;
deviceAccount: TaggedDevice;
env: UserEnv;
alerts: Alert[];
shouldDisplay: ShouldDisplay;
saveFarmwareEnv: SaveFarmwareEnv;
timeSettings: TimeSettings;
bot: BotState;
searchTerm: string;
}
export interface DesignerSettingsSectionProps {
dispatch: Function;
controlPanelState: ControlPanelState;
getConfigValue: GetWebAppConfigValue;
}
export interface SettingDescriptionProps {
setting?: WebAppBooleanConfigKey;
title: DeviceSetting;
description: string;
invert?: boolean;
callback?: () => void;
children?: React.ReactChild;
defaultOn?: boolean;
disabled?: boolean;
}
export interface SettingProps
extends DesignerSettingsPropsBase, SettingDescriptionProps { }

View File

@ -0,0 +1,35 @@
import { Everything } from "../../interfaces";
import { getWebAppConfigValue } from "../../config_storage/actions";
import { validFwConfig, validFbosConfig } from "../../util";
import { getFirmwareConfig, getFbosConfig } from "../../resources/getters";
import {
sourceFwConfigValue, sourceFbosConfigValue,
} from "../../devices/components/source_config_value";
import {
getDeviceAccountSettings, maybeGetTimeSettings,
} from "../../resources/selectors";
import {
saveOrEditFarmwareEnv, getShouldDisplayFn, getEnv,
} from "../../farmware/state_to_props";
import { getAllAlerts } from "../../messages/state_to_props";
import { DesignerSettingsProps } from "./interfaces";
export const mapStateToProps = (props: Everything): DesignerSettingsProps => ({
dispatch: props.dispatch,
getConfigValue: getWebAppConfigValue(() => props),
firmwareConfig: validFwConfig(getFirmwareConfig(props.resources.index)),
sourceFwConfig: sourceFwConfigValue(validFwConfig(getFirmwareConfig(
props.resources.index)), props.bot.hardware.mcu_params),
sourceFbosConfig: sourceFbosConfigValue(validFbosConfig(getFbosConfig(
props.resources.index)), props.bot.hardware.configuration),
resources: props.resources.index,
deviceAccount: getDeviceAccountSettings(props.resources.index),
shouldDisplay: getShouldDisplayFn(props.resources.index, props.bot),
env: getEnv(props.resources.index, getShouldDisplayFn(
props.resources.index, props.bot), props.bot),
saveFarmwareEnv: saveOrEditFarmwareEnv(props.resources.index),
timeSettings: maybeGetTimeSettings(props.resources.index),
alerts: getAllAlerts(props.resources),
bot: props.bot,
searchTerm: props.resources.consumers.farm_designer.settingsSearchTerm,
});

View File

@ -57,7 +57,7 @@ export function mapStateToProps(props: Everything): Props {
const allGenericPoints = selectAllGenericPointers(props.resources.index);
const genericPoints = getConfigValue(BooleanSetting.show_historic_points)
? allGenericPoints
: allGenericPoints.filter(x => !x.body.discarded_at);
: allGenericPoints.filter(x => x);
const weeds = selectAllWeedPointers(props.resources.index);
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));

View File

@ -43,7 +43,7 @@ describe("<AddToolSlot />", () => {
"direction", "gantry-mounted",
].map(string => expect(wrapper.text().toLowerCase()).toContain(string));
expect(init).toHaveBeenCalledWith("Point", {
pointer_type: "ToolSlot", name: "Slot", radius: 0, meta: {},
pointer_type: "ToolSlot", name: "Slot", meta: {},
x: 0, y: 0, z: 0, tool_id: undefined,
pullout_direction: ToolPulloutDirection.NONE,
gantry_mounted: false,
@ -119,7 +119,7 @@ describe("<AddToolSlot />", () => {
const wrapper = mount(<AddToolSlot {...p} />);
expect(wrapper.text().toLowerCase()).not.toContain("tool");
expect(init).toHaveBeenCalledWith("Point", {
pointer_type: "ToolSlot", name: "Slot", radius: 0, meta: {},
pointer_type: "ToolSlot", name: "Slot", meta: {},
x: 0, y: 0, z: 0, tool_id: undefined,
pullout_direction: ToolPulloutDirection.NONE,
gantry_mounted: true,

View File

@ -21,7 +21,7 @@ export class RawAddToolSlot
componentDidMount() {
const action = init("Point", {
pointer_type: "ToolSlot", name: t("Slot"), radius: 0, meta: {},
pointer_type: "ToolSlot", name: t("Slot"), meta: {},
x: 0, y: 0, z: 0, tool_id: undefined,
pullout_direction: ToolPulloutDirection.NONE,
gantry_mounted: !hasUTM(this.props.firmwareHardware) ? true : false,

View File

@ -124,7 +124,7 @@ export class RawFarmwarePage extends React.Component<FarmwareProps, {}> {
return isBotOnline(this.props.syncStatus, this.props.botToMqttStatus);
}
UNSAFE_componentWillMount() {
componentDidMount() {
if (window.innerWidth > 450) {
this.props.dispatch({
type: Actions.SELECT_FARMWARE,

View File

@ -26,6 +26,8 @@ jest.mock("../../api", () => ({
}
}));
jest.mock("../laptop_splash", () => ({ LaptopSplash: () => <div /> }));
import * as React from "react";
import { mount, shallow } from "enzyme";
import { FrontPage, setField, PartialFormEvent } from "../front_page";

View File

@ -0,0 +1,10 @@
import * as React from "react";
import { shallow } from "enzyme";
import { LaptopSplash } from "../laptop_splash";
describe("<LaptopSplash />", () => {
it("renders", () => {
const wrapper = shallow(<LaptopSplash className={""} />);
expect(wrapper.find("video").length).toEqual(1);
});
});

View File

@ -117,7 +117,6 @@ describe("<Logs />", () => {
it("shows filtered overall filter status", () => {
const p = fakeProps();
p.shouldDisplay = () => true;
const wrapper = mount(<Logs {...p} />);
const state = fakeLogsState();
state.assertion = 2;
@ -129,10 +128,9 @@ describe("<Logs />", () => {
it("shows unfiltered overall filter status", () => {
const p = fakeProps();
p.shouldDisplay = () => false;
const wrapper = mount(<Logs {...p} />);
const state = fakeLogsState();
state.assertion = 2;
state.assertion = 3;
wrapper.setState(state);
const filterBtn = wrapper.find("button").first();
expect(filterBtn.text().toLowerCase()).toEqual("filter");

View File

@ -5,7 +5,6 @@ import { Filters } from "../interfaces";
import { startCase } from "lodash";
import { MESSAGE_TYPES, MessageType } from "../../sequences/interfaces";
import { t } from "../../i18next_wrapper";
import { Feature, ShouldDisplay } from "../../devices/interfaces";
const MENU_ORDER: string[] = [
MessageType.success,
@ -26,11 +25,9 @@ const menuSort = (a: string, b: string) =>
/** Get log filter keys from LogsState. */
export const filterStateKeys =
(state: LogsState, shouldDisplay: ShouldDisplay) =>
(state: LogsState) =>
Object.keys(state)
.filter(key => !["autoscroll", "markdown", "searchTerm"].includes(key))
.filter(key => shouldDisplay(Feature.assertion_block)
|| key !== "assertion");
.filter(key => !["autoscroll", "markdown", "searchTerm"].includes(key));
export const LogsFilterMenu = (props: LogsFilterMenuProps) => {
/** Filter level 0: logs hidden. */
@ -56,7 +53,7 @@ export const LogsFilterMenu = (props: LogsFilterMenuProps) => {
{t("normal")}
</button>
</fieldset>
{filterStateKeys(props.state, props.shouldDisplay).sort(menuSort)
{filterStateKeys(props.state).sort(menuSort)
.map((logType: keyof Filters) =>
<fieldset key={logType}>
<label>

View File

@ -76,7 +76,7 @@ export class RawLogs extends React.Component<LogsProps, Partial<LogsState>> {
/** Determine if log type filters are active. */
get filterActive() {
const filterKeys = filterStateKeys(this.state, this.props.shouldDisplay);
const filterKeys = filterStateKeys(this.state);
const filterValues = filterKeys
.map((key: keyof Filters) => this.state[key]);
// Filters active if every log type level is not equal to 3 (max verbosity)

View File

@ -1,25 +1,22 @@
import { regimensReducer, RegimenState } from "../reducer";
import { regimensReducer, RegimenState, newWeek } from "../reducer";
import { Actions } from "../../constants";
import { popWeek, pushWeek, selectDays, deselectDays } from "../bulk_scheduler/actions";
import {
popWeek, pushWeek, selectDays, deselectDays,
} from "../bulk_scheduler/actions";
import { defensiveClone } from "../../util";
import { Week } from "../bulk_scheduler/interfaces";
const week = newWeek();
Object.entries(week.days).map(([day, _]: [keyof Week["days"], boolean]) => {
week.days[day] = true;
});
week.days.day7 = false;
const STATE: RegimenState = {
dailyOffsetMs: 300000,
selectedSequenceUUID: "Sequence.71.167",
currentRegimen: "Regimen.4.56",
weeks: [
{
"days": {
"day1": true,
"day2": true,
"day3": true,
"day4": true,
"day5": true,
"day6": true,
"day7": false
}
},
],
weeks: [week],
schedulerOpen: false,
};

View File

@ -1,39 +0,0 @@
import * as React from "react";
import { render } from "enzyme";
import { WeekRow } from "../bulk_scheduler/week_row";
import { WeekRowProps } from "../bulk_scheduler/interfaces";
import { betterMerge } from "../../util";
function weekProps(p?: Partial<WeekRowProps>): WeekRowProps {
return betterMerge({
dispatch: jest.fn(),
index: 0,
week: {
"days": {
"day1": false,
"day2": false,
"day3": false,
"day4": false,
"day5": false,
"day6": false,
"day7": false
}
}
}, p || {});
}
describe("<WeekRow/>", () => {
it("renders week 1 day numbers", () => {
const wrapper = render(<WeekRow {...weekProps() } />);
const txt = wrapper.text();
expect(txt).toEqual("Week 11234567");
});
});
describe("<WeekRow/>", () => {
it("renders week 2 day numbers", () => {
const wrapper = render(<WeekRow {...weekProps({ index: 1 }) } />);
const txt = wrapper.text();
expect(txt).toEqual("Week 2891011121314");
});
});

View File

@ -19,6 +19,7 @@ import { arrayUnwrap } from "../../../resources/util";
import { overwrite } from "../../../api/crud";
import { fakeVariableNameSet } from "../../../__test_support__/fake_variables";
import { error, warning } from "../../../toast/toast";
import { newWeek } from "../../reducer";
const sequence_id = 23;
const regimen_id = 32;
@ -53,18 +54,9 @@ describe("commitBulkEditor()", () => {
state.resources.consumers.regimens.currentRegimen = regimenUuid;
state.resources.consumers.regimens.selectedSequenceUUID = sequenceUuid;
state.resources.consumers.regimens.dailyOffsetMs = 2000;
state.resources.consumers.regimens.weeks = [{
days:
{
day1: true,
day2: false,
day3: false,
day4: false,
day5: false,
day6: false,
day7: false
}
}];
const week = newWeek();
week.days.day1 = true;
state.resources.consumers.regimens.weeks = [week];
return state;
}

View File

@ -0,0 +1,36 @@
import { groupRegimenItemsByWeek } from "../group_regimen_items_by_week";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { newWeek } from "../../reducer";
describe("groupRegimenItemsByWeek()", () => {
it("groups regimen items by week", () => {
const sequence = fakeSequence();
sequence.body.id = 1;
const week1 = newWeek();
week1.days.day1 = true;
const week2 = newWeek();
const week3 = newWeek();
week3.days.day2 = true;
week3.days.day4 = true;
const { day1, day2, day3, day4, day5, day6, day7 } = week3.days;
week3.days = { day1, day4, day3, day2, day5, day6, day7 };
const weeks = [week1, week2, week3];
const result = groupRegimenItemsByWeek(weeks, 100, sequence.body);
expect(result).toEqual([
{ time_offset: 100, sequence_id: 1 },
{ time_offset: 1296000100, sequence_id: 1 },
{ time_offset: 1468800100, sequence_id: 1 },
]);
});
it("handles missing sequence id", () => {
const sequence = fakeSequence();
sequence.body.id = undefined;
const week = newWeek();
week.days.day1 = true;
const result = groupRegimenItemsByWeek([week], 0, sequence.body);
expect(result).toEqual([
{ time_offset: 0, sequence_id: -1 },
]);
});
});

View File

@ -8,20 +8,12 @@ import {
import { Actions } from "../../../constants";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { AddButton } from "../add_button";
import { newWeek } from "../../reducer";
describe("<BulkScheduler />", () => {
const weeks = [{
days:
{
day1: true,
day2: false,
day3: false,
day4: false,
day5: false,
day6: false,
day7: false
}
}];
const week = newWeek();
week.days.day1 = true;
const weeks = [week];
function fakeProps(): BulkEditorProps {
const sequence = fakeSequence();

View File

@ -3,20 +3,12 @@ import { mount } from "enzyme";
import { WeekGrid } from "../week_grid";
import { WeekGridProps } from "../interfaces";
import { Actions } from "../../../constants";
import { newWeek } from "../../reducer";
describe("<WeekGrid />", () => {
const weeks = [{
days:
{
day1: true,
day2: false,
day3: false,
day4: false,
day5: false,
day6: false,
day7: false
}
}];
const week = newWeek();
week.days.day1 = true;
const weeks = [week];
it("renders", () => {
const props: WeekGridProps = { weeks, dispatch: jest.fn() };

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { render, mount } from "enzyme";
import { WeekRow } from "../week_row";
import { WeekRowProps } from "../interfaces";
import { betterMerge } from "../../../util";
import { newWeek } from "../../reducer";
import { Actions } from "../../../constants";
describe("<WeekRow/>", () => {
const fakeProps = (p?: Partial<WeekRowProps>): WeekRowProps =>
betterMerge({
dispatch: jest.fn(),
index: 0,
week: newWeek()
}, p || {});
it("renders week 1 day numbers", () => {
const wrapper = render(<WeekRow {...fakeProps()} />);
expect(wrapper.text()).toEqual("Week 11234567");
});
it("renders week 2 day numbers", () => {
const wrapper = render(<WeekRow {...fakeProps({ index: 1 })} />);
expect(wrapper.text()).toEqual("Week 2891011121314");
});
it("selects day", () => {
const p = fakeProps();
const wrapper = mount(<WeekRow {...p} />);
wrapper.find("input").first().simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_DAY,
payload: { week: 0, day: 1 },
});
});
});

View File

@ -6,17 +6,15 @@ import { t } from "../../i18next_wrapper";
export function WeekRow({ index, dispatch, week }: WeekRowProps) {
return <div className="week-row">
<label className="week-label">{t("Week")} {index + 1}</label>
{
DAYS.map(function (day, i) {
const id = `${index}-${day}`;
return <Day day={i + 1}
week={index}
dispatch={dispatch}
id={id}
key={id}
active={week.days[day]} />;
})
}
{DAYS.map(function (day, i) {
const id = `${index}-${day}`;
return <Day day={i + 1}
week={index}
dispatch={dispatch}
id={id}
key={id}
active={week.days[day]} />;
})}
</div>;
}

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