Merge pull request #10 from FarmBot/staging

update
pull/1505/head
AscendFB 2019-10-13 18:35:50 +02:00 committed by GitHub
commit 9a249b3416
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1143 additions and 496 deletions

View File

@ -83,6 +83,11 @@ module Api
def clean_expired_farm_events
FarmEvents::CleanExpired.run!(device: current_device)
# TODO: The app is leaking `Fragment` records, creating
# orphaned DB entries. This should be fixable via
# ActiveRecord config. Most likely a misconfiguration.
# - RC 4 OCT 19
Fragment.remove_old_fragments_for_device(current_device)
end
# Rails 5 params are no longer simple hashes. This was for security reasons.

View File

@ -44,9 +44,10 @@ class ApplicationRecord < ActiveRecord::Base
end
def broadcast?
!self.class.auto_sync_paused &&
current_device &&
(gone? || notable_changes?)
return false if self.class.auto_sync_paused
return false unless current_device
return false unless (gone? || notable_changes?)
return true
end
def maybe_broadcast
@ -91,9 +92,9 @@ class ApplicationRecord < ActiveRecord::Base
end
def manually_sync!
device.auto_sync_transaction do
device && (device.auto_sync_transaction do
update_attributes!(updated_at: Time.now)
end if device
end)
self
end
end

View File

@ -60,6 +60,7 @@ module CeleryScriptSettingsBag
BAD_PIN_TYPE = '"%s" is not a type of pin. Allowed values: %s'
BAD_POINTER_ID = "Bad point ID: %s"
BAD_POINTER_TYPE = '"%s" is not a type of point. Allowed values: %s'
BAD_POINT_GROUP_ID = "Can't find PointGroup with id of %s"
BAD_REGIMEN = "Regimen #%s does not exist."
BAD_RESOURCE_ID = "Can't find %s with id of %s"
BAD_RESOURCE_TYPE = '"%s" is not a valid resource_type. Allowed values: %s'
@ -130,7 +131,7 @@ module CeleryScriptSettingsBag
defn: [n(:execute), n(:nothing)],
},
data_value: {
defn: ANY_VAR_TOKENIZED + [n(:point_group), n(:every_point)],
defn: ANY_VAR_TOKENIZED + [n(:point_group)],
},
default_value: {
defn: ANY_VAR_TOKENIZED,
@ -194,6 +195,13 @@ module CeleryScriptSettingsBag
node.invalidate!(BAD_POINTER_ID % node.value) if bad_node
end,
},
point_group_id: {
defn: [v(:integer)],
blk: ->(node, device) do
bad_node = !PointGroup.where(id: node.value, device_id: device.id).exists?
node.invalidate!(BAD_POINT_GROUP_ID % node.value) if bad_node
end,
},
pointer_type: {
defn: [e(:PointType)],
},
@ -270,9 +278,6 @@ module CeleryScriptSettingsBag
lua: {
defn: [v(:string)],
},
every_point_type: {
defn: [e(:PointType)],
},
}.map do |(name, conf)|
blk = conf[:blk]
defn = conf.fetch(:defn)
@ -518,17 +523,13 @@ module CeleryScriptSettingsBag
end,
},
point_group: {
args: [:resource_id],
args: [:point_group_id],
tags: [:data, :list_like],
blk: ->(n) do
resource_id = n.args.fetch(:resource_id).value
resource_id = n.args.fetch(:point_group_id).value
check_resource_type(n, "PointGroup", resource_id, Device.current)
end,
},
every_point: {
args: [:every_point_type],
tags: [:data, :list_like],
},
}.map { |(name, list)| Corpus.node(name, **list) }
HASH = Corpus.as_json

View File

@ -185,14 +185,10 @@ class Device < ApplicationRecord
# Helper method to create an auth token.
# Used by sys admins to debug problems without performing a password reset.
def create_token
# If something manages to call this method, I'd like to be alerted of it.
def help_customer
Rollbar.error("Someone is creating a debug user token", { device: self.id })
fbos_version = Api::AbstractController::EXPECTED_VER
SessionToken
.as_json(users.first, "SUPER", fbos_version)
.fetch(:token)
.encoded
token = SessionToken.as_json(users.first, "staff", fbos_version).to_json
return "localStorage['session'] = JSON.stringify(#{token});"
end
TOO_MANY_CONNECTIONS =

View File

@ -54,9 +54,7 @@ class Fragment < ApplicationRecord
# Avoid N+1s: Fragment.includes(Fragment::EVERYTHING)
EVERYTHING = { nodes: Node::EVERYTHING }
belongs_to :device
belongs_to :owner,
polymorphic: true,
inverse_of: :fragment
belongs_to :owner, polymorphic: true, inverse_of: :fragment
has_many :primitives, dependent: :destroy
has_many :nodes
has_many :primitive_pairs
@ -89,4 +87,8 @@ class Fragment < ApplicationRecord
def broadcast?
false
end
def self.remove_old_fragments_for_device(dev)
dev.fragments.select { |x| x.owner == nil }.map { |x| x.destroy! }
end
end

View File

@ -9,7 +9,7 @@ class Point < ApplicationRecord
belongs_to :device
validates_presence_of :device
has_many :point_group_items
has_many :point_group_items, dependent: :destroy
after_discard :maybe_broadcast

View File

@ -1,6 +1,6 @@
class PointGroup < ApplicationRecord
SORT_TYPES =
%w(xy_ascending xy_decending yx_ascending yx_decending random).sort
%w(xy_ascending xy_descending yx_ascending yx_descending random).sort
BAD_SORT = "%{value} is not valid. Valid options are: " +
SORT_TYPES.map(&:inspect).join(", ")

View File

@ -6,23 +6,23 @@
# CeleryScript is a tree of PrimaryNode objects in the center and primitive
# "EdgeNode" types on the edge of the tree.
class PrimaryNode < ApplicationRecord
belongs_to :sequence
belongs_to :sequence
validates_presence_of :sequence
has_many :edge_nodes
has_many :edge_nodes
BAD_KIND = "`kind` must be one of: " +
CeleryScriptSettingsBag::ANY_NODE_NAME.join(", ")
CeleryScriptSettingsBag::ANY_NODE_NAME.join(", ")
validates :kind, inclusion: { in: CeleryScriptSettingsBag::ANY_NODE_NAME,
message: BAD_KIND,
allow_nil: false }
validates :parent_arg_name,
inclusion: {in: CeleryScriptSettingsBag::ANY_ARG_NAME,
message: BAD_KIND,
allow_nil: true}
inclusion: { in: CeleryScriptSettingsBag::ANY_ARG_NAME,
message: BAD_KIND,
allow_nil: true }
before_save :next_must_be_body_node
def next_must_be_body_node
raise "NO!" if(next_id && self.class.find(next_id).parent_arg_name)
raise "NO!" if (next_id && self.class.find(next_id).parent_arg_name)
end
def parent

View File

@ -1,3 +1,65 @@
module PointGroups
Destroy = CreateDestroyer.run!(resource: PointGroup)
class Destroy < Mutations::Command
STILL_IN_USE = "Can't delete group because it is in use by %{data_users}"
required do
model :device, class: Device
model :point_group, class: PointGroup
end
def validate
Fragment.remove_old_fragments_for_device(device)
add_error "in_use", :in_use, human_readable_error if in_use?
end
def execute
point_group.destroy! && ""
end
private
def human_readable_error
sequence_list = sequences.join(", ")
fragment_users_list = fragment_users.join(", ")
STILL_IN_USE % { data_users: sequence_list + fragment_users_list }
end
def sequence_ids
@sequence_ids ||= EdgeNode.where(kind: "point_group_id", value: point_group.id).pluck(:sequence_id)
end
def sequences
@sequences ||= Sequence
.find(sequence_ids)
.pluck(:name)
.map { |x| "sequence '#{x}'" }
end
def fragment_users
if @fragment_users
@fragment_users
else # TODO: Create SQL view to increase performance. - RC 8 OCT 19
my_fragment_ids = Fragment
.where(device_id: device.id)
.pluck(:id)
primitives = Primitive
.where(fragment_id: my_fragment_ids)
.where(value: point_group.id)
relevant_fragments = PrimitivePair
.where(arg_name_id: ArgName.find_or_create_by!(value: "point_group_id").id)
.where(primitive_id: primitives.pluck(:id))
.pluck(:fragment_id)
.uniq
@fragment_users = Fragment
.find(relevant_fragments)
.map(&:owner)
.compact # Referential integrity issues??? - RC 4 oct 19
.map { |x| "#{x.class} '#{x.fancy_name}'" }
end
end
def in_use?
@in_use ||= (sequences.any? || fragment_users.any?)
end
end
end

View File

@ -31,7 +31,9 @@ module PointGroups
private
def update_attributes
@update_attributes ||= inputs.slice(:name).merge(updated_at: Time.now)
@update_attributes ||= inputs
.except(:device, :point_ids, :point_group)
.merge(updated_at: Time.now)
end
def maybe_reconcile_points

View File

@ -10,7 +10,6 @@ module Points
end
optional do
boolean :hard_delete, default: false
array :point_ids, class: Integer
model :point, class: Point
end
@ -42,29 +41,41 @@ module Points
end
def execute
if hard_delete
points.destroy_all
else
Point.transaction do
archive_points
destroy_all_others
Point.transaction do
PointGroupItem.transaction do
clean_up_groups
points.destroy_all
end
end
end
private
def archive_points
points
.where(pointer_type: "GenericPointer")
.discard_all
def point_groups
@point_groups ||=
PointGroup.find(point_group_items.pluck(:point_group_id).uniq)
end
def destroy_all_others
points
.where
.not(pointer_type: "GenericPointer")
.destroy_all
def point_group_items
@point_group_items ||=
PointGroupItem.where(point_id: point_ids || point.id)
end
def clean_up_groups
# Cache relations *before* deleting PGIs.
pgs = point_groups
point_group_items.destroy_all
pgs.map do |x|
# WOW, THIS IS COMPLICATED.
# Why are you calling `SecureRandom.uuid`, Rick?
# """
# If you don't give the auto_sync message
# a fresh session_id, the frontend will
# think it is an "echo" and cancel it out.
# """ - Rick
x.update_attributes!(updated_at: Time.now)
x.broadcast!(SecureRandom.uuid)
end
end
def points

View File

