commit
9a249b3416
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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(", ")
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -27,8 +27,9 @@ if Rails.env == "development"
|
|||
SavedGarden,
|
||||
SensorReading,
|
||||
FarmwareInstallation,
|
||||
Device,
|
||||
PointGroup,
|
||||
Tool,
|
||||
Device,
|
||||
Delayed::Job,
|
||||
Delayed::Backend::ActiveRecord::Job,
|
||||
].map(&:delete_all)
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -459,6 +459,7 @@ export function fakeAlert(): TaggedAlert {
|
|||
export function fakePointGroup(): TaggedPointGroup {
|
||||
return fakeResource("PointGroup", {
|
||||
name: "Fake",
|
||||
sort_type: "xy_ascending",
|
||||
point_ids: []
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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.`);
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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%");
|
||||
});
|
||||
|
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>>;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()", () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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"]);
|
||||
});
|
||||
});
|
|
@ -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 : ""));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
]
|
||||
});
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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 } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue