criteria ui updates

pull/1752/head
gabrielburnworth 2020-04-13 12:24:38 -07:00
parent dccea4e474
commit b087e08f13
72 changed files with 1851 additions and 928 deletions

View File

@ -0,0 +1,2 @@
export const mockDispatch = (innerDispatch = jest.fn()) =>
jest.fn(x => typeof x === "function" && x(innerDispatch));

View File

@ -2,6 +2,27 @@ import { trim } from "./util";
export namespace ToolTips {
// Farm Designer: Groups
export const SORT_DESCRIPTION =
trim(`When executing a sequence over a Group of locations, FarmBot will
travel to each group member in the order of the chosen sort method.
If the random option is chosen, FarmBot will travel in a random order
every time, so the ordering shown below will only be representative.`);
export const CRITERIA_SELECTION_COUNT =
trim(`Filter additions can only be removed by changing filters.
Click and drag in the map to modify selection filters.
Filters will be applied at the time of sequence execution. The final
selection at that time may differ from the selection currently
displayed.`);
export const CRITERIA_ALPHA_FEATURE =
trim(`Group filters is a new feature under active development.
Use with caution.`);
export const DOT_NOTATION_TIP =
trim(`Tip: Use dot notation (i.e., 'meta.color') to access meta fields.`);
// Controls
export const MOVE =
trim(`Use these manual control buttons to move FarmBot in realtime.
@ -12,7 +33,12 @@ export namespace ToolTips {
export const WEBCAM =
trim(`If you have a webcam, you can view the video stream in this widget.
Press the edit button to update and save your webcam URL.`);
Press the edit button to update and save your webcam URL.
Note: Some webcam services do not allow webcam feeds to be embedded in
other sites. If you see a web browser error after adding a webcam feed,
there is unfortunately nothing FarmBot can do to fix the problem.
Please contact your webcam's customer support to see if the security
policy for embedding feeds into other sites can be changed.`);
export const PERIPHERALS =
trim(`Use these toggle switches to control FarmBot's peripherals in
@ -26,10 +52,19 @@ export namespace ToolTips {
export const SENSOR_HISTORY =
trim(`View and filter historical sensor reading data.`);
// Device
export const OS_SETTINGS =
trim(`View and change device settings.`);
// FarmBot OS Settings: Firmware
export const FIRMWARE_VALUE_API =
trim(`Firmware value from your choice in the dropdown to the left, as
understood by the Web App.`);
export const FIRMWARE_VALUE_FBOS =
trim(`Firmware value reported from the firmware, as understood by
FarmBot OS.`);
export const FIRMWARE_VALUE_MCU =
trim(`Firmware value reported from the firmware.`);
// Hardware Settings
export const HW_SETTINGS =
trim(`Change settings of your FarmBot hardware with the fields below.
Caution: Changing these settings to extreme values can cause hardware
@ -38,18 +73,6 @@ export namespace ToolTips {
Tip: Recalibrate FarmBot after changing settings and test a
few sequences to verify that everything works as expected.`);
export const PIN_BINDINGS =
trim(`Assign an action or sequence to execute when a Raspberry Pi
GPIO pin is activated.`);
export const PIN_BINDING_WARNING =
trim(`Warning: Binding to a pin without a physical button and
pull-down resistor connected may put FarmBot into an unstable state.`);
// Connectivity
export const CONNECTIVITY =
trim(`Diagnose connectivity issues with FarmBot and the browser.`);
// Hardware Settings: Homing and Calibration
export const HOMING_ENCODERS =
trim(`If encoders or end-stops are enabled, home axis (find zero).`);
@ -202,13 +225,16 @@ export namespace ToolTips {
trim(`The number of the pin to guard. This pin will be set to the specified
state after the duration specified by TIMEOUT.`);
// Hardware Settings: Pin Bindings
export const PIN_BINDINGS =
trim(`Assign an action or sequence to execute when a Raspberry Pi
GPIO pin is activated.`);
export const PIN_BINDING_WARNING =
trim(`Warning: Binding to a pin without a physical button and
pull-down resistor connected may put FarmBot into an unstable state.`);
// Farmware
export const FARMWARE =
trim(`Manage Farmware (plugins).`);
export const FARMWARE_LIST =
trim(`View, select, and install new Farmware.`);
export const PHOTOS =
trim(`Take and view photos with your FarmBot's camera.`);
@ -232,9 +258,6 @@ export namespace ToolTips {
You can also edit, copy, and delete existing sequences;
assign a color; and give your commands custom names.`);
export const SEQUENCE_LIST =
trim(`Here is the list of all of your sequences. Click one to edit.`);
export const DEFAULT_VALUE =
trim(`Select a location to be used as the default value for this variable.
If the sequence is ever run without the variable explicitly set to
@ -312,6 +335,7 @@ export namespace ToolTips {
export const TAKE_PHOTO =
trim(`Snaps a photo using the device camera. Select the camera type
on the Device page.`);
export const EMERGENCY_LOCK =
trim(`Stops a device from moving until it is unlocked by a user.`);
@ -363,20 +387,6 @@ export namespace ToolTips {
growing at the same or different times. Multiple regimens can be
applied to any one plant.`);
export const REGIMEN_LIST =
trim(`This is a list of all of your regimens. Click one to begin
editing it.`);
// Tools
export const TOOL_LIST =
trim(`This is a list of all your FarmBot tools and seed containers.
Click the Edit button to add, edit, or delete tools or seed containers.`);
export const TOOLBAY_LIST =
trim(`Tool slots are where you store your FarmBot tools and seed
containers, which should be reflective of your real FarmBot hardware
configuration.`);
// Logs
export const LOGS =
trim(`View and filter log messages.`);
@ -399,16 +409,6 @@ export namespace ToolTips {
export const FIRMWARE_DEBUG_MESSAGES =
trim(`Log all debug received from firmware (clears after refresh).`);
export const MESSAGES =
trim(`View messages.`);
// App
export const LABS =
trim(`Customize your web app experience.`);
export const TOURS =
trim(`Take a guided tour of the Web App.`);
}
export namespace Content {
@ -512,11 +512,9 @@ export namespace Content {
real account at`);
// App Settings
export const CONFIRM_STEP_DELETION =
trim(`Show a confirmation dialog when deleting a sequence step.`);
export const CONFIRM_SEQUENCE_DELETION =
trim(`Show a confirmation dialog when deleting a sequence.`);
export const TIME_FORMAT_24_HOUR =
trim(`Display time using the 24-hour notation,
i.e., 23:00 instead of 11:00pm`);
export const HIDE_WEBCAM_WIDGET =
trim(`If not using a webcam, use this setting to remove the
@ -526,14 +524,6 @@ export namespace Content {
trim(`If not using sensors, use this setting to remove the
widget from the Controls page.`);
export const DYNAMIC_MAP_SIZE =
trim(`Change the garden map size based on axis length.
A value must be input in AXIS LENGTH and STOP AT MAX must be enabled in
the HARDWARE widget. Overrides MAP SIZE values.`);
export const PLANT_ANIMATIONS =
trim(`Enable plant animations in the garden map.`);
export const BROWSER_SPEAK_LOGS =
trim(`Have the browser also read aloud log messages on the
"Speak" channel that are spoken by FarmBot.`);
@ -546,22 +536,25 @@ export namespace Content {
trim(`Warning! When enabled, any unsaved changes
will be discarded when refreshing or closing the page. Are you sure?`);
export const DISCARD_UNSAVED_SEQUENCE_CHANGES =
trim(`Don't ask about saving sequence work before
closing browser tab. Warning: may cause loss of data.`);
export const EMERGENCY_UNLOCK_CONFIRM_CONFIG =
trim(`Confirm when unlocking FarmBot after an emergency stop.`);
export const DISCARD_UNSAVED_SEQUENCE_CHANGES_CONFIRM =
trim(`Warning! When enabled, any unsaved changes to sequences
will be discarded when refreshing or closing the page. Are you sure?`);
export const CONFIRM_EMERGENCY_UNLOCK_CONFIRM_DISABLE =
trim(`Warning! When disabled, clicking the UNLOCK button will immediately
unlock FarmBot instead of confirming that it is safe to do so.
As a result, double-clicking the E-STOP button may not stop FarmBot.
Are you sure you want to disable this feature?`);
export const VIRTUAL_TRAIL =
trim(`Display a virtual trail for FarmBot in the garden map to show
movement and watering history while the map is open. Toggling this setting
will clear data for the current trail.`);
export const USER_INTERFACE_READ_ONLY_MODE =
trim(`Disallow account data changes. This does
not prevent Farmwares or FarmBot OS from changing settings.`);
export const TIME_FORMAT_24_HOUR =
trim(`Display time using the 24-hour notation,
i.e., 23:00 instead of 11:00pm`);
// Sequence Settings
export const CONFIRM_STEP_DELETION =
trim(`Show a confirmation dialog when deleting a sequence step.`);
export const CONFIRM_SEQUENCE_DELETION =
trim(`Show a confirmation dialog when deleting a sequence.`);
export const SHOW_PINS =
trim(`Show raw pin lists in Read Sensor, Control Peripheral, and
@ -570,18 +563,27 @@ export namespace Content {
export const EXPAND_STEP_OPTIONS =
trim(`Choose whether advanced step options are open or closed by default.`);
export const EMERGENCY_UNLOCK_CONFIRM_CONFIG =
trim(`Confirm when unlocking FarmBot after an emergency stop.`);
export const DISCARD_UNSAVED_SEQUENCE_CHANGES =
trim(`Don't ask about saving sequence work before
closing browser tab. Warning: may cause loss of data.`);
export const USER_INTERFACE_READ_ONLY_MODE =
trim(`Disallow account data changes. This does
not prevent Farmwares or FarmBot OS from changing settings.`);
export const DISCARD_UNSAVED_SEQUENCE_CHANGES_CONFIRM =
trim(`Warning! When enabled, any unsaved changes to sequences
will be discarded when refreshing or closing the page. Are you sure?`);
export const CONFIRM_EMERGENCY_UNLOCK_CONFIRM_DISABLE =
trim(`Warning! When disabled, clicking the UNLOCK button will immediately
unlock FarmBot instead of confirming that it is safe to do so.
As a result, double-clicking the E-STOP button may not stop FarmBot.
Are you sure you want to disable this feature?`);
// Farm Designer Settings
export const PLANT_ANIMATIONS =
trim(`Enable plant animations in the garden map.`);
export const VIRTUAL_TRAIL =
trim(`Display a virtual trail for FarmBot in the garden map to show
movement and watering history while the map is open. Toggling this setting
will clear data for the current trail.`);
export const DYNAMIC_MAP_SIZE =
trim(`Change the garden map size based on axis length.
A value must be input in AXIS LENGTH and STOP AT MAX must be enabled in
the HARDWARE widget. Overrides MAP SIZE values.`);
export const MAP_SIZE =
trim(`Specify custom map dimensions (in millimeters).
@ -600,13 +602,41 @@ export namespace Content {
export const CONFIRM_PLANT_DELETION =
trim(`Show a confirmation dialog when deleting a plant.`);
// Device
export const NOT_HTTPS =
trim(`WARNING: Sending passwords via HTTP:// is not secure.`);
// FarmBot OS Settings
export const DIFFERENT_TZ_WARNING =
trim(`Note: The selected timezone for your FarmBot is different than
your local browser time.`);
export const CONTACT_SYSADMIN =
trim(`Please contact the system(s) administrator(s) and ask them to enable
HTTPS://`);
export const OS_BETA_RELEASES =
trim(`Warning! Leaving the stable FarmBot OS release channel may reduce
FarmBot system stability. Are you sure?`);
export const DEVICE_NEVER_SEEN =
trim(`The device has never been seen. Most likely,
there is a network connectivity issue on the device's end.`);
export const TOO_OLD_TO_UPDATE =
trim(`Please re-flash your FarmBot's SD card.`);
export const OS_AUTO_UPDATE =
trim(`When enabled, FarmBot OS will automatically download and install
software updates at the chosen time.`);
export const AUTO_SYNC =
trim(`When enabled, device resources such as sequences and regimens
will be sent to the device automatically. This removes the need to push
"SYNC" after making changes in the web app. Changes to running
sequences and regimens while auto sync is enabled will result in
instantaneous change.`);
// FarmBot OS Settings: Power and Reset
export const RESTART_FARMBOT =
trim(`This will restart FarmBot's Raspberry Pi and controller
software.`);
export const SHUTDOWN_FARMBOT =
trim(`This will shutdown FarmBot's Raspberry Pi. To turn it
back on, unplug FarmBot and plug it back in.`);
export const FACTORY_RESET_WARNING =
trim(`Factory resetting your FarmBot will destroy all data on the device,
@ -624,10 +654,6 @@ export namespace Content {
not delete data stored in your web app account. Are you sure you wish
to continue?`);
export const MCU_RESET_ALERT =
trim(`Warning: This will reset all hardware settings to the default values.
Are you sure you wish to continue?`);
export const AUTO_FACTORY_RESET =
trim(`Automatically factory reset when the WiFi network cannot be
detected. Useful for network changes.`);
@ -636,54 +662,26 @@ export namespace Content {
trim(`Time in minutes to attempt connecting to WiFi before a factory
reset.`);
export const DIFFERENT_TZ_WARNING =
trim(`Note: The selected timezone for your FarmBot is different than
your local browser time.`);
export const NOT_HTTPS =
trim(`WARNING: Sending passwords via HTTP:// is not secure.`);
export const RESTART_FARMBOT =
trim(`This will restart FarmBot's Raspberry Pi and controller
software.`);
export const CONTACT_SYSADMIN =
trim(`Please contact the system(s) administrator(s) and ask them to enable
HTTPS://`);
// FarmBot OS Settings: Firmware
export const RESTART_FIRMWARE =
trim(`Restart the Farmduino or Arduino firmware.`);
export const OS_AUTO_UPDATE =
trim(`When enabled, FarmBot OS will automatically download and install
software updates at the chosen time.`);
export const AUTO_SYNC =
trim(`When enabled, device resources such as sequences and regimens
will be sent to the device automatically. This removes the need to push
"SYNC" after making changes in the web app. Changes to running
sequences and regimens while auto sync is enabled will result in
instantaneous change.`);
export const SHUTDOWN_FARMBOT =
trim(`This will shutdown FarmBot's Raspberry Pi. To turn it
back on, unplug FarmBot and plug it back in.`);
export const OS_BETA_RELEASES =
trim(`Warning! Leaving the stable FarmBot OS release channel may reduce
FarmBot system stability. Are you sure?`);
export const DIAGNOSTIC_CHECK =
trim(`Save snapshot of FarmBot OS system information, including
user and device identity, to the database. A code will be returned
that you can provide in support requests to allow FarmBot to look up
data relevant to the issue to help us identify the problem.`);
export const DEVICE_NEVER_SEEN =
trim(`The device has never been seen. Most likely,
there is a network connectivity issue on the device's end.`);
export const TOO_OLD_TO_UPDATE =
trim(`Please re-flash your FarmBot's SD card.`);
// Hardware Settings
// Hardware Settings: Danger Zone
export const RESTORE_DEFAULT_HARDWARE_SETTINGS =
trim(`Restoring hardware parameter defaults will destroy the
current settings, resetting them to default values.`);
export const MCU_RESET_ALERT =
trim(`Warning: This will reset all hardware settings to the default values.
Are you sure you wish to continue?`);
// App
export const APP_LOAD_TIMEOUT_MESSAGE =
trim(`App could not be fully loaded, we recommend you try
@ -711,10 +709,6 @@ export namespace Content {
broken and may break or otherwise hinder your usage of the rest of the
app. This feature may disappear or break at any time.`);
export const NEW_TOS =
trim(`Before logging in, you must agree to our latest Terms of Service and
Privacy Policy`);
export const FORCE_REFRESH_CONFIRM =
trim(`A new version of the FarmBot web app has been released.
Refresh page?`);
@ -819,7 +813,8 @@ export namespace Content {
export const MOUNTED_TOOL =
trim(`The tool currently mounted to the UTM can be set here or by using
a MARK AS step in a sequence.`);
a MARK AS step in a sequence. Use the verify button or read the tool
verification pin in a sequence to verify that a tool is attached.`);
// Farm Events
export const NOTHING_SCHEDULED =
@ -831,10 +826,6 @@ export namespace Content {
regimen tasks. Consider rescheduling this event to tomorrow if
this is a concern.`);
export const INVALID_RUN_TIME =
trim(`This event does not appear to have a valid run time.
Perhaps you entered bad dates?`);
export const FARM_EVENT_TZ_WARNING =
trim(`Note: Times displayed according to FarmBot's local time, which
is currently different from your browser's time. Timezone data is
@ -849,27 +840,14 @@ export namespace Content {
trim(`You haven't made any sequences or regimens yet. To add an event,
first create a sequence or regimen.`);
// Groups
export const SORT_DESCRIPTION =
trim(`When executing a sequence over a Group of locations, FarmBot will
travel to each group member in the order of the chosen sort method.
If the random option is chosen, FarmBot will travel in a random order
every time, so the ordering shown below will only be representative.`);
export const CRITERIA_SELECTION_COUNT =
trim(`Criteria additions can only be removed by changing criteria.
Click and drag in the map to modify selection criteria.
Criteria will be applied at the time of sequence execution. The final
selection at that time may differ from the selection currently
displayed.`);
// Farmware
export const NO_IMAGES_YET =
trim(`You haven't yet taken any photos with your FarmBot.
Once you do, they will show up here.`);
export const PROCESSING_PHOTO =
trim(`Processing now. Results usually available in one minute.`);
trim(`Processing now. Results usually available in one minute.
Check log messages for result status.`);
export const NOT_AVAILABLE_WHEN_OFFLINE =
trim(`Not available when device is offline.`);
@ -1020,7 +998,6 @@ export enum DeviceSetting {
powerAndReset = `Power and Reset`,
restartFarmbot = `Restart Farmbot`,
shutdownFarmbot = `Shutdown Farmbot`,
restartFirmware = `Restart Firmware`,
factoryReset = `Factory Reset`,
autoFactoryReset = `Automatic Factory Reset`,
connectionAttemptPeriod = `Connection Attempt Period`,
@ -1038,6 +1015,7 @@ export enum DeviceSetting {
// Firmware
firmwareSection = `Firmware`,
restartFirmware = `Restart Firmware`,
flashFirmware = `Flash firmware`,
}

View File

@ -367,9 +367,13 @@
margin-top: 1rem;
}
}
.saucer {
margin: 1rem;
margin-left: 2rem;
.point-color-input {
div[class*=col-] {
padding-left: 0.5rem;
}
.saucer {
margin-top: 2.75rem;
}
}
.delete-row {
margin: 1.5rem;
@ -391,8 +395,27 @@
display: block;
margin-top: 3rem;
}
font-size: 1.4rem;
p {
margin-top: 1rem;
margin-bottom: 0.5rem !important;
font-size: 1.2rem;
}
.weed-removal-method-section {
.weed-removal-method {
display: flex;
input {
margin: 0;
width: 10%;
box-shadow: none;
}
label {
margin: 0;
margin-top: auto;
font-size: 1.25rem;
font-weight: normal;
}
}
}
}
@ -517,6 +540,7 @@
margin-top: 1rem;
p {
font-size: 1.25rem;
margin-bottom: 0.5rem !important;
}
}
input {
@ -836,6 +860,11 @@
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 5rem;
.clear-day-criteria,
.clear-point-ids,
.clear-criteria {
margin-top: 0.2rem;
}
.group-member-display {
i[class*=fa-caret-] {
float: right;
@ -862,20 +891,43 @@
.criteria-heading {
margin-top: 0;
}
.alpha-icon {
display: inline;
float: none !important;
margin-left: 1rem;
color: $orange;
font-size: 1.4rem;
}
p {
&.category {
display: block;
padding-top: 1rem;
padding-bottom: 1rem;
text-transform: none;
font-size: 1.2rem;
font-weight: bold;
}
}
.bp3-popover-wrapper {
float: right;
}
.fb-button {
margin-top: 0.5rem;
}
.point-type-section,
.criteria-checkbox-list-item {
.fb-checkbox {
display: inline;
margin-right: 1rem;
vertical-align: top;
}
p {
display: inline;
text-transform: uppercase;
}
}
.point-type-checkboxes {
.point-type-section {
.fb-checkbox {
display: inline;
margin-right: 1rem;
vertical-align: top;
}
p {
display: inline;
text-transform: uppercase;
}
.point-type-checkbox {
position: relative;
height: 2rem;
@ -894,19 +946,9 @@
}
}
.plant-criteria-options,
.weed-criteria-options,
.point-criteria-options,
.tool-criteria-options {
margin-left: 3rem;
p {
&.category {
display: block;
padding-top: 1rem;
padding-bottom: 1rem;
text-transform: none;
font-size: 1.2rem;
font-weight: bold;
}
}
hr {
margin: 0.5rem;
}
@ -936,7 +978,13 @@
margin-top: 1rem;
}
.day-criteria {
p {
.criteria-checkbox-list-item {
margin-bottom: 1rem;
p {
vertical-align: middle;
}
}
.days-old-text {
display: inline;
vertical-align: bottom;
}
@ -944,6 +992,7 @@
line-height: 1.75rem;
}
}
.number-eq-criteria,
.string-eq-criteria {
margin-top: 1rem;
.row {
@ -970,19 +1019,13 @@
font-size: 1.2rem;
}
}
.fb-toggle-button {
width: 85px;
margin-top: 0;
&.red {
background: $dark_gray !important;
}
}
.clear-criteria {
margin-top: 2rem;
}
.basic,
.advanced {
margin-left: 1rem;
.filter-search {
height: 3rem;
margin-bottom: 1rem;
}
.day-criteria {
.row {
margin-left: 0;
@ -994,6 +1037,12 @@
}
}
.advanced {
.bp3-popover-wrapper {
display: inline;
float: none;
margin-left: 1rem;
font-size: 1.4rem;
}
.row {
margin-left: 0;
}
@ -1011,29 +1060,28 @@
}
}
}
.criteria-point-count-breakdown {
margin-bottom: 1rem;
.manual-group-member-count,
.criteria-group-member-count {
margin-left: 2rem;
div {
display: inline;
padding: 0.25rem;
font-size: 1.2rem;
border: 1px solid $panel_light_blue;
}
p {
display: inline;
margin-left: 1rem;
}
}
.criteria-group-member-count {
div {
border: 1px solid gray;
border-radius: 5px;
}
}
}
}
.group-member-count-breakdown {
margin-bottom: 1rem;
.manual-group-member-count,
.criteria-group-member-count {
div {
display: inline;
padding: 0.25rem;
font-size: 1.2rem;
}
p {
display: inline;
margin-left: 1rem;
}
}
}
.criteria-options-menu {
label {
margin-right: 1rem;
}
}
@ -1042,6 +1090,7 @@
display: inline-block;
.row {
margin-left: 0;
margin-right: -2.5rem;
div[class*=col-] {
padding: 0;
text-align: center;
@ -1057,16 +1106,28 @@
margin-top: 0.5rem;
}
}
button {
margin-top: 2rem !important;
}
.edit-in-map {
float: right;
button {
margin: 1rem !important;
width: 5rem !important;
margin-right: 0 !important;
}
label {
margin-top: 1.1rem !important;
}
}
.location-selection-warning {
i,
p {
display: inline;
margin-right: 1rem;
color: $darkest_red;
}
}
}
.weeds-inventory-panel,

View File

@ -1424,6 +1424,11 @@ ul {
button {
float: none !important;
}
.bp3-popover-wrapper {
display: inline;
margin-left: 0.5rem;
font-size: 1.3rem;
}
}
.problem-alert {

View File

@ -127,6 +127,16 @@ select {
background: $white;
margin-top: 0;
cursor: pointer;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $white;
opacity: 0.5;
}
&:checked:after {
content: "";
position: absolute;

View File

@ -8,6 +8,8 @@ import { FirmwareAlerts } from "../../../messages/alerts";
import { TimeSettings } from "../../../interfaces";
import { Alert } from "farmbot";
import { isFwHardwareValue, boardType } from "../firmware_hardware_support";
import { Help } from "../../../ui";
import { ToolTips } from "../../../constants";
export interface FirmwareHardwareStatusIconProps {
firmwareHardware: string | undefined;
@ -59,10 +61,13 @@ export const FirmwareHardwareStatusDetails =
(props: FirmwareHardwareStatusDetailsProps) => {
return <div className="firmware-hardware-status-details">
<label>{t("Web App")}</label>
<Help text={ToolTips.FIRMWARE_VALUE_API} />
<p>{lookup(props.apiFirmwareValue) || t("unknown")}</p>
<label>{t("FarmBot OS")}</label>
<Help text={ToolTips.FIRMWARE_VALUE_FBOS} />
<p>{lookup(props.botFirmwareValue) || t("unknown")}</p>
<label>{t("Arduino/Farmduino")}</label>
<Help text={ToolTips.FIRMWARE_VALUE_MCU} />
<p>{lookup(props.mcuFirmwareValue) || t("unknown")}</p>
<FirmwareAlerts
alerts={props.alerts}

View File

@ -48,7 +48,10 @@ describe("<FarmDesigner/>", () => {
raw_encoders: { x: undefined, y: undefined, z: undefined },
},
botMcuParams: bot.hardware.mcu_params,
stepsPerMmXY: { x: undefined, y: undefined },
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true },
},
peripherals: [],
eStopStatus: false,
latestImages: [],

View File

@ -1,4 +1,4 @@
import { mapStateToProps, getPlants } from "../state_to_props";
import { mapStateToProps, getPlants, botSize } from "../state_to_props";
import { fakeState } from "../../__test_support__/fake_state";
import {
buildResourceIndex, fakeDevice,
@ -11,6 +11,7 @@ import {
fakeWebAppConfig,
fakeFarmwareEnv,
fakeSensorReading,
fakeFirmwareConfig,
} from "../../__test_support__/fake_state/resources";
import { WebAppConfig } from "farmbot/dist/resources/configs/web_app";
import { generateUuid } from "../../resources/util";
@ -40,13 +41,6 @@ describe("mapStateToProps()", () => {
checkValue(2, true);
});
it("stepsPerMm is defined", () => {
const state = fakeState();
state.bot.hardware.mcu_params.movement_step_per_mm_x = 3;
state.bot.hardware.mcu_params.movement_step_per_mm_y = 4;
expect(mapStateToProps(state).stepsPerMmXY).toEqual({ x: 3, y: 4 });
});
it("returns selected plant", () => {
const state = fakeState();
state.resources = buildResourceIndex([fakePlant(), fakeDevice()]);
@ -144,3 +138,45 @@ describe("getPlants()", () => {
expect.objectContaining({ rotation: "15" }));
});
});
describe("botSize()", () => {
it("returns default bot size", () => {
const state = fakeState();
expect(botSize(state)).toEqual({
x: { value: 2900, isDefault: true },
y: { value: 1400, isDefault: true },
});
});
it("returns map setting bot size", () => {
const state = fakeState();
const webAppConfig = fakeWebAppConfig();
webAppConfig.body.map_size_x = 1000;
webAppConfig.body.map_size_y = 1000;
state.resources = buildResourceIndex([fakeDevice(), webAppConfig]);
expect(botSize(state)).toEqual({
x: { value: 1000, isDefault: true },
y: { value: 1000, isDefault: true },
});
});
it("returns axis length setting bot size", () => {
const state = fakeState();
const firmwareConfig = fakeFirmwareConfig();
firmwareConfig.body.movement_step_per_mm_x = 2;
firmwareConfig.body.movement_step_per_mm_y = 4;
firmwareConfig.body.movement_stop_at_max_x = 1;
firmwareConfig.body.movement_stop_at_max_y = 1;
firmwareConfig.body.movement_axis_nr_steps_x = 100;
firmwareConfig.body.movement_axis_nr_steps_y = 100;
const webAppConfig = fakeWebAppConfig();
webAppConfig.body.map_size_x = 1000;
webAppConfig.body.map_size_y = 1000;
state.resources = buildResourceIndex([
fakeDevice(), firmwareConfig, webAppConfig]);
expect(mapStateToProps(state).botSize).toEqual({
x: { value: 50, isDefault: false },
y: { value: 25, isDefault: false },
});
});
});

View File

@ -11,8 +11,7 @@ import { NumericSetting, BooleanSetting } from "../session_keys";
import { isUndefined, last, isFinite } from "lodash";
import { AxisNumberProperty, BotSize } from "./map/interfaces";
import {
getBotSize, round, getPanelStatus, MapPanelStatus, mapPanelClassName,
getMapPadding,
round, getPanelStatus, MapPanelStatus, mapPanelClassName, getMapPadding,
} from "./map/util";
import {
calcZoomLevel, getZoomLevelIndex, saveZoomLevelIndex,
@ -126,11 +125,6 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
zoom_level
} = this.state;
const botSize = getBotSize(
this.props.botMcuParams,
this.props.stepsPerMmXY,
getDefaultAxisLength(this.props.getConfigValue));
const stopAtHome = {
x: !!this.props.botMcuParams.movement_stop_at_home_x,
y: !!this.props.botMcuParams.movement_stop_at_home_y
@ -167,6 +161,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
dispatch={this.props.dispatch}
timeSettings={this.props.timeSettings}
getConfigValue={this.props.getConfigValue}
shouldDisplay={this.props.shouldDisplay}
imageAgeInfo={imageAgeInfo} />
<DesignerNavTabs hidden={!(getPanelStatus() === MapPanelStatus.closed)} />
@ -200,12 +195,12 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
allPoints={this.props.allPoints}
toolSlots={this.props.toolSlots}
botLocationData={this.props.botLocationData}
botSize={botSize}
botSize={this.props.botSize}
stopAtHome={stopAtHome}
hoveredPlant={this.props.hoveredPlant}
zoomLvl={zoom_level}
botOriginQuadrant={this.getBotOriginQuadrant()}
gridSize={getGridSize(this.props.getConfigValue, botSize)}
gridSize={getGridSize(this.props.getConfigValue, this.props.botSize)}
gridOffset={gridOffset}
peripherals={this.props.peripherals}
eStopStatus={this.props.eStopStatus}

View File

@ -16,7 +16,7 @@ import {
} from "farmbot";
import { SlotWithTool, ResourceIndex, UUID } from "../resources/interfaces";
import {
BotPosition, StepsPerMmXY, BotLocationData, ShouldDisplay,
BotPosition, BotLocationData, ShouldDisplay,
} from "../devices/interfaces";
import { isNumber } from "lodash";
import { McuParams, TaggedCrop } from "farmbot";
@ -73,7 +73,7 @@ export interface Props {
crops: TaggedCrop[];
botLocationData: BotLocationData;
botMcuParams: McuParams;
stepsPerMmXY: StepsPerMmXY;
botSize: BotSize;
peripherals: { label: string, value: boolean }[];
eStopStatus: boolean;
latestImages: TaggedImage[];

View File

@ -4,10 +4,9 @@ jest.mock("../../../history", () => ({
getPathArray: jest.fn(() => mockPath.split("/")),
}));
jest.mock("../../../api/crud", () => ({
edit: jest.fn(),
overwrite: jest.fn(),
}));
jest.mock("../../../api/crud", () => ({ edit: jest.fn() }));
jest.mock("../../point_groups/actions", () => ({ overwriteGroup: jest.fn() }));
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
const mockGroup = fakePointGroup();
@ -22,7 +21,7 @@ import {
} from "../actions";
import { MovePlantProps } from "../../interfaces";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { edit, overwrite } from "../../../api/crud";
import { edit } from "../../../api/crud";
import { Actions } from "../../../constants";
import { DEFAULT_ICON, svgToUrl } from "../../../open_farm/icons";
import { history } from "../../../history";
@ -31,6 +30,8 @@ import { GetState } from "../../../redux/interfaces";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
import { overwriteGroup } from "../../point_groups/actions";
import { mockDispatch } from "../../../__test_support__/fake_dispatch";
describe("movePlant", () => {
it.each<[string, Record<"x" | "y", number>, Record<"x" | "y", number>]>([
@ -128,12 +129,13 @@ describe("clickMapPlant", () => {
const plant = fakePlant();
plant.body.id = 23;
state.resources = buildResourceIndex([plant]);
const dispatch = jest.fn();
const dispatch = mockDispatch();
const getState: GetState = jest.fn(() => state);
clickMapPlant(plant.uuid, "fakeIcon")(dispatch, getState);
expect(overwrite).toHaveBeenCalledWith(mockGroup, expect.objectContaining({
name: "Fake", point_ids: [1, 23]
}));
expect(overwriteGroup).toHaveBeenCalledWith(mockGroup,
expect.objectContaining({
name: "Fake", point_ids: [1, 23]
}));
expect(dispatch).toHaveBeenCalledTimes(1);
});
@ -142,10 +144,10 @@ describe("clickMapPlant", () => {
mockGroup.body.point_ids = [1];
const state = fakeState();
state.resources = buildResourceIndex([]);
const dispatch = jest.fn();
const dispatch = mockDispatch();
const getState: GetState = jest.fn(() => state);
clickMapPlant("missing plant uuid", "fakeIcon")(dispatch, getState);
expect(overwrite).not.toHaveBeenCalled();
expect(overwriteGroup).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(1);
});
@ -156,12 +158,13 @@ describe("clickMapPlant", () => {
const plant = fakePlant();
plant.body.id = 2;
state.resources = buildResourceIndex([plant]);
const dispatch = jest.fn();
const dispatch = mockDispatch();
const getState: GetState = jest.fn(() => state);
clickMapPlant(plant.uuid, "fakeIcon")(dispatch, getState);
expect(overwrite).toHaveBeenCalledWith(mockGroup, expect.objectContaining({
name: "Fake", point_ids: [1]
}));
expect(overwriteGroup).toHaveBeenCalledWith(mockGroup,
expect.objectContaining({
name: "Fake", point_ids: [1]
}));
expect(dispatch).toHaveBeenCalledTimes(1);
});

View File

@ -456,6 +456,16 @@ describe("<GardenMap/>", () => {
expect(allowed).toBeTruthy();
});
it("allows interactions: group edit", () => {
mockMode = Mode.editGroup;
mockInteractionAllow = true;
const p = fakeProps();
p.designer.selectionPointType = undefined;
const wrapper = mount<GardenMap>(<GardenMap {...p} />);
const allowed = wrapper.instance().interactions("Plant");
expect(allowed).toBeTruthy();
});
it("disallows interactions: default", () => {
mockMode = Mode.none;
mockInteractionAllow = false;

View File

@ -1,6 +1,6 @@
import { MovePlantProps, DraggableEvent } from "../interfaces";
import { defensiveClone } from "../../util";
import { edit, overwrite } from "../../api/crud";
import { edit } from "../../api/crud";
import { history } from "../../history";
import { Actions } from "../../constants";
import { svgToUrl, DEFAULT_ICON } from "../../open_farm/icons";
@ -12,6 +12,7 @@ import { TaggedPoint } from "farmbot";
import { getMode } from "../map/util";
import { ResourceIndex, UUID } from "../../resources/interfaces";
import { selectAllPointGroups } from "../../resources/selectors";
import { overwriteGroup } from "../point_groups/actions";
export function movePlant(payload: MovePlantProps) {
const tr = payload.plant;
@ -33,23 +34,24 @@ export const setHoveredPlant = (plantUUID: string | undefined, icon = "") => ({
});
const addOrRemoveFromGroup =
(clickedPlantUuid: UUID, resources: ResourceIndex) => {
const group = findGroupFromUrl(selectAllPointGroups(resources));
const point =
resources.references[clickedPlantUuid] as TaggedPoint | undefined;
if (group && point?.body.id) {
type Body = (typeof group)["body"];
const nextGroup: Body = ({
...group.body,
point_ids: [...group.body.point_ids.filter(p => p != point.body.id)]
});
if (!group.body.point_ids.includes(point.body.id)) {
nextGroup.point_ids.push(point.body.id);
(clickedPlantUuid: UUID, resources: ResourceIndex) =>
(dispatch: Function) => {
const group = findGroupFromUrl(selectAllPointGroups(resources));
const point =
resources.references[clickedPlantUuid] as TaggedPoint | undefined;
if (group && point?.body.id) {
type Body = (typeof group)["body"];
const nextGroup: Body = ({
...group.body,
point_ids: [...group.body.point_ids.filter(p => p != point.body.id)]
});
if (!group.body.point_ids.includes(point.body.id)) {
nextGroup.point_ids.push(point.body.id);
}
nextGroup.point_ids = uniq(nextGroup.point_ids);
dispatch(overwriteGroup(group, nextGroup));
}
nextGroup.point_ids = uniq(nextGroup.point_ids);
return overwrite(group, nextGroup);
}
};
};
const addOrRemoveFromSelection =
(clickedPointUuid: UUID, selectedPoints: UUID[] | undefined) => {

View File

@ -8,9 +8,8 @@ jest.mock("../../../point_groups/criteria", () => ({
editGtLtCriteria: jest.fn(),
}));
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
jest.mock("../../../point_groups/actions", () => ({
overwriteGroup: jest.fn(),
}));
import {
@ -25,8 +24,8 @@ import {
import { Actions } from "../../../../constants";
import { history } from "../../../../history";
import { editGtLtCriteria } from "../../../point_groups/criteria";
import { overwrite, save } from "../../../../api/crud";
import { cloneDeep } from "lodash";
import { overwriteGroup } from "../../../point_groups/actions";
describe("getSelected", () => {
it("returns some", () => {
@ -189,8 +188,7 @@ describe("maybeUpdateGroup()", () => {
expectedBody && (expectedBody.point_ids = [
plant1.body.id || 0, plant2.body.id || 0,
]);
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(save).not.toHaveBeenCalled();
expect(overwriteGroup).toHaveBeenCalledWith(p.group, expectedBody);
});
it("updates criteria", () => {
@ -214,7 +212,6 @@ describe("maybeUpdateGroup()", () => {
maybeUpdateGroup(p);
expect(p.dispatch).not.toHaveBeenCalled();
expect(editGtLtCriteria).not.toHaveBeenCalled();
expect(overwrite).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
expect(overwriteGroup).not.toHaveBeenCalled();
});
});

View File

@ -8,11 +8,11 @@ import { getMode } from "../util";
import { editGtLtCriteria } from "../../point_groups/criteria";
import { TaggedPointGroup, TaggedPoint, PointType } from "farmbot";
import { ShouldDisplay, Feature } from "../../../devices/interfaces";
import { overwrite } from "../../../api/crud";
import { unpackUUID } from "../../../util";
import { UUID } from "../../../resources/interfaces";
import { getFilteredPoints } from "../../plants/select_plants";
import { GetWebAppConfigValue } from "../../../config_storage/actions";
import { overwriteGroup } from "../../point_groups/actions";
/** Return all plants within the selection box. */
export const getSelected = (
@ -109,19 +109,20 @@ export interface MaybeUpdateGroupProps {
export const maybeUpdateGroup =
(props: MaybeUpdateGroupProps) => {
if (props.selectionBox && props.group) {
const { group } = props;
if (props.selectionBox && group) {
if (props.editGroupAreaInMap
&& props.shouldDisplay(Feature.criteria_groups)) {
props.dispatch(editGtLtCriteria(props.group, props.selectionBox));
props.dispatch(editGtLtCriteria(group, props.selectionBox));
} else {
const nextGroupBody = cloneDeep(props.group.body);
const nextGroupBody = cloneDeep(group.body);
props.boxSelected?.map(uuid => {
const { kind, remoteId } = unpackUUID(uuid);
remoteId && kind == "Point" && nextGroupBody.point_ids.push(remoteId);
});
nextGroupBody.point_ids = uniq(nextGroupBody.point_ids);
if (!isEqual(props.group.body.point_ids, nextGroupBody.point_ids)) {
props.dispatch(overwrite(props.group, nextGroupBody));
if (!isEqual(group.body.point_ids, nextGroupBody.point_ids)) {
props.dispatch(overwriteGroup(group, nextGroupBody));
props.dispatch(selectPoint(undefined));
}
}

View File

@ -40,6 +40,7 @@ import { findGroupFromUrl } from "../point_groups/group_detail";
import { pointsSelectedByGroup } from "../point_groups/criteria";
import { DrawnWeed } from "./drawn_point/drawn_weed";
import { UUID } from "../../resources/interfaces";
import { throttle } from "lodash";
export class GardenMap extends
React.Component<GardenMapProps, Partial<GardenMapState>> {
@ -89,7 +90,7 @@ export class GardenMap extends
}
/** Save the current plant (if needed) and reset drag state. */
endDrag = () => {
endDrag = throttle(() => {
maybeSavePlantLocation({
plant: this.getPlant(),
isDragging: this.state.isDragging,
@ -109,7 +110,7 @@ export class GardenMap extends
activeDragSpread: undefined,
selectionBox: undefined
});
}
}, 400);
getGardenCoordinates =
(e: React.DragEvent<HTMLElement> | React.MouseEvent<SVGElement>):
@ -215,6 +216,7 @@ export class GardenMap extends
interactions = (pointerType: PointType): boolean => {
if (allowInteraction()) {
switch (getMode()) {
case Mode.editGroup:
case Mode.boxSelect:
return (this.props.designer.selectionPointType || ["Plant"])
.includes(pointerType);

View File

@ -5,7 +5,9 @@ import {
TaggedWeedPointer,
} from "farmbot";
import { State, BotOriginQuadrant } from "../interfaces";
import { BotPosition, BotLocationData } from "../../devices/interfaces";
import {
BotPosition, BotLocationData, ShouldDisplay,
} from "../../devices/interfaces";
import { GetWebAppConfigValue } from "../../config_storage/actions";
import { TimeSettings } from "../../interfaces";
import { UUID } from "../../resources/interfaces";
@ -48,6 +50,7 @@ export interface GardenMapLegendProps {
imageAgeInfo: { newestDate: string, toOldest: number };
gardenId?: number;
className?: string;
shouldDisplay: ShouldDisplay;
}
export type MapTransformProps = {

View File

@ -7,6 +7,7 @@ import {
import {
fakeMapTransformProps,
} from "../../../../../__test_support__/map_transform_props";
import { ReactWrapper } from "enzyme";
describe("<ZonesLayer />", () => {
const fakeProps = (): ZonesLayerProps => ({
@ -26,6 +27,27 @@ describe("<ZonesLayer />", () => {
expect(wrapper.find(".zones-layer").length).toEqual(1);
});
const expectSolid = (zone2D: ReactWrapper) => {
const zoneProps = zone2D.find("rect").props();
expect(zoneProps.fill).toEqual(undefined);
expect(zoneProps.stroke).toEqual(undefined);
expect(zoneProps.strokeDasharray).toEqual(undefined);
expect(zoneProps.strokeWidth).toEqual(undefined);
};
const expectOutline = (zone2D: ReactWrapper) => {
const zoneProps = zone2D.find("rect").props();
expect(zoneProps.fill).toEqual("none");
expect(zoneProps.stroke).toEqual("white");
expect(zoneProps.strokeDasharray).toEqual(15);
expect(zoneProps.strokeWidth).toEqual(4);
};
const expectNone = (zone2D: ReactWrapper) => {
expect(zone2D.html()).toEqual(
"<g id=\"zones-2D-1\" class=\"current\"></g>");
};
it("renders current group's zones: 2D", () => {
const p = fakeProps();
p.visible = false;
@ -38,6 +60,7 @@ describe("<ZonesLayer />", () => {
expect(wrapper.find("#zones-0D-1").length).toEqual(0);
expect(wrapper.find("#zones-1D-1").length).toEqual(0);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expectSolid(wrapper.find("#zones-2D-1"));
expect(wrapper.find("#zones-2D-2").length).toEqual(0);
});
@ -50,19 +73,22 @@ describe("<ZonesLayer />", () => {
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(0);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("#zones-2D-1").length).toEqual(0);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expectNone(wrapper.find("#zones-2D-1"));
});
it("renders current group's zones: 0D", () => {
const p = fakeProps();
p.visible = false;
p.groups[0].body.id = 1;
p.groups[0].body.criteria.number_gt = { x: 10 };
p.groups[0].body.criteria.number_eq = { x: [100], y: [100] };
p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("#zones-1D-1").length).toEqual(0);
expect(wrapper.find("#zones-2D-1").length).toEqual(0);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expectOutline(wrapper.find("#zones-2D-1"));
});
it("renders current group's zones: none", () => {
@ -72,7 +98,12 @@ describe("<ZonesLayer />", () => {
p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.html()).toEqual(
"<svg><g class=\"zones-layer\" style=\"cursor: pointer;\"></g></svg>");
`<svg>
<g class=\"zones-layer\" style=\"cursor: pointer;\">
<g id=\"zones-2D-1\" class=\"current\">
</g>
</g>
</svg>`.replace(/[ ]{2,}/g, "").replace(/[^\S ]/g, ""));
});
it("doesn't render current group's zones", () => {

View File

@ -1,7 +1,9 @@
jest.mock("../../../../../history", () => ({ history: { push: jest.fn() } }));
import * as React from "react";
import { svgMount } from "../../../../../__test_support__/svg_mount";
import {
Zones0D, ZonesProps, Zones1D, Zones2D, getZoneType, ZoneType,
Zones0D, ZonesProps, Zones1D, Zones2D, getZoneType, ZoneType, spaceSelected,
} from "../zones";
import {
fakePointGroup,
@ -10,6 +12,7 @@ import {
fakeMapTransformProps,
} from "../../../../../__test_support__/map_transform_props";
import { DEFAULT_CRITERIA } from "../../../../point_groups/criteria/interfaces";
import { history } from "../../../../../history";
const fakeProps = (): ZonesProps => ({
group: fakePointGroup(),
@ -57,6 +60,15 @@ describe("<Zones0D />", () => {
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("circle").length).toEqual(2);
});
it("opens group", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [100], y: [200, 300] };
const wrapper = svgMount(<Zones0D {...p} />);
wrapper.find("#zones-0D-1").simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/groups/1");
});
});
describe("<Zones1D />", () => {
@ -104,6 +116,15 @@ describe("<Zones1D />", () => {
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(2);
});
it("opens group", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [], y: [200, 300] };
const wrapper = svgMount(<Zones1D {...p} />);
wrapper.find("#zones-1D-1").simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/groups/1");
});
});
describe("<Zones2D />", () => {
@ -137,6 +158,16 @@ describe("<Zones2D />", () => {
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expect(wrapper.find("rect").length).toEqual(1);
});
it("opens group", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_gt = { x: 100, y: 200 };
p.group.body.criteria.number_lt = { x: 300, y: 400 };
const wrapper = svgMount(<Zones2D {...p} />);
wrapper.find("#zones-2D-1").simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/groups/1");
});
});
describe("getZoneType()", () => {
@ -163,3 +194,58 @@ describe("getZoneType()", () => {
expect(getZoneType(group)).toEqual(ZoneType.points);
});
});
describe("spaceSelected()", () => {
const botSize = {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true }
};
it("is selected: area", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = {};
group.body.criteria.number_lt = {};
group.body.criteria.number_gt = {};
expect(spaceSelected(group, botSize)).toBeTruthy();
});
it("isn't selected: area", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = {};
group.body.criteria.number_lt = { x: 100 };
group.body.criteria.number_gt = { x: 200 };
expect(spaceSelected(group, botSize)).toBeFalsy();
});
it("is selected: lines", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = { x: [0] };
group.body.criteria.number_lt = {};
group.body.criteria.number_gt = {};
expect(spaceSelected(group, botSize)).toBeTruthy();
});
it("isn't selected: lines", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = { x: [0] };
group.body.criteria.number_lt = {};
group.body.criteria.number_gt = { x: 100 };
expect(spaceSelected(group, botSize)).toBeFalsy();
});
it("is selected: points", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = { x: [0], y: [0] };
group.body.criteria.number_lt = {};
group.body.criteria.number_gt = {};
expect(spaceSelected(group, botSize)).toBeTruthy();
});
it("isn't selected: points", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = { x: [0], y: [0] };
group.body.criteria.number_lt = { x: 0 };
group.body.criteria.number_gt = {};
expect(spaceSelected(group, botSize)).toBeFalsy();
});
});

View File

@ -52,10 +52,10 @@ const getBoundary = (props: GetBoundaryProps): Boundary => {
const { criteria } = props.group.body;
const gt = criteria.number_gt;
const lt = criteria.number_lt;
const x1 = gt.x || 0;
const x2 = lt.x || props.botSize.x.value;
const y1 = gt.y || 0;
const y2 = lt.y || props.botSize.y.value;
const x1 = gt.x ?? (0 - 0.01);
const x2 = lt.x ?? (props.botSize.x.value + 0.01);
const y1 = gt.y ?? (0 - 0.01);
const y2 = lt.y ?? (props.botSize.y.value + 0.01);
const selectsAll = !(gt.x || lt.x || gt.y || lt.y);
return { x1, x2, y1, y2, selectsAll };
};
@ -114,11 +114,11 @@ const zone1D = (props: ZonesProps) => {
const boundary = getBoundary(props);
return getLines(boundary, props.group).map(line => {
const min = transformXY(
line.x || boundary.x1,
line.y || boundary.y1, props.mapTransformProps);
line.x ?? boundary.x1,
line.y ?? boundary.y1, props.mapTransformProps);
const max = transformXY(
line.x || boundary.x2,
line.y || boundary.y2, props.mapTransformProps);
line.x ?? boundary.x2,
line.y ?? boundary.y2, props.mapTransformProps);
return {
x1: min.qx,
y1: min.qy,
@ -144,25 +144,59 @@ export const Zones1D = (props: ZonesProps) => {
const zone2D = (boundary: Boundary, mapTransformProps: MapTransformProps) => {
const position = transformXY(boundary.x1, boundary.y1, mapTransformProps);
const { xySwap, quadrant } = mapTransformProps;
const xLength = boundary.x2 - boundary.x1;
const yLength = boundary.y2 - boundary.y1;
const xLength = Math.max(0, boundary.x2 - boundary.x1);
const yLength = Math.max(0, boundary.y2 - boundary.y1);
const width = xySwap ? yLength : xLength;
const height = xySwap ? xLength : yLength;
return {
x: [1, 4].includes(quadrant) ? position.qx - xLength : position.qx,
y: [3, 4].includes(quadrant) ? position.qy - yLength : position.qy,
width: xySwap ? yLength : xLength,
height: xySwap ? xLength : yLength,
x: [1, 4].includes(quadrant) ? position.qx - width : position.qx,
y: [3, 4].includes(quadrant) ? position.qy - height : position.qy,
width,
height,
selectsAll: boundary.selectsAll,
};
};
/** Area selected by x and y number gt/lt values. */
export const Zones2D = (props: ZonesProps) => {
const zone = zone2D(getBoundary(props), props.mapTransformProps);
const current = props.group.uuid == props.currentGroup;
const zone = zone2D(getBoundary(props), props.mapTransformProps);
const not2D = getZoneType(props.group) !== ZoneType.area;
const rectProps: React.SVGProps<SVGElement> = not2D ? {
stroke: current ? "white" : "black",
strokeWidth: 4,
strokeDasharray: 15,
fill: "none",
} : {};
const { id } = props.group.body;
return <g id={`zones-2D-${id}`} onClick={openGroup(id)}
className={current ? "current" : ""}>
{!zone.selectsAll &&
<rect x={zone.x} y={zone.y} width={zone.width} height={zone.height} />}
<rect x={zone.x} y={zone.y} width={zone.width} height={zone.height}
stroke={rectProps.stroke}
strokeWidth={rectProps.strokeWidth}
strokeDasharray={rectProps.strokeDasharray}
fill={rectProps.fill} />}
</g>;
};
/** Determine if location criteria selects some space. */
export const spaceSelected =
(group: TaggedPointGroup, botSize: BotSize) => {
const boundary = getBoundary({ group, botSize });
const area = {
width: Math.max(0, boundary.x2 - boundary.x1),
height: Math.max(0, boundary.y2 - boundary.y1),
};
const lines = getLines(boundary, group);
const points = getPoints(boundary, group);
switch (getZoneType(group)) {
case ZoneType.none:
case ZoneType.area:
return (area.width > 0) && (area.height > 0);
case ZoneType.lines:
return lines.length > 0;
case ZoneType.points:
return points.length > 0;
}
};

View File

@ -23,7 +23,6 @@ export function ZonesLayer(props: ZonesLayerProps) {
? { cursor: "pointer" }
: { pointerEvents: "none" }} onMouseDown={props.startDrag}>
{groups.map(group => visible(group) &&
getZoneType(group) === ZoneType.area &&
<Zones2D {...commonProps} key={group.uuid} group={group} />)}
{groups.map(group => visible(group) &&
getZoneType(group) === ZoneType.lines &&

View File

@ -46,6 +46,7 @@ describe("<GardenMapLegend />", () => {
timeSettings: fakeTimeSettings(),
getConfigValue: jest.fn(),
imageAgeInfo: { newestDate: "", toOldest: 1 },
shouldDisplay: () => true,
});
it("renders", () => {

View File

@ -10,6 +10,7 @@ import { GetWebAppConfigValue } from "../../../config_storage/actions";
import { BooleanSetting } from "../../../session_keys";
import { DevSettings } from "../../../account/dev/dev_support";
import { t } from "../../../i18next_wrapper";
import { Feature } from "../../../devices/interfaces";
export const ZoomControls = ({ zoom, getConfigValue }: {
zoom: (value: number) => () => void,
@ -81,7 +82,7 @@ const LayerToggles = (props: GardenMapLegendProps) => {
dispatch={props.dispatch}
getConfigValue={getConfigValue}
imageAgeInfo={props.imageAgeInfo} />} />
{DevSettings.futureFeaturesEnabled() &&
{props.shouldDisplay(Feature.criteria_groups) &&
<LayerToggle
value={props.showZones}
label={t("areas?")}

View File

@ -1,10 +1,10 @@
import { mapStateToProps, plantAge } from "../map_state_to_props";
import { mapStateToProps, plantAge, formatPlantInfo } from "../map_state_to_props";
import { fakeState } from "../../../__test_support__/fake_state";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
import {
fakePlant, fakePlantTemplate,
fakePlant, fakePlantTemplate, fakeWebAppConfig,
} from "../../../__test_support__/fake_state/resources";
describe("mapStateToProps()", () => {
@ -31,6 +31,15 @@ describe("mapStateToProps()", () => {
expect(result.findPlant("10")).toEqual(
expect.objectContaining({ uuid }));
});
it("returns getConfigValue()", () => {
const state = fakeState();
const webAppConfig = fakeWebAppConfig();
webAppConfig.body.show_plants = false;
state.resources = buildResourceIndex([webAppConfig]);
const result = mapStateToProps(state);
expect(result.getConfigValue("show_plants")).toEqual(false);
});
});
describe("plantAge()", () => {
@ -48,3 +57,20 @@ describe("plantAge()", () => {
expect(plantAge(plant)).toBeGreaterThan(100);
});
});
describe("formatPlantInfo()", () => {
it("returns info for plant", () => {
const plant = fakePlant();
plant.body.plant_stage = "planted";
const result = formatPlantInfo(plant);
expect(result.meta).toEqual({});
expect(result.plantStatus).toEqual("planted");
});
it("returns info for plant template", () => {
const plant = fakePlantTemplate();
const result = formatPlantInfo(plant);
expect(result.meta).toBeUndefined();
expect(result.plantStatus).toEqual("planned");
});
});

View File

@ -48,9 +48,12 @@ describe("<PlantPanel/>", () => {
it("renders: editing", () => {
const p = fakeProps();
p.info.meta = { meta_key: "meta value", gridId: "1", key: undefined };
const wrapper = mount(<PlantPanel {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("1 days old");
expect(txt).toContain("meta value");
expect(txt).not.toContain("gridId");
const x = wrapper.find("input").at(1).props().value;
const y = wrapper.find("input").at(2).props().value;
expect(x).toEqual(12);
@ -59,6 +62,7 @@ describe("<PlantPanel/>", () => {
it("calls destroy", () => {
const p = fakeProps();
p.info.meta = undefined;
const wrapper = mount(<PlantPanel {...p} />);
clickButton(wrapper, 2, "Delete");
expect(p.onDestroy).toHaveBeenCalledWith("Plant.0.0");

View File

@ -18,11 +18,12 @@ import * as React from "react";
import { mount, shallow } from "enzyme";
import {
RawSelectPlants as SelectPlants, SelectPlantsProps, mapStateToProps,
getFilteredPoints, GetFilteredPointsProps,
getFilteredPoints, GetFilteredPointsProps, validPointTypes,
} from "../select_plants";
import {
fakePlant, fakePoint, fakeWeed, fakeToolSlot, fakeTool,
fakePlantTemplate,
fakeWebAppConfig,
} from "../../../__test_support__/fake_state/resources";
import { Actions, Content } from "../../../constants";
import { clickButton } from "../../../__test_support__/helpers";
@ -30,6 +31,10 @@ import { destroy } from "../../../api/crud";
import { createGroup } from "../../point_groups/actions";
import { fakeState } from "../../../__test_support__/fake_state";
import { error } from "../../../toast/toast";
import { mockDispatch } from "../../../__test_support__/fake_dispatch";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
describe("<SelectPlants />", () => {
beforeEach(function () {
@ -85,6 +90,20 @@ describe("<SelectPlants />", () => {
expect(wrapper.text()).toContain(weed.body.name);
});
it("displays selected points and weeds", () => {
const p = fakeProps();
const point = fakePoint();
point.body.name = "fake point";
const weed = fakeWeed();
weed.body.name = "fake weed";
p.allPoints = [point, weed];
p.selected = [point.uuid, weed.uuid];
p.selectionPointType = ["GenericPointer", "Weed"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain(point.body.name);
expect(wrapper.text()).toContain(weed.body.name);
});
it("displays selected slot", () => {
const p = fakeProps();
const tool = fakeTool();
@ -100,11 +119,13 @@ describe("<SelectPlants />", () => {
expect(wrapper.text()).toContain(tool.body.name);
});
it("clears point section type", () => {
it("clears point selection type", () => {
const p = fakeProps();
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount(<SelectPlants {...p} />);
wrapper.unmount();
expect(p.dispatch).toHaveBeenCalledWith({
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: undefined,
});
@ -148,13 +169,29 @@ describe("<SelectPlants />", () => {
it("changes selection type", () => {
const p = fakeProps();
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
actionsWrapper.find("FBSelect").first().simulate("change",
{ label: "", value: "All" });
expect(p.dispatch).toHaveBeenCalledWith({
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant", "GenericPointer", "ToolSlot", "Weed"],
payload: ["Plant", "GenericPointer", "Weed", "ToolSlot"],
});
});
it("changes selection type: Plant pointer_type", () => {
const p = fakeProps();
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
actionsWrapper.find("FBSelect").first().simulate("change",
{ label: "", value: "Plant" });
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant"],
});
});
@ -213,8 +250,8 @@ describe("<SelectPlants />", () => {
const wrapper = mount(<SelectPlants {...p} />);
window.confirm = () => true;
await clickButton(wrapper, DELETE_BTN_INDEX, "Delete");
expect(destroy).toHaveBeenCalledWith("plant.1", true);
expect(destroy).toHaveBeenCalledWith("plant.2", true);
await expect(destroy).toHaveBeenCalledWith("plant.1", true);
await expect(destroy).toHaveBeenCalledWith("plant.2", true);
});
it("shows other buttons", () => {
@ -248,6 +285,14 @@ describe("mapStateToProps", () => {
expect(result.plants.length).toBe(2);
expect(result.dispatch).toBe(state.dispatch);
});
it("returns quadrant", () => {
const state = fakeState();
const webAppConfig = fakeWebAppConfig();
webAppConfig.body.bot_origin_quadrant = 2;
state.resources = buildResourceIndex([webAppConfig]);
expect(mapStateToProps(state).quadrant).toEqual(2);
});
});
describe("getFilteredPoints()", () => {
@ -289,3 +334,13 @@ describe("getFilteredPoints()", () => {
expect(getFilteredPoints(p)).toEqual([slot]);
});
});
describe("validPointTypes()", () => {
it("returns valid pointer types", () => {
expect(validPointTypes(["Plant"])).toEqual(["Plant"]);
});
it("returns undefined", () => {
expect(validPointTypes(["nope"])).toEqual(undefined);
});
});

View File

@ -5,7 +5,7 @@ import {
maybeFindPlantById, maybeFindPlantTemplateById, maybeGetTimeSettings,
} from "../../resources/selectors";
import { history } from "../../history";
import { PlantStage } from "farmbot";
import { PlantStage, TaggedPoint } from "farmbot";
import { TaggedPlant } from "../map/interfaces";
import { isNumber, get } from "lodash";
import { getWebAppConfigValue } from "../../config_storage/actions";
@ -47,17 +47,18 @@ export interface FormattedPlantInfo {
plantedAt: moment.Moment;
slug: string;
plantStatus: PlantStage;
meta?: Record<string, string | undefined>;
}
/** Get date planted or fallback to creation date. */
const plantDate = (plant: TaggedPlant): moment.Moment => {
const plantDate = (plant: TaggedPlant | TaggedPoint): moment.Moment => {
const plantedAt = get(plant, "body.planted_at");
const createdAt = get(plant, "body.created_at", moment());
return plantedAt ? moment(plantedAt) : moment(createdAt);
};
/** Compare planted or created date vs time now to determine age. */
export const plantAge = (plant: TaggedPlant): number => {
export const plantAge = (plant: TaggedPlant | TaggedPoint): number => {
const currentDate = moment();
const daysOld = currentDate.diff(plantDate(plant), "days") + 1;
return daysOld;
@ -73,6 +74,7 @@ export function formatPlantInfo(plant: TaggedPlant): FormattedPlantInfo {
y: plant.body.y,
uuid: plant.uuid,
plantedAt: plantDate(plant),
plantStatus: get(plant, "plant_stage", "planned"),
plantStatus: get(plant, "body.plant_stage", "planned"),
meta: plant.kind == "Point" ? plant.body.meta : undefined,
};
}

View File

@ -186,6 +186,14 @@ export function PlantPanel(props: PlantPanelProps) {
updatePlant={updatePlant} />
: t(startCase(plantStatus))}
</ListItem>
{Object.entries(info.meta || []).map(([key, value]) => {
switch (key) {
case "gridId":
return <div key={key} className={`meta-${key}-not-displayed`} />;
default:
return <ListItem key={key} name={key}>{value || ""}</ListItem>;
}
})}
</ul>
<DeleteButtons destroy={destroy} />
</DesignerPanelContent>;

View File

@ -28,7 +28,9 @@ import {
} from "../../resources/selectors";
import { PointInventoryItem } from "../points/point_inventory_item";
import { ToolSlotInventoryItem } from "../tools";
import { getWebAppConfigValue, GetWebAppConfigValue } from "../../config_storage/actions";
import {
getWebAppConfigValue, GetWebAppConfigValue,
} from "../../config_storage/actions";
import { BooleanSetting, NumericSetting } from "../../session_keys";
import { isBotOriginQuadrant, BotOriginQuadrant } from "../interfaces";
import { isActive } from "../tools/edit_tool";
@ -36,12 +38,27 @@ import { uniq } from "lodash";
import { POINTER_TYPES } from "../point_groups/criteria/interfaces";
import { WeedInventoryItem } from "../weeds/weed_inventory_item";
// tslint:disable-next-line:no-any
export const isPointType = (x: any): x is PointType => POINTER_TYPES.includes(x);
export const validPointTypes =
(pointerTypes: unknown[] | undefined): PointType[] | undefined => {
const validValues = (pointerTypes || [])
.filter(x => isPointType(x)).map(x => x as PointType);
return validValues.length > 0 ? validValues : undefined;
};
export const setSelectionPointType = (payload: PointType[] | undefined) =>
(dispatch: Function) =>
dispatch({ type: Actions.SET_SELECTION_POINT_TYPE, payload });
export const POINTER_TYPE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
Plant: { label: t("Plants"), value: "Plant" },
GenericPointer: { label: t("Points"), value: "GenericPointer" },
Weed: { label: t("Weeds"), value: "Weed" },
ToolSlot: { label: t("Slots"), value: "ToolSlot" },
All: { label: t("All"), value: "All" },
Other: { label: t("Other"), value: "Other" },
});
export const POINTER_TYPE_LIST = () => [
POINTER_TYPE_DDI_LOOKUP().Plant,
@ -96,10 +113,8 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
}
}
componentWillUnmount = () => this.props.dispatch({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: undefined,
});
componentWillUnmount = () =>
this.props.dispatch(setSelectionPointType(undefined));
get selected() { return this.props.selected || []; }
@ -127,10 +142,8 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
selectedItem={POINTER_TYPE_DDI_LOOKUP()[this.selectionPointType]}
onChange={ddi => {
this.props.dispatch(selectPoint(undefined));
this.props.dispatch({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ddi.value == "All" ? POINTER_TYPES : [ddi.value],
});
this.props.dispatch(setSelectionPointType(
ddi.value == "All" ? POINTER_TYPES : validPointTypes([ddi.value])));
}} />
<div className="button-row">
<button className="fb-button gray"

View File

@ -1,5 +1,6 @@
jest.mock("../../../api/crud", () => ({
init: jest.fn(() => ({ payload: { uuid: "???" } })),
overwrite: jest.fn(),
save: jest.fn()
}));
@ -10,18 +11,19 @@ jest.mock("../../../resources/selectors", () => ({
selectAllRegimens: jest.fn()
}));
import { createGroup } from "../actions";
import { init, save } from "../../../api/crud";
import { createGroup, overwriteGroup } from "../actions";
import { init, save, overwrite } from "../../../api/crud";
import { history } from "../../../history";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
import {
fakePoint, fakePlant, fakeToolSlot,
fakePoint, fakePlant, fakeToolSlot, fakePointGroup,
} from "../../../__test_support__/fake_state/resources";
import { DeepPartial } from "redux";
import { Everything } from "../../../interfaces";
import { DEFAULT_CRITERIA } from "../criteria/interfaces";
import { cloneDeep } from "lodash";
describe("group action creators and thunks", () => {
it("creates groups", async () => {
@ -44,3 +46,14 @@ describe("group action creators and thunks", () => {
.toHaveBeenCalledWith("/app/designer/groups/323232332");
});
});
describe("overwriteGroup()", () => {
it("overwrites and saves", () => {
const group = fakePointGroup();
const newGroupBody = cloneDeep(group.body);
newGroupBody.point_ids = [1, 2, 3];
overwriteGroup(group, newGroupBody)(jest.fn());
expect(overwrite).toHaveBeenCalledWith(group, newGroupBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});

View File

@ -6,6 +6,12 @@ jest.mock("../../../api/crud", () => ({
jest.mock("../../map/actions", () => ({ setHoveredPlant: jest.fn() }));
jest.mock("../../plants/select_plants", () => ({
setSelectionPointType: jest.fn(),
validPointTypes: jest.fn(),
POINTER_TYPE_LIST: () => [],
}));
import React from "react";
import {
GroupDetailActive, GroupDetailActiveProps,
@ -14,9 +20,10 @@ import { mount, shallow } from "enzyme";
import {
fakePointGroup, fakePlant,
} from "../../../__test_support__/fake_state/resources";
import { save, edit } from "../../../api/crud";
import { edit } from "../../../api/crud";
import { SpecialStatus } from "farmbot";
import { DEFAULT_CRITERIA } from "../criteria/interfaces";
import { setSelectionPointType } from "../../plants/select_plants";
describe("<GroupDetailActive/>", () => {
const fakeProps = (): GroupDetailActiveProps => {
@ -35,26 +42,14 @@ describe("<GroupDetailActive/>", () => {
slugs: [],
hovered: undefined,
editGroupAreaInMap: false,
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true },
},
selectionPointType: undefined,
};
};
it("saves", () => {
const p = fakeProps();
const el = new GroupDetailActive(p);
el.saveGroup();
expect(p.dispatch).toHaveBeenCalled();
expect(save).toHaveBeenCalledWith(p.group.uuid);
});
it("is already saved", () => {
const p = fakeProps();
p.group.specialStatus = SpecialStatus.SAVED;
const el = new GroupDetailActive(p);
el.saveGroup();
expect(p.dispatch).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("toggles icon view", () => {
const p = fakeProps();
const wrapper = mount<GroupDetailActive>(<GroupDetailActive {...p} />);
@ -68,54 +63,24 @@ describe("<GroupDetailActive/>", () => {
p.group.specialStatus = SpecialStatus.SAVED;
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.find("input").first().prop("defaultValue")).toContain("XYZ");
expect(wrapper.find(".groups-list-wrapper").length).toEqual(1);
expect(wrapper.text()).not.toContain("saving");
});
it("shows saving indicator", () => {
const p = fakeProps();
p.group.specialStatus = SpecialStatus.DIRTY;
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.text()).toContain("saving");
});
it("changes group name", () => {
const NEW_NAME = "new group name";
const wrapper = shallow(<GroupDetailActive {...fakeProps()} />);
wrapper.find("input").first().simulate("change", {
currentTarget: { value: NEW_NAME }
});
expect(edit).toHaveBeenCalledWith(expect.any(Object), { name: NEW_NAME });
});
it("changes the sort type", () => {
const p = fakeProps();
const { dispatch } = p;
const el = new GroupDetailActive(p);
el.changeSortType("random");
expect(dispatch).toHaveBeenCalled();
expect(edit).toHaveBeenCalledWith({
body: {
name: "XYZ",
point_ids: [1],
sort_type: "xy_ascending",
criteria: DEFAULT_CRITERIA
},
kind: "PointGroup",
specialStatus: "DIRTY",
uuid: p.group.uuid,
},
{ sort_type: "random" });
expect(wrapper.find(".group-member-display").length).toEqual(1);
});
it("unmounts", () => {
window.clearInterval = jest.fn();
const p = fakeProps();
const el = new GroupDetailActive(p);
// tslint:disable-next-line:no-any
el.state.timerId = 123 as any;
el.componentWillUnmount && el.componentWillUnmount();
expect(clearInterval).toHaveBeenCalledWith(123);
p.group.body.criteria.string_eq.pointer_type = ["Weed"];
const wrapper = mount(<GroupDetailActive {...p} />);
wrapper.unmount();
expect(setSelectionPointType).toHaveBeenCalledWith(undefined);
});
it("changes group name", () => {
const p = fakeProps();
const wrapper = shallow(<GroupDetailActive {...p} />);
wrapper.find("input").first().simulate("blur", {
currentTarget: { value: "new group name" }
});
expect(edit).toHaveBeenCalledWith(p.group, { name: "new group name" });
});
it("shows paths", () => {

View File

@ -3,6 +3,10 @@ jest.mock("../../../history", () => ({
history: { push: jest.fn() }
}));
jest.mock("../actions", () => ({
createGroup: jest.fn(),
}));
import React from "react";
import { mount, shallow } from "enzyme";
import {
@ -16,6 +20,8 @@ import { fakeState } from "../../../__test_support__/fake_state";
import {
buildResourceIndex,
} from "../../../__test_support__/resource_index_builder";
import { createGroup } from "../actions";
import { DesignerPanelTop } from "../../designer_panel";
describe("<GroupListPanel />", () => {
const fakeProps = (): GroupListPanelProps => {
@ -39,6 +45,13 @@ describe("<GroupListPanel />", () => {
};
};
it("creates new group", () => {
const p = fakeProps();
const wrapper = shallow(<GroupListPanel {...p} />);
wrapper.find(DesignerPanelTop).simulate("click");
expect(createGroup).toHaveBeenCalledWith({ pointUuids: [] });
});
it("changes search term", () => {
const p = fakeProps();
const wrapper = shallow<GroupListPanel>(<GroupListPanel {...p} />);
@ -66,7 +79,9 @@ describe("<GroupListPanel />", () => {
const wrapper = mount(<GroupListPanel {...p} />);
expect(wrapper.text().toLowerCase()).toContain("no groups yet");
});
});
describe("mapStateToProps()", () => {
it("maps state to props", () => {
const state = fakeState();
const group = fakePointGroup();

View File

@ -1,4 +1,7 @@
jest.mock("../../../api/crud", () => ({ edit: jest.fn() }));
jest.mock("../../../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
let mockDev = false;
jest.mock("../../../account/dev/dev_support", () => ({

View File

@ -4,7 +4,7 @@ jest.mock("../../../open_farm/cached_crop", () => ({
}));
jest.mock("../../map/actions", () => ({ setHoveredPlant: jest.fn() }));
jest.mock("../../../api/crud", () => ({ overwrite: jest.fn() }));
jest.mock("../actions", () => ({ overwriteGroup: jest.fn() }));
import React from "react";
import {
@ -19,16 +19,17 @@ import {
maybeGetCachedPlantIcon, setImgSrc,
} from "../../../open_farm/cached_crop";
import { setHoveredPlant } from "../../map/actions";
import { overwrite } from "../../../api/crud";
import { cloneDeep } from "lodash";
import { imgEvent } from "../../../__test_support__/fake_html_events";
import { error } from "../../../toast/toast";
import { svgToUrl, DEFAULT_ICON } from "../../../open_farm/icons";
import { DEFAULT_WEED_ICON } from "../../map/layers/weeds/garden_weed";
import { overwriteGroup } from "../actions";
import { mockDispatch } from "../../../__test_support__/fake_dispatch";
describe("<PointGroupItem/>", () => {
const fakeProps = (): PointGroupItemProps => ({
dispatch: jest.fn(),
dispatch: mockDispatch(),
point: fakePlant(),
group: fakePointGroup(),
hovered: true
@ -56,6 +57,16 @@ describe("<PointGroupItem/>", () => {
expect(setImgSrc).not.toHaveBeenCalled();
});
it("doesn't fetch non-plant icon", async () => {
const p = fakeProps();
p.point = fakeWeed();
const i = new PointGroupItem(p);
const fakeImgEvent = imgEvent();
await i.maybeGetCachedIcon(fakeImgEvent);
expect(maybeGetCachedPlantIcon).not.toHaveBeenCalled();
expect(setImgSrc).not.toHaveBeenCalled();
});
it("sets icon in state", () => {
const i = new PointGroupItem(fakeProps());
i.setState = jest.fn();
@ -121,7 +132,7 @@ describe("<PointGroupItem/>", () => {
expect(i.props.dispatch).toHaveBeenCalledTimes(2);
const expectedGroupBody = cloneDeep(p.group.body);
expectedGroupBody.point_ids = [];
expect(overwrite).toHaveBeenCalledWith(p.group, expectedGroupBody);
expect(overwriteGroup).toHaveBeenCalledWith(p.group, expectedGroupBody);
expect(setHoveredPlant).toHaveBeenCalledWith(undefined);
});
@ -132,9 +143,9 @@ describe("<PointGroupItem/>", () => {
const i = new PointGroupItem(p);
i.click();
expect(i.props.dispatch).not.toHaveBeenCalled();
expect(overwrite).not.toHaveBeenCalled();
expect(overwriteGroup).not.toHaveBeenCalled();
expect(setHoveredPlant).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
"Cannot remove points selected by criteria.");
"Cannot remove points selected by filters.");
});
});

View File

@ -1,12 +1,13 @@
import { betterCompact } from "../../util";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { init, save } from "../../api/crud";
import { init, save, overwrite } from "../../api/crud";
import { history } from "../../history";
import { GetState } from "../../redux/interfaces";
import { findPointGroup } from "../../resources/selectors";
import { t } from "../../i18next_wrapper";
import { UUID } from "../../resources/interfaces";
import { DEFAULT_CRITERIA } from "./criteria/interfaces";
import { TaggedPointGroup } from "farmbot";
interface CreateGroupProps {
pointUuids: UUID[];
@ -35,3 +36,10 @@ export const createGroup = ({ pointUuids, groupName }: CreateGroupProps) =>
history.push("/app/designer/groups/" + (id ? id : ""));
});
};
export const overwriteGroup =
(group: TaggedPointGroup, newGroupBody: PointGroup) =>
(dispatch: Function) => {
dispatch(overwrite(group, newGroupBody));
dispatch(save(group.uuid));
};

View File

@ -1,19 +1,25 @@
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
}));
jest.mock("../../actions", () => ({ overwriteGroup: jest.fn() }));
jest.mock("../edit", () => ({ togglePointTypeCriteria: jest.fn() }));
import React from "react";
import { mount } from "enzyme";
import { GroupCriteria, GroupPointCountBreakdown } from "..";
import { mount, shallow } from "enzyme";
import {
GroupCriteria, GroupPointCountBreakdown, PointTypeSelection,
togglePointTypeCriteria,
} from "..";
import {
GroupCriteriaProps, GroupPointCountBreakdownProps, DEFAULT_CRITERIA,
PointTypeSelectionProps,
} from "../interfaces";
import {
fakePointGroup,
fakePointGroup, fakePoint,
} from "../../../../__test_support__/fake_state/resources";
import { cloneDeep } from "lodash";
import { overwrite, save } from "../../../../api/crud";
import { Checkbox } from "../../../../ui";
import { Actions } from "../../../../constants";
import { overwriteGroup } from "../../actions";
import { mockDispatch } from "../../../../__test_support__/fake_dispatch";
describe("<GroupCriteria />", () => {
const fakeProps = (): GroupCriteriaProps => ({
@ -21,41 +27,162 @@ describe("<GroupCriteria />", () => {
group: fakePointGroup(),
slugs: [],
editGroupAreaInMap: false,
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true },
},
selectionPointType: undefined,
});
it("renders", () => {
const wrapper = mount(<GroupCriteria {...fakeProps()} />);
["criteria", "age selection"].map(string =>
["filters", "age"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
});
it("clears criteria", () => {
it("mounts", () => {
const p = fakeProps();
const wrapper = mount(<GroupCriteria {...p} />);
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(save).toHaveBeenCalledWith(p.group.uuid);
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
p.group.body.criteria.string_eq.pointer_type = ["Weed"];
mount(<GroupCriteria {...p} />);
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Weed"],
});
});
it("toggles advanced view", () => {
const wrapper = mount(<GroupCriteria {...fakeProps()} />);
expect(wrapper.text()).not.toContain("number criteria");
wrapper.find("ToggleButton").first().simulate("click");
expect(wrapper.text()).toContain("number criteria");
const wrapper = mount<GroupCriteria>(<GroupCriteria {...fakeProps()} />);
expect(wrapper.text()).not.toContain("numbers");
const menu = mount(wrapper.instance().AdvancedToggleMenu());
menu.find("ToggleButton").first().simulate("click");
expect(wrapper.text()).toContain("numbers");
});
it("shows day criteria in advanced view", () => {
const wrapper = mount<GroupCriteria>(<GroupCriteria {...fakeProps()} />);
wrapper.setState({ advanced: true });
expect(wrapper.text()).toContain("day");
});
it("changes day criteria", () => {
const wrapper = mount<GroupCriteria>(<GroupCriteria {...fakeProps()} />);
expect(wrapper.state().dayChanged).toBeFalsy();
wrapper.instance().changeDay(true);
expect(wrapper.state().dayChanged).toBeTruthy();
});
});
describe("<GroupPointCountBreakdown />", () => {
const fakeProps = (): GroupPointCountBreakdownProps => ({
manualCount: 1,
totalCount: 3,
group: fakePointGroup(),
dispatch: jest.fn(),
shouldDisplay: () => true,
pointsSelectedByGroup: [],
iconDisplay: true,
hovered: undefined,
});
it("renders", () => {
const wrapper = mount(<GroupPointCountBreakdown {...fakeProps()} />);
["1manually selected", "2selected by criteria"].map(string =>
it("renders point counts", () => {
const p = fakeProps();
const point1 = fakePoint();
point1.body.id = 1;
const point2 = fakePoint();
point2.body.id = 2;
const point3 = fakePoint();
point3.body.id = 3;
p.pointsSelectedByGroup = [point1, point2, point3];
p.group.body.point_ids = [1];
const wrapper = mount(<GroupPointCountBreakdown {...p} />);
["1manually selected", "2selected by filters"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("renders point counts: undefined ids", () => {
const p = fakeProps();
const point1 = fakePoint();
point1.body.id = undefined;
const point2 = fakePoint();
point2.body.id = undefined;
p.pointsSelectedByGroup = [point1, point2];
p.group.body.point_ids = [];
const wrapper = mount(<GroupPointCountBreakdown {...p} />);
["0manually selected", "2selected by filters"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("clears point ids", () => {
const p = fakeProps();
p.group.body.point_ids = [1, 2];
const wrapper = mount(<GroupPointCountBreakdown {...p} />);
window.confirm = () => true;
wrapper.find("button").first().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.point_ids = [];
expect(overwriteGroup).toHaveBeenCalledWith(p.group, expectedBody);
});
it("doesn't clear point ids", () => {
const p = fakeProps();
p.group.body.point_ids = [1, 2];
const wrapper = mount(<GroupPointCountBreakdown {...p} />);
window.confirm = () => false;
wrapper.find("button").first().simulate("click");
expect(overwriteGroup).not.toHaveBeenCalled();
});
it("clears criteria", () => {
const p = fakeProps();
const wrapper = mount(<GroupPointCountBreakdown {...p} />);
window.confirm = () => true;
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
expect(overwriteGroup).toHaveBeenCalledWith(p.group, expectedBody);
});
it("doesn't clear criteria", () => {
const p = fakeProps();
const wrapper = mount(<GroupPointCountBreakdown {...p} />);
window.confirm = () => false;
wrapper.find("button").last().simulate("click");
expect(overwriteGroup).not.toHaveBeenCalled();
});
});
describe("<PointTypeSelection />", () => {
const fakeProps = (): PointTypeSelectionProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
pointTypes: [],
});
it("selects pointer_type", () => {
const p = fakeProps();
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = shallow(<PointTypeSelection {...p} />);
wrapper.find("FBSelect").simulate("change", { label: "", value: "Plant" });
expect(togglePointTypeCriteria).toHaveBeenCalledWith(p.group, "Plant", true);
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant"],
});
});
it("doesn't select pointer_type", () => {
const p = fakeProps();
const wrapper = shallow(<PointTypeSelection {...p} />);
wrapper.find("FBSelect").simulate("change", { label: "", value: "nope" });
expect(togglePointTypeCriteria).not.toHaveBeenCalled();
});
it("changes pointer_type", () => {
const p = fakeProps();
p.pointTypes = ["Plant", "Weed"];
const wrapper = shallow(<PointTypeSelection {...p} />);
wrapper.find(Checkbox).first().simulate("change");
expect(togglePointTypeCriteria).toHaveBeenCalledWith(p.group, "Plant");
});
});

View File

@ -1,7 +1,4 @@
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
}));
jest.mock("../../actions", () => ({ overwriteGroup: jest.fn() }));
import {
editCriteria, toggleEqCriteria,
@ -15,10 +12,11 @@ import {
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { overwrite, save } from "../../../../api/crud";
import { cloneDeep } from "lodash";
import { DEFAULT_CRITERIA, PointGroupCriteria } from "../interfaces";
import { inputEvent } from "../../../../__test_support__/fake_html_events";
import { error } from "../../../../toast/toast";
import { overwriteGroup } from "../../actions";
describe("editCriteria()", () => {
it("edits criteria: all empty", () => {
@ -27,15 +25,13 @@ describe("editCriteria()", () => {
editCriteria(group, {})(jest.fn());
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("edits criteria: empty update", () => {
const group = fakePointGroup();
editCriteria(group, {})(jest.fn());
expect(overwrite).toHaveBeenCalledWith(group, group.body);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, group.body);
});
it("edits criteria: full update", () => {
@ -50,8 +46,7 @@ describe("editCriteria()", () => {
editCriteria(group, criteria)(jest.fn());
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = criteria;
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
});
@ -91,15 +86,14 @@ describe("toggleAndEditEqCriteria()", () => {
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { openfarm_slug: ["mint"] };
toggleAndEditEqCriteria(group, "openfarm_slug", "mint")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("toggles criteria on for point type", () => {
const group = fakePointGroup();
const expectedBody = cloneDeep(group.body);
group.body.criteria.string_eq = {
pointer_type: ["GenericPointer", "Plant", "ToolSlot"],
pointer_type: ["GenericPointer", "Plant", "ToolSlot", "Weed"],
openfarm_slug: ["apple"],
"meta.color": ["red"],
};
@ -107,19 +101,18 @@ describe("toggleAndEditEqCriteria()", () => {
pullout_direction: [0]
};
expectedBody.criteria.string_eq = {
pointer_type: ["Plant"],
pointer_type: ["GenericPointer", "Plant", "ToolSlot", "Weed"],
openfarm_slug: ["apple", "mint"],
};
expectedBody.criteria.number_eq = {};
toggleAndEditEqCriteria(group, "openfarm_slug", "mint", "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("toggles off", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = {
pointer_type: ["GenericPointer", "Plant", "ToolSlot"],
pointer_type: ["GenericPointer", "Plant", "ToolSlot", "Weed"],
openfarm_slug: ["mint"],
"meta.color": ["red"],
};
@ -129,8 +122,7 @@ describe("toggleAndEditEqCriteria()", () => {
const expectedBody = cloneDeep(group.body);
delete expectedBody.criteria.string_eq.openfarm_slug;
toggleAndEditEqCriteria(group, "openfarm_slug", "mint", "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("toggles on: empty criteria", () => {
@ -145,8 +137,7 @@ describe("toggleAndEditEqCriteria()", () => {
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_eq = { pullout_direction: [0] };
toggleAndEditEqCriteria(group, "pullout_direction", 0, "ToolSlot")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
});
@ -159,10 +150,12 @@ describe("togglePointTypeCriteria()", () => {
"meta.color": ["red"],
};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq.pointer_type?.push("Plant");
expectedBody.criteria.string_eq = {
pointer_type: ["GenericPointer", "Plant"],
openfarm_slug: ["mint"],
};
togglePointTypeCriteria(group, "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("toggles off", () => {
@ -175,11 +168,10 @@ describe("togglePointTypeCriteria()", () => {
};
expectedBody.criteria.string_eq = {
pointer_type: ["GenericPointer"],
"meta.color": ["red"],
openfarm_slug: ["mint"],
};
togglePointTypeCriteria(group, "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("toggles on: empty criteria", () => {
@ -188,8 +180,7 @@ describe("togglePointTypeCriteria()", () => {
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { pointer_type: ["Plant"] };
togglePointTypeCriteria(group, "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("toggles off: empty criteria", () => {
@ -201,8 +192,19 @@ describe("togglePointTypeCriteria()", () => {
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = {};
togglePointTypeCriteria(group, "ToolSlot")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("clears other pointer types", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = {
pointer_type: ["Plant", "ToolSlot"],
"plant_stage": ["planned"],
};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { pointer_type: ["Weed"] };
togglePointTypeCriteria(group, "Weed", true)(dispatch);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
});
@ -212,9 +214,8 @@ describe("clearCriteriaField()", () => {
const expectedBody = cloneDeep(group.body);
group.body.criteria.string_eq = { plant_stage: ["planted"] };
expectedBody.criteria.string_eq = {};
clearCriteriaField(group, ["string_eq"], "plant_stage")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
clearCriteriaField(group, ["string_eq"], ["plant_stage"])(dispatch);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
});
@ -226,16 +227,14 @@ describe("editGtLtCriteria()", () => {
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_gt = { x: 0, y: 2 };
expectedBody.criteria.number_lt = { x: 3, y: 4 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("doesn't edit criteria", () => {
const group = fakePointGroup();
const box = { x0: undefined, y0: 2, x1: 3, y1: 4 };
editGtLtCriteria(group, box)(dispatch);
expect(overwrite).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
expect(overwriteGroup).not.toHaveBeenCalled();
});
});
@ -247,8 +246,7 @@ describe("removeEqCriteriaValue()", () => {
"string_eq", "plant_stage", "planned")(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { plant_stage: ["planted"] };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
});
@ -259,8 +257,37 @@ describe("editGtLtCriteriaField()", () => {
editGtLtCriteriaField(group, "number_lt", "radius")(e)(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_lt = { radius: 1 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(error).not.toHaveBeenCalled();
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("errors when changing value: lt", () => {
const group = fakePointGroup();
group.body.criteria.number_gt = { radius: 1 };
const e = inputEvent("0");
editGtLtCriteriaField(group, "number_lt", "radius")(e)(dispatch);
expect(error).toHaveBeenCalledWith("Value must be greater than 1.");
expect(overwriteGroup).not.toHaveBeenCalled();
});
it("errors when changing value: gt", () => {
const group = fakePointGroup();
group.body.criteria.number_lt = { radius: 0 };
const e = inputEvent("1");
editGtLtCriteriaField(group, "number_gt", "radius")(e)(dispatch);
expect(error).toHaveBeenCalledWith("Value must be less than 0.");
expect(overwriteGroup).not.toHaveBeenCalled();
});
it("doesn't error when removing value", () => {
const group = fakePointGroup();
group.body.criteria.number_lt = { radius: 0 };
const e = inputEvent("");
editGtLtCriteriaField(group, "number_gt", "radius")(e)(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_gt = { radius: undefined };
expect(error).not.toHaveBeenCalled();
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
it("clears incompatible criteria", () => {
@ -272,7 +299,7 @@ describe("editGtLtCriteriaField()", () => {
group, "number_lt", "radius", "GenericPointer",
)(e)(dispatch);
expectedBody.criteria.number_lt = { radius: 1 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
expect(error).not.toHaveBeenCalled();
expect(overwriteGroup).toHaveBeenCalledWith(group, expectedBody);
});
});

View File

@ -1,93 +0,0 @@
jest.mock("../edit", () => ({
togglePointTypeCriteria: jest.fn(),
toggleAndEditEqCriteria: jest.fn(),
clearCriteriaField: jest.fn(),
}));
import React from "react";
import { mount, shallow } from "enzyme";
import {
CheckboxSelections, togglePointTypeCriteria, clearCriteriaField,
} from "..";
import { CheckboxSelectionsProps } from "../interfaces";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { Checkbox } from "../../../../ui";
describe("<CheckboxSelections />", () => {
const fakeProps = (): CheckboxSelectionsProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
slugs: ["mint"],
});
it("renders all criteria", () => {
const STRINGS = [
"planted", "mint",
"farm designer", "radius", "green",
"positive x",
];
const wrapper = mount(<CheckboxSelections {...fakeProps()} />);
STRINGS.map(string =>
expect(wrapper.text().toLowerCase()).not.toContain(string.toLowerCase()));
wrapper.setState({ Plant: true, GenericPointer: true, ToolSlot: true });
STRINGS.map(string =>
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
});
it("clears sub criteria", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = { plant_stage: ["planned"] };
const wrapper = mount(<CheckboxSelections {...p} />);
wrapper.setState({ Plant: true, GenericPointer: false, ToolSlot: false });
wrapper.find(".plant-criteria-options")
.find("input").first().simulate("change");
expect(clearCriteriaField).toHaveBeenCalledWith(
p.group, ["string_eq"], "plant_stage",
);
});
it("toggles section", () => {
const wrapper =
shallow<CheckboxSelections>(<CheckboxSelections {...fakeProps()} />);
expect(wrapper.state().Plant).toBeFalsy();
wrapper.instance().toggleMore("Plant")();
expect(wrapper.state().Plant).toBeTruthy();
});
it("toggles point type", () => {
const p = fakeProps();
const wrapper = mount(<CheckboxSelections {...p} />);
wrapper.find("input").first().simulate("change");
expect(togglePointTypeCriteria).toHaveBeenCalledWith(p.group, "Plant");
});
it("stops propagation", () => {
const wrapper = mount(<CheckboxSelections {...fakeProps()} />);
const e = { stopPropagation: jest.fn() };
wrapper.find(".fb-checkbox").first().simulate("click", e);
expect(e.stopPropagation).toHaveBeenCalled();
});
it("is not disabled", () => {
const p = fakeProps();
const wrapper = mount(<CheckboxSelections {...p} />);
const pointTypeBoxes = wrapper.find(".point-type-checkbox").find("input");
expect(pointTypeBoxes.first().props().disabled).toBeFalsy();
expect(pointTypeBoxes.at(1).props().disabled).toBeFalsy();
expect(pointTypeBoxes.last().props().disabled).toBeFalsy();
});
it("is disabled", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = { plant_stage: ["planted"] };
p.group.body.criteria.number_eq = { pullout_direction: [0] };
p.group.body.criteria.number_gt = { radius: 0 };
const wrapper = mount(<CheckboxSelections {...p} />);
const pointTypeBoxes = wrapper.find(".point-type-checkbox").find(Checkbox);
expect(pointTypeBoxes.first().props().disabled).toBeTruthy();
expect(pointTypeBoxes.at(1).props().disabled).toBeTruthy();
expect(pointTypeBoxes.last().props().disabled).toBeTruthy();
});
});

View File

@ -1,4 +1,6 @@
import { eqCriteriaSelected, criteriaHasKey, hasSubCriteria, typeDisabled } from "..";
import {
eqCriteriaSelected, criteriaHasKey, hasSubCriteria, typeDisabled,
} from "..";
import { DEFAULT_CRITERIA, PointGroupCriteria } from "../interfaces";
import { cloneDeep } from "lodash";
@ -6,14 +8,35 @@ const fakeCriteria = (): PointGroupCriteria =>
cloneDeep(DEFAULT_CRITERIA);
describe("eqCriteriaSelected()", () => {
it("returns selected", () => {
it("returns selected numbers", () => {
const criteria = fakeCriteria();
criteria.number_eq = { pullout_direction: [0] };
const result = eqCriteriaSelected(criteria)("pullout_direction", 0);
expect(result).toEqual(true);
});
it("returns not selected", () => {
it("returns numbers not selected", () => {
const criteria = fakeCriteria();
criteria.number_eq = {};
const result = eqCriteriaSelected(criteria)("pullout_direction", 0);
expect(result).toEqual(false);
});
it("returns selected strings", () => {
const criteria = fakeCriteria();
criteria.string_eq = { plant_stage: ["planted"] };
const result = eqCriteriaSelected(criteria)("plant_stage", "planted");
expect(result).toEqual(true);
});
it("returns strings not selected", () => {
const criteria = fakeCriteria();
criteria.string_eq = {};
const result = eqCriteriaSelected(criteria)("plant_stage", "planted");
expect(result).toEqual(false);
});
it("returns other not selected", () => {
const criteria = fakeCriteria();
const result = eqCriteriaSelected(criteria)(
"pullout_direction", false as unknown as string);
@ -35,22 +58,43 @@ describe("criteriaHasKey()", () => {
const result = criteriaHasKey(criteria, ["string_eq"], "plant_stage");
expect(result).toBeFalsy();
});
it("has key", () => {
const criteria = fakeCriteria();
criteria.number_eq = { x: [0] };
const result = criteriaHasKey(criteria, ["number_eq"], "x");
expect(result).toBeTruthy();
});
});
describe("hasSubCriteria()", () => {
it("has criteria", () => {
it("has string criteria", () => {
const criteria = fakeCriteria();
criteria.string_eq = { plant_stage: ["planted"] };
const result = hasSubCriteria(criteria)("Plant");
expect(result).toBeTruthy();
});
it("doesn't have criteria", () => {
it("doesn't have string criteria", () => {
const criteria = fakeCriteria();
criteria.string_eq = { "meta.color": ["red"] };
const result = hasSubCriteria(criteria)("Plant");
expect(result).toBeFalsy();
});
it("has number criteria", () => {
const criteria = fakeCriteria();
criteria.number_eq = { pullout_direction: [0] };
const result = hasSubCriteria(criteria)("ToolSlot");
expect(result).toBeTruthy();
});
it("doesn't have number criteria", () => {
const criteria = fakeCriteria();
criteria.number_eq = {};
const result = hasSubCriteria(criteria)("ToolSlot");
expect(result).toBeFalsy();
});
});
describe("typeDisabled()", () => {

View File

@ -3,6 +3,7 @@ jest.mock("../edit", () => ({
editGtLtCriteriaField: jest.fn(() => jest.fn()),
removeEqCriteriaValue: jest.fn(),
clearCriteriaField: jest.fn(),
clearLocationCriteria: jest.fn(),
}));
import React from "react";
@ -21,15 +22,15 @@ import {
import {
EqCriteriaSelectionProps,
NumberCriteriaProps,
CriteriaSelectionProps,
DEFAULT_CRITERIA,
LocationSelectionProps,
NumberLtGtInputProps,
DaySelectionProps,
} from "../interfaces";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { FBSelect } from "../../../../ui";
import { FBSelect, Checkbox } from "../../../../ui";
import { Actions } from "../../../../constants";
describe("<EqCriteriaSelection<string> />", () => {
@ -88,16 +89,26 @@ describe("<NumberCriteriaSelection />", () => {
expect(clearCriteriaField).toHaveBeenCalledWith(
p.group,
["number_gt"],
"x",
["x"],
);
});
});
describe("<DaySelection />", () => {
const fakeProps = (): CriteriaSelectionProps => ({
const fakeProps = (): DaySelectionProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(),
dayChanged: true,
changeDay: jest.fn(),
advanced: false,
});
it("shows label", () => {
const p = fakeProps();
p.advanced = true;
const wrapper = shallow(<DaySelection {...p} />);
expect(wrapper.html()).toContain("label");
});
it("changes operator", () => {
@ -121,6 +132,16 @@ describe("<DaySelection />", () => {
{ day: { days_ago: 1, op: "<" } },
);
});
it("resets day criteria to default", () => {
const p = fakeProps();
p.group.body.criteria.day = { op: ">", days_ago: 1 };
const wrapper = shallow(<DaySelection {...p} />);
wrapper.find(Checkbox).simulate("change");
expect(editCriteria).toHaveBeenCalledWith(p.group, {
day: { op: "<", days_ago: 0 }
});
});
});
describe("<NumberLtGtInput />", () => {
@ -165,15 +186,46 @@ describe("<LocationSelection />", () => {
group: fakePointGroup(),
dispatch: jest.fn(),
editGroupAreaInMap: false,
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true },
},
});
it("clears location criteria", () => {
const p = fakeProps();
const wrapper = mount(<LocationSelection {...p} />);
wrapper.find("input").first().simulate("change");
expect(clearCriteriaField).toHaveBeenCalledWith(
p.group,
["number_lt", "number_gt"],
["x", "y"],
);
});
it("toggles selection box behavior", () => {
const p = fakeProps();
const wrapper = mount(<LocationSelection {...p} />);
wrapper.find("button").first().simulate("click");
wrapper.find("button").last().simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.EDIT_GROUP_AREA_IN_MAP,
payload: true
});
});
it("doesn't display selection warning", () => {
const p = fakeProps();
p.group.body.criteria.number_gt = {};
p.group.body.criteria.number_gt = {};
const wrapper = mount(<LocationSelection {...p} />);
expect(wrapper.text().toLowerCase()).not.toContain("invalid selection");
});
it("displays selection warning", () => {
const p = fakeProps();
p.group.body.criteria.number_lt = { x: 100 };
p.group.body.criteria.number_gt = { x: 200 };
const wrapper = mount(<LocationSelection {...p} />);
expect(wrapper.text().toLowerCase()).toContain("invalid selection");
});
});

View File

@ -5,11 +5,65 @@ jest.mock("../edit", () => ({
import React from "react";
import { mount } from "enzyme";
import { toggleAndEditEqCriteria } from "..";
import { CheckboxListProps } from "../interfaces";
import { CheckboxListProps, SubCriteriaSectionProps } from "../interfaces";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { CheckboxList } from "../subcriteria";
import { CheckboxList, SubCriteriaSection } from "../subcriteria";
describe("<SubCriteriaSection />", () => {
const fakeProps = (): SubCriteriaSectionProps => ({
dispatch: Function,
group: fakePointGroup(),
disabled: false,
pointerTypes: [],
slugs: [],
});
it("doesn't return criteria", () => {
const p = fakeProps();
p.pointerTypes = [];
const wrapper = mount(<SubCriteriaSection {...p} />);
expect(wrapper.text()).toEqual("");
});
it("doesn't return incompatible criteria", () => {
const p = fakeProps();
p.pointerTypes = ["Plant", "Weed"];
const wrapper = mount(<SubCriteriaSection {...p} />);
expect(wrapper.text()).toEqual("");
});
it("returns plant criteria", () => {
const p = fakeProps();
p.pointerTypes = ["Plant"];
p.slugs = ["strawberry-guava"];
const wrapper = mount(<SubCriteriaSection {...p} />);
expect(wrapper.text().toLowerCase()).toContain("stage");
expect(wrapper.text()).toContain("Strawberry guava");
});
it("returns point criteria", () => {
const p = fakeProps();
p.pointerTypes = ["GenericPointer"];
const wrapper = mount(<SubCriteriaSection {...p} />);
expect(wrapper.text().toLowerCase()).toContain("color");
});
it("returns weed criteria", () => {
const p = fakeProps();
p.pointerTypes = ["Weed"];
const wrapper = mount(<SubCriteriaSection {...p} />);
expect(wrapper.text().toLowerCase()).toContain("source");
});
it("returns tool slot criteria", () => {
const p = fakeProps();
p.pointerTypes = ["ToolSlot"];
const wrapper = mount(<SubCriteriaSection {...p} />);
expect(wrapper.text().toLowerCase()).toContain("direction");
});
});
describe("<CheckboxList />", () => {
const fakeProps = (): CheckboxListProps<string> => ({

View File

@ -48,7 +48,7 @@ export class AddEqCriteria<T extends string | number>
</Col>
<Col xs={2}>
<button className="fb-button green"
title={t("add criteria")}
title={t("add filter")}
onClick={this.commit}>
<i className="fa fa-plus" />
</button>
@ -97,7 +97,7 @@ export class AddNumberCriteria
</Col>
<Col xs={2}>
<button className="fb-button green"
title={t("add number criteria")}
title={t("add number filter")}
onClick={this.commit}>
<i className="fa fa-plus" />
</button>

View File

@ -8,6 +8,11 @@ const eqCriteriaEmpty =
(eqCriteria: Record<string, (string | number)[] | undefined>) =>
every(Object.values(eqCriteria).map(values => !values?.length));
/** Check if day criteria field is unset. */
export const dayCriteriaEmpty =
(dayCriteria: { op: ">" | "<", days_ago: number }) =>
isEqual(dayCriteria, { op: "<", days_ago: 0 });
/** Check if a point matches the criteria in the provided category. */
const checkCriteria =
(criteria: PointGroupCriteria, now: moment.Moment) =>
@ -31,11 +36,11 @@ const checkCriteria =
? point.body.planted_at
: point.body.created_at);
const compareDate = moment(now)
.subtract(criteria[criteriaKey].days_ago, "days");
const matchesDays = criteria[criteriaKey].op == "<"
.subtract(criteria.day.days_ago, "days");
const matchesDays = criteria.day.op == "<"
? pointDate.isAfter(compareDate)
: pointDate.isBefore(compareDate);
return matchesDays || !criteria[criteriaKey].days_ago;
return matchesDays || dayCriteriaEmpty(criteria.day);
}
};

View File

@ -1,81 +1,211 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { overwrite, save } from "../../../api/crud";
import {
DaySelection, EqCriteriaSelection,
NumberCriteriaSelection, LocationSelection, CheckboxSelections,
DaySelection, EqCriteriaSelection, SubCriteriaSection,
NumberCriteriaSelection, LocationSelection, togglePointTypeCriteria,
} from ".";
import {
GroupCriteriaProps, GroupPointCountBreakdownProps, GroupCriteriaState,
DEFAULT_CRITERIA, ClearCriteriaProps,
DEFAULT_CRITERIA, ClearCriteriaProps, ClearPointIdsProps, POINTER_TYPES,
PointerType,
PointTypeSelectionProps,
} from "./interfaces";
import { ToggleButton } from "../../../controls/toggle_button";
import { Popover } from "@blueprintjs/core";
import { selectPoint } from "../../map/actions";
import { FBSelect, Checkbox, Help } from "../../../ui";
import {
POINTER_TYPE_LIST, POINTER_TYPE_DDI_LOOKUP, isPointType, validPointTypes,
setSelectionPointType,
} from "../../plants/select_plants";
import { ToolTips } from "../../../constants";
import { overwriteGroup } from "../actions";
import { sortGroupBy } from "../point_group_sort";
import { PointGroupItem } from "../point_group_item";
import { Feature } from "../../../devices/interfaces";
import { TaggedPoint } from "farmbot";
export const CRITERIA_POINT_TYPE_LOOKUP =
(): Record<PointerType, string> => ({
Plant: t("Plants"),
GenericPointer: t("Points"),
Weed: t("Weeds"),
ToolSlot: t("Slots"),
});
export class GroupCriteria extends
React.Component<GroupCriteriaProps, GroupCriteriaState> {
state: GroupCriteriaState = { advanced: false, clearCount: 0 };
render() {
const { group, dispatch, slugs } = this.props;
const criteria = group.body.criteria;
const commonProps = { group, criteria, dispatch };
return <div className="group-criteria">
<label className="criteria-heading">{t("criteria")}</label>
state: GroupCriteriaState = {
advanced: false, clearCount: 0, dayChanged: false
};
componentDidMount() {
const { pointer_type } = this.props.group.body.criteria.string_eq;
this.props.dispatch(setSelectionPointType(validPointTypes(pointer_type)));
}
AdvancedToggleMenu = () =>
<div className="criteria-options-menu">
<label>{t("advanced mode")}</label>
<ToggleButton
title={t("toggle advanced view")}
toggleValue={!this.state.advanced}
customText={{ textTrue: t("basic"), textFalse: t("advanced") }}
toggleAction={() => this.setState({ advanced: !this.state.advanced })} />
toggleValue={this.state.advanced}
customText={{ textTrue: t("on"), textFalse: t("off") }}
toggleAction={() =>
this.setState({ advanced: !this.state.advanced })} />
</div>
changeDay = (state: boolean) => this.setState({ dayChanged: state });
render() {
const { group, dispatch, slugs } = this.props;
const { criteria } = group.body;
const commonProps = { group, criteria, dispatch };
const dayProps = {
dayChanged: this.state.dayChanged,
changeDay: this.changeDay,
advanced: this.state.advanced,
};
const pointTypes = validPointTypes(criteria.string_eq.pointer_type) || [];
return <div className="group-criteria">
<label className="criteria-heading">{t("filters")}</label>
<Help text={t(ToolTips.CRITERIA_ALPHA_FEATURE)}
customIcon={"exclamation-triangle"} customClass={"alpha-icon"} />
<Popover>
<i className="fa fa-gear dark" />
<this.AdvancedToggleMenu />
</Popover>
{!this.state.advanced
? <div className={"basic"}>
<CheckboxSelections group={group} dispatch={dispatch} slugs={slugs} />
<DaySelection {...commonProps} />
<LocationSelection {...commonProps}
<PointTypeSelection {...commonProps} pointTypes={pointTypes} />
<div className={"point-type-checkboxes"}>
<SubCriteriaSection pointerTypes={pointTypes}
disabled={false} group={group} dispatch={dispatch} slugs={slugs} />
</div>
{!pointTypes.includes("ToolSlot") &&
<DaySelection {...commonProps} {...dayProps} />}
<LocationSelection {...commonProps} botSize={this.props.botSize}
editGroupAreaInMap={this.props.editGroupAreaInMap} />
</div>
: <div className={"advanced"}>
<DaySelection {...commonProps} />
<label>{t("string criteria")}</label>
<DaySelection {...commonProps} {...dayProps} />
<label>{t("strings")}</label>
<Help text={t(ToolTips.DOT_NOTATION_TIP)} />
<EqCriteriaSelection<string> {...commonProps}
type={"string"} eqCriteria={criteria.string_eq}
criteriaKey={"string_eq"} />
<label>{t("number criteria")}</label>
<label>{t("numbers")}</label>
<EqCriteriaSelection<number> {...commonProps}
type={"number"} eqCriteria={criteria.number_eq}
criteriaKey={"number_eq"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_lt"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_gt"} />
</div>}
<ClearCriteria dispatch={dispatch} group={group} />
</div>;
}
}
/** Reset all group criteria to defaults. */
const ClearCriteria = (props: ClearCriteriaProps) =>
<button className="clear-criteria fb-button red no-float"
title={t("clear all criteria")}
<button className="clear-criteria fb-button red"
title={t("clear all filters")}
onClick={() => {
props.dispatch(overwrite(props.group, {
...props.group.body, criteria: DEFAULT_CRITERIA
}));
props.dispatch(save(props.group.uuid));
if (confirm(t("Clear all group filters?"))) {
props.dispatch(overwriteGroup(props.group, {
...props.group.body, criteria: DEFAULT_CRITERIA
}));
}
}}>
{t("clear all criteria")}
{t("clear")}
</button>;
/** Clear manually selected points. */
const ClearPointIds = (props: ClearPointIdsProps) =>
<button className="clear-point-ids fb-button red"
title={t("clear manual selections")}
onClick={() => {
if (confirm(t("Remove all manual selections?"))) {
props.dispatch(overwriteGroup(props.group, {
...props.group.body, point_ids: []
}));
props.dispatch(selectPoint(undefined));
}
}}>
{t("clear")}
</button>;
/** Show counts of manual and criteria selections. */
export const GroupPointCountBreakdown = (props: GroupPointCountBreakdownProps) =>
<div className={"criteria-point-count-breakdown"}>
<div className={"manual-group-member-count"}>
<div className={"manual-selection-count"}>
{props.manualCount}
export const GroupPointCountBreakdown =
(props: GroupPointCountBreakdownProps) => {
const manuallyAddedIds = props.group.body.point_ids;
const sortedPoints =
sortGroupBy(props.group.body.sort_type, props.pointsSelectedByGroup);
const manualPoints = sortedPoints
.filter(p => manuallyAddedIds.includes(p.body.id || 0));
const criteriaPoints = sortedPoints
.filter(p => !manuallyAddedIds.includes(p.body.id || 0));
const generatePointIcons = (point: TaggedPoint) =>
<PointGroupItem
key={point.uuid}
hovered={point.uuid === props.hovered}
group={props.group}
point={point}
dispatch={props.dispatch} />;
return <div className={"group-member-count-breakdown"}>
<div className={"manual-group-member-count"}>
<div className={"manual-selection-count"}>
{manualPoints.length}
</div>
<p>{t("manually selected")}</p>
<ClearPointIds dispatch={props.dispatch} group={props.group} />
</div>
<p>{t("manually selected")}</p>
</div>
<div className={"criteria-group-member-count"}>
<div className={"criteria-selection-count"}>
{props.totalCount - props.manualCount}
</div>
<p>{t("selected by criteria")}</p>
</div>
{props.iconDisplay && manualPoints.length > 0 &&
<div className="groups-list-wrapper">
{manualPoints.map(generatePointIcons)}
</div>}
{props.shouldDisplay(Feature.criteria_groups) &&
<div className={"group-member-section"}>
<div className={"criteria-group-member-count"}>
<div className={"criteria-selection-count"}>
{criteriaPoints.length}
</div>
<p>{t("selected by filters")}</p>
<ClearCriteria dispatch={props.dispatch} group={props.group} />
</div>
{props.iconDisplay && criteriaPoints.length > 0 &&
<div className="groups-list-wrapper">
{criteriaPoints.map(generatePointIcons)}
</div>}
</div>}
</div>;
};
/** Select pointer_type string equal criteria,
* which determines if any additional criteria is shown. */
export const PointTypeSelection = (props: PointTypeSelectionProps) =>
<div className={"point-type-selection"}>
<p className={"category"}>{t("Select all")}</p>
<FBSelect
key={JSON.stringify(props.group.body.criteria)}
list={POINTER_TYPE_LIST().slice(0, -1)}
customNullLabel={t("Select one")}
selectedItem={props.pointTypes[0]
? POINTER_TYPE_DDI_LOOKUP()[props.pointTypes[0]]
: undefined}
onChange={ddi => {
if (isPointType(ddi.value)) {
props.dispatch(togglePointTypeCriteria(props.group, ddi.value, true));
props.dispatch(setSelectionPointType([ddi.value]));
}
}} />
{props.pointTypes.length > 1 &&
POINTER_TYPES.map(pointerType =>
<div className="point-type-section" key={pointerType}>
<Checkbox
onChange={() =>
props.dispatch(togglePointTypeCriteria(props.group, pointerType))}
checked={props.pointTypes.includes(pointerType)}
title={CRITERIA_POINT_TYPE_LOOKUP()[pointerType]} />
<p>{CRITERIA_POINT_TYPE_LOOKUP()[pointerType]}</p>
</div>)}
</div>;

View File

@ -1,11 +1,13 @@
import { overwrite, save } from "../../../api/crud";
import { TaggedPointGroup } from "farmbot";
import { cloneDeep, isNumber } from "lodash";
import { cloneDeep, isNumber, isUndefined } from "lodash";
import { SelectionBoxData } from "../../map/background";
import {
PointGroupCriteria, POINTER_TYPES, EqCriteria, PointerType,
StrAndNumCriteriaKeys,
} from "./interfaces";
import { error } from "../../../toast/toast";
import { t } from "../../../i18next_wrapper";
import { overwriteGroup } from "../actions";
/** Update and save group criteria. */
export const editCriteria =
@ -18,8 +20,7 @@ export const editCriteria =
number_gt: update.number_gt || group.body.criteria.number_gt,
number_lt: update.number_lt || group.body.criteria.number_lt,
};
dispatch(overwrite(group, { ...group.body, criteria }));
dispatch(save(group.uuid));
dispatch(overwriteGroup(group, { ...group.body, criteria }));
};
/** Toggle string or number equal criteria. */
@ -64,29 +65,28 @@ export const toggleAndEditEqCriteria = <T extends string | number>(
};
/** Clear incompatible criteria. */
const clearSubCriteria = (
export const clearSubCriteria = (
pointerTypes: PointerType[],
tempCriteria: PointGroupCriteria,
) => {
const toggleStrEq = toggleEqCriteria<string>(tempCriteria.string_eq, "off");
const toggleNumEq = toggleEqCriteria<number>(tempCriteria.number_eq, "off");
const toggleStrEqMapper = (key: string) =>
tempCriteria.string_eq[key]?.map(value => toggleStrEq(key, value));
if (pointerTypes.includes("Plant")) {
Object.entries(tempCriteria.string_eq).map(([key, values]) =>
["openfarm_slug", "plant_stage"].includes(key)
&& values?.map(v => toggleStrEq(key, v)));
toggleStrEq("pointer_type", "Plant");
["openfarm_slug", "plant_stage"].map(toggleStrEqMapper);
}
if (pointerTypes.includes("GenericPointer")) {
Object.entries(tempCriteria.string_eq).map(([key, values]) =>
key.includes("meta") && values?.map(v => toggleStrEq(key, v)));
if (pointerTypes.includes("Weed")) {
["meta.created_by"].map(toggleStrEqMapper);
}
if (pointerTypes.includes("GenericPointer") && pointerTypes.includes("Weed")) {
["meta.color"].map(toggleStrEqMapper);
delete tempCriteria.number_lt.radius;
delete tempCriteria.number_gt.radius;
toggleStrEq("pointer_type", "GenericPointer");
}
if (pointerTypes.includes("ToolSlot")) {
tempCriteria.number_eq.pullout_direction?.map(value =>
toggleNumEq("pullout_direction", value));
toggleStrEq("pointer_type", "ToolSlot");
}
};
@ -95,13 +95,14 @@ const clearSubCriteria = (
* When removing pointer_type criteria, clear pointer_type-specific criteria.
*/
export const togglePointTypeCriteria =
(group: TaggedPointGroup, pointerType: PointerType) =>
(group: TaggedPointGroup, pointerType: PointerType, clear = false) =>
(dispatch: Function) => {
const tempCriteria = cloneDeep(group.body.criteria);
const wasOn = tempCriteria.string_eq.pointer_type?.includes(pointerType);
const toggle = toggleEqCriteria<string>(tempCriteria.string_eq);
clear && (tempCriteria.string_eq.pointer_type = []);
toggle("pointer_type", pointerType);
wasOn && clearSubCriteria([pointerType], tempCriteria);
clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
dispatch(editCriteria(group, tempCriteria));
};
@ -109,11 +110,12 @@ export const togglePointTypeCriteria =
export const clearCriteriaField = (
group: TaggedPointGroup,
categories: StrAndNumCriteriaKeys,
field: string,
fields: string[],
) =>
(dispatch: Function) => {
const tempCriteria = cloneDeep(group.body.criteria);
categories.map(category => delete tempCriteria[category][field]);
categories.map(category => fields.map(field =>
delete tempCriteria[category][field]));
dispatch(editCriteria(group, tempCriteria));
};
@ -163,7 +165,23 @@ export const editGtLtCriteriaField = (
const tempCriteria = cloneDeep(group.body.criteria);
pointerType && clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
tempCriteria[criteriaField][criteriaKey] =
parseInt(e.currentTarget.value);
const value = e.currentTarget.value != ""
? parseInt(e.currentTarget.value)
: undefined;
if (!isUndefined(value)) {
const ltValue = tempCriteria.number_lt[criteriaKey];
const gtValue = tempCriteria.number_gt[criteriaKey];
if (criteriaField == "number_lt" && !isUndefined(gtValue) &&
!(value > gtValue)) {
return error(t("Value must be greater than {{ num }}.",
{ num: gtValue }));
}
if (criteriaField == "number_gt" && !isUndefined(ltValue) &&
!(value < ltValue)) {
return error(t("Value must be less than {{ num }}.",
{ num: ltValue }));
}
}
tempCriteria[criteriaField][criteriaKey] = value;
dispatch(editCriteria(group, tempCriteria));
};

View File

@ -2,7 +2,6 @@ export * from "./add";
export * from "./apply";
export * from "./component";
export * from "./edit";
export * from "./presets";
export * from "./selected";
export * from "./show";
export * from "./subcriteria";

View File

@ -1,5 +1,8 @@
import { TaggedPointGroup, PointType } from "farmbot";
import { TaggedPointGroup, PointType, TaggedPoint } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { BotSize } from "../../map/interfaces";
import { ShouldDisplay } from "../../../devices/interfaces";
import { UUID } from "../../../resources/interfaces";
export type PointGroupCriteria = PointGroup["criteria"];
export type StringEqCriteria = PointGroupCriteria["string_eq"];
@ -8,7 +11,7 @@ export type StrAndNumCriteriaKeys = (keyof Omit<PointGroupCriteria, "day">)[];
export type EqCriteria<T> = Record<string, T[] | undefined>;
export const POINTER_TYPES: PointerType[] =
["Plant", "GenericPointer", "ToolSlot", "Weed"];
["Plant", "GenericPointer", "Weed", "ToolSlot"];
export const DEFAULT_CRITERIA: Readonly<PointGroupCriteria> = {
day: { op: "<", days_ago: 0 },
@ -23,11 +26,14 @@ export interface GroupCriteriaProps {
group: TaggedPointGroup;
slugs: string[];
editGroupAreaInMap: boolean;
botSize: BotSize;
selectionPointType: PointType[] | undefined;
}
export interface GroupCriteriaState {
advanced: boolean;
clearCount: number;
dayChanged: boolean;
}
export interface ClearCriteriaProps {
@ -35,9 +41,24 @@ export interface ClearCriteriaProps {
group: TaggedPointGroup;
}
export interface ClearPointIdsProps {
dispatch: Function;
group: TaggedPointGroup;
}
export interface GroupPointCountBreakdownProps {
manualCount: number;
totalCount: number;
group: TaggedPointGroup;
dispatch: Function;
shouldDisplay: ShouldDisplay;
pointsSelectedByGroup: TaggedPoint[];
iconDisplay: boolean;
hovered: UUID | undefined;
}
export interface PointTypeSelectionProps {
dispatch: Function;
group: TaggedPointGroup;
pointTypes: PointerType[];
}
export interface CriteriaSelectionProps {
@ -46,8 +67,15 @@ export interface CriteriaSelectionProps {
dispatch: Function;
}
export interface DaySelectionProps extends CriteriaSelectionProps {
dayChanged: boolean;
changeDay(state: boolean): void;
advanced: boolean;
}
export interface LocationSelectionProps extends CriteriaSelectionProps {
editGroupAreaInMap: boolean;
botSize: BotSize;
}
export interface EqCriteriaSelectionProps<T> extends CriteriaSelectionProps {
@ -84,6 +112,10 @@ export interface SubCriteriaProps {
disabled: boolean;
}
export interface PointSubCriteriaProps extends SubCriteriaProps {
pointerType: PointerType;
}
export interface PlantSubCriteriaProps extends SubCriteriaProps {
slugs: string[];
}
@ -92,6 +124,7 @@ export interface CheckboxSelectionsProps {
dispatch: Function;
group: TaggedPointGroup;
slugs: string[];
pointerTypes: PointType[] | undefined;
}
export interface CheckboxSelectionsState {
@ -111,16 +144,26 @@ export interface NumberLtGtInputProps {
pointerType?: PointerType;
}
export interface SubCriteriaSectionProps {
dispatch: Function;
group: TaggedPointGroup;
disabled: boolean;
pointerTypes: PointerType[];
slugs: string[];
}
export interface ClearCategoryProps {
group: TaggedPointGroup;
criteriaCategories: StrAndNumCriteriaKeys;
criteriaKey: string;
criteriaKeys: string[];
dispatch: Function;
}
export type CheckboxListItem<T> = { label: string, value: T, color?: string };
export interface CheckboxListProps<T> {
criteriaKey: string;
list: { label: string, value: T }[];
list: CheckboxListItem<T>[];
dispatch: Function;
group: TaggedPointGroup;
pointerType: PointerType;

View File

@ -1,77 +0,0 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import {
togglePointTypeCriteria,
eqCriteriaSelected,
hasSubCriteria,
typeDisabled,
PlantCriteria,
PointCriteria,
ToolCriteria,
} from ".";
import {
CheckboxSelectionsProps,
CheckboxSelectionsState,
PointerType,
} from "./interfaces";
import { Checkbox } from "../../../ui";
const CRITERIA_POINT_TYPES =
(): { label: string, pointerType: PointerType }[] => [
{ label: t("Plants"), pointerType: "Plant" },
{ label: t("Points and Weeds"), pointerType: "GenericPointer" },
{ label: t("Slots"), pointerType: "ToolSlot" },
];
export class CheckboxSelections extends React.Component
<CheckboxSelectionsProps, Partial<CheckboxSelectionsState>> {
state: CheckboxSelectionsState = {
Plant: false, GenericPointer: false, ToolSlot: false, Weed: false
};
toggleMore = (section: keyof CheckboxSelectionsState) => () =>
this.setState({ [section]: !this.state[section] });
render() {
const { group, dispatch, slugs } = this.props;
const { criteria } = group.body;
const selected = eqCriteriaSelected<string>(criteria);
return <div className={"point-type-checkboxes"}>
{CRITERIA_POINT_TYPES().map(({ label, pointerType }, index) => {
const typeSelected = selected("pointer_type", pointerType);
const partial = hasSubCriteria(criteria)(pointerType) && !typeSelected;
return <div className="point-type-section" key={index}>
<div className="point-type-checkbox"
onClick={this.toggleMore(pointerType)}>
<Checkbox
onChange={() =>
dispatch(togglePointTypeCriteria(group, pointerType))}
checked={typeSelected}
partial={partial}
title={t(label)}
disabled={typeDisabled(criteria, pointerType)}
onClick={e => e.stopPropagation()} />
<p>{label}</p>
<i className={
`fa fa-caret-${this.state[pointerType] ? "up" : "down"}`}
title={this.state[pointerType]
? t("hide additional criteria")
: t("show additional criteria")} />
</div>
{this.state.Plant && pointerType == "Plant" &&
<PlantCriteria
disabled={!typeSelected && !partial}
group={group} dispatch={dispatch} slugs={slugs} />}
{this.state.GenericPointer && pointerType == "GenericPointer" &&
<PointCriteria
disabled={!typeSelected && !partial}
group={group} dispatch={dispatch} />}
{this.state.ToolSlot && pointerType == "ToolSlot" &&
<ToolCriteria
disabled={!typeSelected && !partial}
group={group} dispatch={dispatch} />}
</div>;
})}
</div>;
}
}

View File

@ -37,10 +37,15 @@ export const criteriaHasKey = (
key: string,
) =>
some(categories.map(category => {
if (category == "string_eq") {
return strCriteriaHasKey(criteria.string_eq)(key);
} else {
return numCriteriaHasKey(criteria)(key);
switch (category) {
case "string_eq":
return strCriteriaHasKey(criteria.string_eq)(key);
case "number_eq":
return (criteria.number_eq[key]?.length || 0) > 0;
case "number_lt":
return isNumber(criteria.number_lt[key]);
case "number_gt":
return isNumber(criteria.number_gt[key]);
}
}));
@ -52,9 +57,12 @@ export const hasSubCriteria = (criteria: PointGroupCriteria) =>
switch (pointerType) {
case "GenericPointer":
return !!(
selected("meta.type")
selected("meta.color")
|| numSelected("radius"));
case "Weed":
return !!(
selected("meta.created_by")
|| selected("meta.color")
|| selected("meta.created_by")
|| numSelected("radius"));
case "Plant":
return !!(

View File

@ -1,20 +1,25 @@
import * as React from "react";
import { Row, Col, FBSelect, DropDownItem } from "../../../ui";
import { Row, Col, FBSelect, DropDownItem, Checkbox } from "../../../ui";
import {
AddEqCriteria, editCriteria, AddNumberCriteria,
editGtLtCriteriaField,
removeEqCriteriaValue,
clearCriteriaField,
dayCriteriaEmpty,
ClearCategory,
} from ".";
import {
EqCriteriaSelectionProps, NumberCriteriaProps,
CriteriaSelectionProps, LocationSelectionProps,
EqCriteriaSelectionProps,
NumberCriteriaProps,
LocationSelectionProps,
NumberLtGtInputProps,
PointGroupCriteria,
DaySelectionProps,
} from "./interfaces";
import { t } from "../../../i18next_wrapper";
import { ToggleButton } from "../../../controls/toggle_button";
import { Actions } from "../../../constants";
import { spaceSelected } from "../../map/layers/zones/zones";
/** Add and view string or number equal criteria. */
export class EqCriteriaSelection<T extends string | number>
@ -39,7 +44,7 @@ export class EqCriteriaSelection<T extends string | number>
</Col>
<Col xs={2}>
<button className="fb-button red"
title={t("remove criteria")}
title={t("remove filter")}
onClick={() => dispatch(removeEqCriteriaValue(
group, eqCriteria, criteriaKey, key, value))}>
<i className="fa fa-minus" />
@ -73,7 +78,7 @@ export const NumberCriteriaSelection = (props: NumberCriteriaProps) => {
<button className="fb-button red"
title={t("remove number criteria")}
onClick={() => props.dispatch(clearCriteriaField(
props.group, [props.criteriaKey], key))}>
props.group, [props.criteriaKey], [key]))}>
<i className="fa fa-minus" />
</button>
</Col>
@ -88,34 +93,59 @@ const DAY_OPERATOR_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
});
/** Edit and view day criteria. */
export const DaySelection = (props: CriteriaSelectionProps) => {
const { group, criteria, dispatch } = props;
export const DaySelection = (props: DaySelectionProps) => {
const { group, criteria, dispatch, advanced } = props;
const dayCriteria = criteria.day;
const noDayCriteria = !advanced &&
dayCriteriaEmpty(dayCriteria) && !props.dayChanged;
return <div className="day-criteria">
<label>{t("Age selection")}</label>
{advanced
? <label>{t("Age")}</label>
: <p className={"category"}>{t("Age")}</p>}
{!advanced &&
<div className="criteria-checkbox-list-item">
<Checkbox
onChange={() => {
dispatch(editCriteria(group, { day: { op: "<", days_ago: 0 } }));
props.changeDay(false);
}}
checked={noDayCriteria}
disabled={noDayCriteria}
title={t("clear age selection")}
customDisabledText={t("age selection empty")} />
<p>{t("all")}</p>
</div>}
<Row>
<Col xs={5}>
<FBSelect key={JSON.stringify(criteria)}
list={[DAY_OPERATOR_DDI_LOOKUP()["<"],
DAY_OPERATOR_DDI_LOOKUP()[">"]]}
selectedItem={DAY_OPERATOR_DDI_LOOKUP()[dayCriteria.op]}
onChange={ddi => dispatch(editCriteria(group, {
day: {
days_ago: dayCriteria.days_ago,
op: ddi.value as PointGroupCriteria["day"]["op"]
}
}))} />
selectedItem={noDayCriteria
? { label: t("Select one"), value: "" }
: DAY_OPERATOR_DDI_LOOKUP()[dayCriteria.op]}
onChange={ddi => {
dispatch(editCriteria(group, {
day: {
days_ago: dayCriteria.days_ago,
op: ddi.value as PointGroupCriteria["day"]["op"]
}
}));
props.changeDay(true);
}} />
</Col>
<Col xs={3}>
<input type="number" value={dayCriteria.days_ago} name="days_ago"
<input type="number" name="days_ago"
value={noDayCriteria ? "" : dayCriteria.days_ago}
disabled={noDayCriteria}
onChange={e => {
const { op } = dayCriteria;
const days_ago = parseInt(e.currentTarget.value);
dispatch(editCriteria(group, { day: { days_ago, op } }));
props.changeDay(true);
}} />
</Col>
<Col xs={4}>
<p>{t("days old")}</p>
<p className={"days-old-text"}>{t("days old")}</p>
</Col>
</Row>
</div>;
@ -160,7 +190,17 @@ export const NumberLtGtInput = (props: NumberLtGtInputProps) => {
/** Form inputs to define a 2D group criteria area. */
export const LocationSelection = (props: LocationSelectionProps) =>
<div className="location-criteria">
<label>{t("Location selection")}</label>
<p className={"category"}>{t("Location")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["number_lt", "number_gt"]}
criteriaKeys={["x", "y"]}
dispatch={props.dispatch} />
{!spaceSelected(props.group, props.botSize) &&
<div className="location-selection-warning">
<i className="fa fa-exclamation-triangle" />
<p>{t("Invalid selection.")}</p>
</div>}
{["x", "y"].map((axis: "x" | "y") =>
<NumberLtGtInput
key={axis}
@ -170,7 +210,7 @@ export const LocationSelection = (props: LocationSelectionProps) =>
<div className={"edit-in-map"}>
<ToggleButton
title={props.editGroupAreaInMap
? t("map boxes will change location criteria")
? t("map boxes will change location filter")
: t("map boxes will manually add plants")}
customText={{ textFalse: t("off"), textTrue: t("on") }}
toggleValue={props.editGroupAreaInMap}

View File

@ -1,6 +1,6 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { capitalize, uniq } from "lodash";
import { capitalize, uniq, some, isEqual } from "lodash";
import {
NumberLtGtInput,
toggleAndEditEqCriteria,
@ -13,20 +13,54 @@ import {
SubCriteriaProps,
PlantSubCriteriaProps,
ClearCategoryProps,
PointSubCriteriaProps,
SubCriteriaSectionProps,
CheckboxListItem,
} from "./interfaces";
import { PLANT_STAGE_LIST } from "../../plants/edit_plant_status";
import { DIRECTION_CHOICES } from "../../tools/tool_slot_edit_components";
import { Checkbox } from "../../../ui";
import { PointType } from "farmbot";
export const SubCriteriaSection = (props: SubCriteriaSectionProps) => {
const { group, dispatch, disabled } = props;
const pointTypes = props.pointerTypes.sort();
if (pointTypes.length > 1 &&
!isEqual(pointTypes, ["GenericPointer", "Weed"])) {
return <div className={"criteria-checkboxes"} />;
}
switch (pointTypes[0]) {
case "Plant":
return <PlantCriteria
disabled={disabled}
group={group} dispatch={dispatch} slugs={props.slugs} />;
case "GenericPointer":
return <PointCriteria
disabled={disabled}
group={group} dispatch={dispatch} />;
case "Weed":
return <WeedCriteria
disabled={disabled}
group={group} dispatch={dispatch} />;
case "ToolSlot":
return <ToolCriteria
disabled={disabled}
group={group} dispatch={dispatch} />;
default:
return <div className={"criteria-checkboxes"} />;
}
};
/** "All" (any) checkbox to show or choose state of criteria subcategory. */
const ClearCategory = (props: ClearCategoryProps) => {
const { group, criteriaCategories, criteriaKey, dispatch } = props;
export const ClearCategory = (props: ClearCategoryProps) => {
const { group, criteriaCategories, criteriaKeys, dispatch } = props;
const all =
!criteriaHasKey(group.body.criteria, criteriaCategories, criteriaKey);
!some(criteriaKeys.map(criteriaKey =>
criteriaHasKey(group.body.criteria, criteriaCategories, criteriaKey)));
return <div className="criteria-checkbox-list-item">
<Checkbox
onChange={() =>
dispatch(clearCriteriaField(group, criteriaCategories, criteriaKey))}
dispatch(clearCriteriaField(group, criteriaCategories, criteriaKeys))}
checked={all}
disabled={all}
title={t("clear selections")}
@ -42,13 +76,14 @@ export const CheckboxList =
const selected = eqCriteriaSelected<T>(criteria);
const toggle = toggleAndEditEqCriteria;
return <div className={"criteria-checkbox-list"}>
{props.list.map(({ label, value }: { label: string, value: T }, index) =>
{props.list.map(({ label, value, color }: CheckboxListItem<T>, index) =>
<div className="criteria-checkbox-list-item" key={index}>
<Checkbox
onChange={() => props.dispatch(toggle<T>(
props.group, props.criteriaKey, value, props.pointerType))}
checked={selected(props.criteriaKey, value)}
title={t(label)}
color={color}
disabled={props.disabled} />
<p>{label}</p>
</div>)}
@ -71,7 +106,7 @@ const PlantStage = (props: SubCriteriaProps) =>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"plant_stage"}
criteriaKeys={["plant_stage"]}
dispatch={props.dispatch} />
<CheckboxList<string>
disabled={props.disabled}
@ -89,7 +124,7 @@ const PlantType = (props: PlantSubCriteriaProps) =>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"openfarm_slug"}
criteriaKeys={["openfarm_slug"]}
dispatch={props.dispatch} />
<CheckboxList<string>
disabled={props.disabled}
@ -103,49 +138,40 @@ const PlantType = (props: PlantSubCriteriaProps) =>
({ label: capitalize(slug).replace("-", " "), value: slug }))} />
</div>;
/** Criteria specific to map points. */
export const PointCriteria = (props: SubCriteriaProps) => {
/** Criteria specific to weeds. */
export const WeedCriteria = (props: SubCriteriaProps) => {
const { group, dispatch, disabled } = props;
const commonProps = { group, dispatch, disabled };
return <div className={"point-criteria-options"}>
<PointType {...commonProps} />
const pointerType: PointType = "Weed";
const commonProps = { group, dispatch, disabled, pointerType };
return <div className={"weed-criteria-options"}>
<PointSource {...commonProps} />
<Color {...commonProps} />
<Radius {...commonProps} />
</div>;
};
const PointType = (props: SubCriteriaProps) =>
<div className={"point-type-criteria"}>
<p className={"category"}>{t("Type")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"meta.type"}
dispatch={props.dispatch} />
<CheckboxList
disabled={props.disabled}
pointerType={"GenericPointer"}
criteriaKey={"meta.type"}
group={props.group}
dispatch={props.dispatch}
list={[
{ label: t("Weeds"), value: "weed" },
{ label: t("Points"), value: "point" },
]} />
/** Criteria specific to map points. */
export const PointCriteria = (props: SubCriteriaProps) => {
const { group, dispatch, disabled } = props;
const pointerType: PointType = "GenericPointer";
const commonProps = { group, dispatch, disabled, pointerType };
return <div className={"point-criteria-options"}>
<Color {...commonProps} />
<Radius {...commonProps} />
</div>;
};
const PointSource = (props: SubCriteriaProps) =>
const PointSource = (props: PointSubCriteriaProps) =>
<div className={"point-source-criteria"}>
<p className={"category"}>{t("Source")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"meta.created_by"}
criteriaKeys={["meta.created_by"]}
dispatch={props.dispatch} />
<CheckboxList
disabled={props.disabled}
pointerType={"GenericPointer"}
pointerType={props.pointerType}
criteriaKey={"meta.created_by"}
group={props.group}
dispatch={props.dispatch}
@ -155,13 +181,13 @@ const PointSource = (props: SubCriteriaProps) =>
]} />
</div>;
const Radius = (props: SubCriteriaProps) =>
const Radius = (props: PointSubCriteriaProps) =>
<div className={"radius-criteria"}>
<p className={"category"}>{t("Radius")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["number_gt", "number_lt"]}
criteriaKey={"radius"}
criteriaKeys={["radius"]}
dispatch={props.dispatch} />
<div className={"lt-gt-criteria"}>
<NumberLtGtInput
@ -170,35 +196,34 @@ const Radius = (props: SubCriteriaProps) =>
inputWidth={3}
labelWidth={2}
group={props.group}
pointerType={"GenericPointer"}
pointerType={props.pointerType}
dispatch={props.dispatch} />
</div>
</div>;
const Color = (props: SubCriteriaProps) =>
const Color = (props: PointSubCriteriaProps) =>
<div className={"color-criteria"}>
<p className={"category"}>{t("Color")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"meta.color"}
criteriaKeys={["meta.color"]}
dispatch={props.dispatch} />
<CheckboxList
disabled={props.disabled}
pointerType={"GenericPointer"}
pointerType={props.pointerType}
criteriaKey={"meta.color"}
group={props.group}
dispatch={props.dispatch}
list={[
{ label: t("Green"), value: "green" },
{ label: t("Red"), value: "red" },
{ label: t("Cyan"), value: "cyan" },
{ label: t("Blue"), value: "blue" },
{ label: t("Yellow"), value: "yellow" },
{ label: t("Orange"), value: "orange" },
{ label: t("Purple"), value: "purple" },
{ label: t("Pink"), value: "pink" },
{ label: t("Gray"), value: "gray" },
{ label: t("Green"), value: "green", color: "green" },
{ label: t("Red"), value: "red", color: "red" },
{ label: t("Blue"), value: "blue", color: "blue" },
{ label: t("Yellow"), value: "yellow", color: "yellow" },
{ label: t("Orange"), value: "orange", color: "orange" },
{ label: t("Purple"), value: "purple", color: "purple" },
{ label: t("Pink"), value: "pink", color: "pink" },
{ label: t("Gray"), value: "gray", color: "gray" },
]} />
</div>;
@ -217,7 +242,7 @@ const PulloutDirection = (props: SubCriteriaProps) =>
<ClearCategory
group={props.group}
criteriaCategories={["number_eq"]}
criteriaKey={"pullout_direction"}
criteriaKeys={["pullout_direction"]}
dispatch={props.dispatch} />
<CheckboxList<number>
disabled={props.disabled}

View File

@ -1,7 +1,7 @@
import * as React from "react";
import { connect } from "react-redux";
import { Everything } from "../../interfaces";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { TaggedPointGroup, TaggedPoint, PointType } from "farmbot";
import {
selectAllActivePoints, selectAllPlantPointers, selectAllPointGroups,
} from "../../resources/selectors";
@ -16,6 +16,8 @@ import {
} from "../designer_panel";
import { Panel } from "../panel_header";
import { t } from "../../i18next_wrapper";
import { BotSize } from "../map/interfaces";
import { botSize } from "../state_to_props";
interface GroupDetailProps {
dispatch: Function;
@ -25,6 +27,8 @@ interface GroupDetailProps {
slugs: string[];
hovered: UUID | undefined;
editGroupAreaInMap: boolean;
botSize: BotSize;
selectionPointType: PointType[] | undefined;
}
/** Find a group from a URL-provided ID. */
@ -36,7 +40,7 @@ export const findGroupFromUrl = (groups: TaggedPointGroup[]) => {
};
function mapStateToProps(props: Everything): GroupDetailProps {
const { hoveredPlantListItem, editGroupAreaInMap } =
const { hoveredPlantListItem, editGroupAreaInMap, selectionPointType } =
props.resources.consumers.farm_designer;
return {
allPoints: selectAllActivePoints(props.resources.index),
@ -47,6 +51,8 @@ function mapStateToProps(props: Everything): GroupDetailProps {
.map(p => p.body.openfarm_slug)),
hovered: hoveredPlantListItem,
editGroupAreaInMap,
botSize: botSize(props),
selectionPointType,
};
}

View File

@ -1,20 +1,19 @@
import * as React from "react";
import { t } from "../../i18next_wrapper";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { TaggedPointGroup, TaggedPoint, PointType } from "farmbot";
import { DeleteButton } from "../../ui/delete_button";
import { save, edit } from "../../api/crud";
import { sortGroupBy } from "./point_group_sort";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { PointGroupItem } from "./point_group_item";
import { Paths } from "./paths";
import { Feature, ShouldDisplay } from "../../devices/interfaces";
import { ErrorBoundary } from "../../error_boundary";
import {
GroupCriteria, GroupPointCountBreakdown, pointsSelectedByGroup,
} from "./criteria";
import { Content } from "../../constants";
import { ToolTips } from "../../constants";
import { UUID } from "../../resources/interfaces";
import { Help } from "../../ui";
import { BotSize } from "../map/interfaces";
import { setSelectionPointType } from "../plants/select_plants";
export interface GroupDetailActiveProps {
dispatch: Function;
@ -24,10 +23,11 @@ export interface GroupDetailActiveProps {
slugs: string[];
hovered: UUID | undefined;
editGroupAreaInMap: boolean;
botSize: BotSize;
selectionPointType: PointType[] | undefined;
}
interface GroupDetailActiveState {
timerId?: ReturnType<typeof setInterval>;
iconDisplay: boolean;
}
@ -35,51 +35,12 @@ export class GroupDetailActive
extends React.Component<GroupDetailActiveProps, GroupDetailActiveState> {
state: GroupDetailActiveState = { iconDisplay: true };
update = ({ currentTarget }: React.SyntheticEvent<HTMLInputElement>) => {
this.props.dispatch(edit(this.props.group, { name: currentTarget.value }));
};
get pointsSelectedByGroup() {
return pointsSelectedByGroup(this.props.group, this.props.allPoints);
}
get icons() {
const sortedPoints =
sortGroupBy(this.props.group.body.sort_type, this.pointsSelectedByGroup);
return sortedPoints.map(point => {
return <PointGroupItem
key={point.uuid}
hovered={point.uuid === this.props.hovered}
group={this.props.group}
point={point}
dispatch={this.props.dispatch} />;
});
}
get saved(): boolean {
return !this.props.group.specialStatus;
}
saveGroup = () => {
if (!this.saved) {
this.props.dispatch(save(this.props.group.uuid));
}
}
changeSortType = (sort_type: PointGroupSortType) => {
const { dispatch, group } = this.props;
dispatch(edit(group, { sort_type }));
}
componentDidMount() {
// There are better ways to do this.
this.setState({ timerId: setInterval(this.saveGroup, 900) });
}
componentWillUnmount() {
const { timerId } = this.state;
(typeof timerId == "number") && clearInterval(timerId);
}
componentWillUnmount = () =>
this.props.dispatch(setSelectionPointType(undefined));
toggleIconShow = () => this.setState({ iconDisplay: !this.state.iconDisplay });
@ -87,24 +48,29 @@ export class GroupDetailActive
const { group, dispatch } = this.props;
return <ErrorBoundary>
<label>{t("GROUP NAME")}</label>
<i style={{ float: "right" }}>{this.saved ? "" : " saving..."}</i>
<input
name="name"
defaultValue={group.body.name}
onChange={this.update}
onBlur={this.saveGroup} />
onBlur={e => {
const newGroupName = e.currentTarget.value;
if (newGroupName != "" && newGroupName != this.props.group.body.name) {
this.props.dispatch(edit(this.props.group, { name: newGroupName }));
this.props.dispatch(save(this.props.group.uuid));
}
}} />
<GroupSortSelection group={group} dispatch={dispatch}
pointsSelectedByGroup={this.pointsSelectedByGroup} />
<GroupMemberDisplay group={group} dispatch={dispatch}
pointsSelectedByGroup={this.pointsSelectedByGroup}
icons={this.icons}
hovered={this.props.hovered}
iconDisplay={this.state.iconDisplay}
toggleIconShow={this.toggleIconShow}
shouldDisplay={this.props.shouldDisplay} />
{this.props.shouldDisplay(Feature.criteria_groups) &&
<GroupCriteria dispatch={dispatch}
group={group} slugs={this.props.slugs}
editGroupAreaInMap={this.props.editGroupAreaInMap} />}
group={group} slugs={this.props.slugs} botSize={this.props.botSize}
editGroupAreaInMap={this.props.editGroupAreaInMap}
selectionPointType={this.props.selectionPointType} />}
<DeleteButton
className="group-delete-btn"
dispatch={dispatch}
@ -130,7 +96,7 @@ const GroupSortSelection = (props: GroupSortSelectionProps) =>
</label>
{props.group.body.sort_type == "random" &&
<Help
text={Content.SORT_DESCRIPTION}
text={ToolTips.SORT_DESCRIPTION}
customIcon={"exclamation-triangle"} />}
<Paths
key={JSON.stringify(props.pointsSelectedByGroup
@ -145,32 +111,34 @@ interface GroupMemberDisplayProps {
dispatch: Function;
pointsSelectedByGroup: TaggedPoint[];
shouldDisplay: ShouldDisplay;
icons: JSX.Element[];
iconDisplay: boolean;
toggleIconShow(): void;
hovered: UUID | undefined;
}
/** View group point counts and icon list. */
const GroupMemberDisplay = (props: GroupMemberDisplayProps) =>
<div className="group-member-display">
const GroupMemberDisplay = (props: GroupMemberDisplayProps) => {
return <div className="group-member-display">
<label>
{t("GROUP MEMBERS ({{count}})", { count: props.icons.length })}
{t("GROUP MEMBERS ({{count}})", {
count: props.pointsSelectedByGroup.length
})}
</label>
<Help text={`${t("Click plants in map to add or remove.")} ${(
props.shouldDisplay(Feature.criteria_groups) &&
props.pointsSelectedByGroup.length != props.group.body.point_ids.length)
? t(Content.CRITERIA_SELECTION_COUNT) : ""}`} />
? t(ToolTips.CRITERIA_SELECTION_COUNT) : ""}`} />
<i onClick={props.toggleIconShow}
className={`fa fa-caret-${props.iconDisplay ? "up" : "down"}`}
title={props.iconDisplay
? t("hide icons")
: t("show icons")} />
{props.shouldDisplay(Feature.criteria_groups) &&
<GroupPointCountBreakdown
manualCount={props.group.body.point_ids.length}
totalCount={props.pointsSelectedByGroup.length} />}
{props.iconDisplay &&
<div className="groups-list-wrapper">
{props.icons}
</div>}
<GroupPointCountBreakdown
group={props.group}
dispatch={props.dispatch}
shouldDisplay={props.shouldDisplay}
iconDisplay={props.iconDisplay}
hovered={props.hovered}
pointsSelectedByGroup={props.pointsSelectedByGroup} />
</div>;
};

View File

@ -10,9 +10,12 @@ import { findAll } from "../../resources/find_all";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { history } from "../../history";
import { GroupInventoryItem } from "./group_inventory_item";
import { EmptyStateWrapper, EmptyStateGraphic } from "../../ui/empty_state_wrapper";
import {
EmptyStateWrapper, EmptyStateGraphic,
} from "../../ui/empty_state_wrapper";
import { Content } from "../../constants";
import { selectAllActivePoints } from "../../resources/selectors";
import { createGroup } from "./actions";
export interface GroupListPanelProps {
dispatch: Function;
@ -32,8 +35,8 @@ export function mapStateToProps(props: Everything): GroupListPanelProps {
};
}
export class RawGroupListPanel extends React.Component<GroupListPanelProps, State> {
export class RawGroupListPanel
extends React.Component<GroupListPanelProps, State> {
state: State = { searchTerm: "" };
update = ({ currentTarget }: React.SyntheticEvent<HTMLInputElement>) => {
@ -47,7 +50,7 @@ export class RawGroupListPanel extends React.Component<GroupListPanelProps, Stat
<DesignerNavTabs />
<DesignerPanelTop
panel={Panel.Groups}
linkTo={"/app/designer/plants/select"}
onClick={() => this.props.dispatch(createGroup({ pointUuids: [] }))}
title={t("Add group")}>
<input type="text"
name="searchTerm"

View File

@ -7,7 +7,7 @@ import { Color } from "../../ui";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { t } from "../../i18next_wrapper";
import { Actions } from "../../constants";
import { edit } from "../../api/crud";
import { edit, save } from "../../api/crud";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { error } from "../../toast/toast";
import { DevSettings } from "../../account/dev/dev_support";
@ -73,10 +73,14 @@ export const PathInfoBar = (props: PathInfoBarProps) => {
dispatch({ type: Actions.TRY_SORT_TYPE, payload: sortTypeKey })}
onMouseLeave={() =>
dispatch({ type: Actions.TRY_SORT_TYPE, payload: undefined })}
onClick={() =>
sortTypeKey == "nn"
? error(t("Not supported yet."))
: dispatch(edit(group, { sort_type: sortTypeKey }))}>
onClick={() => {
if (sortTypeKey == "nn") {
error(t("Not supported yet."));
} else {
dispatch(edit(group, { sort_type: sortTypeKey }));
dispatch(save(group.uuid));
}
}}>
<div className={"sort-path-info-bar"}
style={{ width: `${normalizedLength}%` }}>
{`${sortLabel}: ${Math.round(pathLength / 10) / 100}m`}

View File

@ -3,10 +3,11 @@ import { DEFAULT_ICON, svgToUrl } from "../../open_farm/icons";
import { maybeGetCachedPlantIcon } from "../../open_farm/cached_crop";
import { setHoveredPlant } from "../map/actions";
import { TaggedPointGroup, uuid, TaggedPoint } from "farmbot";
import { overwrite } from "../../api/crud";
import { error } from "../../toast/toast";
import { t } from "../../i18next_wrapper";
import { DEFAULT_WEED_ICON } from "../map/layers/weeds/garden_weed";
import { uniq } from "lodash";
import { overwriteGroup } from "./actions";
export interface PointGroupItemProps {
point: TaggedPoint;
@ -17,12 +18,13 @@ export interface PointGroupItemProps {
interface PointGroupItemState { icon: string; }
const removePoint = (group: TaggedPointGroup, pointId: number) => {
type Body = (typeof group)["body"];
const nextGroup: Body = { ...group.body };
nextGroup.point_ids = nextGroup.point_ids.filter(x => x !== pointId);
return overwrite(group, nextGroup);
};
const removePoint = (group: TaggedPointGroup, pointId: number) =>
(dispatch: Function) => {
type Body = (typeof group)["body"];
const nextGroup: Body = { ...group.body };
nextGroup.point_ids = uniq(nextGroup.point_ids.filter(x => x !== pointId));
dispatch(overwriteGroup(group, nextGroup));
};
export const genericPointIcon = (color: string | undefined) =>
`<svg xmlns='http://www.w3.org/2000/svg'
@ -65,7 +67,7 @@ export class PointGroupItem
click = () => {
if (this.criteriaIcon) {
return error(t("Cannot remove points selected by criteria."));
return error(t("Cannot remove points selected by filters."));
}
this.props.dispatch(
removePoint(this.props.group, this.props.point.body.id || 0));
@ -115,11 +117,7 @@ export class PointGroupItem
width={32}
height={32} />}
<img
style={{
border: this.criteriaIcon ? "1px solid gray" : "none",
borderRadius: "5px",
background: this.props.hovered ? "lightgray" : "none",
}}
style={{ background: this.props.hovered ? "lightgray" : "none" }}
src={this.initIcon}
onLoad={this.maybeGetCachedIcon}
width={32}

View File

@ -64,14 +64,14 @@ describe("<CreatePoints />", () => {
it("renders for points", () => {
mockPath = "/app/designer";
const wrapper = mount(<CreatePoints {...fakeProps()} />);
["add point", "delete", "x", "y", "radius", "color"]
["add point", "delete", "x", "y", "radius"]
.map(string => expect(wrapper.text().toLowerCase()).toContain(string));
});
it("renders for weeds", () => {
mockPath = "/app/designer/weeds/add";
const wrapper = mount(<CreatePoints {...fakeProps()} />);
["add weed", "delete", "x", "y", "radius", "color"]
["add weed", "delete", "x", "y", "radius"]
.map(string => expect(wrapper.text().toLowerCase()).toContain(string));
});

View File

@ -4,11 +4,14 @@ jest.mock("../../../api/crud", () => ({
}));
import * as React from "react";
import { shallow } from "enzyme";
import { shallow, mount } from "enzyme";
import {
EditPointLocation, EditPointLocationProps,
EditPointRadius, EditPointRadiusProps,
EditPointColor, EditPointColorProps, updatePoint, EditPointName, EditPointNameProps,
EditPointColor, EditPointColorProps, updatePoint, EditPointName,
EditPointNameProps,
AdditionalWeedProperties,
EditPointPropertiesProps,
} from "../point_edit_actions";
import { fakePoint } from "../../../__test_support__/fake_state/resources";
import { edit, save } from "../../../api/crud";
@ -89,3 +92,30 @@ describe("<EditPointColor />", () => {
expect(p.updatePoint).toHaveBeenCalledWith({ meta: { color: "blue" } });
});
});
describe("<AdditionalWeedProperties />", () => {
const fakeProps = (): EditPointPropertiesProps => ({
point: fakePoint(),
updatePoint: jest.fn(),
});
it("renders unknown source", () => {
const p = fakeProps();
p.point.body.meta = {
meta_key: "meta value", created_by: undefined, key: undefined
};
const wrapper = mount(<AdditionalWeedProperties {...p} />);
expect(wrapper.text()).toContain("unknown");
expect(wrapper.text()).toContain("meta value");
});
it("changes method", () => {
const p = fakeProps();
p.point.body.meta = { removal_method: "automatic" };
const wrapper = shallow(<AdditionalWeedProperties {...p} />);
wrapper.find("input").last().simulate("change");
expect(p.updatePoint).toHaveBeenCalledWith({
meta: { removal_method: "manual" }
});
});
});

View File

@ -45,8 +45,24 @@ describe("<EditPoint />", () => {
it("renders with points", () => {
mockPath = "/app/designer/points/1";
const wrapper = mount(<EditPoint {...fakeProps()} />);
const p = fakeProps();
const point = fakePoint();
point.body.meta = { meta_key: "meta value" };
p.findPoint = () => point;
const wrapper = mount(<EditPoint {...p} />);
expect(wrapper.text()).toContain("Edit point");
expect(wrapper.text()).toContain("meta value");
});
it("doesn't render duplicate values", () => {
mockPath = "/app/designer/points/1";
const p = fakeProps();
const point = fakePoint();
point.body.meta = { color: "red", meta_key: undefined };
p.findPoint = () => point;
const wrapper = mount(<EditPoint {...p} />);
expect(wrapper.text()).toContain("Edit point");
expect(wrapper.text()).not.toContain("red");
});
it("moves the device to a particular point", () => {

View File

@ -196,14 +196,25 @@ export class RawCreatePoints
PointProperties = () =>
<ul>
<li>
<div className={"point-name-input"}>
<label>{t("Name")}</label>
<BlurableInput
name="name"
type="text"
onCommit={this.updateValue("name")}
value={this.attr("name") || this.defaultName} />
</div>
<Row>
<div className={"point-name-input"}>
<Col xs={10}>
<label>{t("Name")}</label>
<BlurableInput
name="name"
type="text"
onCommit={this.updateValue("name")}
value={this.attr("name") || this.defaultName} />
</Col>
</div>
<div className={"point-color-input"}>
<Col xs={2}>
<ColorPicker
current={(this.attr("color") || this.defaultColor) as ResourceColor}
onChange={this.changeColor} />
</Col>
</div>
</Row>
</li>
<ListItem name={t("Location")}>
<Row>
@ -238,13 +249,6 @@ export class RawCreatePoints
</Col>
</Row>
</ListItem>
<ListItem name={t("Color")}>
<Row>
<ColorPicker
current={(this.attr("color") || this.defaultColor) as ResourceColor}
onChange={this.changeColor} />
</Row>
</ListItem>
</ul>
PointActions = () =>

View File

@ -5,10 +5,11 @@ import { destroy, edit, save } from "../../api/crud";
import { ResourceColor } from "../../interfaces";
import { TaggedGenericPointer, TaggedWeedPointer } from "farmbot";
import { ListItem } from "../plants/plant_panel";
import { round } from "lodash";
import { round, cloneDeep } from "lodash";
import { Row, Col, BlurableInput, ColorPicker } from "../../ui";
import { parseIntInput } from "../../util";
import { UUID } from "../../resources/interfaces";
import { plantAge } from "../plants/map_state_to_props";
type PointUpdate =
Partial<TaggedGenericPointer["body"] | TaggedWeedPointer["body"]>;
@ -52,6 +53,51 @@ export const EditPointProperties = (props: EditPointPropertiesProps) =>
</ListItem>
</ul>;
export const AdditionalWeedProperties = (props: EditPointPropertiesProps) =>
<ul className="additional-weed-properties">
<ListItem name={t("Age")}>
{`${plantAge(props.point)} ${t("days old")}`}
</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")}>
{SOURCE_LOOKUP()[value || ""] || t("unknown")}
</ListItem>;
case "removal_method":
return <ListItem name={t("Removal method")}>
<div className="weed-removal-method-section">
{REMOVAL_METHODS.map(method =>
<div className={"weed-removal-method"} key={method}>
<input type="radio" name="weed-removal-method"
checked={value == method}
onChange={() => {
const newMeta = cloneDeep(props.point.body.meta);
newMeta.removal_method = method;
props.updatePoint({ meta: newMeta });
}} />
<label>{t(method)}</label>
</div>)}
</div>
</ListItem>;
default:
return <ListItem name={key}>
{value || ""}
</ListItem>;
}
})}
</ul>;
const REMOVAL_METHODS = ["automatic", "manual"];
const SOURCE_LOOKUP = (): Record<string, string> => ({
"plant-detection": t("Weed Detector"),
"farm-designer": t("Farm Designer"),
});
export interface PointActionsProps {
x: number;
y: number;

View File

@ -13,6 +13,7 @@ import { Actions } from "../../constants";
import {
EditPointProperties, updatePoint, PointActions,
} from "./point_edit_actions";
import { ListItem } from "../plants/plant_panel";
export interface EditPointProps {
dispatch: Function;
@ -50,6 +51,21 @@ export class RawEditPoint extends React.Component<EditPointProps, {}> {
? <div className={"point-panel-content-wrapper"}>
<EditPointProperties point={this.point}
updatePoint={updatePoint(this.point, this.props.dispatch)} />
<ul className="meta">
{Object.entries(this.point.body.meta).map(([key, value]) => {
switch (key) {
case "color":
case "created_by":
case "type":
return <div key={key}
className={`meta-${key}-not-displayed`} />;
default:
return <ListItem key={key} name={key}>
{value || ""}
</ListItem>;
}
})}
</ul>
<PointActions
x={this.point.body.x}
y={this.point.body.y}

View File

@ -19,13 +19,15 @@ import {
import { validBotLocationData, validFwConfig, unpackUUID } from "../util";
import { getWebAppConfigValue } from "../config_storage/actions";
import { Props } from "./interfaces";
import { TaggedPlant } from "./map/interfaces";
import { TaggedPlant, BotSize } from "./map/interfaces";
import { RestResources } from "../resources/interfaces";
import { isString, uniq, chain } from "lodash";
import { BooleanSetting } from "../session_keys";
import { getEnv, getShouldDisplayFn } from "../farmware/state_to_props";
import { getFirmwareConfig } from "../resources/getters";
import { calcMicrostepsPerMm } from "../controls/move/direction_axes_props";
import { getBotSize } from "./map/util";
import { getDefaultAxisLength } from ".";
const plantFinder = (plants: TaggedPlant[]) =>
(uuid: string | undefined): TaggedPlant =>
@ -62,12 +64,6 @@ export function mapStateToProps(props: Everything): Props {
const { mcu_params } = props.bot.hardware;
const firmwareSettings = fwConfig || mcu_params;
const fw = firmwareSettings;
const stepsPerMmXY = {
x: calcMicrostepsPerMm(fw.movement_step_per_mm_x, fw.movement_microsteps_x),
y: calcMicrostepsPerMm(fw.movement_step_per_mm_y, fw.movement_microsteps_y),
};
const mountedToolId =
getDeviceAccountSettings(props.resources.index).body.mounted_tool_id;
const mountedToolName =
@ -122,7 +118,7 @@ export function mapStateToProps(props: Everything): Props {
plants,
botLocationData: validBotLocationData(props.bot.hardware.location_data),
botMcuParams: firmwareSettings,
stepsPerMmXY,
botSize: botSize(props),
peripherals,
eStopStatus: props.bot.hardware.informational_settings.locked,
latestImages,
@ -136,3 +132,19 @@ export function mapStateToProps(props: Everything): Props {
mountedToolName,
};
}
export const botSize = (props: Everything): BotSize => {
const getConfigValue = getWebAppConfigValue(() => props);
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const { mcu_params } = props.bot.hardware;
const firmwareSettings = fwConfig || mcu_params;
const fw = firmwareSettings;
const stepsPerMmXY = {
x: calcMicrostepsPerMm(fw.movement_step_per_mm_x, fw.movement_microsteps_x),
y: calcMicrostepsPerMm(fw.movement_step_per_mm_y, fw.movement_microsteps_y),
};
return getBotSize(
firmwareSettings,
stepsPerMmXY,
getDefaultAxisLength(getConfigValue));
};

View File

@ -17,6 +17,7 @@ import { initSave, init, destroy } from "../../../api/crud";
import { history } from "../../../history";
import { FirmwareHardware } from "farmbot";
import { AddToolProps } from "../interfaces";
import { mockDispatch } from "../../../__test_support__/fake_dispatch";
describe("<AddTool />", () => {
const fakeProps = (): AddToolProps => ({
@ -58,7 +59,7 @@ describe("<AddTool />", () => {
it("saves", async () => {
mockSave = () => Promise.resolve();
const p = fakeProps();
p.dispatch = jest.fn(x => typeof x === "function" && x());
p.dispatch = mockDispatch();
const wrapper = shallow<AddTool>(<AddTool {...p} />);
wrapper.setState({ toolName: "Foo" });
await wrapper.find(SaveBtn).simulate("click");
@ -70,7 +71,7 @@ describe("<AddTool />", () => {
it("removes unsaved tool on exit", async () => {
mockSave = () => Promise.reject();
const p = fakeProps();
p.dispatch = jest.fn(x => typeof x === "function" && x());
p.dispatch = mockDispatch();
const wrapper = shallow<AddTool>(<AddTool {...p} />);
wrapper.setState({ toolName: "Foo" });
await wrapper.find(SaveBtn).simulate("click");

View File

@ -42,10 +42,12 @@ describe("<EditToolSlot />", () => {
it("renders", () => {
const p = fakeProps();
p.findToolSlot = () => fakeToolSlot();
const toolSlot = fakeToolSlot();
toolSlot.body.meta = { meta_key: "meta value" };
p.findToolSlot = () => toolSlot;
const wrapper = mount(<EditToolSlot {...p} />);
["edit slot", "x (mm)", "y (mm)", "z (mm)", "tool or seed container",
"direction", "gantry-mounted",
"direction", "gantry-mounted", "meta value",
].map(string => expect(wrapper.text().toLowerCase()).toContain(string));
});

View File

@ -52,6 +52,17 @@ export class RawEditToolSlot extends React.Component<EditToolSlotProps> {
quadrant={this.props.quadrant}
isActive={this.props.isActive}
updateToolSlot={this.updateSlot(toolSlot)} />
<ul className="meta">
{Object.entries(toolSlot.body.meta).map(([key, value]) => {
switch (key) {
default:
return <li key={key}>
<label>{key}</label>
<div>{value}</div>
</li>;
}
})}
</ul>
<button
className="fb-button gray no-float"
title={t("move to this location")}

View File

@ -10,7 +10,7 @@ import { TaggedWeedPointer } from "farmbot";
import { maybeFindWeedPointerById } from "../../resources/selectors";
import { Panel } from "../panel_header";
import {
EditPointProperties, PointActions, updatePoint,
EditPointProperties, PointActions, updatePoint, AdditionalWeedProperties,
} from "../points/point_edit_actions";
import { Actions } from "../../constants";
@ -50,6 +50,8 @@ export class RawEditWeed extends React.Component<EditWeedProps, {}> {
? <div className={"weed-panel-content-wrapper"}>
<EditPointProperties point={this.point}
updatePoint={updatePoint(this.point, this.props.dispatch)} />
<AdditionalWeedProperties point={this.point}
updatePoint={updatePoint(this.point, this.props.dispatch)} />
<PointActions
x={this.point.body.x}
y={this.point.body.y}

View File

@ -25,6 +25,10 @@ describe("<EditZone />", () => {
const fakeProps = (): EditZoneProps => ({
dispatch: jest.fn(),
findZone: () => undefined,
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true },
},
});
it("redirects", () => {

View File

@ -11,16 +11,20 @@ import { selectAllPointGroups } from "../../resources/selectors";
import { TaggedPointGroup } from "farmbot";
import { edit, save } from "../../api/crud";
import { LocationSelection } from "../point_groups/criteria";
import { BotSize } from "../map/interfaces";
import { botSize } from "../state_to_props";
export interface EditZoneProps {
dispatch: Function;
findZone(id: number): TaggedPointGroup | undefined;
botSize: BotSize;
}
export const mapStateToProps = (props: Everything): EditZoneProps => ({
dispatch: props.dispatch,
findZone: id => selectAllPointGroups(props.resources.index)
.filter(g => g.body.id == id)[0],
botSize: botSize(props),
});
export class RawEditZone extends React.Component<EditZoneProps, {}> {
@ -54,6 +58,7 @@ export class RawEditZone extends React.Component<EditZoneProps, {}> {
group={zone}
criteria={zone.body.criteria}
dispatch={this.props.dispatch}
botSize={this.props.botSize}
editGroupAreaInMap={true} />
</div>
: <span>{t("Redirecting")}...</span>}

View File

@ -10,6 +10,7 @@ import { SetServoAngle } from "farmbot";
import { emptyState } from "../../../resources/reducer";
import { StepParams } from "../../interfaces";
import { editStep } from "../../../api/crud";
import { mockDispatch } from "../../../__test_support__/fake_dispatch";
describe("<TileSetServoAngle/>", () => {
const currentStep: SetServoAngle = {
@ -23,7 +24,7 @@ describe("<TileSetServoAngle/>", () => {
const fakeProps = (): StepParams => ({
currentSequence: fakeSequence(),
currentStep: currentStep,
dispatch: jest.fn((fn: Function) => typeof fn === "function" && fn()),
dispatch: mockDispatch(),
index: 0,
resources: emptyState().index,
confirmStepDeletion: false,

View File

@ -9,6 +9,7 @@ interface CheckboxProps {
partial?: boolean;
onClick?: (e: React.FormEvent) => void;
customDisabledText?: string;
color?: string;
}
export const Checkbox = (props: CheckboxProps) =>
@ -19,6 +20,7 @@ export const Checkbox = (props: CheckboxProps) =>
title={props.disabled ? props.customDisabledText ?? t("incompatible") : ""}
onClick={props.onClick}>
<input type="checkbox"
className={props.color ?? ""}
title={props.title}
onChange={props.onChange}
checked={props.checked} />

View File

@ -9,6 +9,7 @@ interface HelpProps {
onHover?: boolean;
position?: PopoverPosition;
customIcon?: string;
customClass?: string;
}
export function Help(props: HelpProps) {
@ -17,6 +18,7 @@ export function Help(props: HelpProps) {
interactionKind={props.onHover
? PopoverInteractionKind.HOVER
: PopoverInteractionKind.CLICK}
className={props.customClass}
popoverClassName={"help"}>
<i className={`fa fa-${props.customIcon || "question-circle"} help-icon`} />
<div className={"help-text-content"}>{t(props.text)}</div>