@ -0,0 +1,44 @@
class AddKeys < ActiveRecord::Migration[5.2]
def change
add_foreign_key "farm_events", "devices", name: "farm_events_device_id_fk"
add_foreign_key "fragments", "devices", name: "fragments_device_id_fk"
add_foreign_key "logs", "devices", name: "logs_device_id_fk"
add_foreign_key "plant_templates", "devices", name: "plant_templates_device_id_fk"
add_foreign_key "plant_templates", "saved_gardens", name: "plant_templates_saved_garden_id_fk"
add_foreign_key "point_group_items", "point_groups", name: "point_group_items_point_group_id_fk"
add_foreign_key "point_group_items", "points", name: "point_group_items_point_id_fk"
add_foreign_key "point_groups", "devices", name: "point_groups_device_id_fk"
add_foreign_key "saved_gardens", "devices", name: "saved_gardens_device_id_fk"
add_foreign_key "tools", "devices", name: "tools_device_id_fk"
add_foreign_key "users", "devices", name: "users_device_id_fk"
# PROBLEMS- probably need manual cleanup?
# https://stackoverflow.com/questions/10921144/how-to-find-records-that-violate-referential-integrity
# add_foreign_key "fbos_configs", "devices", name: "fbos_configs_device_id_fk"
# add_foreign_key "firmware_configs", "devices", name: "firmware_configs_device_id_fk"
# add_foreign_key "images", "devices", name: "images_device_id_fk"
# add_foreign_key "regimens", "devices", name: "regimens_device_id_fk"
# add_foreign_key "sequences", "devices", name: "sequences_device_id_fk"
# add_foreign_key "web_app_configs", "devices", name: "web_app_configs_device_id_fk"
# add_foreign_key "webcam_feeds", "devices", name: "webcam_feeds_device_id_fk"
# add_foreign_key "edge_nodes", "primary_nodes", name: "edge_nodes_primary_node_id_fk"
# add_foreign_key "nodes", "nodes", column: "body_id", name: "nodes_body_id_fk"
# add_foreign_key "nodes", "fragments", name: "nodes_fragment_id_fk"
# add_foreign_key "nodes", "kinds", name: "nodes_kind_id_fk"
# add_foreign_key "nodes", "nodes", column: "next_id", name: "nodes_next_id_fk"
# add_foreign_key "nodes", "nodes", column: "parent_id", name: "nodes_parent_id_fk"
# add_foreign_key "primitive_pairs", "arg_names", name: "primitive_pairs_arg_name_id_fk"
# add_foreign_key "primitive_pairs", "arg_sets", name: "primitive_pairs_arg_set_id_fk"
# add_foreign_key "primitive_pairs", "fragments", name: "primitive_pairs_fragment_id_fk"
# add_foreign_key "primitive_pairs", "primitives", name: "primitive_pairs_primitive_id_fk"
# add_foreign_key "primitives", "fragments", name: "primitives_fragment_id_fk"
# add_foreign_key "regimen_items", "regimens", name: "regimen_items_regimen_id_fk"
# add_foreign_key "regimen_items", "sequences", name: "regimen_items_sequence_id_fk"
# add_foreign_key "standard_pairs", "arg_names", name: "standard_pairs_arg_name_id_fk"
# add_foreign_key "standard_pairs", "arg_sets", name: "standard_pairs_arg_set_id_fk"
# add_foreign_key "standard_pairs", "fragments", name: "standard_pairs_fragment_id_fk"
# add_foreign_key "standard_pairs", "nodes", name: "standard_pairs_node_id_fk"
# add_foreign_key "arg_sets", "fragments", name: "arg_sets_fragment_id_fk"
# add_foreign_key "arg_sets", "nodes", name: "arg_sets_node_id_fk"
end
end

View File

@ -27,8 +27,9 @@ if Rails.env == "development"
SavedGarden,
SensorReading,
FarmwareInstallation,
Device,
PointGroup,
Tool,
Device,
Delayed::Job,
Delayed::Backend::ActiveRecord::Job,
].map(&:delete_all)

View File

@ -1371,7 +1371,7 @@ CREATE VIEW public.resource_update_steps AS
edge_nodes.kind,
edge_nodes.value
FROM public.edge_nodes
WHERE (((edge_nodes.kind)::text = 'resource_type'::text) AND ((edge_nodes.value)::text = ANY ((ARRAY['"GenericPointer"'::character varying, '"ToolSlot"'::character varying, '"Plant"'::character varying])::text[])))
WHERE (((edge_nodes.kind)::text = 'resource_type'::text) AND ((edge_nodes.value)::text = ANY (ARRAY[('"GenericPointer"'::character varying)::text, ('"ToolSlot"'::character varying)::text, ('"Plant"'::character varying)::text])))
), resource_id AS (
SELECT edge_nodes.primary_node_id,
edge_nodes.kind,
@ -3002,6 +3002,14 @@ CREATE INDEX index_web_app_configs_on_device_id ON public.web_app_configs USING
CREATE INDEX index_webcam_feeds_on_device_id ON public.webcam_feeds USING btree (device_id);
--
-- Name: farm_events farm_events_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.farm_events
ADD CONSTRAINT farm_events_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: sensor_readings fk_rails_04297fb1ff; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -3130,6 +3138,86 @@ ALTER TABLE ONLY public.peripherals
ADD CONSTRAINT fk_rails_fdaad0007f FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: fragments fragments_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.fragments
ADD CONSTRAINT fragments_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: logs logs_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.logs
ADD CONSTRAINT logs_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: plant_templates plant_templates_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.plant_templates
ADD CONSTRAINT plant_templates_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: plant_templates plant_templates_saved_garden_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.plant_templates
ADD CONSTRAINT plant_templates_saved_garden_id_fk FOREIGN KEY (saved_garden_id) REFERENCES public.saved_gardens(id);
--
-- Name: point_group_items point_group_items_point_group_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.point_group_items
ADD CONSTRAINT point_group_items_point_group_id_fk FOREIGN KEY (point_group_id) REFERENCES public.point_groups(id);
--
-- Name: point_group_items point_group_items_point_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.point_group_items
ADD CONSTRAINT point_group_items_point_id_fk FOREIGN KEY (point_id) REFERENCES public.points(id);
--
-- Name: point_groups point_groups_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.point_groups
ADD CONSTRAINT point_groups_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: saved_gardens saved_gardens_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.saved_gardens
ADD CONSTRAINT saved_gardens_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: tools tools_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.tools
ADD CONSTRAINT tools_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: users users_device_id_fk; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_device_id_fk FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- PostgreSQL database dump complete
--
@ -3277,6 +3365,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20190823164837'),
('20190918185359'),
('20190924190539'),
('20190930202839');
('20190930202839'),
('20191002125625');

View File

@ -39,6 +39,7 @@ services:
env_file: ".env"
image: postgres:10
volumes: ["./docker_volumes/db:/var/lib/postgresql/data"]
# ports: ["5432:5432"]
web:
env_file: ".env"

View File

@ -459,6 +459,7 @@ export function fakeAlert(): TaggedAlert {
export function fakePointGroup(): TaggedPointGroup {
return fakeResource("PointGroup", {
name: "Fake",
sort_type: "xy_ascending",
point_ids: []
});
}

View File

@ -1,8 +0,0 @@
import { safeEveryPointType } from "../sequences/locals_list/location_form_list";
describe("safeEveryPointType", () => {
it("crashes on unknown values", () => {
const boom = () => safeEveryPointType("nope");
expect(boom).toThrowError("'nope' is not of type EveryPointType");
});
});

View File

@ -562,6 +562,12 @@ export namespace Content {
export const CONFIRM_PLANT_DELETION =
trim(`Show a confirmation dialog when deleting a plant.`);
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.`);
// Device
export const NOT_HTTPS =
trim(`WARNING: Sending passwords via HTTP:// is not secure.`);

View File

@ -9,7 +9,8 @@ import {
} from "../resources/selectors";
import { Props } from "./interfaces";
import {
validFwConfig, shouldDisplay as shouldDisplayFunc,
validFwConfig,
createShouldDisplayFn as shouldDisplayFunc,
determineInstalledOsVersion
} from "../util";
import { getWebAppConfigValue } from "../config_storage/actions";

View File

@ -181,7 +181,7 @@
transition: all 0.2s ease;
}
}
.plant-search-item {
%search-item {
cursor: pointer;
padding: 0.5rem 1rem;
&:hover,
@ -195,6 +195,19 @@
width: 4rem;
}
}
.plant-search-item {
@extend %search-item;
}
.group-search-item {
@extend %search-item;
&:hover,
&.hovered {
background: #d7eaea;
}
}
%panel-item-base {
text-align: right;
font-size: 1rem;

View File

@ -229,9 +229,8 @@
max-height: calc(100vh - 10rem);
overflow-y: auto;
overflow-x: hidden;
.plant-search-item {
pointer-events: none;
}
.plant-search-item,
.group-search-item { pointer-events: none; }
img {
filter: grayscale(100%);
}

View File

@ -1,3 +1,5 @@
jest.mock("../../api/crud", () => ({ save: jest.fn() }));
import * as React from "react";
import { shallow, render } from "enzyme";
import { RawDevices as Devices } from "../devices";
@ -10,6 +12,8 @@ import {
import { FarmbotOsSettings } from "../components/farmbot_os_settings";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { HardwareSettings } from "../components/hardware_settings";
import { DeepPartial } from "redux";
import { save } from "../../api/crud";
describe("<Devices/>", () => {
const fakeProps = (): Props => ({
@ -59,4 +63,17 @@ describe("<Devices/>", () => {
expect(wrapper.find(HardwareSettings).props().firmwareHardware)
.toEqual("arduino");
});
it("triggers a save", () => {
type P = FarmbotOsSettings["props"];
type DPP = DeepPartial<P>;
const props: DPP = {
deviceAccount: { uuid: "a.b.c" },
dispatch: jest.fn()
};
const el = new FarmbotOsSettings(props as P);
el.updateBot();
expect(save)
.toHaveBeenCalledWith(props.deviceAccount && props.deviceAccount.uuid);
});
});

View File

@ -13,7 +13,7 @@ export interface Props {
export class DiagnosticDumpRow extends React.Component<Props, {}> {
get ticket() { return this.props.diag.body.ticket_identifier; }
get age() { return ago(new Date(this.props.diag.body.created_at).getTime()); }
get age() { return ago(this.props.diag.body.created_at); }
destroy = () => this.props.dispatch(destroy(this.props.diag.uuid));

View File

@ -1,7 +1,7 @@
import * as React from "react";
import axios from "axios";
import { t } from "../../i18next_wrapper";
import { FarmbotOsProps, FarmbotOsState } from "../interfaces";
import { FarmbotOsProps, FarmbotOsState, Feature } from "../interfaces";
import { Widget, WidgetHeader, WidgetBody, Row, Col } from "../../ui";
import { save, edit } from "../../api/crud";
import { MustBeOnline, isBotOnline } from "../must_be_online";
@ -95,16 +95,6 @@ export class FarmbotOsSettings
value={this.props.deviceAccount.body.name} />
</Col>
</Row>
<Row>
<Col xs={ColWidth.label}>
<label>
{t("BOOT SEQUENCE")}
</label>
</Col>
<Col xs={9}>
<BootSequenceSelector />
</Col>
</Row>
<Row>
<Col xs={ColWidth.label}>
<label>
@ -160,6 +150,16 @@ export class FarmbotOsSettings
shouldDisplay={this.props.shouldDisplay}
timeSettings={this.props.timeSettings}
sourceFbosConfig={sourceFbosConfig} />
<Row>
<Col xs={ColWidth.label}>
<label>
{t("BOOT SEQUENCE")}
</label>
</Col>
<Col xs={7}>
{this.props.shouldDisplay(Feature.boot_sequence) && <BootSequenceSelector />}
</Col>
</Row>
<PowerAndReset
controlPanelState={this.props.bot.controlPanelState}
dispatch={this.props.dispatch}

View File

@ -176,8 +176,7 @@ describe("<FbosDetails/>", () => {
it("displays cpu usage", () => {
const p = fakeProps();
// tslint:disable-next-line:no-any
(p.botInfoSettings as any).cpu_usage = 10;
p.botInfoSettings.cpu_usage = 10;
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text().toLowerCase()).toContain("cpu usage: 10%");
});

View File

@ -9,7 +9,6 @@ describe("botToAPI()", () => {
it("handles connectivity", () => {
const result = botToAPI(moment().subtract(4, "minutes").toJSON());
expect(result.connectionStatus).toBeTruthy();
expect(result.children).toContain("Last message seen 4 minutes ago.");
});
@ -59,13 +58,13 @@ describe("browserToMQTT()", () => {
it("handles unknown connectivity", () => {
const output = browserToMQTT(undefined);
expect(output.connectionStatus).toBe(undefined);
expect(output.children).toContain("No messages seen yet.");
expect(output.children).toContain("No recent messages.");
});
it("handles lack of connectivity", () => {
const output = browserToMQTT({ state: "down", at: NOW });
expect(output.connectionStatus).toBe(false);
expect(output.children).toContain("Last message seen a few seconds ago.");
expect(output.children).toContain("No recent messages.");
});
});

View File

@ -7,90 +7,90 @@ import {
getBoardCategory, isKnownBoard
} from "../components/firmware_hardware_support";
const HOUR = 1000 * 60 * 60;
/** "<how long> ago" for a given ISO time string or time in milliseconds. */
export const ago = (input: string | number) => moment(input).fromNow();
const SIX_HOURS = HOUR * 6;
const lastSeen = (lastSaw: string | number | undefined) =>
lastSaw
? t("Last message seen ") + `${ago(lastSaw)}.`
: t("No messages seen yet.");
const NOT_SEEN = t("No messages seen yet.");
const isUp = (stat: ConnectionStatus | undefined): boolean | undefined =>
stat && stat.state == "up";
export function ago(input: number) {
return moment(new Date(input)).fromNow();
}
/** The bot to API connection is considered up if there have been requests
* within the last few hours. */
const isApiUp = (lastSaw: string | undefined, now: number): boolean =>
!!lastSaw && moment(now).diff(lastSaw, "hours") < 6;
function lastSeen(stat: ConnectionStatus | undefined): string {
return stat ? t("Last message seen ") + `${ago(stat.at)}.` : NOT_SEEN;
}
/**
* MQTT connection status `at` time is updated even when the network goes down.
* This function rejects inaccurate last up times by checking the connection
* state before returning a last seen time. Ideally, MQTT last up times
* (browser and bot) would be stored and used here to provide accurate
* last up times when the connection state is down.
*/
const lastSeenUp = (stat: ConnectionStatus | undefined): string =>
(stat && isUp(stat)) ? lastSeen(stat.at) : t("No recent messages.");
function statusOf(stat: ConnectionStatus | undefined): boolean | undefined {
return (stat && stat.state == "up");
}
export function botToAPI(stat: string | undefined,
now = (new Date).getTime()): StatusRowProps {
const connectionStatus =
(stat ? ((now - new Date(stat).getTime()) < SIX_HOURS) : false);
export function botToAPI(
lastSawApi: string | undefined,
now = (new Date).getTime()):
StatusRowProps {
return {
connectionName: "botAPI",
from: "FarmBot",
to: "Web App",
connectionStatus,
children: stat
? t("Last message seen ") + `${ago(new Date(stat).getTime())}.`
: NOT_SEEN
to: t("Web App"),
connectionStatus: isApiUp(lastSawApi, now),
children: lastSeen(lastSawApi),
};
}
export function botToMQTT(stat: ConnectionStatus | undefined): StatusRowProps {
export function browserToAPI(status: ConnectionStatus | undefined):
StatusRowProps {
return {
connectionName: "browserAPI",
from: t("Browser"),
to: t("Internet"),
children: lastSeen(status ? status.at : undefined),
connectionStatus: isUp(status),
};
}
export function botToMQTT(status: ConnectionStatus | undefined):
StatusRowProps {
return {
connectionName: "botMQTT",
from: "FarmBot",
to: t("Message Broker"),
connectionStatus: statusOf(stat),
children: (stat && stat.state === "up")
? lastSeen(stat)
: t("No recent messages.")
connectionStatus: isUp(status),
children: lastSeenUp(status),
};
}
export function browserToMQTT(status:
ConnectionStatus | undefined): StatusRowProps {
export function browserToMQTT(status: ConnectionStatus | undefined):
StatusRowProps {
return {
connectionName: "browserMQTT",
from: t("Browser"),
to: t("Message Broker"),
children: lastSeen(status),
connectionStatus: statusOf(status)
connectionStatus: isUp(status),
children: lastSeenUp(status),
};
}
export function botToFirmware(version: string | undefined): StatusRowProps {
const connection = (): { status: boolean | undefined, msg: string } => {
const status = isKnownBoard(version);
if (isUndefined(version)) {
return { status: undefined, msg: t("Unknown.") };
}
return {
status, msg: status
? t("Connected.")
: t("Disconnected.")
};
return isUndefined(version)
? { status: undefined, msg: t("Unknown.") }
: { status, msg: status ? t("Connected.") : t("Disconnected.") };
};
return {
connectionName: "botFirmware",
from: "Raspberry Pi",
to: getBoardCategory(version),
connectionStatus: connection().status,
children: connection().msg,
connectionStatus: connection().status
};
}
export function browserToAPI(status?: ConnectionStatus | undefined): StatusRowProps {
return {
connectionName: "browserAPI",
from: t("Browser"),
to: t("Internet"),
children: lastSeen(status),
connectionStatus: statusOf(status)
};
}

View File

@ -65,29 +65,31 @@ export type SourceFwConfig = (config: McuParamName) =>
export type ShouldDisplay = (x: Feature) => boolean;
/** Names of features that use minimum FBOS version checking. */
export enum Feature {
assertion_block = "assertion_block",
named_pins = "named_pins",
sensors = "sensors",
change_ownership = "change_ownership",
variables = "variables",
loops = "loops",
api_pin_bindings = "api_pin_bindings",
farmduino_k14 = "farmduino_k14",
jest_feature = "jest_feature", // for tests
backscheduled_regimens = "backscheduled_regimens",
endstop_invert = "endstop_invert",
diagnostic_dumps = "diagnostic_dumps",
rpi_led_control = "rpi_led_control",
mark_as_step = "mark_as_step",
firmware_restart = "firmware_restart",
api_farmware_installations = "api_farmware_installations",
api_farmware_env = "api_farmware_env",
use_update_channel = "use_update_channel",
long_scaling_factor = "long_scaling_factor",
flash_firmware = "flash_firmware",
api_farmware_installations = "api_farmware_installations",
api_pin_bindings = "api_pin_bindings",
assertion_block = "assertion_block",
backscheduled_regimens = "backscheduled_regimens",
boot_sequence = "boot_sequence",
change_ownership = "change_ownership",
diagnostic_dumps = "diagnostic_dumps",
endstop_invert = "endstop_invert",
express_k10 = "express_k10",
farmduino_k14 = "farmduino_k14",
firmware_restart = "firmware_restart",
flash_firmware = "flash_firmware",
groups = "groups",
jest_feature = "jest_feature",
long_scaling_factor = "long_scaling_factor",
mark_as_step = "mark_as_step",
named_pins = "named_pins",
none_firmware = "none_firmware",
rpi_led_control = "rpi_led_control",
sensors = "sensors",
use_update_channel = "use_update_channel",
variables = "variables"
}
/** Object fetched from FEATURE_MIN_VERSIONS_URL. */
export type MinOsFeatureLookup = Partial<Record<Feature, string>>;

View File

@ -11,7 +11,7 @@ import {
} from "./components/source_config_value";
import {
determineInstalledOsVersion, validFwConfig, validFbosConfig,
shouldDisplay as shouldDisplayFunc
createShouldDisplayFn as shouldDisplayFunc
} from "../util";
import {
saveOrEditFarmwareEnv, reduceFarmwareEnv
@ -27,8 +27,9 @@ export function mapStateToProps(props: Everything): Props {
const installedOsVersion = determineInstalledOsVersion(
props.bot, maybeGetDevice(props.resources.index));
const fbosVersionOverride = DevSettings.overriddenFbosVersion();
const shouldDisplay = shouldDisplayFunc(
installedOsVersion, props.bot.minOsFeatureData, fbosVersionOverride);
const shouldDisplay = shouldDisplayFunc(installedOsVersion,
props.bot.minOsFeatureData,
fbosVersionOverride);
const env = shouldDisplay(Feature.api_farmware_env)
? reduceFarmwareEnv(props.resources.index)
: props.bot.hardware.user_env;

View File

@ -21,7 +21,7 @@ describe("mapStateToPropsAddEdit()", () => {
describe("handleTime()", () => {
const { handleTime } = mapStateToPropsAddEdit(fakeState());
it("start_time", () => {
it("handles an element with name `start_time`", () => {
const e = {
currentTarget: { value: "10:54", name: "start_time" }
} as React.SyntheticEvent<HTMLInputElement>;
@ -29,13 +29,21 @@ describe("mapStateToPropsAddEdit()", () => {
expect(result).toContain("54");
});
it("end_time", () => {
it("handles an element with name `end_time`", () => {
const e = {
currentTarget: { value: "10:53", name: "end_time" }
} as React.SyntheticEvent<HTMLInputElement>;
const result = handleTime(e, "2017-05-21T22:00:00.000");
expect(result).toContain("53");
});
it("crashes on other names", () => {
const e = {
currentTarget: { value: "10:52", name: "other" }
} as React.SyntheticEvent<HTMLInputElement>;
const boom = () => handleTime(e, "2017-05-21T22:00:00.000");
expect(boom).toThrowError("Expected a name attribute from time field.");
});
});
describe("executableOptions()", () => {

View File

@ -24,7 +24,7 @@ import {
import { DropDownItem } from "../../ui/index";
import {
validFbosConfig,
shouldDisplay as shouldDisplayFunc,
createShouldDisplayFn as shouldDisplayFunc,
determineInstalledOsVersion
} from "../../util";
import {
@ -134,7 +134,6 @@ export function mapStateToPropsAddEdit(props: Everything): AddEditFarmEventProps
switch (kind) {
case "Sequence": return findSequenceById(props.resources.index, id);
case "Regimen": return findRegimenById(props.resources.index, id);
default: throw new Error("GOT A BAD `KIND` STRING");
}
};
const dev = getDeviceAccountSettings(props.resources.index);

View File

@ -27,6 +27,7 @@ import {
dropPlant, dragPlant, beginPlantDrag, maybeSavePlantLocation
} from "./layers/plants/plant_actions";
import { chooseLocation } from "../move_to";
import { GroupOrder } from "../point_groups/group_order_visual";
export class GardenMap extends
React.Component<GardenMapProps, Partial<GardenMapState>> {
@ -343,6 +344,9 @@ export class GardenMap extends
data={this.props.designer.currentPoint}
key={"currentPoint"}
mapTransformProps={this.mapTransformProps} />
GroupOrder = () => <GroupOrder
plants={this.props.plants}
mapTransformProps={this.mapTransformProps} />
Bugs = () => showBugs() ? <Bugs mapTransformProps={this.mapTransformProps}
botSize={this.props.botSize} /> : <g />
@ -365,6 +369,7 @@ export class GardenMap extends
<this.SelectionBox />
<this.TargetCoordinate />
<this.DrawnPoint />
<this.GroupOrder />
<this.Bugs />
</svg>
</svg>

View File

@ -1,5 +1,8 @@
jest.mock("../../../api/crud", () => {
return { initSave: jest.fn(() => () => Promise.resolve({})) };
return {
init: jest.fn(() => ({ payload: { uuid: "???" } })),
save: jest.fn()
};
});
jest.mock("../../../history", () => {
@ -8,8 +11,13 @@ jest.mock("../../../history", () => {
};
});
jest.mock("../../../resources/selectors", () => ({
findPointGroup: jest.fn(() => ({ body: { id: 323232332 } })),
selectAllRegimens: jest.fn()
}));
import { createGroup } from "../actions";
import { initSave } from "../../../api/crud";
import { init, save } from "../../../api/crud";
import { history } from "../../../history";
import { buildResourceIndex } from "../../../__test_support__/resource_index_builder";
import { fakePoint, fakePlant, fakeToolSlot } from "../../../__test_support__/fake_state/resources";
@ -26,12 +34,13 @@ describe("group action creators and thunks", () => {
const thunk = createGroup({ points, name: "Name123" });
await thunk(dispatch, () => fakeS as Everything);
const expected = ["PointGroup", {
expect(init).toHaveBeenCalledWith("PointGroup", {
name: "Name123",
point_ids: [0, 1].map(x => fakePoints[x].body.id)
}];
const xz = initSave;
expect(xz).toHaveBeenCalledWith(...expected);
expect(history.push).toHaveBeenCalledWith("/app/designer/groups");
point_ids: [1, 2],
sort_type: "xy_ascending"
});
expect(save).toHaveBeenCalledWith("???");
expect(history.push)
.toHaveBeenCalledWith("/app/designer/groups/323232332");
});
});

View File

@ -9,14 +9,13 @@ jest.mock("../../actions", () => ({
}));
import React from "react";
import { GroupDetailActive, LittleIcon } from "../group_detail_active";
import { GroupDetailActive } from "../group_detail_active";
import { mount, shallow } from "enzyme";
import {
fakePointGroup, fakePlant
} from "../../../__test_support__/fake_state/resources";
import { save, overwrite, edit } from "../../../api/crud";
import { toggleHoveredPlant } from "../../actions";
import { DEFAULT_ICON } from "../../../open_farm/icons";
import { save, edit } from "../../../api/crud";
import { SpecialStatus } from "farmbot";
describe("<GroupDetailActive/>", () => {
function fakeProps() {
@ -24,51 +23,11 @@ describe("<GroupDetailActive/>", () => {
plant.body.id = 1;
const plants = [plant];
const group = fakePointGroup();
group.specialStatus = SpecialStatus.DIRTY;
group.body.name = "XYZ";
group.body.point_ids = [plant.body.id];
return { dispatch: jest.fn(), group, plants };
}
const icon = "doge.jpg";
it("removes points onClick", () => {
const { plants, dispatch, group } = fakeProps();
const el = shallow(<LittleIcon
plant={plants[0]}
group={group}
dispatch={dispatch}
icon="doge.jpg" />);
el.simulate("click");
const emptyGroup = expect.objectContaining({
name: "XYZ",
point_ids: []
});
expect(overwrite).toHaveBeenCalledWith(group, emptyGroup);
expect(dispatch).toHaveBeenCalled();
});
it("toggles onMouseEnter", () => {
const { plants, dispatch, group } = fakeProps();
const plant = plants[0];
const el = shallow(<LittleIcon
plant={plant}
group={group}
dispatch={dispatch}
icon={icon} />);
el.simulate("mouseEnter");
expect(toggleHoveredPlant).toHaveBeenCalledWith(plant.uuid, icon);
});
it("toggled onMouseLeave", () => {
const { plants, dispatch, group } = fakeProps();
const plant = plants[0];
const el = shallow(<LittleIcon
plant={plant}
group={group}
dispatch={dispatch}
icon={icon} />);
el.simulate("mouseLeave");
expect(toggleHoveredPlant).toHaveBeenCalledWith(undefined, icon);
});
it("saves", () => {
const p = fakeProps();
@ -85,19 +44,6 @@ describe("<GroupDetailActive/>", () => {
expect(el.find("input").prop("defaultValue")).toContain("XYZ");
});
it("provides the DEFAULT_ICON when OF has no icon to provide", () => {
const plant = fakePlant();
const comp = new GroupDetailActive(fakeProps());
comp.state = {
[plant.uuid]: {
slug: plant.uuid,
svg_icon: undefined
}
};
const result = comp.findIcon(plant);
expect(result).toEqual(DEFAULT_ICON);
});
it("changes group name", () => {
const NEW_NAME = "new group name";
const wrapper = shallow(<GroupDetailActive {...fakeProps()} />);
@ -106,4 +52,23 @@ describe("<GroupDetailActive/>", () => {
});
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",
},
kind: "PointGroup",
specialStatus: "DIRTY",
uuid: p.group.uuid,
},
{ sort_type: "random" });
});
});

View File

@ -39,7 +39,7 @@ describe("<GroupListPanel />", () => {
it("renders relevant group data as a list", () => {
const p = fakeProps();
const wrapper = mount(<GroupListPanel {...p} />);
wrapper.find(".plant-search-item").first().simulate("click");
wrapper.find(".group-search-item").first().simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/groups/9");
["3 items",

View File

@ -0,0 +1,36 @@
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
const mockGroup = fakePointGroup();
mockGroup.body.point_ids = [1, 2, 3];
jest.mock("../group_detail", () => ({ fetchGroupFromUrl: () => mockGroup }));
import * as React from "react";
import { mount } from "enzyme";
import { GroupOrder, GroupOrderProps } from "../group_order_visual";
import {
fakeMapTransformProps
} from "../../../__test_support__/map_transform_props";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
describe("<GroupOrder />", () => {
const fakeProps = (): GroupOrderProps => {
const plant1 = fakePlant();
plant1.body.id = 1;
const plant2 = fakePlant();
plant2.body.id = 2;
const plant3 = fakePlant();
plant3.body.id = 3;
const plant4 = fakePlant();
plant4.body.id = undefined;
const plant5 = fakePlant();
plant5.body.id = 5;
return {
mapTransformProps: fakeMapTransformProps(),
plants: [plant1, plant2, plant3],
};
};
it("renders group order", () => {
const wrapper = mount(<GroupOrder {...fakeProps()} />);
expect(wrapper.find("line").length).toEqual(3);
});
});

View File

@ -0,0 +1,81 @@
jest.mock("../../actions", () => ({ toggleHoveredPlant: jest.fn() }));
jest.mock("../../../api/crud", () => ({ overwrite: jest.fn() }));
import React from "react";
import { PointGroupItem } from "../point_group_item";
import { shallow } from "enzyme";
import { fakePlant, fakePointGroup } from "../../../__test_support__/fake_state/resources";
import { DeepPartial } from "redux";
import { cachedCrop } from "../../../open_farm/cached_crop";
import { toggleHoveredPlant } from "../../actions";
import { overwrite } from "../../../api/crud";
describe("<PointGroupItem/>", () => {
const newProps = (): PointGroupItem["props"] => ({
dispatch: jest.fn(),
plant: fakePlant(),
group: fakePointGroup(),
hovered: true
});
it("renders", () => {
const props = newProps();
const el = shallow<HTMLSpanElement>(<PointGroupItem {...props} />);
const i = el.instance() as PointGroupItem;
expect(el.first().prop("onMouseEnter")).toEqual(i.enter);
expect(el.first().prop("onMouseLeave")).toEqual(i.leave);
expect(el.first().prop("onClick")).toEqual(i.click);
});
it("handles hovering", async () => {
const i = new PointGroupItem(newProps());
i.setState = jest.fn();
type E = React.SyntheticEvent<HTMLImageElement, Event>;
const partialE: DeepPartial<E> = {
currentTarget: {
getAttribute: jest.fn(),
setAttribute: jest.fn(),
}
};
const e = partialE as E;
await i.maybeGetCachedIcon(e as E);
const slug = i.props.plant.body.openfarm_slug;
expect(cachedCrop).toHaveBeenCalledWith(slug);
const icon = "data:image/svg+xml;utf8,icon";
expect(i.setState).toHaveBeenCalledWith({ icon });
expect(e.currentTarget.setAttribute).toHaveBeenCalledWith("src", icon);
});
it("handles mouse enter", () => {
const i = new PointGroupItem(newProps());
i.state.icon = "X";
i.enter();
expect(i.props.dispatch).toHaveBeenCalledTimes(1);
expect(toggleHoveredPlant)
.toHaveBeenCalledWith(i.props.plant.uuid, "X");
});
it("handles mouse exit", () => {
const i = new PointGroupItem(newProps());
i.state.icon = "X";
i.leave();
expect(i.props.dispatch).toHaveBeenCalledTimes(1);
expect(toggleHoveredPlant).toHaveBeenCalledWith(undefined, "");
});
it("handles clicks", () => {
const i = new PointGroupItem(newProps());
i.click();
expect(i.props.dispatch).toHaveBeenCalledTimes(1);
expect(overwrite).toHaveBeenCalledWith({
body: { name: "Fake", point_ids: [], sort_type: "xy_ascending" },
kind: "PointGroup",
specialStatus: "",
uuid: expect.any(String),
}, {
name: "Fake",
point_ids: [],
sort_type: "xy_ascending",
});
});
});

View File

@ -0,0 +1,81 @@
import { isSortType, sortTypeChange, SORT_OPTIONS } from "../point_group_sort_selector";
import { DropDownItem } from "../../../ui";
import { DeepPartial } from "redux";
import { TaggedPlant } from "../../map/interfaces";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
const tests: [string, boolean][] = [
["", false],
["nope", false],
["random", true],
["xy_ascending", true],
["xy_descending", true],
["yx_ascending", true],
["yx_descending", true]
];
describe("isSortType", () => {
it("identifies malformed sort types", () => {
tests.map(([sortType, valid]) => {
expect(isSortType(sortType)).toBe(valid);
});
});
});
describe("sortTypeChange", () => {
it("selectively triggers the callback", () => {
tests.map(([value, valid]) => {
const cb = jest.fn();
const ddi: DropDownItem = { value, label: "TEST" };
if (valid) {
sortTypeChange(cb)(ddi);
expect(cb).toHaveBeenCalledWith(value);
} else {
sortTypeChange(cb)(ddi);
expect(cb).not.toHaveBeenCalled();
}
});
});
});
describe("", () => {
const phony = (name: string, x: number, y: number): DeepPartial<TaggedPlant> => {
return { body: { name, x, y } };
};
const plants = [
phony("A", 0, 0),
phony("B", 1, 0),
phony("C", 1, 1),
phony("D", 0, 1)
];
const sort = (sortType: PointGroupSortType): string[] => {
const array = SORT_OPTIONS[sortType](plants as TaggedPlant[]);
return array.map(x => x && x.body && (x.body.name || "NA"));
};
it("sorts randomly", () => {
const results = sort("random");
expect(results.length).toEqual(plants.length);
});
it("sorts by xy_ascending", () => {
const results = sort("xy_ascending");
expect(results).toEqual(["A", "D", "B", "C"]);
});
it("sorts by xy_descending", () => {
const results = sort("xy_descending");
expect(results).toEqual(["C", "B", "D", "A"]);
});
it("sorts by yx_ascending", () => {
const results = sort("yx_ascending");
expect(results).toEqual(["A", "B", "D", "C"]);
});
it("sorts by yx_descending", () => {
const results = sort("yx_descending");
expect(results).toEqual(["C", "D", "B", "A"]);
});
});

View File

@ -1,10 +1,12 @@
import { betterCompact } from "../../util";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { initSave } from "../../api/crud";
import { init, save } from "../../api/crud";
import { history } from "../../history";
import { GetState } from "../../redux/interfaces";
import { findPointGroup } from "../../resources/selectors";
import { t } from "../../i18next_wrapper";
const UNTITLED = "Untitled Group";
const UNTITLED = () => t("Untitled Group");
interface CreateGroupProps {
/** TaggedPoint UUIDs */
@ -12,10 +14,6 @@ interface CreateGroupProps {
name?: string;
}
/** This probably won't work as a long term
* solution because it won't add points created
* during the current user session (localId of 0)
*/
export const createGroup = ({ points, name }: CreateGroupProps) => {
return function (dispatch: Function, getState: GetState) {
const { references } = getState().resources.index;
@ -23,8 +21,14 @@ export const createGroup = ({ points, name }: CreateGroupProps) => {
.map(x => references[x])
.map(x => x ? x.body.id : undefined);
const point_ids = betterCompact(possiblyNil);
const group: PointGroup = ({ name: name || UNTITLED, point_ids });
const thunk = initSave("PointGroup", group);
return thunk(dispatch).then(() => history.push("/app/designer/groups"));
const group: PointGroup =
({ name: name || UNTITLED(), point_ids, sort_type: "xy_ascending" });
const action = init("PointGroup", group);
dispatch(action);
return dispatch(save(action.payload.uuid)).then(() => {
const pg = findPointGroup(getState().resources.index, action.payload.uuid);
const { id } = pg.body;
history.push("/app/designer/groups/" + (id ? id : ""));
});
};
};

View File

@ -6,14 +6,15 @@ import {
DesignerPanelContent,
DesignerPanelHeader
} from "../plants/designer_panel";
import { TaggedPointGroup, SpecialStatus } from "farmbot";
import { TaggedPointGroup } from "farmbot";
import { DeleteButton } from "../../controls/pin_form_fields";
import { svgToUrl, DEFAULT_ICON } from "../../open_farm/icons";
import { overwrite, save, edit } from "../../api/crud";
import { save, edit } from "../../api/crud";
import { Dictionary } from "lodash";
import { cachedCrop, OFIcon } from "../../open_farm/cached_crop";
import { toggleHoveredPlant } from "../actions";
import { OFIcon } from "../../open_farm/cached_crop";
import { TaggedPlant } from "../map/interfaces";
import { PointGroupSortSelector, sortGroupBy } from "./point_group_sort_selector";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { PointGroupItem } from "./point_group_item";
interface GroupDetailActiveProps {
dispatch: Function;
@ -22,92 +23,45 @@ interface GroupDetailActiveProps {
}
type State = Dictionary<OFIcon | undefined>;
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);
};
interface LittleIconProps {
/** URL (or even a data-url) to the icon image. */
icon: string;
group: TaggedPointGroup;
plant: TaggedPlant;
dispatch: Function;
}
export const LittleIcon =
({ group, plant: point, icon, dispatch }: LittleIconProps) => {
const { body } = point;
const p = point;
const plantUUID = point.uuid;
return <span
key={plantUUID}
onMouseEnter={() => dispatch(toggleHoveredPlant(plantUUID, icon))}
onMouseLeave={() => dispatch(toggleHoveredPlant(undefined, icon))}
onClick={() => dispatch(removePoint(group, body.id || 0))}>
<img src={icon} alt={p.body.name} width={32} height={32} />
</span>;
};
export class GroupDetailActive
extends React.Component<GroupDetailActiveProps, State> {
state: State = {};
update = ({ currentTarget }: React.SyntheticEvent<HTMLInputElement>) => {
this
.props
.dispatch(edit(this.props.group, { name: currentTarget.value }));
this.props.dispatch(edit(this.props.group, { name: currentTarget.value }));
};
handleIcon =
(uuid: string) =>
(icon: Readonly<OFIcon>) =>
this.setState({ [uuid]: icon });
performLookup = (plant: TaggedPlant) => {
cachedCrop(plant.body.openfarm_slug).then(this.handleIcon(plant.uuid));
return DEFAULT_ICON;
}
findIcon = (plant: TaggedPlant) => {
const svg = this.state[plant.uuid];
if (svg) {
if (svg.svg_icon) {
return svgToUrl(svg.svg_icon);
}
return DEFAULT_ICON;
}
return this.performLookup(plant);
}
get name() {
const { group } = this.props;
return group ? group.body.name : "Group Not found";
}
get icons() {
return this
.props
.plants
.map(point => {
return <LittleIcon
key={point.uuid}
icon={this.findIcon(point)}
group={this.props.group}
plant={point}
dispatch={this.props.dispatch} />;
});
const plants = sortGroupBy(this.props.group.body.sort_type,
this.props.plants);
return plants.map(point => {
return <PointGroupItem
key={point.uuid}
hovered={false}
group={this.props.group}
plant={point}
dispatch={this.props.dispatch} />;
});
}
get saved(): boolean {
return !this.props.group.specialStatus;
}
saveGroup = () => {
this.props.dispatch(save(this.props.group.uuid));
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 }));
}
render() {
const { group } = this.props;
return <DesignerPanel panelName={"groups"} panelColor={"blue"}>
<DesignerPanelHeader
onBack={this.saveGroup}
@ -115,19 +69,17 @@ export class GroupDetailActive
panelColor={"blue"}
title={t("Edit Group")}
backTo={"/app/designer/groups"}>
<a
className="right-button"
title={t("Save Changes to Group")}
onClick={this.saveGroup}>
{t("Save")}{group.specialStatus === SpecialStatus.SAVED ? "" : "*"}
</a>
</DesignerPanelHeader>
<DesignerPanelContent
panelName={"groups"}>
<label>{t("GROUP NAME")}</label>
<label>{t("GROUP NAME")}{this.saved ? "" : "*"}</label>
<input
defaultValue={this.name}
onChange={this.update} />
defaultValue={this.props.group.body.name}
onChange={this.update}
onBlur={this.saveGroup} />
<PointGroupSortSelector
value={this.props.group.body.sort_type}
onChange={this.changeSortType} />
<label>
{t("GROUP MEMBERS ({{count}})", { count: this.icons.length })}
</label>
@ -140,7 +92,7 @@ export class GroupDetailActive
<DeleteButton
className="groups-delete-btn"
dispatch={this.props.dispatch}
uuid={group.uuid}
uuid={this.props.group.uuid}
onDestroy={history.back}>
{t("DELETE GROUP")}
</DeleteButton>

View File

@ -12,8 +12,8 @@ interface GroupInventoryItemProps {
export function GroupInventoryItem(props: GroupInventoryItemProps) {
return <div
onClick={props.onClick}
className={`plant-search-item ${props.hovered ? "hovered" : ""}`}>
<span className="plant-search-item-name">
className={`group-search-item ${props.hovered ? "hovered" : ""}`}>
<span className="group-search-item-name">
{props.group.body.name}
</span>
<i className="group-item-count">

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { store } from "../../redux/store";
import { MapTransformProps, TaggedPlant } from "../map/interfaces";
import { fetchGroupFromUrl } from "./group_detail";
import { isUndefined } from "lodash";
import { sortGroupBy } from "./point_group_sort_selector";
import { Color } from "../../ui";
import { transformXY } from "../map/util";
export interface GroupOrderProps {
plants: TaggedPlant[];
mapTransformProps: MapTransformProps;
}
const sortedPointCoordinates =
(plants: TaggedPlant[]): { x: number, y: number }[] => {
const group = fetchGroupFromUrl(store.getState().resources.index);
if (isUndefined(group)) { return []; }
const groupPlants = plants
.filter(p => group.body.point_ids.includes(p.body.id || 0));
return sortGroupBy(group.body.sort_type, groupPlants)
.map(p => ({ x: p.body.x, y: p.body.y }));
};
export const GroupOrder = (props: GroupOrderProps) => {
const points = sortedPointCoordinates(props.plants);
return <g id="group-order"
stroke={Color.mediumGray} strokeWidth={3} strokeDasharray={12}>
{points.map((p, i) => {
const prev = i > 0 ? points[i - 1] : p;
const one = transformXY(prev.x, prev.y, props.mapTransformProps);
const two = transformXY(p.x, p.y, props.mapTransformProps);
return <line key={i} x1={one.qx} y1={one.qy} x2={two.qx} y2={two.qy} />;
})}
</g>;
};

View File

@ -0,0 +1,69 @@
import * as React from "react";
import { DEFAULT_ICON, svgToUrl } from "../../open_farm/icons";
import { TaggedPlant } from "../map/interfaces";
import { cachedCrop } from "../../open_farm/cached_crop";
import { toggleHoveredPlant } from "../actions";
import { TaggedPointGroup, uuid } from "farmbot";
import { overwrite } from "../../api/crud";
type IMGEvent = React.SyntheticEvent<HTMLImageElement>;
export interface PointGroupItemProps {
plant: TaggedPlant;
group: TaggedPointGroup;
dispatch: Function;
hovered: boolean;
}
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);
};
// The individual plants in the point group detail page.
export class PointGroupItem extends React.Component<PointGroupItemProps, PointGroupItemState> {
state: PointGroupItemState = { icon: "" };
key = uuid();
enter = () => this
.props
.dispatch(toggleHoveredPlant(this.props.plant.uuid, this.state.icon));
leave = () => this
.props
.dispatch(toggleHoveredPlant(undefined, ""));
click = () => this
.props
.dispatch(removePoint(this.props.group, this.props.plant.body.id || 0));
maybeGetCachedIcon = ({ currentTarget }: IMGEvent) => {
return cachedCrop(this.props.plant.body.openfarm_slug).then((crop) => {
const i = svgToUrl(crop.svg_icon);
if (i !== currentTarget.getAttribute("src")) {
currentTarget.setAttribute("src", i);
}
this.setState({ icon: i });
});
};
render() {
return <span
key={this.key}
onMouseEnter={this.enter}
onMouseLeave={this.leave}
onClick={this.click}>
<img
src={DEFAULT_ICON}
onLoad={this.maybeGetCachedIcon}
width={32}
height={32} />
</span>;
}
}

View File

@ -0,0 +1,83 @@
import * as React from "react";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { FBSelect, DropDownItem } from "../../ui";
import { t } from "../../i18next_wrapper";
import { TaggedPlant } from "../map/interfaces";
import { shuffle, sortBy } from "lodash";
import { Content } from "../../constants";
interface Props {
onChange(value: PointGroupSortType): void;
value: PointGroupSortType;
}
const optionsTable = (): Record<PointGroupSortType, string> => ({
"random": t("Random Order"),
"xy_ascending": t("X/Y, Ascending"),
"xy_descending": t("X/Y, Descending"),
"yx_ascending": t("Y/X, Ascending"),
"yx_descending": t("Y/X, Descending"),
}); // Typechecker will remind us when this needs an update. Don't simplify - RC
const optionPlusDescriptions = () =>
(Object
.entries(optionsTable()) as [PointGroupSortType, string][])
.map(x => ({ label: x[1], value: x[0] }));
const optionList =
optionPlusDescriptions().map(x => x.value);
export const isSortType = (x: unknown): x is PointGroupSortType => {
return optionList.includes(x as PointGroupSortType);
};
const selected = (value: PointGroupSortType) => ({
label: t(optionsTable()[value] || value),
value: value
});
export const sortTypeChange = (cb: Function) => (ddi: DropDownItem) => {
const { value } = ddi;
isSortType(value) && cb(value);
};
export function PointGroupSortSelector(p: Props) {
return <div>
<div className="default-value-tooltip">
<label>
{t("SORT BY")}
</label>
</div>
<FBSelect
list={optionPlusDescriptions()}
selectedItem={selected(p.value as PointGroupSortType)}
onChange={sortTypeChange(p.onChange)} />
<p>
{(p.value == "random") ? t(Content.SORT_DESCRIPTION) : ""}
</p>
</div>;
}
type Sorter = (p: TaggedPlant[]) => TaggedPlant[];
type SortDictionary = Record<PointGroupSortType, Sorter>;
export const SORT_OPTIONS: SortDictionary = {
random(plants) {
return shuffle(plants);
},
xy_ascending(plants) {
return sortBy(plants, ["body.x", "body.y"]);
},
xy_descending(plants) {
return sortBy(plants, ["body.x", "body.y"]).reverse();
},
yx_ascending(plants) {
return sortBy(plants, ["body.y", "body.x"]);
},
yx_descending(plants) {
return sortBy(plants, ["body.y", "body.x"]).reverse();
}
};
export const sortGroupBy =
(st: PointGroupSortType, p: TaggedPlant[]) => SORT_OPTIONS[st](p);

View File

@ -14,7 +14,7 @@ import {
} from "../resources/selectors";
import {
validBotLocationData, validFwConfig, unpackUUID,
shouldDisplay as shouldDisplayFunc,
createShouldDisplayFn as shouldDisplayFunc,
determineInstalledOsVersion
} from "../util";
import { getWebAppConfigValue } from "../config_storage/actions";

View File

@ -11,7 +11,7 @@ import {
} from "../resources/selectors_by_kind";
import {
determineInstalledOsVersion,
shouldDisplay as shouldDisplayFunc,
createShouldDisplayFn as shouldDisplayFunc,
betterCompact
} from "../util";
import { ResourceIndex } from "../resources/interfaces";

View File

@ -8,7 +8,7 @@ import {
} from "../devices/components/source_config_value";
import {
validFbosConfig, determineInstalledOsVersion,
shouldDisplay as shouldDisplayFunc
createShouldDisplayFn as shouldDisplayFunc
} from "../util";
import { ResourceIndex } from "../resources/interfaces";
import { TaggedLog } from "farmbot";

View File

@ -4,6 +4,8 @@ import { isObject } from "lodash";
import { OFCropAttrs, OFCropResponse, OpenFarmAPI } from "./icons";
export type OFIcon = Readonly<OFCropAttrs>;
type IconDictionary = Dictionary<OFIcon | undefined>;
const STORAGE_KEY = "openfarm_icons_with_spread";
function initLocalStorage() {
@ -11,8 +13,6 @@ function initLocalStorage() {
return {};
}
type IconDictionary = Dictionary<OFIcon | undefined>;
function getAllIconsFromCache(): IconDictionary {
try {
const dictionary = JSON.parse(localStorage.getItem(STORAGE_KEY) || "");
@ -40,21 +40,6 @@ function localStorageIconSet(icon: OFIcon): void {
* efficient */
const promiseCache: Dictionary<Promise<Readonly<OFCropAttrs>>> = {};
function HTTPIconFetch(slug: string) {
const url = OpenFarmAPI.OFBaseURL + slug;
promiseCache[url] = axios
.get<OFCropResponse>(url)
.then(cacheTheIcon(slug), cacheTheIcon(slug));
return promiseCache[url];
}
/** PROBLEM: You have 100 lettuce plants. You don't want to download an SVG icon
* 100 times.
* SOLUTION: Cache stuff. */
export function cachedCrop(slug: string): Promise<OFIcon> {
return localStorageIconFetch(slug) || HTTPIconFetch(slug);
}
const cacheTheIcon = (slug: string) =>
(resp: AxiosResponse<OFCropResponse>): OFIcon => {
if (resp
@ -72,3 +57,18 @@ const cacheTheIcon = (slug: string) =>
return { slug, spread: undefined, svg_icon: undefined };
}
};
function HTTPIconFetch(slug: string) {
const url = OpenFarmAPI.OFBaseURL + slug;
promiseCache[url] = axios
.get<OFCropResponse>(url)
.then(cacheTheIcon(slug), cacheTheIcon(slug));
return promiseCache[url];
}
/** PROBLEM: You have 100 lettuce plants. You don't want to download an SVG icon
* 100 times.
* SOLUTION: Cache stuff. */
export function cachedCrop(slug: string): Promise<OFIcon> {
return localStorageIconFetch(slug) || HTTPIconFetch(slug);
}

View File

@ -38,6 +38,9 @@ export function getMiddleware(env: EnvName) {
const dtCompose = wow && wow({
actionsBlacklist: [
Actions.NETWORK_EDGE_CHANGE,
Actions.PING_NO,
Actions.PING_OK,
Actions.PING_START,
Actions.RESOURCE_READY
]
});

View File

@ -18,7 +18,7 @@ import moment from "moment";
import { ResourceIndex, UUID, VariableNameSet } from "../resources/interfaces";
import {
randomColor, determineInstalledOsVersion,
shouldDisplay as shouldDisplayFunc,
createShouldDisplayFn as shouldDisplayFunc,
timeFormatString
} from "../util";
import { resourceUsageList } from "../resources/in_use";

View File

@ -51,7 +51,7 @@ describe("determineDropdown", () => {
args: {
label: "x",
data_value: {
kind: "point_group", args: { resource_id: 12 }
kind: "point_group", args: { point_group_id: 12 }
}
}
}, buildResourceIndex([pg]).index);
@ -99,18 +99,6 @@ describe("determineDropdown", () => {
expect(r.value).toBe("?");
});
it("Returns a label for `every_point`", () => {
const r = determineDropdown({
kind: "parameter_application",
args: {
label: "x",
data_value: { kind: "every_point", args: { every_point_type: "Plant" } }
}
}, buildResourceIndex([]).index);
expect(r.label).toBe("All plants");
expect(r.value).toBe("Plant");
});
it("Returns a label for `point`", () => {
const point = fakePoint();
const pointNode: Point = {

View File

@ -6,9 +6,14 @@ import {
} from "farmbot";
import { DropDownItem } from "../ui";
import { findPointerByTypeAndId, findPointGroup } from "./selectors";
import { findSlotByToolId, findToolById, findResourceById } from "./selectors_by_id";
import {
formatPoint, safeEveryPointType, everyPointDDI, NO_VALUE_SELECTED_DDI,
findSlotByToolId,
findToolById,
findResourceById
} from "./selectors_by_id";
import {
formatPoint,
NO_VALUE_SELECTED_DDI,
formatTool,
COORDINATE_DDI
} from "../sequences/locals_list/location_form_list";
@ -103,9 +108,6 @@ export const determineDropdown =
const { label } = data_value.args;
const varName = determineVarDDILabel({ label, resources, uuid });
return { label: varName, value: "?" };
case "every_point":
const { every_point_type } = data_value.args;
return everyPointDDI(safeEveryPointType(every_point_type));
case "point":
const { pointer_id, pointer_type } = data_value.args;
const pointer =
@ -116,7 +118,7 @@ export const determineDropdown =
const toolSlot = findSlotByToolId(resources, tool_id);
return formatTool(findToolById(resources, tool_id), toolSlot);
case "point_group":
const value = data_value.args.resource_id;
const value = data_value.args.point_group_id;
const uuid2 = findResourceById(resources, "PointGroup", value);
const group = findPointGroup(resources, uuid2);
return {

View File

@ -36,7 +36,7 @@ describe("convertDDItoDeclaration()", () => {
kind: "parameter_application",
args: {
label: "Y",
data_value: { kind: "point_group", args: { resource_id: 23 } }
data_value: { kind: "point_group", args: { point_group_id: 23 } }
}
});
});
@ -152,23 +152,4 @@ describe("convertDDItoDeclaration()", () => {
};
expect(variable).toEqual(expected);
});
it("returns location data: every_point", () => {
const dropdown = ({ headingId: "every_point", label: "All Plants", value: "Plant" });
const variable = convertDDItoVariable({
identifierLabel: "label",
allowedVariableNodes,
dropdown
});
const expected: VariableNode = {
kind: "parameter_application",
args: {
label: "label",
data_value: {
kind: "every_point", args: { every_point_type: "Plant" }
}
}
};
expect(variable).toEqual(expected);
});
});

View File

@ -12,7 +12,7 @@ import {
LocationFormProps, PARENT, AllowedVariableNodes
} from "../locals_list_support";
import { difference } from "lodash";
import { locationFormList, everyPointDDI } from "../location_form_list";
import { locationFormList } from "../location_form_list";
import { convertDDItoVariable } from "../handle_select";
describe("<LocationForm/>", () => {
@ -118,8 +118,11 @@ describe("<LocationForm/>", () => {
p.shouldDisplay = () => true;
p.disallowGroups = false;
const wrapper = shallow(<LocationForm {...p} />);
expect(wrapper.find(FBSelect).first().props().list)
.toContainEqual(everyPointDDI("Tool"));
expect(wrapper.find(FBSelect).first().props().list).toContainEqual({
headingId: "Coordinate",
label: "Custom Coordinates",
value: ""
});
});
it("renders collapse icon: open", () => {

View File

@ -40,18 +40,14 @@ const change =
(onChange: (v: ParameterDeclaration) => void, variable: VariableNode) =>
(formResponse: ParameterApplication) => {
const { data_value } = formResponse.args;
switch (data_value.kind) {
case "every_point":
case "point_group":
return;
default:
onChange({
kind: "parameter_declaration",
args: {
label: variable.args.label,
default_value: data_value
}
});
if (data_value.kind !== "point_group") {
onChange({
kind: "parameter_declaration",
args: {
label: variable.args.label,
default_value: data_value
}
});
}
};

View File

@ -11,8 +11,6 @@ import {
Tool,
ScopeDeclarationBodyItem,
VariableDeclaration,
PointType,
EveryPoint,
PointGroup,
} from "farmbot";
import { VariableNode, AllowedVariableNodes } from "./locals_list_support";
@ -28,7 +26,6 @@ export const NOTHING_SELECTED: any = { kind: "nothing", args: {} };
type DataValue =
| Coordinate
| EveryPoint
| Identifier
| Point
| PointGroup
@ -89,13 +86,6 @@ const pointVar = (
args: { pointer_type, pointer_id: parseInt("" + value) }
});
const everyPointVar = (value: string | number) =>
({ identifierLabel: label, allowedVariableNodes }: NewVarProps): VariableWithAValue =>
createVariableNode(allowedVariableNodes)(label, {
kind: "every_point",
args: { every_point_type: "" + value as PointType }
});
const manualEntry = (value: string | number) =>
({ identifierLabel: label, allowedVariableNodes }: NewVarProps): VariableWithAValue =>
createVariableNode(allowedVariableNodes)(label, {
@ -136,15 +126,14 @@ const createNewVariable = (props: NewVarProps): VariableNode | undefined => {
case "GenericPointer": return pointVar(ddi.headingId, ddi.value)(props);
case "Tool": return toolVar(ddi.value)(props);
case "parameter": return newParameter(props);
case "every_point": return everyPointVar(ddi.value)(props);
case "Coordinate": return manualEntry(ddi.value)(props);
case "PointGroup":
const resource_id = parseInt("" + ddi.value, 10);
const point_group_id = parseInt("" + ddi.value, 10);
return {
kind: "parameter_application",
args: {
label: props.identifierLabel,
data_value: { kind: "point_group", args: { resource_id } }
data_value: { kind: "point_group", args: { point_group_id } }
}
};
}

View File

@ -55,7 +55,7 @@ export const LocationForm =
const variableListItems = displayVariables ? [PARENT(determineVarDDILabel({
label: "parent", resources, uuid: sequenceUuid, forceExternal: headerForm
}))] : [];
const displayGroups = props.shouldDisplay(Feature.loops) && !disallowGroups;
const displayGroups = props.shouldDisplay(Feature.groups) && !disallowGroups;
const list = locationFormList(resources, variableListItems, displayGroups);
/** Variable name. */
const { label } = celeryNode.args;

View File

@ -56,10 +56,6 @@ const points2ddi = (points: TaggedPoint[], pointerType: PointerTypeName) => poin
.map(formatPoint)
.filter(x => parseInt("" + x.value) > 0);
const maybeGroup = (display: boolean) =>
(groupDDI: DropDownItem): DropDownItem[] =>
display ? [groupDDI] : [];
export const groups2Ddi = (groups: TaggedPointGroup[]): DropDownItem[] => {
return groups
.filter(x => x.body.id)
@ -76,18 +72,13 @@ export function locationFormList(resources: ResourceIndex,
const plantDDI = points2ddi(points, "Plant");
const genericPointerDDI = points2ddi(points, "GenericPointer");
const toolDDI = activeToolDDIs(resources);
const clump = maybeGroup(!!displayGroups);
const output = [COORDINATE_DDI()]
.concat(additionalItems)
.concat(heading("Tool"))
.concat(clump(everyPointDDI("Tool")))
.concat(clump(everyPointDDI("ToolSlot")))
.concat(toolDDI)
.concat(heading("Plant"))
.concat(clump(everyPointDDI("Plant")))
.concat(plantDDI)
.concat(heading("GenericPointer"))
.concat(clump(everyPointDDI("GenericPointer")))
.concat(genericPointerDDI);
if (displayGroups) {
return output
@ -136,28 +127,14 @@ export function dropDownName(name: string, v?: Record<Xyz, number | undefined>)
return capitalize(label);
}
export const EVERY_POINT_LABEL = {
export const ALL_POINT_LABELS = {
"Plant": "All plants",
"GenericPointer": "All map points",
"Tool": "All tools",
"ToolSlot": "All tool slots",
};
export type EveryPointType = keyof typeof EVERY_POINT_LABEL;
const isEveryPointType = (x: string): x is EveryPointType =>
Object.keys(EVERY_POINT_LABEL).includes(x);
export const safeEveryPointType = (x: string): EveryPointType => {
if (isEveryPointType(x)) {
return x;
} else {
throw new Error(`'${x}' is not of type EveryPointType`);
}
};
export const everyPointDDI = (value: EveryPointType): DropDownItem =>
({ value, label: t(EVERY_POINT_LABEL[value]), headingId: "every_point" });
export type EveryPointType = keyof typeof ALL_POINT_LABELS;
export const COORDINATE_DDI = (vector?: Vector3): DropDownItem => ({
label: vector

View File

@ -6,7 +6,7 @@ import {
import { getStepTag } from "../resources/sequence_tagging";
import { enabledAxisMap } from "../devices/components/axis_tracking_status";
import {
shouldDisplay as shouldDisplayFunc,
createShouldDisplayFn as shouldDisplayFunc,
determineInstalledOsVersion, validFwConfig
} from "../util";
import { BooleanSetting } from "../session_keys";

View File

@ -5,7 +5,11 @@ import {
fakeSequence, fakePoint, fakeTool
} from "../../../__test_support__/fake_state/resources";
import {
MoveAbsolute, Point, Coordinate, Tool, ParameterApplication, EveryPoint
Coordinate,
MoveAbsolute,
ParameterApplication,
Point,
Tool,
} from "farmbot";
import {
fakeHardwareFlags
@ -197,20 +201,6 @@ describe("<TileMoveAbsolute/>", () => {
});
});
it("does not handle every_point nodes", () => {
const p = fakeProps();
const block = ordinaryMoveAbs(p);
const data_value: EveryPoint = {
kind: "every_point",
args: { every_point_type: "Plant" }
};
const boom = () => block.updateLocation({
kind: "parameter_application",
args: { label: "parent", data_value }
});
expect(boom).toThrowError("Can't put `every_point` into `move_abs");
});
it("handles variables", () => {
const p = fakeProps();
const block = ordinaryMoveAbs(p);

View File

@ -57,12 +57,8 @@ export class TileMoveAbsolute extends React.Component<StepParams, MoveAbsState>
/** Handle changes to step.args.location. */
updateLocation = (variable: ParameterApplication) => {
const location = variable.args.data_value;
switch (location.kind) {
case "every_point":
case "point_group":
throw new Error("Can't put `every_point` into `move_abs");
default:
this.updateArgs({ location });
if (location.kind !== "point_group") {
return this.updateArgs({ location });
}
}

View File

@ -1,5 +1,4 @@
import * as React from "react";
import { DocSlug } from "./doc_link";
import { t } from "../i18next_wrapper";
import { ToolTip } from "./tooltip";
@ -16,7 +15,6 @@ export function WidgetHeader(props: WidgetHeaderProps) {
{props.children}
<h5>{t(props.title)}</h5>
{props.helpText &&
<ToolTip helpText={props.helpText} docPage={props.docPage} />
}
<ToolTip helpText={props.helpText} docPage={props.docPage} />}
</div>;
}

View File

@ -2,7 +2,7 @@ import {
semverCompare,
SemverResult,
minFwVersionCheck,
shouldDisplay,
createShouldDisplayFn,
determineInstalledOsVersion,
versionOK,
} from "../version";
@ -121,34 +121,34 @@ describe("shouldDisplay()", () => {
const fakeMinOsData = { jest_feature: "1.0.0" };
it("should display", () => {
expect(shouldDisplay("1.0.0", fakeMinOsData, undefined)(
expect(createShouldDisplayFn("1.0.0", fakeMinOsData, undefined)(
Feature.jest_feature)).toBeTruthy();
expect(shouldDisplay("10.0.0", fakeMinOsData, undefined)(
expect(createShouldDisplayFn("10.0.0", fakeMinOsData, undefined)(
Feature.jest_feature)).toBeTruthy();
expect(shouldDisplay("10.0.0",
expect(createShouldDisplayFn("10.0.0",
{ jest_feature: "1.0.0" }, undefined)(
Feature.jest_feature)).toBeTruthy();
});
it("shouldn't display", () => {
expect(shouldDisplay("0.9.0", fakeMinOsData, undefined)(
expect(createShouldDisplayFn("0.9.0", fakeMinOsData, undefined)(
Feature.jest_feature)).toBeFalsy();
expect(shouldDisplay(undefined, fakeMinOsData, undefined)(
expect(createShouldDisplayFn(undefined, fakeMinOsData, undefined)(
Feature.jest_feature)).toBeFalsy();
// tslint:disable-next-line:no-any
const unknown_feature = "unknown_feature" as any;
expect(shouldDisplay("1.0.0", fakeMinOsData, undefined)(
expect(createShouldDisplayFn("1.0.0", fakeMinOsData, undefined)(
unknown_feature)).toBeFalsy();
expect(shouldDisplay("1.0.0", undefined, undefined)(
expect(createShouldDisplayFn("1.0.0", undefined, undefined)(
unknown_feature)).toBeFalsy();
// tslint:disable-next-line:no-any
expect(shouldDisplay("1.0.0", "" as any, undefined)(
expect(createShouldDisplayFn("1.0.0", "" as any, undefined)(
unknown_feature)).toBeFalsy();
// tslint:disable-next-line:no-any
expect(shouldDisplay("1.0.0", "{}" as any, undefined)(
expect(createShouldDisplayFn("1.0.0", "{}" as any, undefined)(
unknown_feature)).toBeFalsy();
// tslint:disable-next-line:no-any
expect(shouldDisplay("1.0.0", "bad" as any, undefined)(
expect(createShouldDisplayFn("1.0.0", "bad" as any, undefined)(
unknown_feature)).toBeFalsy();
});
});

View File

@ -109,14 +109,15 @@ export enum MinVersionOverride {
* @param current installed OS version string to compare against data ("0.0.0")
* @param lookupData min req versions data, for example {"feature": "1.0.0"}
*/
export function shouldDisplay(
export function createShouldDisplayFn(
current: string | undefined,
lookupData: MinOsFeatureLookup | undefined,
override: string | undefined) {
return function (feature: Feature): boolean {
const target = override || current;
if (isString(target)) {
const min = (lookupData || {})[feature] || MinVersionOverride.NEVER;
const table = lookupData || {};
const min = table[feature] || MinVersionOverride.NEVER;
switch (semverCompare(target, min)) {
case SemverResult.LEFT_IS_GREATER:
case SemverResult.EQUAL:
@ -143,8 +144,6 @@ export function determineInstalledOsVersion(
return fromBotState === "" ? undefined : fromBotState;
case SemverResult.RIGHT_IS_GREATER:
return fromAPI === "" ? undefined : fromAPI;
default:
return undefined;
}
}

View File

@ -45,7 +45,7 @@
"coveralls": "3.0.6",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.14.0",
"farmbot": "8.3.0-rc2",
"farmbot": "8.3.0-rc5",
"i18next": "17.0.16",
"lodash": "4.17.15",
"markdown-it": "10.0.0",

View File

@ -18,19 +18,21 @@ npm run translation-check
See the [README](https://github.com/FarmBot/Farmbot-Web-App#translating-the-web-app-into-your-language) for contribution instructions.
Total number of phrases identified by the language helper for translation: __1086__
Total number of phrases identified by the language helper for translation: __1101__
|Language|Percent translated|Translated|Untranslated|Other Translations|
|:---:|---:|---:|---:|---:|
|da|10%|110|976|27|
|de|39%|421|665|125|
|es|94%|1019|67|156|
|fr|70%|765|321|182|
|it|8%|89|997|175|
|nl|7%|79|1007|145|
|pt|7%|71|1015|164|
|ru|56%|606|480|205|
|zh|8%|86|1000|145|
|af|100%|1101|0|1|
|da|10%|110|991|31|
|de|38%|420|681|128|
|es|92%|1015|86|160|
|fr|69%|762|339|186|
|it|8%|89|1012|178|
|nl|7%|79|1022|148|
|pt|6%|71|1030|167|
|ru|55%|604|497|208|
|th|0%|0|1101|0|
|zh|8%|86|1015|148|
**Percent translated** refers to the percent of phrases identified by the
language helper that have been translated. Additional phrases not identified

View File

@ -238,7 +238,7 @@ describe CeleryScript::Corpus do
point_ids: [])
bad = CeleryScript::AstNode.new({
kind: "point_group",
args: { resource_id: pg.id },
args: { point_group_id: pg.id },
})
check = CeleryScript::Checker.new(bad, corpus, device)
expect(check.valid?).to be true
@ -249,7 +249,7 @@ describe CeleryScript::Corpus do
device.auto_sync_transaction do
bad = CeleryScript::AstNode.new({
kind: "point_group",
args: { resource_id: -1 },
args: { point_group_id: -1 },
})
check = CeleryScript::Checker.new(bad, corpus, device)
expect(check.valid?).to be false

View File

@ -5,10 +5,8 @@ describe Device do
let(:user) { device.users.first }
it "creates a token" do
jwt = device.create_token
jwt = device.help_customer
expect(jwt).to be_kind_of(String)
d2 = Auth::FromJWT.run!(jwt: jwt).device
expect(d2.id).to eq(device.id)
end
it "is associated with a user" do

View File

@ -0,0 +1,126 @@
require "spec_helper"
describe PointGroup do
let(:device) { FactoryBot.create(:user).device }
let(:point) do
Points::Create.run!(x: 0,
y: 0,
z: 0,
device: device,
pointer_type: "GenericPointer")
end
let!(:point_group) do
PointGroups::Create.run!(device: device,
name: "test",
point_ids: [point.id])
end
let(:s1) do
Sequences::Create.run!(kind: "sequence",
device: device,
name: "has parameters",
args: {
locals: {
kind: "scope_declaration",
args: {},
body: [
{
kind: "parameter_declaration",
args: {
label: "parent",
default_value: {
kind: "coordinate",
args: { x: 9, y: 9, z: 9 },
},
},
},
],
},
},
body: [
{
kind: "move_absolute",
args: {
speed: 100,
location: {
kind: "identifier",
args: { label: "parent" },
},
offset: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 },
},
},
},
])
end
it "maintains referential integrity" do
PointGroupItem.destroy_all
Point.destroy_all
expect(PointGroupItem.count).to eq(0)
Points::Destroy.run!(point: point,
device: device,
hard_delete: true)
expect(PointGroupItem.count).to eq(0)
end
it "refuses to delete groups in-use by sequences" do
# Create a group and use it in a sequence
Sequences::Create.run!(name: "Wrapper",
device: device,
body: [
{
kind: "execute",
args: {
sequence_id: s1.fetch(:id),
},
body: [
{
kind: "parameter_application",
args: {
label: "parent",
data_value: {
kind: "point_group",
args: {
point_group_id: point_group.id,
},
},
},
body: [],
},
],
},
])
result = PointGroups::Destroy.run(point_group: point_group, device: device)
error = result.errors.fetch("in_use").message
expect(error).to eq("Can't delete group because it is in use by sequence 'Wrapper'")
end
fit "refuses to delete groups in-use by regimens" do
point_group.update_attributes!(name: "@@@")
Regimens::Create.run!(name: "Wrapper 26",
device: device,
color: "red",
regimen_items: [],
body: [
{
kind: "parameter_application",
args: {
label: "parent",
data_value: {
kind: "point_group",
args: {
point_group_id: point_group.id,
},
},
},
},
])
result = PointGroups::Destroy.run(point_group: point_group, device: device)
error = result.errors.fetch("in_use").message
expect(error).to eq("Can't delete group because it is in use by Regimen 'Wrapper 26'")
end
it "refuses to delete groups in-use by farm events"
end

View File

@ -4,6 +4,31 @@ require_relative "scenario"
describe Points::Destroy do
let(:device) { FactoryBot.create(:device) }
it "cleans up point groups" do
previous_count = PointGroupItem.count
# Create point
point = Points::Create.run!(device: device,
name: "ref integrity",
x: 0,
y: 0,
z: 0,
pointer_type: "GenericPointer")
# add it to a group
pg = PointGroups::Create.run!(device: device,
point_ids: [point.id],
name: "ref integrity")
expect(pg.point_group_items.count).to eq(1)
old_ts = pg.updated_at
# Destroy the point
Points::Destroy.run!(device: device, point: point)
# Ensure `point_id` is gone from group
expect(pg.reload.point_group_items.count).to eq(0)
expect(pg.updated_at).to be > old_ts
expect(PointGroupItem.count).to eq(previous_count)
end
it "prevents deletion of points that are in use" do
# create many points
points = FactoryBot.create_list(:generic_pointer, 3, device: device)

View File

@ -71,6 +71,7 @@ require "database_cleaner"
DatabaseCleaner.strategy = :truncation
# then, whenever you need to clean the DB
DatabaseCleaner.clean
Rails.cache.redis.flushdb
RSpec.configure do |config|
config.color = true