commit
a810743fa7
|
@ -1,6 +1,7 @@
|
|||
.cache
|
||||
.env
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
*.pem
|
||||
coverage_api
|
||||
|
|
134
Gemfile.lock
134
Gemfile.lock
|
@ -7,38 +7,38 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
actioncable (6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
activejob (= 6.0.1)
|
||||
activerecord (= 6.0.1)
|
||||
activestorage (= 6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
actionmailbox (6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
activejob (= 6.0.2.1)
|
||||
activerecord (= 6.0.2.1)
|
||||
activestorage (= 6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
actionview (= 6.0.1)
|
||||
activejob (= 6.0.1)
|
||||
actionmailer (6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
actionview (= 6.0.2.1)
|
||||
activejob (= 6.0.2.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.0.1)
|
||||
actionview (= 6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
rack (~> 2.0)
|
||||
actionpack (6.0.2.1)
|
||||
actionview (= 6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
rack (~> 2.0, >= 2.0.8)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
activerecord (= 6.0.1)
|
||||
activestorage (= 6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
actiontext (6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
activerecord (= 6.0.2.1)
|
||||
activestorage (= 6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
actionview (6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
|
@ -48,20 +48,20 @@ GEM
|
|||
activemodel (>= 4.1, < 6.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
activejob (6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
activerecord (6.0.1)
|
||||
activemodel (= 6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
activestorage (6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
activejob (= 6.0.1)
|
||||
activerecord (= 6.0.1)
|
||||
activemodel (6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
activerecord (6.0.2.1)
|
||||
activemodel (= 6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
activestorage (6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
activejob (= 6.0.2.1)
|
||||
activerecord (= 6.0.2.1)
|
||||
marcel (~> 0.3.1)
|
||||
activesupport (6.0.1)
|
||||
activesupport (6.0.2.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 0.7, < 2)
|
||||
minitest (~> 5.1)
|
||||
|
@ -71,7 +71,7 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 5.0)
|
||||
amq-protocol (2.3.0)
|
||||
bcrypt (3.1.13)
|
||||
builder (3.2.3)
|
||||
builder (3.2.4)
|
||||
bunny (2.14.3)
|
||||
amq-protocol (~> 2.3, >= 2.3.0)
|
||||
case_transform (0.2)
|
||||
|
@ -109,7 +109,7 @@ GEM
|
|||
factory_bot_rails (5.1.1)
|
||||
factory_bot (~> 5.1.0)
|
||||
railties (>= 4.2.0)
|
||||
faker (2.7.0)
|
||||
faker (2.9.0)
|
||||
i18n (>= 1.6, < 1.8)
|
||||
faraday (0.15.4)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
|
@ -119,7 +119,7 @@ GEM
|
|||
railties (>= 3.2, < 6.1)
|
||||
globalid (0.4.2)
|
||||
activesupport (>= 4.2.0)
|
||||
google-api-client (0.34.1)
|
||||
google-api-client (0.36.1)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (~> 0.9)
|
||||
httpclient (>= 2.8.1, < 3.0)
|
||||
|
@ -131,7 +131,7 @@ GEM
|
|||
google-cloud-env (~> 1.0)
|
||||
google-cloud-env (1.3.0)
|
||||
faraday (~> 0.11)
|
||||
google-cloud-storage (1.24.0)
|
||||
google-cloud-storage (1.25.0)
|
||||
addressable (~> 2.5)
|
||||
digest-crc (~> 0.4)
|
||||
google-api-client (~> 0.33)
|
||||
|
@ -150,17 +150,17 @@ GEM
|
|||
httpclient (2.8.3)
|
||||
i18n (1.7.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.2.0)
|
||||
json (2.3.0)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.2.1)
|
||||
loofah (2.3.1)
|
||||
loofah (2.4.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (0.3.3)
|
||||
mimemagic (~> 0.3.2)
|
||||
memoist (0.16.1)
|
||||
memoist (0.16.2)
|
||||
method_source (0.9.2)
|
||||
mimemagic (0.3.3)
|
||||
mini_mime (1.0.2)
|
||||
|
@ -171,7 +171,7 @@ GEM
|
|||
mutations (0.9.0)
|
||||
activesupport
|
||||
nio4r (2.5.2)
|
||||
nokogiri (1.10.5)
|
||||
nokogiri (1.10.7)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.0.1)
|
||||
|
@ -190,27 +190,27 @@ GEM
|
|||
faraday_middleware (~> 0.13.0)
|
||||
hashie (~> 3.6)
|
||||
multi_json (~> 1.13.1)
|
||||
rack (2.0.7)
|
||||
rack-attack (6.2.1)
|
||||
rack (2.0.8)
|
||||
rack-attack (6.2.2)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.0.6)
|
||||
rack (>= 1.6.0)
|
||||
rack-cors (1.1.0)
|
||||
rack (>= 2.0.0)
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rails (6.0.1)
|
||||
actioncable (= 6.0.1)
|
||||
actionmailbox (= 6.0.1)
|
||||
actionmailer (= 6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
actiontext (= 6.0.1)
|
||||
actionview (= 6.0.1)
|
||||
activejob (= 6.0.1)
|
||||
activemodel (= 6.0.1)
|
||||
activerecord (= 6.0.1)
|
||||
activestorage (= 6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
rails (6.0.2.1)
|
||||
actioncable (= 6.0.2.1)
|
||||
actionmailbox (= 6.0.2.1)
|
||||
actionmailer (= 6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
actiontext (= 6.0.2.1)
|
||||
actionview (= 6.0.2.1)
|
||||
activejob (= 6.0.2.1)
|
||||
activemodel (= 6.0.2.1)
|
||||
activerecord (= 6.0.2.1)
|
||||
activestorage (= 6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
bundler (>= 1.3.0)
|
||||
railties (= 6.0.1)
|
||||
railties (= 6.0.2.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
|
@ -222,9 +222,9 @@ GEM
|
|||
rails_stdout_logging
|
||||
rails_serve_static_assets (0.0.5)
|
||||
rails_stdout_logging (0.0.5)
|
||||
railties (6.0.1)
|
||||
actionpack (= 6.0.1)
|
||||
activesupport (= 6.0.1)
|
||||
railties (6.0.2.1)
|
||||
actionpack (= 6.0.2.1)
|
||||
activesupport (= 6.0.2.1)
|
||||
method_source
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.20.3, < 2.0)
|
||||
|
@ -234,13 +234,13 @@ GEM
|
|||
declarative (< 0.1.0)
|
||||
declarative-option (< 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
request_store (1.4.1)
|
||||
request_store (1.5.0)
|
||||
rack (>= 1.4)
|
||||
responders (3.0.0)
|
||||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
retriable (3.1.2)
|
||||
rollbar (2.22.1)
|
||||
rollbar (2.23.1)
|
||||
rspec (3.9.0)
|
||||
rspec-core (~> 3.9.0)
|
||||
rspec-expectations (~> 3.9.0)
|
||||
|
@ -283,7 +283,7 @@ GEM
|
|||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets (>= 3.0.0)
|
||||
thor (0.20.3)
|
||||
thor (1.0.1)
|
||||
thread_safe (0.3.6)
|
||||
tzinfo (1.2.5)
|
||||
thread_safe (~> 0.1)
|
||||
|
@ -297,7 +297,7 @@ GEM
|
|||
websocket-driver (0.7.1)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.4)
|
||||
zeitwerk (2.2.1)
|
||||
zeitwerk (2.2.2)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
module Api
|
||||
class FoldersController < Api::AbstractController
|
||||
def create
|
||||
mutate Folders::Create.run(raw_json, device: current_device)
|
||||
end
|
||||
|
||||
def index
|
||||
render json: folders
|
||||
end
|
||||
|
||||
def show
|
||||
render json: folder
|
||||
end
|
||||
|
||||
def update
|
||||
mutate Folders::Update.run(raw_json,
|
||||
folder: folder,
|
||||
device: current_device)
|
||||
end
|
||||
|
||||
def destroy
|
||||
mutate Folders::Destroy.run(folder: folder, device: current_device)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def folder
|
||||
folders.find(params[:id])
|
||||
end
|
||||
|
||||
def folders
|
||||
current_device.folders
|
||||
end
|
||||
end
|
||||
end
|
|
@ -90,6 +90,7 @@ module CeleryScript
|
|||
id: sequence.id,
|
||||
created_at: sequence.created_at,
|
||||
updated_at: sequence.updated_at,
|
||||
folder_id: sequence.folder_id,
|
||||
args: Sequence::DEFAULT_ARGS,
|
||||
color: sequence.color,
|
||||
name: sequence.name,
|
||||
|
|
|
@ -34,6 +34,7 @@ class Device < ApplicationRecord
|
|||
has_many :in_use_tools
|
||||
has_many :in_use_points
|
||||
has_many :users
|
||||
has_many :folders
|
||||
|
||||
validates_presence_of :name
|
||||
validates :timezone, inclusion: {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
class Folder < ApplicationRecord
|
||||
belongs_to :device
|
||||
|
||||
has_many :sub_folders, class_name: "Folder",
|
||||
foreign_key: "parent_id"
|
||||
|
||||
belongs_to :parent, class_name: "Folder", optional: true
|
||||
end
|
|
@ -16,6 +16,7 @@ class Sequence < ApplicationRecord
|
|||
include CeleryScriptSettingsBag
|
||||
|
||||
belongs_to :device
|
||||
belongs_to :folder
|
||||
belongs_to :fbos_config, foreign_key: :boot_sequence_id
|
||||
has_one :sequence_usage_report
|
||||
has_many :farm_events, as: :executable
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
module Folders
|
||||
class Create < Mutations::Command
|
||||
required do
|
||||
model :device
|
||||
string :color
|
||||
string :name
|
||||
end
|
||||
|
||||
optional do
|
||||
integer :parent_id
|
||||
end
|
||||
|
||||
def execute
|
||||
Folder.create!(update_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_params
|
||||
inputs.except(:parent_id).merge({ parent: parent })
|
||||
end
|
||||
|
||||
def parent
|
||||
@parent ||= device.folders.find_by(id: parent_id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,44 @@
|
|||
module Folders
|
||||
class Destroy < Mutations::Command
|
||||
IN_USE = "This folder still contains %s %s(s). " \
|
||||
"They must be removed prior to deletion"
|
||||
|
||||
required do
|
||||
model :device
|
||||
model :folder
|
||||
end
|
||||
|
||||
def validate
|
||||
check_subfolders
|
||||
check_sequences
|
||||
end
|
||||
|
||||
def execute
|
||||
folder.destroy! && ""
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sequences
|
||||
@sequences ||= Sequence.where(folder: folder)
|
||||
end
|
||||
|
||||
def subfolders
|
||||
@subfolders ||= Folder.where(parent: folder)
|
||||
end
|
||||
|
||||
def check_sequences
|
||||
count = sequences.count
|
||||
if count > 0
|
||||
add_error :in_use, :in_use, IN_USE % [count, "sequence"]
|
||||
end
|
||||
end
|
||||
|
||||
def check_subfolders
|
||||
count = subfolders.count
|
||||
if count > 0
|
||||
add_error :in_use, :in_use, IN_USE % [count, "subfolder"]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,28 @@
|
|||
module Folders
|
||||
class Update < Mutations::Command
|
||||
required do
|
||||
model :device
|
||||
model :folder
|
||||
integer :parent_id, nils: true, empty_is_nil: true
|
||||
end
|
||||
|
||||
optional do
|
||||
string :name
|
||||
string :color
|
||||
end
|
||||
|
||||
def execute
|
||||
folder.update!(update_params) && folder
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_params
|
||||
inputs.except(:device, :folder).merge({ parent: parent })
|
||||
end
|
||||
|
||||
def parent
|
||||
@parent ||= parent_id && device.folders.find_by(id: parent_id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,13 +10,14 @@ module SensorReadings
|
|||
end
|
||||
|
||||
optional do
|
||||
time :read_at
|
||||
integer :mode,
|
||||
in: CeleryScriptSettingsBag::ALLOWED_PIN_MODES,
|
||||
default: CeleryScriptSettingsBag::DIGITAL
|
||||
end
|
||||
|
||||
def execute
|
||||
SensorReading.create!(inputs)
|
||||
SensorReading.create!(inputs.merge(read_at: read_at || Time.now))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,6 +12,7 @@ module Sequences
|
|||
optional do
|
||||
color
|
||||
args
|
||||
integer :folder_id
|
||||
end
|
||||
|
||||
def validate
|
||||
|
@ -25,6 +26,7 @@ module Sequences
|
|||
p = inputs
|
||||
.merge(migrated_nodes: true)
|
||||
.without(:body, :args, "body", "args")
|
||||
.merge(folder: device.folders.find_by(id: folder_id))
|
||||
seq = Sequence.create!(p)
|
||||
x = CeleryScript::FirstPass.run!(sequence: seq,
|
||||
args: args || {},
|
||||
|
|
|
@ -2,7 +2,12 @@ module Sequences
|
|||
class Update < Mutations::Command
|
||||
include CeleryScriptValidators
|
||||
using CanonicalCeleryHelpers
|
||||
BLACKLIST = [:sequence, :device, :args, :body]
|
||||
BLACKLIST = [:sequence, :device, :args, :body, :folder_id]
|
||||
BASE = "Can't add 'parent' to sequence because "
|
||||
EXPL = {
|
||||
FarmEvent => BASE + "it is in use by FarmEvents on these dates: %{items}",
|
||||
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
|
||||
}
|
||||
|
||||
required do
|
||||
model :device, class: Device
|
||||
|
@ -27,6 +32,7 @@ module Sequences
|
|||
|
||||
optional do
|
||||
color
|
||||
integer :folder_id
|
||||
end
|
||||
|
||||
def validate
|
||||
|
@ -40,7 +46,7 @@ module Sequences
|
|||
Sequence.auto_sync_debounce do
|
||||
ActiveRecord::Base.transaction do
|
||||
sequence.migrated_nodes = true
|
||||
sequence.update!(inputs.except(*BLACKLIST))
|
||||
sequence.update!(inputs.except(*BLACKLIST).merge(folder_stuff))
|
||||
CeleryScript::StoreCelery.run!(sequence: sequence,
|
||||
args: args,
|
||||
body: body)
|
||||
|
@ -49,11 +55,12 @@ module Sequences
|
|||
end
|
||||
CeleryScript::FetchCelery.run!(sequence: sequence, args: args, body: body)
|
||||
end
|
||||
|
||||
BASE = "Can't add 'parent' to sequence because "
|
||||
EXPL = {
|
||||
FarmEvent => BASE + "it is in use by FarmEvents on these dates: %{items}",
|
||||
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
|
||||
}
|
||||
def folder_stuff
|
||||
if folder_id
|
||||
return { folder: device.folders.find_by(id: folder_id) }
|
||||
else
|
||||
return {}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
class FolderSerializer < ApplicationSerializer
|
||||
attributes :id, :parent_id, :color, :name
|
||||
end
|
|
@ -1,3 +1,10 @@
|
|||
class SensorReadingSerializer < ApplicationSerializer
|
||||
attributes :mode, :pin, :value, :x, :y, :z
|
||||
attributes :mode, :pin, :value, :x, :y, :z, :read_at
|
||||
# This is for legacy support reasons.
|
||||
# Very old sensor_readings will have a
|
||||
# read_at value of `nil`, so we pre-populate it
|
||||
# to `created_at` for the convinience of API users.
|
||||
def read_at
|
||||
object.read_at || object.created_at
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,6 +25,7 @@ FarmBot::Application.routes.draw do
|
|||
sequences: [:create, :destroy, :index, :show, :update],
|
||||
tools: [:create, :destroy, :index, :show, :update],
|
||||
webcam_feeds: [:create, :destroy, :index, :show, :update],
|
||||
folders: [:create, :destroy, :index, :show, :update],
|
||||
}.to_a.map { |(name, only)| resources name, only: only }
|
||||
|
||||
# Singular API Resources:
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
class AddFolderColumns < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
create_table :folders do |t|
|
||||
t.references :device, null: false
|
||||
t.timestamps
|
||||
# https://twitter.com/wesbos/status/719678818831757313?lang=en
|
||||
t.string :color, limit: 20, null: false
|
||||
t.string :name, limit: 40, null: false
|
||||
end
|
||||
add_column :folders,
|
||||
:parent_id,
|
||||
:integer,
|
||||
null: true,
|
||||
index: true
|
||||
add_foreign_key :folders,
|
||||
:folders,
|
||||
column: :parent_id
|
||||
|
||||
add_reference :sequences, :folder, index: true
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
class AddReadAtToSensorReadings < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :sensor_readings, :read_at, :datetime, default: nil
|
||||
end
|
||||
end
|
|
@ -1406,7 +1406,7 @@ CREATE VIEW public.resource_update_steps AS
|
|||
edge_nodes.kind,
|
||||
edge_nodes.value
|
||||
FROM public.edge_nodes
|
||||
WHERE (((edge_nodes.kind)::text = 'resource_type'::text) AND ((edge_nodes.value)::text = ANY (ARRAY[('"GenericPointer"'::character varying)::text, ('"ToolSlot"'::character varying)::text, ('"Plant"'::character varying)::text])))
|
||||
WHERE (((edge_nodes.kind)::text = 'resource_type'::text) AND ((edge_nodes.value)::text = ANY ((ARRAY['"GenericPointer"'::character varying, '"ToolSlot"'::character varying, '"Plant"'::character varying])::text[])))
|
||||
), resource_id AS (
|
||||
SELECT edge_nodes.primary_node_id,
|
||||
edge_nodes.kind,
|
||||
|
@ -1483,7 +1483,8 @@ CREATE TABLE public.sensor_readings (
|
|||
pin integer,
|
||||
created_at timestamp without time zone NOT NULL,
|
||||
updated_at timestamp without time zone NOT NULL,
|
||||
mode integer DEFAULT 0
|
||||
mode integer DEFAULT 0,
|
||||
read_at timestamp without time zone
|
||||
);
|
||||
|
||||
|
||||
|
@ -1685,8 +1686,7 @@ CREATE TABLE public.users (
|
|||
agreed_to_terms_at timestamp without time zone,
|
||||
confirmation_sent_at timestamp without time zone,
|
||||
unconfirmed_email character varying,
|
||||
inactivity_warning_sent_at timestamp without time zone,
|
||||
inactivity_warning_count integer
|
||||
inactivity_warning_sent_at timestamp without time zone
|
||||
);
|
||||
|
||||
|
||||
|
@ -3444,6 +3444,7 @@ INSERT INTO "schema_migrations" (version) VALUES
|
|||
('20191107170431'),
|
||||
('20191119204916'),
|
||||
('20191203163621'),
|
||||
('20191219212755');
|
||||
('20191219212755'),
|
||||
('20191220010646');
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Everything } from "../../interfaces";
|
||||
|
||||
export let bot: Everything["bot"] = {
|
||||
export const bot: Everything["bot"] = {
|
||||
"consistent": true,
|
||||
"stepSize": 100,
|
||||
"controlPanelState": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Everything } from "../../interfaces";
|
||||
|
||||
export let config: Everything["config"] = {
|
||||
export const config: Everything["config"] = {
|
||||
"host": "localhost",
|
||||
"port": "3000"
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Everything } from "../../interfaces";
|
||||
|
||||
export let draggable: Everything["draggable"] = {
|
||||
export const draggable: Everything["draggable"] = {
|
||||
"dataTransfer": {}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { TaggedImage, SpecialStatus } from "farmbot";
|
||||
|
||||
export let fakeImages: TaggedImage[] = [
|
||||
export const fakeImages: TaggedImage[] = [
|
||||
{
|
||||
"kind": "Image",
|
||||
"specialStatus": SpecialStatus.SAVED,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Everything } from "../../interfaces";
|
||||
|
||||
export let peripherals: Everything["Peripheral"] = {
|
||||
export const peripherals: Everything["Peripheral"] = {
|
||||
"isEditing": true
|
||||
};
|
||||
|
|
|
@ -26,29 +26,42 @@ import {
|
|||
TaggedFarmwareInstallation,
|
||||
TaggedAlert,
|
||||
TaggedPointGroup,
|
||||
TaggedFolder,
|
||||
} from "farmbot";
|
||||
import { fakeResource } from "../fake_resource";
|
||||
import {
|
||||
ExecutableType, PinBindingType
|
||||
ExecutableType, PinBindingType, Folder
|
||||
} from "farmbot/dist/resources/api_resources";
|
||||
import { FirmwareConfig } from "farmbot/dist/resources/configs/firmware";
|
||||
import { MessageType } from "../../sequences/interfaces";
|
||||
|
||||
export let resources: Everything["resources"] = buildResourceIndex();
|
||||
export const resources: Everything["resources"] = buildResourceIndex();
|
||||
let idCounter = 1;
|
||||
|
||||
export function fakeSequence(): TaggedSequence {
|
||||
return fakeResource("Sequence", {
|
||||
args: {
|
||||
version: 4,
|
||||
locals: { kind: "scope_declaration", args: {} },
|
||||
},
|
||||
export const fakeSequence =
|
||||
(body: Partial<TaggedSequence["body"]> = {}): TaggedSequence => {
|
||||
return fakeResource("Sequence", {
|
||||
args: {
|
||||
version: 4,
|
||||
locals: { kind: "scope_declaration", args: {} },
|
||||
},
|
||||
id: idCounter++,
|
||||
color: "red",
|
||||
folder_id: undefined,
|
||||
name: "fake",
|
||||
kind: "sequence",
|
||||
body: [],
|
||||
...body
|
||||
});
|
||||
};
|
||||
|
||||
export function fakeFolder(input: Partial<Folder> = {}): TaggedFolder {
|
||||
return fakeResource("Folder", {
|
||||
id: idCounter++,
|
||||
color: "red",
|
||||
parent_id: undefined,
|
||||
name: "fake",
|
||||
kind: "sequence",
|
||||
folder_id: undefined,
|
||||
body: []
|
||||
...input
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -229,6 +242,7 @@ export function fakeSensorReading(): TaggedSensorReading {
|
|||
return fakeResource("SensorReading", {
|
||||
id: idCounter++,
|
||||
created_at: "2018-01-11T20:20:38.362Z",
|
||||
read_at: "2018-01-11T20:20:38.362Z",
|
||||
pin: 1,
|
||||
value: 0,
|
||||
mode: 0,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { AuthState } from "../../auth/interfaces";
|
||||
|
||||
export let auth: AuthState = {
|
||||
export const auth: AuthState = {
|
||||
"token": {
|
||||
"unencoded": {
|
||||
"jti": "xyz",
|
||||
|
|
|
@ -12,7 +12,7 @@ export const TIME = {
|
|||
SATURDAY: moment("2017-06-24T06:30:00.000-05:00")
|
||||
};
|
||||
|
||||
export let fakeFarmEventWithExecutable = (): FarmEventWithExecutable => {
|
||||
export const fakeFarmEventWithExecutable = (): FarmEventWithExecutable => {
|
||||
return {
|
||||
id: 1,
|
||||
start_time: "---",
|
||||
|
@ -29,7 +29,7 @@ export let fakeFarmEventWithExecutable = (): FarmEventWithExecutable => {
|
|||
};
|
||||
};
|
||||
|
||||
export let calendarRows = [
|
||||
export const calendarRows = [
|
||||
{
|
||||
"sortKey": 1500922800,
|
||||
"year": 17,
|
||||
|
|
|
@ -328,7 +328,7 @@ const log: TaggedLog = {
|
|||
uuid: "Log.1091396.70"
|
||||
};
|
||||
|
||||
export let FAKE_RESOURCES: TaggedResource[] = [
|
||||
export const FAKE_RESOURCES: TaggedResource[] = [
|
||||
tr1,
|
||||
fakeDevice(),
|
||||
tr2,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { User } from "farmbot/dist/resources/api_resources";
|
||||
import { TaggedUser, SpecialStatus } from "farmbot";
|
||||
|
||||
export let user: User = {
|
||||
export const user: User = {
|
||||
created_at: "2016-10-05T03:02:58.000Z",
|
||||
email: "farmbot1@farmbot.io",
|
||||
id: 2,
|
||||
|
@ -9,7 +9,7 @@ export let user: User = {
|
|||
updated_at: "2017-08-04T19:53:29.724Z"
|
||||
};
|
||||
|
||||
export let taggedUser: TaggedUser = {
|
||||
export const taggedUser: TaggedUser = {
|
||||
kind: "User",
|
||||
uuid: "1234-5678",
|
||||
specialStatus: SpecialStatus.SAVED,
|
||||
|
|
|
@ -164,5 +164,7 @@ export class API {
|
|||
get alertPath() { return `${this.baseUrl}/api/alerts/`; }
|
||||
/** /api/global_bulletins/:id */
|
||||
get globalBulletinPath() { return `${this.baseUrl}/api/global_bulletins/`; }
|
||||
/** /api/folders */
|
||||
get foldersPath() { return `${this.baseUrl}/api/folders/`; }
|
||||
// get syncPath() { return `${this.baseUrl}/api/device/sync/`; }
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import {
|
||||
TaggedResource, SpecialStatus, ResourceName, TaggedSequence
|
||||
TaggedResource,
|
||||
SpecialStatus,
|
||||
ResourceName,
|
||||
TaggedSequence,
|
||||
} from "farmbot";
|
||||
import {
|
||||
isTaggedResource,
|
||||
|
@ -8,7 +11,11 @@ import { GetState, ReduxAction } from "../redux/interfaces";
|
|||
import { API } from "./index";
|
||||
import axios from "axios";
|
||||
import {
|
||||
updateNO, destroyOK, destroyNO, GeneralizedError, saveOK
|
||||
updateNO,
|
||||
destroyOK,
|
||||
destroyNO,
|
||||
GeneralizedError,
|
||||
saveOK,
|
||||
} from "../resources/actions";
|
||||
import { UnsafeError } from "../interfaces";
|
||||
import { defensiveClone, unpackUUID } from "../util";
|
||||
|
@ -274,6 +281,7 @@ export function urlFor(tag: ResourceName) {
|
|||
User: API.current.usersPath,
|
||||
WebAppConfig: API.current.webAppConfigPath,
|
||||
WebcamFeed: API.current.webcamFeedPath,
|
||||
Folder: API.current.foldersPath,
|
||||
};
|
||||
const url = OPTIONS[tag];
|
||||
if (url) {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { AuthState } from "./interfaces";
|
|||
import { generateReducer } from "../redux/generate_reducer";
|
||||
import { Actions } from "../constants";
|
||||
|
||||
export let authReducer = generateReducer<AuthState | undefined>(undefined)
|
||||
export const authReducer = generateReducer<AuthState | undefined>(undefined)
|
||||
.add<AuthState>(Actions.REPLACE_TOKEN, (_, { payload }) => {
|
||||
return payload;
|
||||
});
|
||||
|
|
|
@ -10,7 +10,7 @@ const initialState: ConfigState = {
|
|||
port: API.inferPort()
|
||||
};
|
||||
|
||||
export let configReducer = generateReducer<ConfigState>(initialState)
|
||||
export const configReducer = generateReducer<ConfigState>(initialState)
|
||||
.add<ChangeApiPort>(Actions.CHANGE_API_PORT, (s, { payload }) => {
|
||||
s.port = payload.port.replace(/\D/g, "");
|
||||
return s;
|
||||
|
|
|
@ -14,4 +14,4 @@ const change = (state: "up" | "down") =>
|
|||
|
||||
export const networkUp = change("up");
|
||||
|
||||
export let networkDown = change("down");
|
||||
export const networkDown = change("down");
|
||||
|
|
|
@ -202,6 +202,6 @@ export const attachEventListeners =
|
|||
};
|
||||
|
||||
/** Connect to MQTT and attach all relevant event handlers. */
|
||||
export let connectDevice = (token: AuthState) =>
|
||||
export const connectDevice = (token: AuthState) =>
|
||||
(dispatch: Function, getState: GetState) => fetchNewDevice(token)
|
||||
.then(bot => attachEventListeners(bot, dispatch, getState), onOffline);
|
||||
|
|
|
@ -37,13 +37,13 @@ export const dispatchQosStart = (id: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
export let dispatchNetworkUp = (edge: Edge, at: number) => {
|
||||
export const dispatchNetworkUp = (edge: Edge, at: number) => {
|
||||
if (shouldThrottle(edge, at)) { return; }
|
||||
store.dispatch(networkUp(edge, at));
|
||||
bumpThrottle(edge, at);
|
||||
};
|
||||
|
||||
export let dispatchNetworkDown = (edge: Edge, at: number) => {
|
||||
export const dispatchNetworkDown = (edge: Edge, at: number) => {
|
||||
if (shouldThrottle(edge, at)) { return; }
|
||||
store.dispatch(networkDown(edge, at));
|
||||
bumpThrottle(edge, at);
|
||||
|
|
|
@ -17,7 +17,7 @@ export const DEFAULT_STATE: ConnectionState = {
|
|||
};
|
||||
export type PingResultPayload = { id: string, at: number };
|
||||
|
||||
export let connectivityReducer =
|
||||
export const connectivityReducer =
|
||||
generateReducer<ConnectionState>(DEFAULT_STATE)
|
||||
.add<{ id: string }>(Actions.PING_START, (s, { payload }) => {
|
||||
return { ...s, pings: startPing(s.pings, payload.id) };
|
||||
|
|
|
@ -2,5 +2,5 @@ import { throttle } from "lodash";
|
|||
|
||||
/** Too many status updates === too many screen redraws. */
|
||||
export const slowDown =
|
||||
(fn: (...args: unknown[]) => unknown) =>
|
||||
<Returns, Args, Fn extends (u: Args) => Returns>(fn: Fn) =>
|
||||
throttle(fn, 600, { leading: false, trailing: true });
|
||||
|
|
|
@ -1011,5 +1011,11 @@ export enum Actions {
|
|||
SET_CONSISTENCY = "SET_CONSISTENCY",
|
||||
PING_START = "PING_START",
|
||||
PING_OK = "PING_OK",
|
||||
PING_NO = "PING_NO"
|
||||
PING_NO = "PING_NO",
|
||||
|
||||
// Sequence Folders
|
||||
FOLDER_TOGGLE = "FOLDER_TOGGLE",
|
||||
FOLDER_TOGGLE_ALL = "FOLDER_TOGGLE_ALL",
|
||||
FOLDER_TOGGLE_EDIT = "FOLDER_TOGGLE_EDIT",
|
||||
FOLDER_SEARCH = "FOLDER_SEARCH"
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ const Axis = ({ val }: { val: number | undefined }) => <Col xs={3}>
|
|||
<input disabled value={isNumber(val) ? val : "---"} />
|
||||
</Col>;
|
||||
|
||||
export let AxisDisplayGroup = ({ position, label }: AxisDisplayGroupProps) => {
|
||||
export const AxisDisplayGroup = ({ position, label }: AxisDisplayGroupProps) => {
|
||||
const { x, y, z } = position;
|
||||
return <Row>
|
||||
<Axis val={x} />
|
||||
|
|
|
@ -3,7 +3,7 @@ import { AxisInputBoxProps } from "./interfaces";
|
|||
import { Col, BlurableInput } from "../ui/index";
|
||||
import { isUndefined } from "lodash";
|
||||
|
||||
export let AxisInputBox = ({ onChange, value, axis }: AxisInputBoxProps) => {
|
||||
export const AxisInputBox = ({ onChange, value, axis }: AxisInputBoxProps) => {
|
||||
return <Col xs={3}>
|
||||
<BlurableInput
|
||||
value={(isUndefined(value) ? "" : value)}
|
||||
|
|
|
@ -3,6 +3,7 @@ $translucent: rgba(0, 0, 0, 0.2);
|
|||
$translucent2: rgba(0, 0, 0, 0.6);
|
||||
$white: #fff;
|
||||
$off_white: #f4f4f4;
|
||||
$lighter_gray: #eee;
|
||||
$light_gray: #ddd;
|
||||
$gray: #ccc;
|
||||
$medium_light_gray: #bcbcbc;
|
||||
|
@ -129,9 +130,36 @@ $panel_light_red: #fff7f6;
|
|||
background: $blue !important;
|
||||
}
|
||||
|
||||
|
||||
.dark-blue,
|
||||
.fun,
|
||||
.saucer-fun {
|
||||
background: $dark_blue !important;
|
||||
}
|
||||
|
||||
.icon-saucer {
|
||||
background: none !important;
|
||||
&.blue {
|
||||
color: $blue;
|
||||
}
|
||||
&.green {
|
||||
color: $green;
|
||||
}
|
||||
&.yellow {
|
||||
color: $yellow;
|
||||
}
|
||||
&.orange {
|
||||
color: $orange;
|
||||
}
|
||||
&.purple {
|
||||
color: $purple;
|
||||
}
|
||||
&.pink {
|
||||
color: $pink;
|
||||
}
|
||||
&.gray {
|
||||
color: $gray;
|
||||
}
|
||||
&.red {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -394,7 +394,7 @@
|
|||
a {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
i {
|
||||
i:not(.fa-stack-2x) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,10 @@ body {
|
|||
width: 13rem;
|
||||
background: $dark_gray;
|
||||
}
|
||||
div {
|
||||
.bp3-popover-content,
|
||||
.color-picker-cluster,
|
||||
.color-picker-item-wrapper,
|
||||
.saucer {
|
||||
display: inline-block;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
@ -113,6 +116,21 @@ fieldset {
|
|||
}
|
||||
}
|
||||
|
||||
.icon-saucer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
color: $dark_gray;
|
||||
cursor: pointer;
|
||||
&.active {
|
||||
border: 2px solid white;
|
||||
}
|
||||
&.hover {
|
||||
border: 2px solid $dark_gray;
|
||||
}
|
||||
}
|
||||
|
||||
.saucer-connector {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
|
|
@ -229,8 +229,224 @@
|
|||
}
|
||||
}
|
||||
|
||||
.sequence-list-item {
|
||||
margin-right: 15px;
|
||||
.folders-panel {
|
||||
margin-left: -30px;
|
||||
margin-right: -20px;
|
||||
@media screen and (max-width: 767px) {
|
||||
margin-left: -15px;
|
||||
}
|
||||
.non-empty-state {
|
||||
height: calc(100vh - 15rem);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.panel-top {
|
||||
margin-left: 1rem !important;
|
||||
button {
|
||||
margin-top: 0.7rem !important;
|
||||
}
|
||||
}
|
||||
.panel-top,
|
||||
.folder-button-cluster {
|
||||
i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.fa-stack {
|
||||
font-size: 1rem;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
.fa-stack-2x {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.fa-stack-1x {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5rem;
|
||||
margin-left: 0.75rem;
|
||||
filter: drop-shadow(0 0 0.2rem $dark_green);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.folder-button-cluster {
|
||||
display: flex;
|
||||
i {
|
||||
width: 1.5rem !important;
|
||||
line-height: 2rem !important;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.folder-drop-area {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
transition: height 0.5s ease-out,
|
||||
padding-top 0.5s ease-out,
|
||||
padding-bottom 0.5s ease-out;
|
||||
transition-delay: 0.4s;
|
||||
color: $gray;
|
||||
font-weight: bold;
|
||||
background: $white;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
&.visible {
|
||||
transition: height 0.3s ease-in,
|
||||
padding-top 0.3s ease-in,
|
||||
padding-bottom 0.3s ease-in;
|
||||
transition-delay: 0.2s;
|
||||
height: 3rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
&:hover, &.hovered {
|
||||
color: $medium_gray;
|
||||
}
|
||||
}
|
||||
.folders {
|
||||
.folder > div:not(:first-child), ul {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
.folder-list-item,
|
||||
.sequence-list-item {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3.5rem;
|
||||
border-bottom: 1px solid $light_gray;
|
||||
cursor: pointer;
|
||||
background: $lighter_gray;
|
||||
border-left: 4px solid transparent;
|
||||
&.active {
|
||||
border-left: 4px solid $dark_gray;
|
||||
}
|
||||
.fa-chevron-down, .fa-chevron-right {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 3rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.folder-settings-icon,
|
||||
.fa-bars {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
.fa-bars, .fa-ellipsis-v {
|
||||
display: none;
|
||||
}
|
||||
.fa-ellipsis-v {
|
||||
&.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.fa-bars, .fa-ellipsis-v {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
i {
|
||||
margin: 0;
|
||||
line-height: 3.5rem;
|
||||
width: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
.saucer, .icon-saucer {
|
||||
position: absolute;
|
||||
margin: 0.5rem;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
a {
|
||||
width: 100%;
|
||||
}
|
||||
p {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
width: 75%;
|
||||
padding: 0.5rem;
|
||||
padding-left: 0;
|
||||
margin-left: 3rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
.folder-name {
|
||||
width: 100%;
|
||||
margin-right: 3rem;
|
||||
margin-left: 6rem;
|
||||
p {
|
||||
margin-left: 0;
|
||||
}
|
||||
.folder-name-input {
|
||||
display: flex;
|
||||
button {
|
||||
top: 0.5rem;
|
||||
width: auto;
|
||||
height: 2rem;
|
||||
i {
|
||||
line-height: 0;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
.input {
|
||||
margin: 0.3rem;
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.sequence-list-item-icons {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
.fa-hdd-o {
|
||||
margin-right: 3rem;
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
&.move-source {
|
||||
border: 1px solid $dark_gray;
|
||||
}
|
||||
&.move-target {
|
||||
background: $white;
|
||||
}
|
||||
}
|
||||
.folder-list-item,
|
||||
.sequence-list-item {
|
||||
.bp3-popover-wrapper.color-picker {
|
||||
position: absolute;
|
||||
line-height: 0;
|
||||
.bp3-popover-target {
|
||||
width: 2rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
}
|
||||
padding-left: 3rem;
|
||||
.saucer, .icon-saucer {
|
||||
position: relative;
|
||||
top: 0.55rem;
|
||||
margin: auto;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
}
|
||||
.folder-list-item {
|
||||
padding-left: 0;
|
||||
.bp3-popover-wrapper.color-picker {
|
||||
margin-left: 3rem;
|
||||
}
|
||||
.color-picker {
|
||||
.icon-saucer {
|
||||
top: 0;
|
||||
margin-top: 0;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.farmware-list-panel,
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import {
|
||||
fakeFbosConfig, fakeImage, fakeFarmwareEnv, fakeWebAppConfig
|
||||
fakeFbosConfig,
|
||||
fakeImage,
|
||||
fakeFarmwareEnv,
|
||||
fakeWebAppConfig
|
||||
} from "../../__test_support__/fake_state/resources";
|
||||
|
||||
let mockFbosConfig: TaggedFbosConfig | undefined = fakeFbosConfig();
|
||||
|
@ -12,6 +15,8 @@ jest.mock("../../resources/selectors_by_kind", () => ({
|
|||
selectAllRegimens: () => [],
|
||||
selectAllLogs: () => [],
|
||||
selectAllImages: () => [mockImages],
|
||||
selectAllFolders: () => [],
|
||||
selectAllSequences: () => [],
|
||||
selectAllFarmwareEnvs: () => [fakeFarmwareEnv()]
|
||||
}));
|
||||
|
||||
|
|
|
@ -245,7 +245,7 @@ function validMinOsFeatureLookup(x: MinOsFeatureLookup): boolean {
|
|||
* Fetch and save minimum FBOS version data for UI feature display.
|
||||
* @param url location of data
|
||||
*/
|
||||
export let fetchMinOsFeatureData = (url: string) =>
|
||||
export const fetchMinOsFeatureData = (url: string) =>
|
||||
(dispatch: Function) => {
|
||||
axios
|
||||
.get<MinOsFeatureLookup>(url)
|
||||
|
|
|
@ -10,7 +10,7 @@ interface Props {
|
|||
expanded: boolean;
|
||||
}
|
||||
|
||||
export let Header = (props: Props) => {
|
||||
export const Header = (props: Props) => {
|
||||
const { dispatch, name, title, expanded } = props;
|
||||
return <ExpandableHeader
|
||||
expanded={expanded}
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import {
|
||||
BotState, HardwareState, ControlPanelState, OsUpdateInfo,
|
||||
MinOsFeatureLookup
|
||||
BotState,
|
||||
ControlPanelState,
|
||||
HardwareState,
|
||||
MinOsFeatureLookup,
|
||||
OsUpdateInfo
|
||||
} from "./interfaces";
|
||||
import { generateReducer } from "../redux/generate_reducer";
|
||||
import { Actions } from "../constants";
|
||||
|
@ -18,7 +21,7 @@ const afterEach = (state: BotState, a: ReduxAction<{}>) => {
|
|||
return state;
|
||||
};
|
||||
|
||||
export let initialState = (): BotState => ({
|
||||
export const initialState = (): BotState => ({
|
||||
consistent: true,
|
||||
stepSize: 100,
|
||||
controlPanelState: {
|
||||
|
@ -81,7 +84,7 @@ export let initialState = (): BotState => ({
|
|||
}
|
||||
});
|
||||
|
||||
export let botReducer = generateReducer<BotState>(initialState())
|
||||
export const botReducer = generateReducer<BotState>(initialState())
|
||||
.afterEach(afterEach)
|
||||
.add<boolean>(Actions.SET_CONSISTENCY, (s, a) => {
|
||||
s.consistent = a.payload;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export let list = ["Africa/Abidjan",
|
||||
export const list = ["Africa/Abidjan",
|
||||
"Africa/Accra",
|
||||
"Africa/Addis_Ababa",
|
||||
"Africa/Algiers",
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Everything } from "../interfaces";
|
|||
import { ReduxAction } from "../redux/interfaces";
|
||||
import * as React from "react";
|
||||
import { Actions } from "../constants";
|
||||
import { UUID } from "../resources/interfaces";
|
||||
export const STEP_DATATRANSFER_IDENTIFER = "farmbot/sequence-step";
|
||||
|
||||
/** SIDE EFFECT-Y!! Stores a step into store.draggable.dataTransfer and
|
||||
|
@ -12,7 +13,8 @@ export const STEP_DATATRANSFER_IDENTIFER = "farmbot/sequence-step";
|
|||
export function stepPut(value: Step,
|
||||
ev: React.DragEvent<HTMLElement>,
|
||||
intent: DataXferIntent,
|
||||
draggerId: number):
|
||||
draggerId: number,
|
||||
resourceUuid?: UUID):
|
||||
ReduxAction<DataXferBase> {
|
||||
const uuid = id();
|
||||
ev.dataTransfer.setData(STEP_DATATRANSFER_IDENTIFER, uuid);
|
||||
|
@ -22,7 +24,8 @@ export function stepPut(value: Step,
|
|||
intent,
|
||||
uuid,
|
||||
value,
|
||||
draggerId
|
||||
draggerId,
|
||||
resourceUuid,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { SequenceBodyItem as Step } from "farmbot";
|
||||
import { UUID } from "../resources/interfaces";
|
||||
|
||||
/** An entry in the data transfer table. Used to transfer data from a "draggable"
|
||||
* to a "dropable". For type safety, this is a "tagged union". See Typescript
|
||||
|
@ -17,6 +18,8 @@ export interface DataXferBase {
|
|||
/** "why" the drag/drop event took place (tagged union- See Typescript
|
||||
* documentation for more information). */
|
||||
intent: DataXferIntent;
|
||||
/** Optional resource UUID. */
|
||||
resourceUuid?: UUID;
|
||||
}
|
||||
|
||||
/** Data transfer payload used when moving a *new* step into an existing step */
|
||||
|
@ -51,4 +54,5 @@ export interface StepDraggerProps {
|
|||
intent: DataXferIntent;
|
||||
children?: React.ReactNode;
|
||||
draggerId: number;
|
||||
resourceUuid?: UUID;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ const INITIAL_STATE: DraggableState = {
|
|||
dataTransfer: {}
|
||||
};
|
||||
|
||||
export let draggableReducer = generateReducer<DraggableState>(INITIAL_STATE)
|
||||
export const draggableReducer = generateReducer<DraggableState>(INITIAL_STATE)
|
||||
.add<DataXfer>(Actions.PUT_DATA_XFER, (s, { payload }) => {
|
||||
s.dataTransfer[payload.uuid] = payload;
|
||||
return s;
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as React from "react";
|
|||
import { stepPut } from "./actions";
|
||||
import { SequenceBodyItem as Step } from "farmbot";
|
||||
import { DataXferIntent, StepDraggerProps } from "./interfaces";
|
||||
import { UUID } from "../resources/interfaces";
|
||||
|
||||
/** Magic number to indicate that the draggerId was not provided or can't be
|
||||
* known. */
|
||||
|
@ -18,25 +19,24 @@ export const NULL_DRAGGER_ID = 0xCAFEF00D;
|
|||
* Drag this!
|
||||
* </button>
|
||||
* */
|
||||
export let stepDragEventHandler = (dispatch: Function,
|
||||
export const stepDragEventHandler = (dispatch: Function,
|
||||
step: Step,
|
||||
intent: DataXferIntent,
|
||||
draggerId: number) => {
|
||||
draggerId: number,
|
||||
resourceUuid?: UUID) => {
|
||||
return (ev: React.DragEvent<HTMLElement>) => {
|
||||
dispatch(stepPut(step, ev, intent, draggerId));
|
||||
dispatch(stepPut(step, ev, intent, draggerId, resourceUuid));
|
||||
};
|
||||
};
|
||||
|
||||
export function StepDragger({ dispatch,
|
||||
step,
|
||||
children,
|
||||
intent,
|
||||
draggerId }: StepDraggerProps) {
|
||||
return <div
|
||||
export function StepDragger(props: StepDraggerProps) {
|
||||
const { dispatch, step, children, intent, draggerId, resourceUuid } = props;
|
||||
return <div className="step-dragger"
|
||||
onDragStart={stepDragEventHandler(dispatch,
|
||||
step,
|
||||
intent,
|
||||
draggerId)}>
|
||||
draggerId,
|
||||
resourceUuid)}>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/// <reference path="./hacks.d.ts" />
|
||||
/**
|
||||
* THIS IS THE ENTRY POINT FOR THE MAIN PORTION OF THE WEB APP.
|
||||
*
|
||||
* Try to keep this file light. */
|
||||
import { detectLanguage } from "./i18n";
|
||||
import { stopIE } from "./util/stop_ie";
|
||||
|
|
|
@ -161,4 +161,4 @@ export class PureFarmEvents
|
|||
* It avoids mocking `connect` in unit tests.
|
||||
* See testing pattern noted here: https://github.com/airbnb/enzyme/issues/98
|
||||
*/
|
||||
export let FarmEvents = connect(mapStateToProps)(PureFarmEvents);
|
||||
export const FarmEvents = connect(mapStateToProps)(PureFarmEvents);
|
||||
|
|
|
@ -65,7 +65,7 @@ export const nextRegItemTimes =
|
|||
&& time.isSameOrAfter(moment(startTime)));
|
||||
};
|
||||
|
||||
export let regimenCalendarAdder = (
|
||||
export const regimenCalendarAdder = (
|
||||
index: ResourceIndex, timeSettings: TimeSettings) =>
|
||||
(f: FarmEventWithRegimen, c: Calendar, now = moment()) => {
|
||||
const { regimen_items } = f.executable;
|
||||
|
@ -96,7 +96,7 @@ export let regimenCalendarAdder = (
|
|||
}
|
||||
};
|
||||
|
||||
export let addSequenceToCalendar =
|
||||
export const addSequenceToCalendar =
|
||||
(f: FarmEventWithSequence, c: Calendar, timeSettings: TimeSettings,
|
||||
now = moment()) => {
|
||||
const schedule = scheduleForFarmEvent(f, now);
|
||||
|
|
|
@ -36,17 +36,17 @@ import { getFbosConfig } from "../../resources/getters";
|
|||
import { t } from "../../i18next_wrapper";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
export let formatTime = (input: string, timeSettings: TimeSettings) => {
|
||||
export const formatTime = (input: string, timeSettings: TimeSettings) => {
|
||||
const iso = new Date(input).toISOString();
|
||||
return moment(iso).utcOffset(timeSettings.utcOffset).format("HH:mm");
|
||||
};
|
||||
|
||||
export let formatDate = (input: string, timeSettings: TimeSettings) => {
|
||||
export const formatDate = (input: string, timeSettings: TimeSettings) => {
|
||||
const iso = new Date(input).toISOString();
|
||||
return moment(iso).utcOffset(timeSettings.utcOffset).format("YYYY-MM-DD");
|
||||
};
|
||||
|
||||
export let repeatOptions = [
|
||||
export const repeatOptions = [
|
||||
{ label: t("Minutes"), value: "minutely", name: "time_unit" },
|
||||
{ label: t("Hours"), value: "hourly", name: "time_unit" },
|
||||
{ label: t("Days"), value: "daily", name: "time_unit" },
|
||||
|
|
|
@ -10,6 +10,7 @@ describe("<Grid/>", () => {
|
|||
function fakeProps(): GridProps {
|
||||
return {
|
||||
mapTransformProps: fakeMapTransformProps(),
|
||||
zoomLvl: 1,
|
||||
onClick: jest.fn(),
|
||||
onMouseDown: jest.fn(),
|
||||
};
|
||||
|
@ -38,4 +39,27 @@ describe("<Grid/>", () => {
|
|||
expect.objectContaining(expectedGridShape));
|
||||
});
|
||||
|
||||
it("render default patterns strokes above 0.5 zoom", () => {
|
||||
const p = fakeProps();
|
||||
p.zoomLvl = 0.6;
|
||||
const wrapper = shallow(<Grid {...p} />);
|
||||
const minorGrid = wrapper.find("#minor_grid>path");
|
||||
const majorGrid = wrapper.find("#major_grid>path");
|
||||
const superiorGrid = wrapper.find("#superior_grid>path");
|
||||
expect(minorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.15)");
|
||||
expect(majorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.3)");
|
||||
expect(superiorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.4)");
|
||||
});
|
||||
|
||||
it("change patterns strokes on 0.5 zoom and below", () => {
|
||||
const p = fakeProps();
|
||||
p.zoomLvl = 0.5;
|
||||
const wrapper = shallow(<Grid {...p} />);
|
||||
const minorGrid = wrapper.find("#minor_grid>path");
|
||||
const majorGrid = wrapper.find("#major_grid>path");
|
||||
const superiorGrid = wrapper.find("#superior_grid>path");
|
||||
expect(minorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0)");
|
||||
expect(majorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.6)");
|
||||
expect(superiorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.8)");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ import { Color } from "../../../ui/index";
|
|||
import { range } from "lodash";
|
||||
|
||||
export function Grid(props: GridProps) {
|
||||
const { mapTransformProps } = props;
|
||||
const { mapTransformProps, zoomLvl } = props;
|
||||
const { gridSize, xySwap } = mapTransformProps;
|
||||
const gridSizeW = xySwap ? gridSize.y : gridSize.x;
|
||||
const gridSizeH = xySwap ? gridSize.x : gridSize.y;
|
||||
|
@ -13,19 +13,27 @@ export function Grid(props: GridProps) {
|
|||
const arrowEnd = transformXY(25, 25, mapTransformProps);
|
||||
const xLabel = transformXY(15, -10, mapTransformProps);
|
||||
const yLabel = transformXY(-11, 18, mapTransformProps);
|
||||
const minorGridStroke = zoomLvl <= 0.5 ? "rgba(0, 0, 0, 0)" : "rgba(0, 0, 0, 0.15)";
|
||||
const majorGridStroke = zoomLvl <= 0.5 ? "rgba(0, 0, 0, 0.6)" : "rgba(0, 0, 0, 0.3)";
|
||||
const superiorGridStroke = zoomLvl <= 0.5 ? "rgba(0, 0, 0, 0.8)" : "rgba(0, 0, 0, 0.4)";
|
||||
return <g className="drop-area-background" onClick={props.onClick}
|
||||
onMouseDown={props.onMouseDown}>
|
||||
<defs>
|
||||
<pattern id="minor_grid"
|
||||
width={10} height={10} patternUnits="userSpaceOnUse">
|
||||
<path d="M10,0 L0,0 L0,10" strokeWidth={1}
|
||||
fill="none" stroke="rgba(0, 0, 0, 0.15)" />
|
||||
fill="none" stroke={minorGridStroke} />
|
||||
</pattern>
|
||||
|
||||
<pattern id={"major_grid"}
|
||||
width={100} height={100} patternUnits="userSpaceOnUse">
|
||||
<path d="M100,0 L0,0 0,100" strokeWidth={2}
|
||||
fill="none" stroke="rgba(0, 0, 0, 0.15)" />
|
||||
fill="none" stroke={majorGridStroke} />
|
||||
</pattern>
|
||||
|
||||
<pattern id="superior_grid" width={1000} height={1000} patternUnits="userSpaceOnUse">
|
||||
<path d="M1000,0 L0,0 0,1000" strokeWidth={2}
|
||||
fill="none" stroke={superiorGridStroke} />
|
||||
</pattern>
|
||||
|
||||
<marker id="arrow"
|
||||
|
@ -40,6 +48,8 @@ export function Grid(props: GridProps) {
|
|||
width={gridSizeW} height={gridSizeH} fill="url(#minor_grid)" />
|
||||
<rect id="major-grid" transform={transformForQuadrant(mapTransformProps)}
|
||||
width={gridSizeW} height={gridSizeH} fill="url(#major_grid)" />
|
||||
<rect id="superior-grid" transform={transformForQuadrant(mapTransformProps)}
|
||||
width={gridSizeW} height={gridSizeH} fill="url(#superior_grid)" />
|
||||
<rect id="border" width={gridSizeW} height={gridSizeH} fill="none"
|
||||
stroke="rgba(0,0,0,0.3)" strokeWidth={2} />
|
||||
</g>
|
||||
|
|
|
@ -300,7 +300,8 @@ export class GardenMap extends
|
|||
Grid = () => <Grid
|
||||
onClick={this.closePanel()}
|
||||
onMouseDown={this.startDragOnBackground}
|
||||
mapTransformProps={this.mapTransformProps} />
|
||||
mapTransformProps={this.mapTransformProps}
|
||||
zoomLvl={this.props.zoomLvl} />
|
||||
SensorReadingsLayer = () => <SensorReadingsLayer
|
||||
visible={!!this.props.showSensorReadings}
|
||||
sensorReadings={this.props.sensorReadings}
|
||||
|
|
|
@ -110,6 +110,7 @@ export interface MapBackgroundProps {
|
|||
|
||||
export interface GridProps {
|
||||
mapTransformProps: MapTransformProps;
|
||||
zoomLvl: number;
|
||||
onClick(): void;
|
||||
onMouseDown(e: React.MouseEvent<SVGElement>): void;
|
||||
}
|
||||
|
|
|
@ -68,8 +68,8 @@ export namespace OpenFarm {
|
|||
attributes: ImageAttrs;
|
||||
}
|
||||
|
||||
export let cropUrl = "https://openfarm.cc/api/v1/crops";
|
||||
export let browsingCropUrl = "https://openfarm.cc/crops/";
|
||||
export const cropUrl = "https://openfarm.cc/api/v1/crops";
|
||||
export const browsingCropUrl = "https://openfarm.cc/crops/";
|
||||
}
|
||||
/** Returned by https://openfarm.cc/api/v1/crops?filter=q */
|
||||
export interface CropSearchResult {
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Actions } from "../constants";
|
|||
import { BotPosition } from "../devices/interfaces";
|
||||
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
|
||||
|
||||
export let initialState: DesignerState = {
|
||||
export const initialState: DesignerState = {
|
||||
selectedPlants: undefined,
|
||||
hoveredPlant: {
|
||||
plantUUID: undefined,
|
||||
|
@ -24,7 +24,7 @@ export let initialState: DesignerState = {
|
|||
tryGroupSortType: undefined,
|
||||
};
|
||||
|
||||
export let designer = generateReducer<DesignerState>(initialState)
|
||||
export const designer = generateReducer<DesignerState>(initialState)
|
||||
.add<string>(Actions.SEARCH_QUERY_CHANGE, (s, { payload }) => {
|
||||
s.cropSearchInProgress = true;
|
||||
const state = cloneDeep(s);
|
||||
|
|
|
@ -15,7 +15,7 @@ interface IdURL {
|
|||
}
|
||||
|
||||
const FALLBACK: OpenFarm.Included[] = [];
|
||||
export let OFSearch = (searchTerm: string) =>
|
||||
export const OFSearch = (searchTerm: string) =>
|
||||
(dispatch: Function) => {
|
||||
dispatch({ type: Actions.OF_SEARCH_RESULTS_START, payload: undefined });
|
||||
openFarmSearchQuery(searchTerm)
|
||||
|
|
|
@ -3,14 +3,14 @@ import { FarmwareState } from "./interfaces";
|
|||
import { TaggedResource } from "farmbot";
|
||||
import { Actions } from "../constants";
|
||||
|
||||
export let farmwareState: FarmwareState = {
|
||||
export const farmwareState: FarmwareState = {
|
||||
currentFarmware: undefined,
|
||||
currentImage: undefined,
|
||||
firstPartyFarmwareNames: [],
|
||||
infoOpen: false,
|
||||
};
|
||||
|
||||
export let farmwareReducer = generateReducer<FarmwareState>(farmwareState)
|
||||
export const farmwareReducer = generateReducer<FarmwareState>(farmwareState)
|
||||
.add<TaggedResource>(Actions.INIT_RESOURCE, (s, { payload }) => {
|
||||
if (payload.kind === "Image") {
|
||||
s.currentImage = payload.uuid;
|
||||
|
|
|
@ -5,14 +5,14 @@ import { envGet } from "./remote_env/selectors";
|
|||
|
||||
/** Convert values like SPECIAL_VALUES.TRUE to drop down items with friendly
|
||||
* label/value pairs. */
|
||||
export let translateSpecialValue = (input: number): DropDownItem => {
|
||||
export const translateSpecialValue = (input: number): DropDownItem => {
|
||||
return SPECIAL_VALUE_DDI[input] || NULL_CHOICE;
|
||||
};
|
||||
|
||||
/** Generates a lookup function to convert WeedDetector ENV items to
|
||||
* DropDownItems. Used to display currently selected options within dropdown
|
||||
* menus. */
|
||||
export let getDropdownSelection = (env: Partial<WD_ENV>) =>
|
||||
export const getDropdownSelection = (env: Partial<WD_ENV>) =>
|
||||
(key: keyof WD_ENV): DropDownItem => {
|
||||
return translateSpecialValue(envGet(key, env));
|
||||
};
|
||||
|
|
|
@ -0,0 +1,341 @@
|
|||
const mockStepGetResult = {
|
||||
value: { kind: "execute", args: { sequence_id: 1 } },
|
||||
resourceUuid: "",
|
||||
};
|
||||
jest.mock("../../draggable/actions", () => ({
|
||||
stepGet: jest.fn(() => () => mockStepGetResult),
|
||||
}));
|
||||
|
||||
import { FolderNode } from "../constants";
|
||||
import { ingest } from "../data_transfer";
|
||||
import {
|
||||
collapseAll,
|
||||
setFolderColor,
|
||||
setFolderName,
|
||||
addNewSequenceToFolder,
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
updateSearchTerm,
|
||||
toggleFolderOpenState,
|
||||
toggleFolderEditState,
|
||||
toggleAll,
|
||||
moveSequence,
|
||||
dropSequence,
|
||||
sequenceEditMaybeSave,
|
||||
} from "../actions";
|
||||
import { sample } from "lodash";
|
||||
import { cloneAndClimb, climb } from "../climb";
|
||||
import { store } from "../../redux/store";
|
||||
import { DeepPartial } from "redux";
|
||||
import { Everything } from "../../interfaces";
|
||||
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
|
||||
import { newTaggedResource } from "../../sync/actions";
|
||||
import { save, edit, init, initSave, destroy } from "../../api/crud";
|
||||
import { setActiveSequenceByName } from "../../sequences/set_active_sequence_by_name";
|
||||
import { push } from "../../history";
|
||||
import { fakeSequence } from "../../__test_support__/fake_state/resources";
|
||||
import { stepGet } from "../../draggable/actions";
|
||||
import { SpecialStatus } from "farmbot";
|
||||
|
||||
/** A set of fake Folder resources used exclusively for testing purposes.
|
||||
```
|
||||
├─ One
|
||||
├─ Two
|
||||
│ └─ Three
|
||||
├─ Four
|
||||
│ └─ Five
|
||||
├─ Six
|
||||
│ └─ Seven
|
||||
│ ├─ Eight
|
||||
│ └─ Nine
|
||||
├─ Ten
|
||||
│ ├─ Eleven
|
||||
│ └─ Twelve
|
||||
│ └─ Thirteen
|
||||
└─ Fourteen
|
||||
├─ Fifteen
|
||||
└─ Sixteen
|
||||
├─ Seventeen
|
||||
└─ Eighteen
|
||||
``` */
|
||||
const mockFolders: FolderNode[] = [
|
||||
{ id: 1, parent_id: undefined, color: "blue", name: "One" },
|
||||
{ id: 2, parent_id: undefined, color: "blue", name: "Two" },
|
||||
{ id: 3, parent_id: 2, color: "blue", name: "Three" },
|
||||
{ id: 4, parent_id: undefined, color: "blue", name: "Four" },
|
||||
{ id: 5, parent_id: 4, color: "blue", name: "Five" },
|
||||
{ id: 6, parent_id: undefined, color: "blue", name: "Six" },
|
||||
{ id: 7, parent_id: 6, color: "blue", name: "Seven" },
|
||||
{ id: 8, parent_id: 7, color: "blue", name: "Eight" },
|
||||
{ id: 9, parent_id: 7, color: "blue", name: "Nine" },
|
||||
{ id: 10, parent_id: undefined, color: "blue", name: "Ten" },
|
||||
{ id: 11, parent_id: 10, color: "blue", name: "Eleven" },
|
||||
{ id: 12, parent_id: 10, color: "blue", name: "Twelve" },
|
||||
{ id: 13, parent_id: 12, color: "blue", name: "Thirteen" },
|
||||
{ id: 14, parent_id: undefined, color: "blue", name: "Fourteen" },
|
||||
{ id: 15, parent_id: 14, color: "blue", name: "Fifteen" },
|
||||
{ id: 16, parent_id: 14, color: "blue", name: "Sixteen" },
|
||||
{ id: 17, parent_id: 16, color: "blue", name: "Seventeen" },
|
||||
{ id: 18, parent_id: 16, color: "blue", name: "Eighteen" }
|
||||
];
|
||||
|
||||
const mockSequence = fakeSequence();
|
||||
const i = buildResourceIndex(newTaggedResource("Folder", mockFolders));
|
||||
|
||||
const mockState: DeepPartial<Everything> =
|
||||
({ resources: buildResourceIndex([mockSequence], i) });
|
||||
|
||||
jest.mock("../../redux/store", () => {
|
||||
return {
|
||||
store: {
|
||||
dispatch: jest.fn(x => typeof x === "function" && x()),
|
||||
getState: jest.fn(() => mockState)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../api/crud", () => {
|
||||
return {
|
||||
destroy: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
init: jest.fn(),
|
||||
initSave: jest.fn(),
|
||||
save: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("../../sequences/set_active_sequence_by_name", () => {
|
||||
return { setActiveSequenceByName: jest.fn() };
|
||||
});
|
||||
|
||||
jest.mock("../../history", () => {
|
||||
return { push: jest.fn() };
|
||||
});
|
||||
|
||||
/**
|
||||
```
|
||||
├─ One
|
||||
├─ Two
|
||||
│ └─ Three
|
||||
├─ Four
|
||||
│ └─ Five
|
||||
├─ Six
|
||||
│ └─ Seven
|
||||
│ ├─ Eight
|
||||
│ └─ Nine
|
||||
├─ Ten
|
||||
│ ├─ Eleven
|
||||
│ └─ Twelve
|
||||
│ └─ Thirteen
|
||||
└─ Fourteen
|
||||
├─ Fifteen
|
||||
└─ Sixteen
|
||||
├─ Seventeen
|
||||
└─ Eighteen
|
||||
```
|
||||
*/
|
||||
export const TEST_GRAPH = ingest({
|
||||
folders: mockFolders,
|
||||
localMetaAttributes: {
|
||||
[1]: { editing: false, open: true, sequences: ["childOfFolder1"] },
|
||||
[2]: { editing: false, open: true, sequences: ["childOfFolder2"] },
|
||||
[3]: { editing: false, open: true, sequences: ["childOfFolder3"] },
|
||||
[4]: { editing: false, open: true, sequences: ["childOfFolder4"] },
|
||||
[5]: { editing: false, open: true, sequences: ["childOfFolder5"] },
|
||||
[6]: { editing: false, open: true, sequences: ["childOfFolder6"] },
|
||||
[7]: { editing: false, open: true, sequences: ["childOfFolder7"] },
|
||||
[8]: { editing: false, open: true, sequences: ["childOfFolder8"] },
|
||||
[9]: { editing: false, open: true, sequences: ["childOfFolder9"] },
|
||||
[10]: { editing: false, open: true, sequences: ["childOfFolder10"] },
|
||||
[11]: { editing: false, open: true, sequences: ["childOfFolder11"] },
|
||||
[12]: { editing: false, open: true, sequences: ["childOfFolder12"] },
|
||||
[13]: { editing: false, open: true, sequences: ["childOfFolder13"] },
|
||||
[14]: { editing: false, open: true, sequences: ["childOfFolder14"] },
|
||||
[15]: { editing: false, open: true, sequences: ["childOfFolder15"] },
|
||||
[16]: { editing: false, open: true, sequences: ["childOfFolder16"] },
|
||||
[17]: { editing: false, open: true, sequences: ["childOfFolder17"] },
|
||||
[18]: { editing: false, open: true, sequences: ["childOfFolder18"] },
|
||||
}
|
||||
});
|
||||
|
||||
describe("expand/collapse all", () => {
|
||||
const halfOpen = cloneAndClimb(TEST_GRAPH, (node) => {
|
||||
node.open = !sample([true, false]);
|
||||
});
|
||||
|
||||
it("collapses all folders", async () => {
|
||||
const closed = await collapseAll(halfOpen);
|
||||
climb(closed, (node) => {
|
||||
expect(node.open).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFolderColor", () => {
|
||||
it("updates a folder's color", () => {
|
||||
setFolderColor(11, "blue");
|
||||
const uuid = expect.stringContaining("Folder.11.");
|
||||
const body = expect.objectContaining({ color: "blue" });
|
||||
const resource = expect.objectContaining({ uuid, body });
|
||||
expect(store.dispatch).toHaveBeenCalled();
|
||||
expect(save).toHaveBeenCalledWith(uuid);
|
||||
expect(edit).toHaveBeenCalledWith(resource, body);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFolderName", () => {
|
||||
it("updates a folder's name", () => {
|
||||
setFolderName(11, "Harold");
|
||||
const uuid = expect.stringContaining("Folder.11.");
|
||||
const body = expect.objectContaining({ name: "Harold" });
|
||||
const resource = expect.objectContaining({ uuid });
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalled();
|
||||
expect(edit).toHaveBeenCalledWith(resource, body);
|
||||
expect(save).toHaveBeenCalledWith(uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addNewSequenceToFolder", () => {
|
||||
it("Adds a new sequence to a folder", () => {
|
||||
addNewSequenceToFolder(11);
|
||||
expect(setActiveSequenceByName).toHaveBeenCalled();
|
||||
expect(init).toHaveBeenCalledWith("Sequence", expect.objectContaining({
|
||||
name: "new sequence 1"
|
||||
}));
|
||||
expect(push).toHaveBeenCalledWith("/app/sequences/new_sequence_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFolder", () => {
|
||||
it("saves a new folder", () => {
|
||||
createFolder({ name: "test case 1" });
|
||||
expect(store.dispatch).toHaveReturnedTimes(1);
|
||||
expect(initSave).toHaveBeenCalledWith("Folder", {
|
||||
color: "gray",
|
||||
name: "test case 1",
|
||||
parent_id: 0
|
||||
});
|
||||
});
|
||||
|
||||
it("saves a new folder without inputs", () => {
|
||||
createFolder();
|
||||
expect(store.dispatch).toHaveReturnedTimes(1);
|
||||
expect(initSave).toHaveBeenCalledWith("Folder", {
|
||||
color: "gray",
|
||||
name: "New Folder",
|
||||
parent_id: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFolder", () => {
|
||||
it("deletes a folder", () => {
|
||||
const uuid = expect.stringContaining("Folder.12.");
|
||||
deleteFolder(12);
|
||||
expect(store.dispatch).toHaveBeenCalled();
|
||||
expect(destroy).toHaveBeenCalledWith(uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSearchTerm", () => {
|
||||
it("updates a search term", () => {
|
||||
const argss =
|
||||
(payload: string | undefined) => ({ type: "FOLDER_SEARCH", payload });
|
||||
[undefined, "foo"].map(term => {
|
||||
updateSearchTerm(term);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(argss(term));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleFolderOpenState", () => {
|
||||
it("dispatches the correct action", () => {
|
||||
const id = 12;
|
||||
toggleFolderOpenState(id);
|
||||
expect(store.dispatch)
|
||||
.toHaveBeenCalledWith({ type: "FOLDER_TOGGLE", payload: { id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleFolderEditState", () => {
|
||||
it("dispatches the correct action", () => {
|
||||
const id = 12;
|
||||
toggleFolderEditState(id);
|
||||
expect(store.dispatch)
|
||||
.toHaveBeenCalledWith({ type: "FOLDER_TOGGLE_EDIT", payload: { id } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleAll", () => {
|
||||
it("toggles all folders", () => {
|
||||
[true, false].map(payload => {
|
||||
toggleAll(payload);
|
||||
expect(store.dispatch)
|
||||
.toHaveBeenCalledWith({ type: "FOLDER_TOGGLE_ALL", payload });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sequenceEditMaybeSave()", () => {
|
||||
it("saves", () => {
|
||||
const sequence = fakeSequence();
|
||||
sequence.specialStatus = SpecialStatus.SAVED;
|
||||
sequenceEditMaybeSave(sequence, {});
|
||||
expect(edit).toHaveBeenCalled();
|
||||
expect(save).toHaveBeenCalledWith(sequence.uuid);
|
||||
});
|
||||
|
||||
it("doesn't save", () => {
|
||||
const sequence = fakeSequence();
|
||||
sequence.specialStatus = SpecialStatus.DIRTY;
|
||||
sequenceEditMaybeSave(sequence, {});
|
||||
expect(edit).toHaveBeenCalled();
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveSequence", () => {
|
||||
it("silently fails when given bad UUIDs", () => {
|
||||
const uuid = "a.b.c";
|
||||
moveSequence(uuid, 123);
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves a sequence", () => {
|
||||
const uuid = mockSequence.uuid;
|
||||
moveSequence(uuid, 12);
|
||||
expect(store.dispatch).toHaveBeenCalled();
|
||||
const update1 = expect.objectContaining({ uuid });
|
||||
const update2 = expect.objectContaining({ folder_id: 12 });
|
||||
expect(edit).toHaveBeenCalledWith(update1, update2);
|
||||
expect(save).toHaveBeenCalledWith(uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropSequence()", () => {
|
||||
const fakeDragEvent = ({
|
||||
dataTransfer: { getData: () => "fakeKey" }
|
||||
} as unknown as React.DragEvent<HTMLElement>);
|
||||
|
||||
it("updates folder_id", () => {
|
||||
dropSequence(1)(fakeDragEvent);
|
||||
expect(stepGet).toHaveBeenCalledWith("fakeKey");
|
||||
expect(edit).toHaveBeenCalledWith(mockSequence, { folder_id: 1 });
|
||||
});
|
||||
|
||||
it("handles missing sequence", () => {
|
||||
mockStepGetResult.value.args.sequence_id = -1;
|
||||
dropSequence(1)(fakeDragEvent);
|
||||
expect(stepGet).toHaveBeenCalledWith("fakeKey");
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gets sequence by UUID", () => {
|
||||
mockStepGetResult.value.args.sequence_id = -1;
|
||||
mockStepGetResult.resourceUuid = mockSequence.uuid;
|
||||
dropSequence(1)(fakeDragEvent);
|
||||
expect(stepGet).toHaveBeenCalledWith("fakeKey");
|
||||
expect(edit).toHaveBeenCalledWith(mockSequence, { folder_id: 1 });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,546 @@
|
|||
jest.mock("../actions", () => ({
|
||||
updateSearchTerm: jest.fn(),
|
||||
toggleAll: jest.fn(),
|
||||
moveSequence: jest.fn(),
|
||||
dropSequence: jest.fn(() => jest.fn()),
|
||||
sequenceEditMaybeSave: jest.fn(),
|
||||
deleteFolder: jest.fn(),
|
||||
toggleFolderEditState: jest.fn(),
|
||||
createFolder: jest.fn(),
|
||||
addNewSequenceToFolder: jest.fn(),
|
||||
setFolderName: jest.fn(),
|
||||
toggleFolderOpenState: jest.fn(),
|
||||
setFolderColor: jest.fn(),
|
||||
}));
|
||||
|
||||
let mockPath = "";
|
||||
jest.mock("../../history", () => ({
|
||||
history: { getCurrentLocation: () => ({ pathname: mockPath }) }
|
||||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import {
|
||||
Folders, FolderPanelTop, SequenceDropArea, FolderNameEditor,
|
||||
FolderButtonCluster, FolderListItem, FolderNameInput,
|
||||
} from "../component";
|
||||
import {
|
||||
FolderProps, FolderPanelTopProps, SequenceDropAreaProps, FolderNodeProps,
|
||||
FolderNodeInitial, FolderButtonClusterProps, FolderItemProps,
|
||||
FolderNameInputProps,
|
||||
FolderNodeMedial,
|
||||
FolderNodeTerminal,
|
||||
} from "../constants";
|
||||
import {
|
||||
updateSearchTerm, toggleAll, moveSequence, dropSequence,
|
||||
sequenceEditMaybeSave,
|
||||
deleteFolder,
|
||||
toggleFolderEditState,
|
||||
createFolder,
|
||||
addNewSequenceToFolder,
|
||||
setFolderName,
|
||||
toggleFolderOpenState,
|
||||
setFolderColor,
|
||||
} from "../actions";
|
||||
import { fakeSequence } from "../../__test_support__/fake_state/resources";
|
||||
import { SpecialStatus, Color } from "farmbot";
|
||||
|
||||
const fakeRootFolder = (): FolderNodeInitial => ({
|
||||
kind: "initial",
|
||||
children: [],
|
||||
id: 1,
|
||||
name: "my folder",
|
||||
content: [],
|
||||
color: "gray",
|
||||
open: true,
|
||||
editing: false,
|
||||
});
|
||||
|
||||
const fakeFolderNode = (): FolderNodeMedial => {
|
||||
const folder = fakeRootFolder() as unknown as FolderNodeMedial;
|
||||
folder.kind = "medial";
|
||||
return folder;
|
||||
};
|
||||
|
||||
const fakeTerminalFolder = (): FolderNodeTerminal => {
|
||||
const folder = fakeRootFolder() as unknown as FolderNodeTerminal;
|
||||
folder.children = undefined;
|
||||
folder.kind = "terminal";
|
||||
return folder;
|
||||
};
|
||||
|
||||
describe("<Folders />", () => {
|
||||
const fakeProps = (): FolderProps => ({
|
||||
rootFolder: {
|
||||
folders: [],
|
||||
noFolder: [],
|
||||
},
|
||||
sequences: {},
|
||||
searchTerm: undefined,
|
||||
dispatch: Function,
|
||||
resourceUsage: {},
|
||||
sequenceMetas: {},
|
||||
});
|
||||
|
||||
it("renders empty state", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount<Folders>(<Folders {...p} />);
|
||||
expect(wrapper.text()).toContain("No Sequences.");
|
||||
});
|
||||
|
||||
it("renders sequences outside of folders", () => {
|
||||
const p = fakeProps();
|
||||
p.rootFolder.folders[0] = fakeRootFolder();
|
||||
const sequence = fakeSequence();
|
||||
p.sequences = { [sequence.uuid]: sequence };
|
||||
sequence.body.name = "my sequence";
|
||||
p.rootFolder.noFolder = [sequence.uuid];
|
||||
const wrapper = mount<Folders>(<Folders {...p} />);
|
||||
expect(wrapper.text()).toContain("my sequence");
|
||||
});
|
||||
|
||||
it("renders empty folder", () => {
|
||||
const p = fakeProps();
|
||||
p.rootFolder.folders[0] = fakeRootFolder();
|
||||
const wrapper = mount<Folders>(<Folders {...p} />);
|
||||
expect(wrapper.text()).toContain("my folder");
|
||||
});
|
||||
|
||||
it("renders sequences in folder", () => {
|
||||
const p = fakeProps();
|
||||
const sequence = fakeSequence();
|
||||
sequence.body.name = "my sequence";
|
||||
p.sequences = { [sequence.uuid]: sequence };
|
||||
const folder = fakeRootFolder();
|
||||
folder.content = [sequence.uuid];
|
||||
p.rootFolder.folders[0] = folder;
|
||||
const wrapper = mount<Folders>(<Folders {...p} />);
|
||||
expect(wrapper.text()).toContain("my sequence");
|
||||
});
|
||||
|
||||
it("renders folders in folder", () => {
|
||||
const p = fakeProps();
|
||||
const folder = fakeRootFolder();
|
||||
const childFolder = fakeFolderNode();
|
||||
childFolder.name = "deeper folder";
|
||||
folder.children = [childFolder];
|
||||
p.rootFolder.folders[0] = folder;
|
||||
const wrapper = mount<Folders>(<Folders {...p} />);
|
||||
expect(wrapper.text()).toContain("deeper folder");
|
||||
});
|
||||
|
||||
it("renders terminal folder", () => {
|
||||
const p = fakeProps();
|
||||
const folder = fakeRootFolder();
|
||||
folder.name = "folder";
|
||||
const childFolder = fakeFolderNode();
|
||||
childFolder.name = "deeper folder";
|
||||
const terminalFolder = fakeTerminalFolder();
|
||||
terminalFolder.name = "deepest folder";
|
||||
childFolder.children = [terminalFolder];
|
||||
folder.children = [childFolder];
|
||||
p.rootFolder.folders[0] = folder;
|
||||
const wrapper = mount<Folders>(<Folders {...p} />);
|
||||
["folder", "deeper folder", "deepest folder"].map(string =>
|
||||
expect(wrapper.text()).toContain(string));
|
||||
});
|
||||
|
||||
it("toggles all folders", () => {
|
||||
const wrapper = mount<Folders>(<Folders {...fakeProps()} />);
|
||||
expect(wrapper.state().toggleDirection).toEqual(false);
|
||||
wrapper.instance().toggleAll();
|
||||
expect(toggleAll).toHaveBeenCalledWith(false);
|
||||
expect(wrapper.state().toggleDirection).toEqual(true);
|
||||
});
|
||||
|
||||
it("starts sequence move", () => {
|
||||
const wrapper = mount<Folders>(<Folders {...fakeProps()} />);
|
||||
expect(wrapper.state().movedSequenceUuid).toEqual(undefined);
|
||||
wrapper.instance().startSequenceMove("fakeUuid");
|
||||
expect(wrapper.state().movedSequenceUuid).toEqual("fakeUuid");
|
||||
expect(wrapper.state().stashedUuid).toEqual(undefined);
|
||||
});
|
||||
|
||||
const toggleMoveTest = (p: {
|
||||
prev: string | undefined,
|
||||
current: string | undefined,
|
||||
arg: string | undefined,
|
||||
new: string | undefined
|
||||
}) => {
|
||||
const wrapper = mount<Folders>(<Folders {...fakeProps()} />);
|
||||
wrapper.setState({ movedSequenceUuid: p.current, stashedUuid: p.prev });
|
||||
wrapper.instance().toggleSequenceMove(p.arg);
|
||||
expect(wrapper.state().movedSequenceUuid).toEqual(p.new);
|
||||
};
|
||||
|
||||
it("toggle sequence move: on", () => {
|
||||
toggleMoveTest({
|
||||
prev: undefined, current: undefined, arg: "fakeUuid", new: "fakeUuid"
|
||||
});
|
||||
toggleMoveTest({
|
||||
prev: undefined, current: "oldFakeUuid", arg: "fakeUuid", new: "fakeUuid"
|
||||
});
|
||||
});
|
||||
|
||||
it("toggle sequence move: off", () => {
|
||||
toggleMoveTest({
|
||||
prev: undefined, current: undefined, arg: undefined, new: undefined
|
||||
});
|
||||
toggleMoveTest({
|
||||
prev: "fakeUuid", current: "fakeUuid", arg: undefined, new: undefined
|
||||
});
|
||||
toggleMoveTest({
|
||||
prev: "fakeUuid", current: undefined, arg: "fakeUuid", new: undefined
|
||||
});
|
||||
toggleMoveTest({
|
||||
prev: "fakeUuid", current: "fakeUuid", arg: "fakeUuid", new: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("ends sequence move", () => {
|
||||
const wrapper = mount<Folders>(<Folders {...fakeProps()} />);
|
||||
wrapper.setState({ movedSequenceUuid: "fakeUuid" });
|
||||
wrapper.instance().endSequenceMove(1);
|
||||
expect(moveSequence).toHaveBeenCalledWith("fakeUuid", 1);
|
||||
expect(wrapper.state().movedSequenceUuid).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("ends sequence move: undefined", () => {
|
||||
const wrapper = mount<Folders>(<Folders {...fakeProps()} />);
|
||||
wrapper.setState({ movedSequenceUuid: undefined });
|
||||
wrapper.instance().endSequenceMove(1);
|
||||
expect(moveSequence).toHaveBeenCalledWith("", 1);
|
||||
expect(wrapper.state().movedSequenceUuid).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FolderListItem />", () => {
|
||||
const fakeProps = (): FolderItemProps => ({
|
||||
startSequenceMove: jest.fn(),
|
||||
toggleSequenceMove: jest.fn(),
|
||||
sequence: fakeSequence(),
|
||||
movedSequenceUuid: undefined,
|
||||
dispatch: jest.fn(),
|
||||
variableData: undefined,
|
||||
inUse: false,
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const p = fakeProps();
|
||||
p.sequence.body.name = "my sequence";
|
||||
const wrapper = mount(<FolderListItem {...p} />);
|
||||
expect(wrapper.text()).toContain("my sequence");
|
||||
expect(wrapper.find("li").hasClass("move-source")).toBeFalsy();
|
||||
expect(wrapper.find("li").hasClass("active")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("renders: move in progress", () => {
|
||||
const p = fakeProps();
|
||||
p.movedSequenceUuid = p.sequence.uuid;
|
||||
const wrapper = mount(<FolderListItem {...p} />);
|
||||
expect(wrapper.find("li").hasClass("move-source")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders: active", () => {
|
||||
const p = fakeProps();
|
||||
p.sequence.body.name = "sequence";
|
||||
mockPath = "/app/sequences/sequence";
|
||||
const wrapper = mount(<FolderListItem {...p} />);
|
||||
expect(wrapper.find("li").hasClass("active")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders: unsaved", () => {
|
||||
const p = fakeProps();
|
||||
p.sequence.body.name = "my sequence";
|
||||
p.sequence.specialStatus = SpecialStatus.DIRTY;
|
||||
const wrapper = mount(<FolderListItem {...p} />);
|
||||
expect(wrapper.text()).toContain("my sequence*");
|
||||
});
|
||||
|
||||
it("renders: in use", () => {
|
||||
const p = fakeProps();
|
||||
p.inUse = true;
|
||||
const wrapper = mount(<FolderListItem {...p} />);
|
||||
expect(wrapper.find(".in-use").length).toEqual(1);
|
||||
});
|
||||
|
||||
it("changes color", () => {
|
||||
const p = fakeProps();
|
||||
p.sequence.body.id = undefined;
|
||||
p.sequence.body.name = "";
|
||||
p.sequence.body.color = "" as Color;
|
||||
const wrapper = shallow(<FolderListItem {...p} />);
|
||||
wrapper.find("ColorPicker").simulate("change", "green");
|
||||
expect(sequenceEditMaybeSave).toHaveBeenCalledWith(p.sequence, {
|
||||
color: "green"
|
||||
});
|
||||
});
|
||||
|
||||
it("starts sequence move", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<FolderListItem {...p} />);
|
||||
wrapper.find(".fa-bars").simulate("mouseDown");
|
||||
expect(p.startSequenceMove).toHaveBeenCalledWith(p.sequence.uuid);
|
||||
});
|
||||
|
||||
it("toggles sequence move", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<FolderListItem {...p} />);
|
||||
wrapper.find(".fa-bars").simulate("mouseUp");
|
||||
expect(p.toggleSequenceMove).toHaveBeenCalledWith(p.sequence.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FolderButtonCluster />", () => {
|
||||
const fakeProps = (): FolderButtonClusterProps => ({
|
||||
node: fakeRootFolder(),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<FolderButtonCluster {...fakeProps()} />);
|
||||
expect(wrapper.find("button").length).toEqual(4);
|
||||
});
|
||||
|
||||
it("deletes folder", () => {
|
||||
const p = fakeProps();
|
||||
p.node.id = 1;
|
||||
const wrapper = mount(<FolderButtonCluster {...p} />);
|
||||
wrapper.find("button").at(0).simulate("click");
|
||||
expect(deleteFolder).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("edits folder", () => {
|
||||
const p = fakeProps();
|
||||
p.node.id = 1;
|
||||
const wrapper = mount(<FolderButtonCluster {...p} />);
|
||||
wrapper.find("button").at(1).simulate("click");
|
||||
expect(p.close).toHaveBeenCalled();
|
||||
expect(toggleFolderEditState).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("creates new folder", () => {
|
||||
const p = fakeProps();
|
||||
p.node.id = 1;
|
||||
const wrapper = mount(<FolderButtonCluster {...p} />);
|
||||
wrapper.find("button").at(2).simulate("click");
|
||||
expect(p.close).toHaveBeenCalled();
|
||||
expect(createFolder).toHaveBeenCalledWith({ parent_id: p.node.id });
|
||||
});
|
||||
|
||||
it("creates new sequence", () => {
|
||||
const p = fakeProps();
|
||||
p.node.id = 1;
|
||||
const wrapper = mount(<FolderButtonCluster {...p} />);
|
||||
wrapper.find("button").at(3).simulate("click");
|
||||
expect(p.close).toHaveBeenCalled();
|
||||
expect(addNewSequenceToFolder).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FolderNameInput />", () => {
|
||||
const fakeProps = (): FolderNameInputProps => ({
|
||||
node: fakeFolderNode(),
|
||||
});
|
||||
|
||||
it("edits folder name", () => {
|
||||
const p = fakeProps();
|
||||
p.node.editing = true;
|
||||
const wrapper = shallow(<FolderNameInput {...p} />);
|
||||
wrapper.find("BlurableInput").simulate("commit", {
|
||||
currentTarget: { value: "new name" }
|
||||
});
|
||||
expect(setFolderName).toHaveBeenCalledWith(p.node.id, "new name");
|
||||
});
|
||||
|
||||
it("closes folder name input", () => {
|
||||
const p = fakeProps();
|
||||
p.node.editing = true;
|
||||
const wrapper = shallow(<FolderNameInput {...p} />);
|
||||
wrapper.find("button").simulate("click");
|
||||
expect(toggleFolderEditState).toHaveBeenCalledWith(p.node.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FolderNameEditor />", () => {
|
||||
const fakeProps = (): FolderNodeProps => ({
|
||||
node: fakeRootFolder(),
|
||||
sequences: {},
|
||||
movedSequenceUuid: undefined,
|
||||
startSequenceMove: jest.fn(),
|
||||
toggleSequenceMove: jest.fn(),
|
||||
onMoveEnd: jest.fn(),
|
||||
dispatch: jest.fn(),
|
||||
resourceUsage: {},
|
||||
sequenceMetas: {},
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount(<FolderNameEditor {...p} />);
|
||||
expect(wrapper.text()).toContain("my folder");
|
||||
expect(wrapper.find(".fa-ellipsis-v").hasClass("open")).toBeFalsy();
|
||||
expect(wrapper.find(".fa-chevron-down").length).toEqual(1);
|
||||
expect(wrapper.find(".fa-chevron-right").length).toEqual(0);
|
||||
expect(wrapper.find(".folder-name-input").length).toEqual(0);
|
||||
});
|
||||
|
||||
it("renders: settings open", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount<FolderNameEditor>(<FolderNameEditor {...p} />);
|
||||
wrapper.setState({ settingsOpen: true });
|
||||
expect(wrapper.find(".fa-ellipsis-v").hasClass("open")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders: folder closed", () => {
|
||||
const p = fakeProps();
|
||||
p.node.open = false;
|
||||
const wrapper = mount(<FolderNameEditor {...p} />);
|
||||
expect(wrapper.find(".fa-chevron-down").length).toEqual(0);
|
||||
expect(wrapper.find(".fa-chevron-right").length).toEqual(1);
|
||||
});
|
||||
|
||||
it("renders: editing", () => {
|
||||
const p = fakeProps();
|
||||
p.node.editing = true;
|
||||
const wrapper = mount(<FolderNameEditor {...p} />);
|
||||
expect(wrapper.find(".folder-name-input").length).toEqual(1);
|
||||
});
|
||||
|
||||
it("closes folder", () => {
|
||||
const p = fakeProps();
|
||||
p.node.open = true;
|
||||
const wrapper = mount(<FolderNameEditor {...p} />);
|
||||
wrapper.find("i").first().simulate("click");
|
||||
expect(toggleFolderOpenState).toHaveBeenCalledWith(p.node.id);
|
||||
});
|
||||
|
||||
it("changes folder color", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<FolderNameEditor {...p} />);
|
||||
wrapper.find("ColorPicker").simulate("change", "green");
|
||||
expect(setFolderColor).toHaveBeenCalledWith(p.node.id, "green");
|
||||
});
|
||||
|
||||
it("opens settings menu", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow<FolderNameEditor>(<FolderNameEditor {...p} />);
|
||||
expect(wrapper.state().settingsOpen).toBeFalsy();
|
||||
wrapper.find("i").last().simulate("click");
|
||||
expect(wrapper.state().settingsOpen).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes settings menu", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount<FolderNameEditor>(<FolderNameEditor {...p} />);
|
||||
wrapper.setState({ settingsOpen: true });
|
||||
wrapper.instance().close();
|
||||
expect(wrapper.state().settingsOpen).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SequenceDropArea />", () => {
|
||||
const fakeProps = (): SequenceDropAreaProps => ({
|
||||
dropAreaVisible: true,
|
||||
onMoveEnd: jest.fn(),
|
||||
toggleSequenceMove: jest.fn(),
|
||||
folderId: 1,
|
||||
folderName: "my folder",
|
||||
});
|
||||
|
||||
it("shows drop area", () => {
|
||||
const p = fakeProps();
|
||||
p.dropAreaVisible = true;
|
||||
const wrapper = mount(<SequenceDropArea {...p} />);
|
||||
expect(wrapper.find(".folder-drop-area").hasClass("visible")).toBeTruthy();
|
||||
expect(wrapper.text().toLowerCase()).toContain("move into my folder");
|
||||
});
|
||||
|
||||
it("hides drop area", () => {
|
||||
const p = fakeProps();
|
||||
p.dropAreaVisible = false;
|
||||
const wrapper = mount(<SequenceDropArea {...p} />);
|
||||
expect(wrapper.find(".folder-drop-area").hasClass("visible")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("has 'remove from folders' text", () => {
|
||||
const p = fakeProps();
|
||||
p.dropAreaVisible = true;
|
||||
p.folderId = 0;
|
||||
const wrapper = mount(<SequenceDropArea {...p} />);
|
||||
expect(wrapper.find(".folder-drop-area").hasClass("visible")).toBeTruthy();
|
||||
expect(wrapper.text()).not.toContain("my folder");
|
||||
expect(wrapper.text().toLowerCase()).toContain("move out of folders");
|
||||
});
|
||||
|
||||
it("handles click", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<SequenceDropArea {...p} />);
|
||||
wrapper.find(".folder-drop-area").simulate("click");
|
||||
expect(p.onMoveEnd).toHaveBeenCalledWith(p.folderId);
|
||||
});
|
||||
|
||||
it("handles drop", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow<SequenceDropArea>(<SequenceDropArea {...p} />);
|
||||
wrapper.setState({ hovered: true });
|
||||
expect(wrapper.find(".folder-drop-area").hasClass("hovered")).toBeTruthy();
|
||||
wrapper.find(".folder-drop-area").simulate("drop");
|
||||
expect(wrapper.state().hovered).toBeFalsy();
|
||||
expect(dropSequence).toHaveBeenCalledWith(p.folderId);
|
||||
expect(p.toggleSequenceMove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles drag over", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<SequenceDropArea {...p} />);
|
||||
const e = { preventDefault: jest.fn() };
|
||||
wrapper.find(".folder-drop-area").simulate("dragOver", e);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles drag enter", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow<SequenceDropArea>(<SequenceDropArea {...p} />);
|
||||
wrapper.find(".folder-drop-area").simulate("dragEnter");
|
||||
expect(wrapper.state().hovered).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handles drag leave", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow<SequenceDropArea>(<SequenceDropArea {...p} />);
|
||||
wrapper.find(".folder-drop-area").simulate("dragLeave");
|
||||
expect(wrapper.state().hovered).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FolderPanelTop />", () => {
|
||||
const fakeProps = (): FolderPanelTopProps => ({
|
||||
searchTerm: "",
|
||||
toggleDirection: true,
|
||||
toggleAll: jest.fn(),
|
||||
});
|
||||
|
||||
it("changes search term", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<FolderPanelTop {...p} />);
|
||||
wrapper.find("input").simulate("change", {
|
||||
currentTarget: { value: "new" }
|
||||
});
|
||||
expect(updateSearchTerm).toHaveBeenCalledWith("new");
|
||||
});
|
||||
|
||||
it("creates new folder", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount(<FolderPanelTop {...p} />);
|
||||
wrapper.find("button").at(1).simulate("click");
|
||||
expect(createFolder).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("creates new sequence", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount(<FolderPanelTop {...p} />);
|
||||
wrapper.find("button").at(2).simulate("click");
|
||||
expect(addNewSequenceToFolder).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
import { FolderNode } from "../constants";
|
||||
import { ingest } from "../data_transfer";
|
||||
import { climb } from "../climb";
|
||||
|
||||
const FOLDERS: FolderNode[] = [
|
||||
{ id: 1, color: "blue", name: "Water stuff", parent_id: undefined },
|
||||
{ id: 2, color: "green", name: "Folder for growing things", parent_id: undefined },
|
||||
{ id: 3, color: "yellow", name: "subfolder", parent_id: 2 },
|
||||
{ id: 4, color: "gray", name: "tests", parent_id: undefined },
|
||||
{ id: 5, color: "pink", name: "deeply nested directory", parent_id: 3 }
|
||||
];
|
||||
const TREE = ingest({
|
||||
folders: FOLDERS,
|
||||
localMetaAttributes: {}
|
||||
});
|
||||
|
||||
describe("climb()", () => {
|
||||
it("traverses through the nodes", () => {
|
||||
const results: string[] = [];
|
||||
climb(TREE, (node) => results.push(node.color));
|
||||
expect(results.length).toBe(FOLDERS.length);
|
||||
expect(results.sort()).toEqual(FOLDERS.map(x => x.color).sort());
|
||||
});
|
||||
|
||||
it("halts a tree climb", () => {
|
||||
let count = 0;
|
||||
climb(TREE, (_node, halt) => {
|
||||
count += 1;
|
||||
if (count == 3) { halt(); }
|
||||
});
|
||||
expect(count).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("data transfer", () => {
|
||||
it("converts flat data into hierarchical data", () => {
|
||||
const { folders } = TREE;
|
||||
expect(folders.length).toEqual(3);
|
||||
// ├─ FOLDER FOR GROWING THINGS
|
||||
// │ └─ SUBFOLDER
|
||||
// │ └─ DEEPLY NESTED DIRECTORY
|
||||
// ├─ TESTS
|
||||
// └─ WATER STUFF
|
||||
|
||||
const l0 = folders[0];
|
||||
|
||||
// Level 0, first folder
|
||||
expect(l0.name).toEqual(FOLDERS[1].name);
|
||||
expect(l0.id).toEqual(FOLDERS[1].id);
|
||||
expect(l0.color).toEqual(FOLDERS[1].color);
|
||||
expect(l0.children.length).toEqual(1);
|
||||
|
||||
// Level 0, second folder
|
||||
const l0_2 = l0.children[0];
|
||||
expect(l0_2.name).toEqual(FOLDERS[2].name);
|
||||
expect(l0_2.color).toEqual(FOLDERS[2].color);
|
||||
expect((l0_2.children || []).length).toEqual(1);
|
||||
|
||||
// Level 0, third folder
|
||||
const l0_3 = l0_2.children[0];
|
||||
expect(l0_3.name).toEqual(FOLDERS[4].name);
|
||||
expect(l0_3.color).toEqual(FOLDERS[4].color);
|
||||
expect((l0_3.children || []).length).toEqual(0);
|
||||
|
||||
// Level 1, first folder
|
||||
expect(folders[1].name).toEqual(FOLDERS[3].name);
|
||||
expect(folders[1].color).toEqual(FOLDERS[3].color);
|
||||
expect(folders[1].children.length).toEqual(0);
|
||||
|
||||
// Level 2, first folder
|
||||
expect(folders[2].name).toEqual(FOLDERS[0].name);
|
||||
expect(folders[2].color).toEqual(FOLDERS[0].color);
|
||||
expect(folders[2].children.length).toEqual(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
import { mapStateToFolderProps } from "../map_state_to_props";
|
||||
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
|
||||
import { fakeFolder, fakeSequence } from "../../__test_support__/fake_state/resources";
|
||||
import { fakeState } from "../../__test_support__/fake_state";
|
||||
|
||||
describe("mapStateToFolderProps", () => {
|
||||
it("maps state to props", () => {
|
||||
const f1 = fakeFolder({ name: "@" });
|
||||
const f2 = fakeFolder({ name: "#", parent_id: f1.body.id });
|
||||
const f3 = fakeFolder({ name: "$", parent_id: f2.body.id });
|
||||
const state = fakeState();
|
||||
state.resources = buildResourceIndex([f1,
|
||||
f2,
|
||||
f3,
|
||||
fakeSequence({ name: "%", folder_id: f1.body.id }),
|
||||
fakeSequence({ name: "^", folder_id: f2.body.id }),
|
||||
fakeSequence({ name: "&", folder_id: f3.body.id }),
|
||||
fakeSequence({ name: "*", folder_id: undefined }),
|
||||
fakeSequence({ name: "!", folder_id: undefined })]);
|
||||
const props = mapStateToFolderProps(state);
|
||||
expect(props).toBeDefined();
|
||||
expect(props.rootFolder.folders.length).toBe(1);
|
||||
expect(props.rootFolder.noFolder.length).toBe(2);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,112 @@
|
|||
import { resourceReducer } from "../../resources/reducer";
|
||||
import { RestResources } from "../../resources/interfaces";
|
||||
import {
|
||||
fakeSequence,
|
||||
fakeFolder
|
||||
} from "../../__test_support__/fake_state/resources";
|
||||
import {
|
||||
buildResourceIndex
|
||||
} from "../../__test_support__/resource_index_builder";
|
||||
import { Actions } from "../../constants";
|
||||
|
||||
const f1 = fakeFolder({ name: "@" });
|
||||
const f2 = fakeFolder({ name: "#", parent_id: f1.body.id });
|
||||
const f3 = fakeFolder({ name: "$", parent_id: f2.body.id });
|
||||
const s1 = fakeSequence({ name: "%", folder_id: f1.body.id });
|
||||
const s2 = fakeSequence({ name: "^", folder_id: f2.body.id });
|
||||
const s3 = fakeSequence({ name: "&", folder_id: f3.body.id });
|
||||
const s4 = fakeSequence({ name: "*", folder_id: undefined });
|
||||
const s5 = fakeSequence({ name: "!", folder_id: undefined });
|
||||
|
||||
function initialState(): RestResources {
|
||||
return buildResourceIndex([f1, f2, f3, s1, s2, s3, s4, s5]);
|
||||
}
|
||||
|
||||
describe("Actions.FOLDER_TOGGLE", () => {
|
||||
it("toggles a folder's open state", () => {
|
||||
const state = initialState();
|
||||
const id = f1.body.id;
|
||||
const action = { type: Actions.FOLDER_TOGGLE, payload: { id } };
|
||||
const { index } = resourceReducer(state, action);
|
||||
const oldOpen =
|
||||
state.index.sequenceFolders.localMetaAttributes[f1.body.id || 0];
|
||||
const nextOpen = index.sequenceFolders.localMetaAttributes[f1.body.id || 0];
|
||||
|
||||
expect(oldOpen.open).not.toEqual(nextOpen.open);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Actions.FOLDER_TOGGLE_ALL", () => {
|
||||
it("toggles a folder's open state", () => {
|
||||
const state = initialState();
|
||||
[true, false].map(payload => {
|
||||
|
||||
const action = { type: Actions.FOLDER_TOGGLE_ALL, payload };
|
||||
const { index } = resourceReducer(state, action);
|
||||
[f1, f2, f3].map(f => {
|
||||
const nextOpen = index
|
||||
.sequenceFolders
|
||||
.localMetaAttributes[f.body.id || 0];
|
||||
expect(nextOpen.open).toEqual(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Actions.FOLDER_TOGGLE_EDIT", () => {
|
||||
it("toggles a folder's edit state", () => {
|
||||
const state = initialState();
|
||||
const id = f1.body.id;
|
||||
const action = { type: Actions.FOLDER_TOGGLE_EDIT, payload: { id } };
|
||||
const { index } = resourceReducer(state, action);
|
||||
const oldedit =
|
||||
state.index.sequenceFolders.localMetaAttributes[f1.body.id || 0];
|
||||
const nextedit = index.sequenceFolders.localMetaAttributes[f1.body.id || 0];
|
||||
expect(oldedit.editing).not.toEqual(nextedit.editing);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Actions.FOLDER_SEARCH", () => {
|
||||
it("searches folderless sequences", () => {
|
||||
const state = initialState();
|
||||
const action = { type: Actions.FOLDER_SEARCH, payload: "!" };
|
||||
const { index } = resourceReducer(state, action);
|
||||
const { sequenceFolders } = index;
|
||||
const { filteredFolders } = sequenceFolders;
|
||||
expect(sequenceFolders.searchTerm).toBe("!");
|
||||
const list = filteredFolders?.noFolder || [];
|
||||
expect(list.length).toBe(1);
|
||||
expect(list[0]).toBe(s5.uuid);
|
||||
});
|
||||
|
||||
it("searches folders", () => {
|
||||
const state = initialState();
|
||||
const action = { type: Actions.FOLDER_SEARCH, payload: "" };
|
||||
const { index } = resourceReducer(state, action);
|
||||
expect(index.sequenceFolders.filteredFolders).toBeUndefined();
|
||||
expect(index.sequenceFolders.searchTerm).toBe("");
|
||||
|
||||
const action2 = { type: Actions.FOLDER_SEARCH, payload: "" };
|
||||
const index2 = resourceReducer(state, action2).index;
|
||||
expect(index2.sequenceFolders.searchTerm).toBe("");
|
||||
expect(index2.sequenceFolders.filteredFolders).toBeUndefined();
|
||||
|
||||
const action3 = { type: Actions.FOLDER_SEARCH, payload: "&" };
|
||||
const index3 = resourceReducer(state, action3).index;
|
||||
expect(index3.sequenceFolders.searchTerm).toBe("&");
|
||||
expect(index3.sequenceFolders.filteredFolders).not.toBeUndefined();
|
||||
const { filteredFolders } = index3.sequenceFolders;
|
||||
if (filteredFolders) {
|
||||
expect(filteredFolders.noFolder.length).toEqual(0);
|
||||
expect(filteredFolders.folders.length).toEqual(1);
|
||||
const one = filteredFolders.folders[0];
|
||||
const two = one.children[0];
|
||||
const three = two.children[0];
|
||||
const four = three.content[0];
|
||||
expect(one.name).toBe("@");
|
||||
expect(two.name).toBe("#");
|
||||
expect(three.name).toBe("$");
|
||||
expect(four).toBe(s3.uuid);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -0,0 +1,165 @@
|
|||
import { TEST_GRAPH } from "./actions_test";
|
||||
import { searchFolderTree, FolderSearchProps } from "../search_folder_tree";
|
||||
import { TaggedResource } from "farmbot";
|
||||
import { FolderUnion } from "../constants";
|
||||
|
||||
describe("searchFolderTree", () => {
|
||||
const searchFor = (input: string) => searchFolderTree({
|
||||
references: {},
|
||||
input,
|
||||
root: TEST_GRAPH
|
||||
});
|
||||
|
||||
it("returns an empty result set when no match is found.", () => {
|
||||
const before = JSON.stringify(TEST_GRAPH);
|
||||
const results = searchFor("foo");
|
||||
const after = JSON.stringify(TEST_GRAPH);
|
||||
expect(results).toBeTruthy();
|
||||
expect(results.length).toEqual(0);
|
||||
expect(before).toEqual(after); // Prevent mutation of original data.
|
||||
});
|
||||
|
||||
it("finds an `inital` folder", () => {
|
||||
const results = searchFor("one").map(x => x.name);
|
||||
expect(results.length).toEqual(1);
|
||||
expect(results).toContain("One");
|
||||
const results2 = searchFor("Ten").map(x => x.name);
|
||||
expect(results2.length).toEqual(1);
|
||||
expect(results2).toContain("Ten");
|
||||
});
|
||||
|
||||
it("finds a `medial` folder", () => {
|
||||
const results = searchFor("seven").map(x => x.name);
|
||||
[ // === DIRECT MATCH:
|
||||
"Seven",
|
||||
"Seventeen",
|
||||
// == PARENTS
|
||||
"Six",
|
||||
"Sixteen",
|
||||
// == GRANDPARENTS
|
||||
"Fourteen"
|
||||
].map(x => expect(results).toContain(x));
|
||||
expect(results.length).toEqual(5);
|
||||
const results2 = searchFor("Eleven").map(x => x.name);
|
||||
["Eleven", "Ten"].map(x => expect(results2).toContain(x));
|
||||
expect(results2.length).toEqual(2);
|
||||
});
|
||||
|
||||
it("finds a `terminal` folder", () => {
|
||||
const results = searchFor("ighteen").map(x => x.name);
|
||||
["Eighteen", "Sixteen", "Fourteen"].map(x => expect(results).toContain(x));
|
||||
});
|
||||
|
||||
it("finds sequences in an `terminal` folder node", () => {
|
||||
const byName: Record<string, FolderUnion> = {};
|
||||
const results = searchFolderTree(fakeSearchProps("level three"));
|
||||
results.map(x => byName[x.name] = x);
|
||||
const folder1 = byName["First Folder"];
|
||||
const folder2 = byName["Second Folder"];
|
||||
const folder3 = byName["Third Folder"];
|
||||
const folder3seq = TEST_REFERENCES[folder3.content[0]];
|
||||
expect(results.length).toEqual(3);
|
||||
expect(folder3).toBeTruthy();
|
||||
expect(folder2).toBeTruthy();
|
||||
expect(folder1).toBeTruthy();
|
||||
expect(folder3.content.length).toBe(1);
|
||||
expect(folder3seq).toBeTruthy();
|
||||
expect(folder2.content.length).toBe(0);
|
||||
expect(folder1.content.length).toBe(0);
|
||||
});
|
||||
|
||||
it("finds sequences in an `medial` folder node", () => {
|
||||
const byName: Record<string, FolderUnion> = {};
|
||||
const results = searchFolderTree(fakeSearchProps("level two"));
|
||||
results.map(x => byName[x.name] = x);
|
||||
const folder1 = byName["First Folder"];
|
||||
const folder2 = byName["Second Folder"];
|
||||
const folder3 = byName["Third Folder"];
|
||||
const folder2seq = TEST_REFERENCES[folder2.content[0]];
|
||||
expect(results.length).toEqual(2);
|
||||
expect(folder3).toBeUndefined();
|
||||
expect(folder2).toBeTruthy();
|
||||
expect(folder1).toBeTruthy();
|
||||
expect(folder2.content.length).toBe(1);
|
||||
expect(folder2seq).toBeTruthy();
|
||||
expect(folder1.content.length).toBe(0);
|
||||
});
|
||||
|
||||
it("finds sequences in an `initial` folder node", () => {
|
||||
const byName: Record<string, FolderUnion> = {};
|
||||
const results = searchFolderTree(fakeSearchProps("level one"));
|
||||
results.map(x => byName[x.name] = x);
|
||||
const folder1 = byName["First Folder"];
|
||||
const folder2 = byName["Second Folder"];
|
||||
const folder3 = byName["Third Folder"];
|
||||
const folder1seq = TEST_REFERENCES[folder1.content[0]];
|
||||
expect(results.length).toEqual(1);
|
||||
expect(folder1seq).toBeTruthy();
|
||||
expect(folder3).toBeUndefined();
|
||||
expect(folder2).toBeUndefined();
|
||||
expect(folder1).toBeTruthy();
|
||||
expect(folder1.content.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
const TEST_REFERENCES = {
|
||||
"Sequence.65.10": {
|
||||
"kind": "Sequence",
|
||||
"body": { "id": 65, "folder_id": 54, "color": "gray", "name": "level one" },
|
||||
"uuid": "Sequence.65.10",
|
||||
"specialStatus": ""
|
||||
},
|
||||
"Sequence.66.11": {
|
||||
"kind": "Sequence",
|
||||
"body": { "id": 66, "folder_id": 55, "color": "gray", "name": "level two" },
|
||||
"uuid": "Sequence.66.11",
|
||||
"specialStatus": ""
|
||||
},
|
||||
"Sequence.67.12": {
|
||||
"kind": "Sequence",
|
||||
"body": { "id": 67, "folder_id": 57, "color": "gray", "name": "level three" },
|
||||
"uuid": "Sequence.67.12",
|
||||
"specialStatus": ""
|
||||
}
|
||||
} as unknown as Record<string, TaggedResource | undefined>;
|
||||
|
||||
const fakeSearchProps = (input: string): FolderSearchProps => ({
|
||||
input,
|
||||
references: TEST_REFERENCES,
|
||||
root: {
|
||||
noFolder: [],
|
||||
folders: [
|
||||
{
|
||||
"id": 54,
|
||||
"color": "blue",
|
||||
"name": "First Folder",
|
||||
"kind": "initial",
|
||||
"open": true,
|
||||
"editing": false,
|
||||
"children": [
|
||||
{
|
||||
"id": 55,
|
||||
"color": "yellow",
|
||||
"name": "Second Folder",
|
||||
"kind": "medial",
|
||||
"open": true,
|
||||
"editing": false,
|
||||
"children": [
|
||||
{
|
||||
"id": 57,
|
||||
"color": "purple",
|
||||
"name": "Third Folder",
|
||||
"kind": "terminal",
|
||||
"content": ["Sequence.67.12"],
|
||||
"open": true,
|
||||
"editing": false
|
||||
}
|
||||
],
|
||||
"content": ["Sequence.66.11"]
|
||||
}
|
||||
],
|
||||
"content": ["Sequence.65.10"]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
|
@ -0,0 +1,125 @@
|
|||
import { RootFolderNode as Tree } from "./constants";
|
||||
import { cloneAndClimb } from "./climb";
|
||||
import { Color, SpecialStatus, TaggedSequence } from "farmbot";
|
||||
import { store } from "../redux/store";
|
||||
import { initSave, destroy, edit, save, init } from "../api/crud";
|
||||
import { Folder } from "farmbot/dist/resources/api_resources";
|
||||
import { DeepPartial } from "redux";
|
||||
import { findFolderById } from "../resources/selectors_by_id";
|
||||
import { Actions } from "../constants";
|
||||
import { t } from "../i18next_wrapper";
|
||||
import { push } from "../history";
|
||||
import { urlFriendly } from "../util";
|
||||
import { setActiveSequenceByName } from "../sequences/set_active_sequence_by_name";
|
||||
import { stepGet, STEP_DATATRANSFER_IDENTIFER } from "../draggable/actions";
|
||||
import { joinKindAndId } from "../resources/reducer_support";
|
||||
import { maybeGetSequence } from "../resources/selectors";
|
||||
|
||||
type TreePromise = Promise<Tree>;
|
||||
|
||||
export const collapseAll = (tree: Tree): TreePromise => {
|
||||
return Promise.resolve(cloneAndClimb(tree, (node) => {
|
||||
node.open = false;
|
||||
}));
|
||||
};
|
||||
|
||||
export const setFolderColor = (id: number, color: Color) => {
|
||||
const d = store.dispatch as Function;
|
||||
const f = findFolderById(store.getState().resources.index, id);
|
||||
|
||||
d(edit(f, { color }));
|
||||
d(save(f.uuid));
|
||||
};
|
||||
|
||||
export const setFolderName = (id: number, name: string) => {
|
||||
const d = store.dispatch as Function;
|
||||
const { index } = store.getState().resources;
|
||||
const folder = findFolderById(index, id);
|
||||
const action = edit(folder, { name });
|
||||
d(action);
|
||||
return d(save(folder.uuid)) as Promise<{}>;
|
||||
};
|
||||
|
||||
const DEFAULTS: Folder = {
|
||||
name: "New Folder",
|
||||
color: "gray",
|
||||
parent_id: 0,
|
||||
};
|
||||
|
||||
export const addNewSequenceToFolder = (folder_id?: number) => {
|
||||
const uuidMap = store.getState().resources.index.byKind["Sequence"];
|
||||
const seqCount = Object.keys(uuidMap).length;
|
||||
const newSequence = {
|
||||
name: t("new sequence {{ num }}", { num: seqCount }),
|
||||
args: {
|
||||
version: -999,
|
||||
locals: { kind: "scope_declaration", args: {} },
|
||||
},
|
||||
color: "gray",
|
||||
folder_id,
|
||||
kind: "sequence",
|
||||
body: []
|
||||
};
|
||||
store.dispatch(init("Sequence", newSequence));
|
||||
push("/app/sequences/" + urlFriendly(newSequence.name));
|
||||
setActiveSequenceByName();
|
||||
};
|
||||
|
||||
export const createFolder = (config: DeepPartial<Folder> = {}) => {
|
||||
const d: Function = store.dispatch;
|
||||
const folder: Folder = { ...DEFAULTS, ...config };
|
||||
const action = initSave("Folder", folder);
|
||||
// tslint:disable-next-line:no-any
|
||||
const p: Promise<{}> = d(action);
|
||||
return p;
|
||||
};
|
||||
|
||||
export const deleteFolder = (id: number) => {
|
||||
const d: Function = store.dispatch;
|
||||
const { index } = store.getState().resources;
|
||||
const folder = findFolderById(index, id);
|
||||
const action = destroy(folder.uuid);
|
||||
|
||||
return d(action) as ReturnType<typeof action>;
|
||||
};
|
||||
|
||||
export const updateSearchTerm = (payload: string | undefined) =>
|
||||
store.dispatch({ type: Actions.FOLDER_SEARCH, payload });
|
||||
|
||||
export const toggleFolderOpenState = (id: number) =>
|
||||
store.dispatch({ type: Actions.FOLDER_TOGGLE, payload: { id } });
|
||||
|
||||
export const toggleFolderEditState = (id: number) =>
|
||||
store.dispatch({ type: Actions.FOLDER_TOGGLE_EDIT, payload: { id } });
|
||||
|
||||
export const toggleAll = (payload: boolean) =>
|
||||
store.dispatch({ type: Actions.FOLDER_TOGGLE_ALL, payload });
|
||||
|
||||
export const sequenceEditMaybeSave =
|
||||
(sequence: TaggedSequence, update: Partial<TaggedSequence["body"]>) => {
|
||||
const dispatch: Function = store.dispatch;
|
||||
dispatch(edit(sequence, update));
|
||||
if (sequence.specialStatus == SpecialStatus.SAVED) {
|
||||
dispatch(save(sequence.uuid));
|
||||
}
|
||||
};
|
||||
|
||||
export function moveSequence(sequenceUuid: string, folder_id: number) {
|
||||
const s = store.getState().resources.index.references[sequenceUuid];
|
||||
if (s && s.kind === "Sequence") {
|
||||
sequenceEditMaybeSave(s, { folder_id });
|
||||
}
|
||||
}
|
||||
|
||||
export const dropSequence = (folder_id: number) =>
|
||||
(e: React.DragEvent<HTMLElement>) => {
|
||||
const key = e.dataTransfer.getData(STEP_DATATRANSFER_IDENTIFER);
|
||||
const dispatch: Function = store.dispatch;
|
||||
const dataXferObj = dispatch(stepGet(key));
|
||||
const { sequence_id } = dataXferObj.value.args;
|
||||
const ri = store.getState().resources.index;
|
||||
const seqUuid = dataXferObj.resourceUuid ||
|
||||
ri.byKindAndId[joinKindAndId("Sequence", sequence_id)];
|
||||
const sequence = maybeGetSequence(ri, seqUuid);
|
||||
if (sequence) { sequenceEditMaybeSave(sequence, { folder_id }); }
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import { RootFolderNode, FolderUnion, FolderNodeMedial, FolderNodeInitial } from "./constants";
|
||||
import { defensiveClone } from "../util";
|
||||
|
||||
interface TreeClimberState {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface VisitorProps {
|
||||
node: FolderNodeInitial | FolderNodeMedial;
|
||||
callback: TreeClimber;
|
||||
halt: Halt;
|
||||
state: TreeClimberState;
|
||||
}
|
||||
|
||||
type Halt = () => RootFolderNode;
|
||||
type TreeClimber = (t: FolderUnion,
|
||||
/** Calling this function stops tree climb from continuing. */
|
||||
halt: Halt) => void;
|
||||
|
||||
function visit(p: VisitorProps) {
|
||||
const { node, callback, halt } = p;
|
||||
|
||||
callback(node, halt);
|
||||
const children: FolderUnion[] = (node.children);
|
||||
return p.state.active && children.map(nextNode => {
|
||||
if (nextNode.kind != "terminal") {
|
||||
p.state.active && visit({ ...p, node: nextNode });
|
||||
} else {
|
||||
p.state.active && callback(nextNode, p.halt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Recursively climb a directory structure. */
|
||||
export const climb = (t: RootFolderNode, callback: TreeClimber) => {
|
||||
const state: TreeClimberState = { active: true };
|
||||
const halt = () => {
|
||||
state.active = false;
|
||||
return t;
|
||||
};
|
||||
t.folders.map((node) => {
|
||||
const props = { node, callback, halt, state };
|
||||
state.active && visit(props);
|
||||
});
|
||||
return t;
|
||||
};
|
||||
|
||||
/** TODO: Create strategies for non-destructively
|
||||
* transforming a RootFolderNode. */
|
||||
export const cloneAndClimb = (t: RootFolderNode, callback: TreeClimber) => {
|
||||
return climb(defensiveClone(t), callback);
|
||||
};
|
|
@ -0,0 +1,336 @@
|
|||
import React from "react";
|
||||
import {
|
||||
BlurableInput,
|
||||
EmptyStateWrapper,
|
||||
EmptyStateGraphic,
|
||||
ColorPicker,
|
||||
} from "../ui";
|
||||
import {
|
||||
FolderUnion,
|
||||
FolderItemProps,
|
||||
FolderNodeProps,
|
||||
FolderProps,
|
||||
FolderState,
|
||||
AddFolderBtn,
|
||||
AddSequenceProps,
|
||||
ToggleFolderBtnProps,
|
||||
FolderNodeState,
|
||||
FolderPanelTopProps,
|
||||
SequenceDropAreaProps,
|
||||
FolderButtonClusterProps,
|
||||
FolderNameInputProps,
|
||||
SequenceDropAreaState,
|
||||
} from "./constants";
|
||||
import {
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
setFolderName,
|
||||
toggleFolderOpenState,
|
||||
toggleFolderEditState,
|
||||
toggleAll,
|
||||
updateSearchTerm,
|
||||
addNewSequenceToFolder,
|
||||
moveSequence,
|
||||
setFolderColor,
|
||||
dropSequence,
|
||||
sequenceEditMaybeSave,
|
||||
} from "./actions";
|
||||
import { Link } from "../link";
|
||||
import { urlFriendly, lastUrlChunk } from "../util";
|
||||
import {
|
||||
setActiveSequenceByName
|
||||
} from "../sequences/set_active_sequence_by_name";
|
||||
import { Popover } from "@blueprintjs/core";
|
||||
import { t } from "../i18next_wrapper";
|
||||
import { Content } from "../constants";
|
||||
import { StepDragger, NULL_DRAGGER_ID } from "../draggable/step_dragger";
|
||||
import { variableList } from "../sequences/locals_list/variable_support";
|
||||
import { UUID } from "../resources/interfaces";
|
||||
|
||||
export const FolderListItem = (props: FolderItemProps) => {
|
||||
const { sequence, movedSequenceUuid } = props;
|
||||
const seqName = sequence.body.name;
|
||||
const url = `/app/sequences/${urlFriendly(seqName) || ""}`;
|
||||
const moveSource = movedSequenceUuid === sequence.uuid ? "move-source" : "";
|
||||
const nameWithSaveIndicator = seqName + (sequence.specialStatus ? "*" : "");
|
||||
const active = lastUrlChunk() === urlFriendly(seqName) ? "active" : "";
|
||||
return <StepDragger
|
||||
dispatch={props.dispatch}
|
||||
step={{
|
||||
kind: "execute",
|
||||
args: { sequence_id: props.sequence.body.id || 0 },
|
||||
body: variableList(props.variableData)
|
||||
}}
|
||||
intent="step_splice"
|
||||
draggerId={NULL_DRAGGER_ID}
|
||||
resourceUuid={sequence.uuid}>
|
||||
<li className={`sequence-list-item ${active} ${moveSource}`}
|
||||
draggable={true}>
|
||||
<ColorPicker
|
||||
current={sequence.body.color || "gray"}
|
||||
onChange={color => sequenceEditMaybeSave(sequence, { color })} />
|
||||
<Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}>
|
||||
<p>{nameWithSaveIndicator}</p>
|
||||
</Link>
|
||||
<div className="sequence-list-item-icons">
|
||||
{props.inUse &&
|
||||
<i className="in-use fa fa-hdd-o" title={t(Content.IN_USE)} />}
|
||||
<i className="fa fa-bars"
|
||||
onMouseDown={() => props.startSequenceMove(sequence.uuid)}
|
||||
onMouseUp={() => props.toggleSequenceMove(sequence.uuid)} />
|
||||
</div>
|
||||
</li>
|
||||
</StepDragger >;
|
||||
};
|
||||
|
||||
const ToggleFolderBtn = (props: ToggleFolderBtnProps) => {
|
||||
return <button className="fb-button gray" onClick={props.onClick}>
|
||||
<i className={`fa fa-chevron-${props.expanded ? "right" : "down"}`} />
|
||||
</button>;
|
||||
};
|
||||
|
||||
const AddFolderBtn = ({ folder, close }: AddFolderBtn) => {
|
||||
return <button
|
||||
className="fb-button green"
|
||||
onClick={() => { close?.(); createFolder(folder); }}>
|
||||
<div className="fa-stack fa-2x" title={"Create Subfolder"}>
|
||||
<i className="fa fa-folder fa-stack-2x" />
|
||||
<i className="fa fa-plus fa-stack-1x" />
|
||||
</div>
|
||||
</button>;
|
||||
};
|
||||
|
||||
const AddSequenceBtn = ({ folderId, close }: AddSequenceProps) => {
|
||||
return <button
|
||||
className="fb-button green"
|
||||
onClick={() => { close?.(); addNewSequenceToFolder(folderId); }}>
|
||||
<div className="fa-stack fa-2x">
|
||||
<i className="fa fa-server fa-stack-2x" />
|
||||
<i className="fa fa-plus fa-stack-1x" />
|
||||
</div>
|
||||
</button>;
|
||||
};
|
||||
|
||||
export const FolderButtonCluster =
|
||||
({ node, close }: FolderButtonClusterProps) => {
|
||||
return <div className="folder-button-cluster">
|
||||
<button
|
||||
className="fb-button red"
|
||||
onClick={() => deleteFolder(node.id)}>
|
||||
<i className="fa fa-trash" />
|
||||
</button>
|
||||
<button
|
||||
className="fb-button gray"
|
||||
onClick={() => { close(); toggleFolderEditState(node.id); }}>
|
||||
<i className="fa fa-pencil" />
|
||||
</button>
|
||||
{node.kind !== "terminal" &&
|
||||
<AddFolderBtn folder={{ parent_id: node.id }} close={close} />}
|
||||
<AddSequenceBtn folderId={node.id} close={close} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const FolderNameInput = ({ node }: FolderNameInputProps) =>
|
||||
<div className="folder-name-input">
|
||||
<BlurableInput value={node.name} onCommit={e =>
|
||||
setFolderName(node.id, e.currentTarget.value)} />
|
||||
<button
|
||||
className="fb-button green"
|
||||
onClick={() => toggleFolderEditState(node.id)}>
|
||||
<i className="fa fa-check" />
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
export class FolderNameEditor
|
||||
extends React.Component<FolderNodeProps, FolderNodeState> {
|
||||
state: FolderNodeState = { settingsOpen: false };
|
||||
close = () => this.setState({ settingsOpen: false });
|
||||
render() {
|
||||
const { node } = this.props;
|
||||
const settingsOpenClass = this.state.settingsOpen ? "open" : "";
|
||||
return <div className={"folder-list-item"}>
|
||||
<i className={`fa fa-chevron-${node.open ? "down" : "right"}`}
|
||||
title={"Open/Close Folder"}
|
||||
onClick={() => toggleFolderOpenState(node.id)} />
|
||||
<ColorPicker
|
||||
saucerIcon={"fa-folder"}
|
||||
current={node.color}
|
||||
onChange={color => setFolderColor(node.id, color)} />
|
||||
<div className="folder-name">
|
||||
{node.editing
|
||||
? <FolderNameInput node={node} />
|
||||
: <p>{node.name}</p>}
|
||||
</div>
|
||||
<Popover className="folder-settings-icon" usePortal={false}
|
||||
isOpen={this.state.settingsOpen}>
|
||||
<i className={`fa fa-ellipsis-v ${settingsOpenClass}`}
|
||||
onClick={() =>
|
||||
this.setState({ settingsOpen: !this.state.settingsOpen })} />
|
||||
<FolderButtonCluster {...this.props} close={this.close} />
|
||||
</Popover>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const FolderNode = (props: FolderNodeProps) => {
|
||||
const { node, sequences } = props;
|
||||
|
||||
const sequenceItems = node.content.map(seqUuid =>
|
||||
<FolderListItem
|
||||
sequence={sequences[seqUuid]}
|
||||
key={"F" + seqUuid}
|
||||
dispatch={props.dispatch}
|
||||
variableData={props.sequenceMetas[seqUuid]}
|
||||
inUse={!!props.resourceUsage[seqUuid]}
|
||||
toggleSequenceMove={props.toggleSequenceMove}
|
||||
startSequenceMove={props.startSequenceMove}
|
||||
movedSequenceUuid={props.movedSequenceUuid} />);
|
||||
|
||||
const childFolders: FolderUnion[] = node.children || [];
|
||||
const folderNodes = childFolders.map(folder =>
|
||||
<FolderNode
|
||||
node={folder}
|
||||
key={folder.id}
|
||||
sequences={sequences}
|
||||
dispatch={props.dispatch}
|
||||
sequenceMetas={props.sequenceMetas}
|
||||
resourceUsage={props.resourceUsage}
|
||||
movedSequenceUuid={props.movedSequenceUuid}
|
||||
toggleSequenceMove={props.toggleSequenceMove}
|
||||
startSequenceMove={props.startSequenceMove}
|
||||
onMoveEnd={props.onMoveEnd} />);
|
||||
|
||||
return <div className="folder">
|
||||
<FolderNameEditor {...props} />
|
||||
{!!node.open && <ul className="in-folder-sequences">{sequenceItems}</ul>}
|
||||
<SequenceDropArea
|
||||
dropAreaVisible={!!props.movedSequenceUuid}
|
||||
onMoveEnd={props.onMoveEnd}
|
||||
toggleSequenceMove={props.toggleSequenceMove}
|
||||
folderId={node.id}
|
||||
folderName={node.name} />
|
||||
{!!node.open && folderNodes}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export class SequenceDropArea
|
||||
extends React.Component<SequenceDropAreaProps, SequenceDropAreaState> {
|
||||
state: SequenceDropAreaState = { hovered: false };
|
||||
render() {
|
||||
const { dropAreaVisible, folderId, onMoveEnd, folderName } = this.props;
|
||||
const visible = dropAreaVisible ? "visible" : "";
|
||||
const hovered = this.state.hovered ? "hovered" : "";
|
||||
return <div
|
||||
className={`folder-drop-area ${visible} ${hovered}`}
|
||||
onClick={() => onMoveEnd(folderId)}
|
||||
onDrop={e => {
|
||||
this.setState({ hovered: false });
|
||||
dropSequence(folderId)(e);
|
||||
this.props.toggleSequenceMove();
|
||||
}}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDragEnter={() => this.setState({ hovered: true })}
|
||||
onDragLeave={() => this.setState({ hovered: false })}>
|
||||
{folderId
|
||||
? `${t("Move into")} ${folderName}`
|
||||
: t("Move out of folders")}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export class Folders extends React.Component<FolderProps, FolderState> {
|
||||
state: FolderState = { toggleDirection: false };
|
||||
|
||||
Graph = () => {
|
||||
return <div className="folders">
|
||||
{this.props.rootFolder.folders.map(grandparent => {
|
||||
return <FolderNode
|
||||
node={grandparent}
|
||||
key={grandparent.id}
|
||||
dispatch={this.props.dispatch}
|
||||
sequenceMetas={this.props.sequenceMetas}
|
||||
resourceUsage={this.props.resourceUsage}
|
||||
movedSequenceUuid={this.state.movedSequenceUuid}
|
||||
toggleSequenceMove={this.toggleSequenceMove}
|
||||
startSequenceMove={this.startSequenceMove}
|
||||
onMoveEnd={this.endSequenceMove}
|
||||
sequences={this.props.sequences} />;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
toggleAll = () => {
|
||||
toggleAll(this.state.toggleDirection);
|
||||
this.setState({ toggleDirection: !this.state.toggleDirection });
|
||||
}
|
||||
|
||||
startSequenceMove = (seqUuid: UUID) => this.setState({
|
||||
movedSequenceUuid: seqUuid,
|
||||
stashedUuid: this.state.movedSequenceUuid,
|
||||
})
|
||||
|
||||
toggleSequenceMove = (seqUuid?: UUID) => this.setState({
|
||||
movedSequenceUuid: this.state.stashedUuid ? undefined : seqUuid,
|
||||
})
|
||||
|
||||
endSequenceMove = (folderId: number) => {
|
||||
moveSequence(this.state.movedSequenceUuid || "", folderId);
|
||||
this.setState({ movedSequenceUuid: undefined });
|
||||
}
|
||||
|
||||
rootSequences = () => this.props.rootFolder.noFolder.map(seqUuid =>
|
||||
<FolderListItem
|
||||
key={seqUuid}
|
||||
dispatch={this.props.dispatch}
|
||||
variableData={this.props.sequenceMetas[seqUuid]}
|
||||
inUse={!!this.props.resourceUsage[seqUuid]}
|
||||
sequence={this.props.sequences[seqUuid]}
|
||||
toggleSequenceMove={this.toggleSequenceMove}
|
||||
startSequenceMove={this.startSequenceMove}
|
||||
movedSequenceUuid={this.state.movedSequenceUuid} />);
|
||||
|
||||
render() {
|
||||
return <div className="folders-panel">
|
||||
<FolderPanelTop
|
||||
searchTerm={this.props.searchTerm}
|
||||
toggleDirection={this.state.toggleDirection}
|
||||
toggleAll={this.toggleAll} />
|
||||
<EmptyStateWrapper
|
||||
notEmpty={Object.values(this.props.sequences).length > 0
|
||||
|| this.props.rootFolder.folders.length > 0}
|
||||
graphic={EmptyStateGraphic.sequences}
|
||||
title={t("No Sequences.")}
|
||||
text={Content.NO_SEQUENCES}>
|
||||
<ul className="sequences-not-in-folders">
|
||||
{this.rootSequences()}
|
||||
</ul>
|
||||
<SequenceDropArea
|
||||
dropAreaVisible={!!this.state.movedSequenceUuid}
|
||||
onMoveEnd={this.endSequenceMove}
|
||||
toggleSequenceMove={this.toggleSequenceMove}
|
||||
folderId={0}
|
||||
folderName={"none"} />
|
||||
<this.Graph />
|
||||
</EmptyStateWrapper>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export const FolderPanelTop = (props: FolderPanelTopProps) =>
|
||||
<div className="panel-top with-button">
|
||||
<div className="thin-search-wrapper">
|
||||
<div className="text-input-wrapper">
|
||||
<i className="fa fa-search" />
|
||||
<input
|
||||
value={props.searchTerm || ""}
|
||||
onChange={e => updateSearchTerm(e.currentTarget.value)}
|
||||
type="text"
|
||||
placeholder={t("Search sequences")} />
|
||||
</div>
|
||||
</div>
|
||||
<ToggleFolderBtn
|
||||
expanded={props.toggleDirection}
|
||||
onClick={props.toggleAll} />
|
||||
<AddFolderBtn />
|
||||
<AddSequenceBtn />
|
||||
</div>;
|
|
@ -0,0 +1,141 @@
|
|||
import { Color } from "farmbot/dist/corpus";
|
||||
import { TaggedSequence } from "farmbot";
|
||||
import { DeepPartial } from "redux";
|
||||
import { Folder } from "farmbot/dist/resources/api_resources";
|
||||
import { VariableNameSet, UUID } from "../resources/interfaces";
|
||||
|
||||
export interface FolderMeta {
|
||||
open: boolean;
|
||||
editing: boolean;
|
||||
sequences: string[];
|
||||
}
|
||||
|
||||
interface FolderUI {
|
||||
id: number;
|
||||
name: string;
|
||||
/** We can change this to `TaggedResource` later.
|
||||
* Not going to optimize prematurely -RC */
|
||||
content: string[];
|
||||
color: Color;
|
||||
open: boolean;
|
||||
editing: boolean;
|
||||
}
|
||||
|
||||
/** A top-level directory */
|
||||
export interface FolderNodeInitial extends FolderUI {
|
||||
kind: "initial";
|
||||
children: FolderNodeMedial[];
|
||||
}
|
||||
|
||||
/** A mid-level directory. */
|
||||
export interface FolderNodeMedial extends FolderUI {
|
||||
kind: "medial";
|
||||
children: FolderNodeTerminal[];
|
||||
}
|
||||
|
||||
/** A leaf node on the directory tree.
|
||||
* Never has a child */
|
||||
export interface FolderNodeTerminal extends FolderUI {
|
||||
kind: "terminal";
|
||||
children?: undefined;
|
||||
}
|
||||
|
||||
export type FolderUnion =
|
||||
| FolderNodeInitial
|
||||
| FolderNodeMedial
|
||||
| FolderNodeTerminal;
|
||||
|
||||
export interface RootFolderNode {
|
||||
folders: FolderNodeInitial[];
|
||||
noFolder: string[];
|
||||
}
|
||||
|
||||
export interface FolderNode {
|
||||
id: number;
|
||||
color: Color;
|
||||
parent_id?: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FolderProps {
|
||||
rootFolder: RootFolderNode;
|
||||
sequences: Record<string, TaggedSequence>;
|
||||
searchTerm: string | undefined;
|
||||
dispatch: Function;
|
||||
resourceUsage: Record<UUID, boolean | undefined>;
|
||||
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
|
||||
}
|
||||
|
||||
export interface FolderNodeState {
|
||||
settingsOpen: boolean;
|
||||
}
|
||||
|
||||
export interface FolderState {
|
||||
toggleDirection: boolean;
|
||||
movedSequenceUuid?: string;
|
||||
stashedUuid?: string;
|
||||
}
|
||||
|
||||
export interface FolderPanelTopProps {
|
||||
searchTerm: string | undefined;
|
||||
toggleDirection: boolean;
|
||||
toggleAll(): void;
|
||||
}
|
||||
|
||||
export interface FolderNodeProps {
|
||||
node: FolderUnion;
|
||||
sequences: Record<string, TaggedSequence>;
|
||||
movedSequenceUuid: string | undefined;
|
||||
startSequenceMove(sequenceUuid: UUID): void;
|
||||
toggleSequenceMove(sequenceUuid?: UUID): void;
|
||||
onMoveEnd(folderId: number): void;
|
||||
dispatch: Function;
|
||||
resourceUsage: Record<UUID, boolean | undefined>;
|
||||
sequenceMetas: Record<UUID, VariableNameSet | undefined>;
|
||||
}
|
||||
|
||||
export interface FolderButtonClusterProps {
|
||||
node: FolderUnion;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface FolderNameInputProps {
|
||||
node: FolderUnion;
|
||||
}
|
||||
|
||||
export interface FolderItemProps {
|
||||
startSequenceMove(sequenceUuid: UUID): void;
|
||||
toggleSequenceMove(sequenceUuid?: UUID): void;
|
||||
sequence: TaggedSequence;
|
||||
movedSequenceUuid: UUID | undefined;
|
||||
dispatch: Function;
|
||||
variableData: VariableNameSet | undefined;
|
||||
inUse: boolean;
|
||||
}
|
||||
|
||||
export interface SequenceDropAreaProps {
|
||||
dropAreaVisible: boolean;
|
||||
onMoveEnd(id: number): void;
|
||||
toggleSequenceMove(sequenceUuid?: UUID): void;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
export interface SequenceDropAreaState {
|
||||
hovered: boolean;
|
||||
}
|
||||
|
||||
export interface AddFolderBtn {
|
||||
folder?: DeepPartial<Folder>;
|
||||
close?(): void;
|
||||
}
|
||||
|
||||
export interface AddSequenceProps {
|
||||
folderId?: number;
|
||||
close?(): void;
|
||||
}
|
||||
|
||||
export interface ToggleFolderBtnProps {
|
||||
expanded: boolean;
|
||||
onClick(): void;
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
FolderNode,
|
||||
FolderNodeMedial,
|
||||
FolderNodeTerminal,
|
||||
RootFolderNode,
|
||||
FolderMeta,
|
||||
} from "./constants";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
type FoldersIndexedByParentId = Record<number, FolderNode[]>;
|
||||
|
||||
/** Set empty `parent_id` to -1 to increase index simplicity. */
|
||||
const setDefaultParentId = (input: FolderNode): Required<FolderNode> => {
|
||||
return { ...input, parent_id: input.parent_id || -1 };
|
||||
};
|
||||
|
||||
type AddToIndex = (a: FoldersIndexedByParentId, i: Required<FolderNode>) =>
|
||||
Record<number, FolderNode[] | undefined>;
|
||||
const addToIndex: AddToIndex = (accumulator, item) => {
|
||||
const key = item.parent_id;
|
||||
const lastValue: FolderNode[] = accumulator[key] || [];
|
||||
const nextValue: FolderNode[] = [...lastValue, item];
|
||||
return { ...accumulator, [key]: nextValue };
|
||||
};
|
||||
|
||||
const emptyIndex: FoldersIndexedByParentId = {};
|
||||
|
||||
export const PARENTLESS = -1;
|
||||
type IngestFn =
|
||||
(props: IngestFnProps) => RootFolderNode;
|
||||
|
||||
interface IngestFnProps {
|
||||
folders: FolderNode[];
|
||||
localMetaAttributes: Record<number, FolderMeta>;
|
||||
}
|
||||
|
||||
export const ingest: IngestFn = ({ folders, localMetaAttributes }) => {
|
||||
const output: RootFolderNode = {
|
||||
folders: [],
|
||||
noFolder: (localMetaAttributes[PARENTLESS] || {}).sequences || []
|
||||
};
|
||||
const index = folders.map(setDefaultParentId).reduce(addToIndex, emptyIndex);
|
||||
const childrenOf = (i: number) => sortBy(index[i] || [], (x) => x.name.toLowerCase());
|
||||
|
||||
const terminal = (x: FolderNode): FolderNodeTerminal => ({
|
||||
...x,
|
||||
kind: "terminal",
|
||||
content: (localMetaAttributes[x.id] || {}).sequences || [],
|
||||
open: true,
|
||||
editing: false,
|
||||
// children: [],
|
||||
...(localMetaAttributes[x.id] || {})
|
||||
});
|
||||
|
||||
const medial = (x: FolderNode): FolderNodeMedial => ({
|
||||
...x,
|
||||
kind: "medial",
|
||||
open: true,
|
||||
editing: false,
|
||||
children: childrenOf(x.id).map(terminal),
|
||||
content: (localMetaAttributes[x.id] || {}).sequences || [],
|
||||
...(localMetaAttributes[x.id] || {})
|
||||
});
|
||||
|
||||
childrenOf(-1).map((root) => {
|
||||
const children = childrenOf(root.id).map(medial);
|
||||
return output.folders.push({
|
||||
...root,
|
||||
kind: "initial",
|
||||
open: true,
|
||||
editing: false,
|
||||
children,
|
||||
content: (localMetaAttributes[root.id] || {}).sequences || [],
|
||||
...(localMetaAttributes[root.id] || {})
|
||||
});
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import { FolderProps } from "./constants";
|
||||
import { selectAllSequences } from "../resources/selectors";
|
||||
import { TaggedSequence } from "farmbot";
|
||||
import { resourceUsageList } from "../resources/in_use";
|
||||
import { Everything } from "../interfaces";
|
||||
type SequenceDict = Record<string, TaggedSequence>;
|
||||
type Reducer = (a: FolderProps["sequences"], b: TaggedSequence) => SequenceDict;
|
||||
|
||||
const reduce: Reducer = (a, b) => {
|
||||
a[b.uuid] = b;
|
||||
return a;
|
||||
};
|
||||
|
||||
export function mapStateToFolderProps(props: Everything): FolderProps {
|
||||
const x = props.resources.index.sequenceFolders;
|
||||
|
||||
return {
|
||||
rootFolder: x.filteredFolders ? x.filteredFolders : x.folders,
|
||||
sequences: selectAllSequences(props.resources.index).reduce(reduce, {}),
|
||||
searchTerm: x.searchTerm,
|
||||
dispatch: props.dispatch,
|
||||
sequenceMetas: props.resources.index.sequenceMetas,
|
||||
resourceUsage: resourceUsageList(props.resources.index.inUse),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
import { TaggedResource, TaggedSequence } from "farmbot";
|
||||
|
||||
import { RootFolderNode, FolderUnion } from "./constants";
|
||||
|
||||
export interface FolderSearchProps {
|
||||
references: Record<string, TaggedResource | undefined>;
|
||||
input: string;
|
||||
root: RootFolderNode;
|
||||
}
|
||||
|
||||
const isSearchMatchSeq =
|
||||
(searchTerm: string, s?: TaggedResource): s is TaggedSequence => {
|
||||
if (s && s.kind == "Sequence") {
|
||||
const name = s.body.name.toLowerCase();
|
||||
return name.includes(searchTerm);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isSearchMatchFolder = (searchTerm: string, f: FolderUnion) => {
|
||||
if (f.name.toLowerCase().includes(searchTerm)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Given an input search term, returns folder IDs (number) and Sequence UUIDs
|
||||
* that match */
|
||||
export const searchFolderTree = (props: FolderSearchProps): FolderUnion[] => {
|
||||
// A sequence is included if:
|
||||
// * CASE 1: The name is a search match
|
||||
// * CASE 2: The containing folder is a search match.
|
||||
// A folder is included if:
|
||||
// * CASE 3: The name is a search match
|
||||
// * CASE 4: It contains a sequence that is a match.
|
||||
// * CASE 5: It has a child that has a search match.
|
||||
|
||||
const searchTerm = props.input.toLowerCase();
|
||||
const sequenceSet = new Set<string>();
|
||||
const folderSet = new Set<FolderUnion>();
|
||||
|
||||
props.root.folders.map(level1 => {
|
||||
level1.content.map(level1Sequence => { // ========= Level 1
|
||||
if (isSearchMatchSeq(searchTerm, props.references[level1Sequence])) {
|
||||
// CASE 1:
|
||||
sequenceSet.add(level1Sequence);
|
||||
// CASE 4:
|
||||
folderSet.add(level1);
|
||||
}
|
||||
});
|
||||
|
||||
if (isSearchMatchFolder(searchTerm, level1)) {
|
||||
// CASE 2
|
||||
level1.content.map(uuid => sequenceSet.add(uuid));
|
||||
// CASE 3
|
||||
folderSet.add(level1);
|
||||
}
|
||||
|
||||
level1.children.map(level2 => { // ================ LEVEL 2
|
||||
if (isSearchMatchFolder(searchTerm, level2)) {
|
||||
// CASE 2
|
||||
level2.content.map(uuid => sequenceSet.add(uuid));
|
||||
// CASE 3
|
||||
folderSet.add(level2);
|
||||
// CASE 5
|
||||
folderSet.add(level1);
|
||||
}
|
||||
|
||||
level2.content.map(level2Sequence => {
|
||||
if (isSearchMatchSeq(searchTerm, props.references[level2Sequence])) {
|
||||
// CASE 1:
|
||||
sequenceSet.add(level2Sequence);
|
||||
// CASE 4:
|
||||
folderSet.add(level2);
|
||||
// CASE 5
|
||||
folderSet.add(level1);
|
||||
}
|
||||
});
|
||||
level2.children.map(level3 => { // ============== LEVEL 3
|
||||
if (isSearchMatchFolder(searchTerm, level3)) {
|
||||
// CASE 2
|
||||
level3.content.map(uuid => sequenceSet.add(uuid));
|
||||
// CASE 3
|
||||
folderSet.add(level3);
|
||||
// CASE 5
|
||||
folderSet.add(level2);
|
||||
// CASE 5
|
||||
folderSet.add(level1);
|
||||
}
|
||||
level3.content.map(level3Sequence => {
|
||||
if (isSearchMatchSeq(searchTerm, props.references[level3Sequence])) {
|
||||
// CASE 1:
|
||||
sequenceSet.add(level3Sequence);
|
||||
// CASE 3
|
||||
folderSet.add(level3);
|
||||
// CASE 5
|
||||
folderSet.add(level2);
|
||||
// CASE 5
|
||||
folderSet.add(level1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return Array
|
||||
.from(folderSet)
|
||||
.map(f => {
|
||||
return {
|
||||
...f,
|
||||
content: f.content.filter(c => sequenceSet.has(c))
|
||||
};
|
||||
});
|
||||
};
|
|
@ -5,11 +5,11 @@ export interface HelpState {
|
|||
currentTour: string | undefined;
|
||||
}
|
||||
|
||||
export let initialState: HelpState = {
|
||||
export const initialState: HelpState = {
|
||||
currentTour: undefined,
|
||||
};
|
||||
|
||||
export let helpReducer = generateReducer<HelpState>(initialState)
|
||||
export const helpReducer = generateReducer<HelpState>(initialState)
|
||||
.add<string>(Actions.START_TOUR, (s, { payload }) => {
|
||||
s.currentTour = payload;
|
||||
return s;
|
||||
|
|
|
@ -2,13 +2,13 @@ import { DataChangeType, Dictionary } from "farmbot/dist";
|
|||
import { box } from "boxed_value";
|
||||
import { isNumber, isNaN } from "lodash";
|
||||
|
||||
export let METHOD_MAP: Dictionary<DataChangeType> = {
|
||||
export const METHOD_MAP: Dictionary<DataChangeType> = {
|
||||
"post": "add",
|
||||
"put": "update",
|
||||
"patch": "update",
|
||||
"delete": "remove"
|
||||
};
|
||||
export let METHODS = ["post", "put", "patch", "delete"];
|
||||
export const METHODS = ["post", "put", "patch", "delete"];
|
||||
|
||||
/** More nasty hacks until we have time to implement proper API push state
|
||||
* notifications. */
|
||||
|
|
|
@ -5,7 +5,7 @@ import { MobileMenuProps } from "./interfaces";
|
|||
|
||||
const classes = [Classes.CARD, Classes.ELEVATION_4, "mobile-menu"];
|
||||
|
||||
export let MobileMenu = (props: MobileMenuProps) => {
|
||||
export const MobileMenu = (props: MobileMenuProps) => {
|
||||
const isActive = props.mobileMenuOpen ? "active" : "inactive";
|
||||
const { alertCount } = props;
|
||||
return <div>
|
||||
|
|
|
@ -72,7 +72,7 @@ const Ticker = (log: TaggedLog, timeSettings: TimeSettings) => {
|
|||
};
|
||||
|
||||
/** The logs ticker, with closed/open views, and a link to the Logs page. */
|
||||
export let TickerList = (props: TickerListProps) => {
|
||||
export const TickerList = (props: TickerListProps) => {
|
||||
return <ErrorBoundary>
|
||||
<div className="ticker-list" onClick={props.toggle("tickerListOpen")}>
|
||||
<div className="first-ticker">
|
||||
|
|
|
@ -21,7 +21,7 @@ export interface OFCropResponse {
|
|||
}
|
||||
|
||||
export namespace OpenFarmAPI {
|
||||
export let OFBaseURL = BASE;
|
||||
export const OFBaseURL = BASE;
|
||||
}
|
||||
|
||||
export function svgToUrl(xml: string | undefined): string {
|
||||
|
|
|
@ -20,7 +20,7 @@ export interface MiddlewareConfig { fn: MW; env: EnvName; }
|
|||
|
||||
/** To make it easier to manage all things watching the state tree,
|
||||
* we keep subscriber functions in this array. */
|
||||
export let mwConfig: MiddlewareConfig[] = [
|
||||
export const mwConfig: MiddlewareConfig[] = [
|
||||
{ env: "*", fn: thunk },
|
||||
{ env: "development", fn: require("redux-immutable-state-invariant").default() },
|
||||
stateFetchMiddlewareConfig,
|
||||
|
|
|
@ -9,21 +9,17 @@ import { resourceReducer as resources } from "../resources/reducer";
|
|||
import { Everything } from "../interfaces";
|
||||
import { Actions } from "../constants";
|
||||
|
||||
export let reducers = combineReducers({
|
||||
export const reducers = combineReducers({
|
||||
auth,
|
||||
bot,
|
||||
config,
|
||||
draggable,
|
||||
resources,
|
||||
resources
|
||||
});
|
||||
|
||||
/** This is the topmost reducer in the application. If you need to preempt a
|
||||
* "normal" reducer this is the place to do it */
|
||||
export function rootReducer(
|
||||
/** Sorry for the `any` here. */
|
||||
state: Everything,
|
||||
action: ReduxAction<{}>) {
|
||||
export function rootReducer(state: Everything, action: ReduxAction<{}>) {
|
||||
(action.type === Actions.LOGOUT) && Session.clear();
|
||||
|
||||
return reducers(state, action);
|
||||
}
|
||||
|
|
|
@ -6,10 +6,9 @@ import { getMiddleware } from "./middlewares";
|
|||
import { set } from "lodash";
|
||||
|
||||
function dev(): Store {
|
||||
store = createStore(rootReducer,
|
||||
return createStore(rootReducer,
|
||||
maybeFetchOldState(),
|
||||
getMiddleware("development"));
|
||||
return store;
|
||||
}
|
||||
|
||||
function prod(): Store {
|
||||
|
@ -26,7 +25,7 @@ export function configureStore() {
|
|||
return store2;
|
||||
}
|
||||
|
||||
export let store = configureStore();
|
||||
export const store = configureStore();
|
||||
|
||||
/** Tries to fetch previous state from `sessionStorage`.
|
||||
* Returns {} if nothing is found. Used mostly for hot reloading. */
|
||||
|
|
|
@ -52,7 +52,7 @@ export interface Subscription { fn: (state: Everything) => void; env: EnvName; }
|
|||
|
||||
/** To make it easier to manage all things watching the state tree,
|
||||
* we keep subscriber functions in this array. */
|
||||
export let subscriptions: Subscription[] = [{ env: "*", fn: unsavedCheck }];
|
||||
export const subscriptions: Subscription[] = [{ env: "*", fn: unsavedCheck }];
|
||||
|
||||
export function registerSubscribers(store: Store) {
|
||||
const ENV_LIST = [process.env.NODE_ENV, "*"];
|
||||
|
|
|
@ -11,7 +11,7 @@ const ok = (x: AxiosResponse<AuthState>) => {
|
|||
|
||||
/** Grab a new token from the API (won't extend token's exp. date).
|
||||
* Redirect to home page on failure. */
|
||||
export let maybeRefreshToken
|
||||
export const maybeRefreshToken
|
||||
= (old: AuthState): Promise<AuthState | undefined> => {
|
||||
API.setBaseUrl(old.token.unencoded.iss);
|
||||
setToken(old); // Precaution: The Axios interceptors might not be set yet.
|
||||
|
|
|
@ -36,9 +36,9 @@ function newState(): RegimenState {
|
|||
};
|
||||
}
|
||||
|
||||
export let initialState: RegimenState = newState();
|
||||
export const initialState: RegimenState = newState();
|
||||
|
||||
export let regimensReducer = generateReducer<RegimenState>(initialState)
|
||||
export const regimensReducer = generateReducer<RegimenState>(initialState)
|
||||
.add<TaggedResource>(Actions.DESTROY_RESOURCE_OK, (s, { payload }) => {
|
||||
switch (payload.uuid) {
|
||||
case s.selectedSequenceUUID:
|
||||
|
|
|
@ -28,13 +28,15 @@ describe("resourceUsageList", () => {
|
|||
"Sequence.Sequence": {
|
||||
"Regimen.9.9": { "Sequence.10.10": true, "Sequence.11.11": true }
|
||||
},
|
||||
"Sequence.FbosConfig": { "Device.99.99": { "Sequence.12.12": true } }
|
||||
"Sequence.FbosConfig": { "Sequence.12.12": { "FbosConfig.13.13": true } },
|
||||
"Sequence.PinBinding": { "Sequence.14.14": { "PinBinding.15.15": true } },
|
||||
};
|
||||
const actual = Object.keys(resourceUsageList(x)).sort();
|
||||
const expected = [
|
||||
"FarmEvent.0.0", "FarmEvent.3.3",
|
||||
"Regimen.6.6", "Regimen.9.9",
|
||||
"Device.99.99",
|
||||
"Sequence.12.12",
|
||||
"Sequence.14.14",
|
||||
].sort();
|
||||
expect(actual.length).toEqual(expected.length);
|
||||
expected.map(y => expect(actual).toContain(y));
|
||||
|
|
|
@ -15,6 +15,7 @@ import { fakeResource } from "../../__test_support__/fake_resource";
|
|||
import { resourceReducer } from "../reducer";
|
||||
import { findByUuid } from "../reducer_support";
|
||||
import { EditResourceParams } from "../../api/interfaces";
|
||||
import { fakeFolder } from "../../__test_support__/fake_state/resources";
|
||||
|
||||
describe("resource reducer", () => {
|
||||
it("marks resources as DIRTY when reducing OVERWRITE_RESOURCE", () => {
|
||||
|
@ -114,6 +115,17 @@ describe("resource reducer", () => {
|
|||
.concat(["Image", "SensorReading"])
|
||||
.map((kind: ResourceName) => testResourceDestroy(kind));
|
||||
});
|
||||
|
||||
it("toggles folder open state", () => {
|
||||
const folder = fakeFolder();
|
||||
folder.body.id = 1;
|
||||
const startingState = buildResourceIndex([folder]);
|
||||
delete startingState.index.sequenceFolders.localMetaAttributes[1].open;
|
||||
const action = { type: Actions.FOLDER_TOGGLE, payload: { id: 1 } };
|
||||
const newState = resourceReducer(startingState, action);
|
||||
expect(newState.index.sequenceFolders.localMetaAttributes[1].open)
|
||||
.toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByUuid", () => {
|
||||
|
|
|
@ -17,7 +17,6 @@ import { resourceReducer } from "../reducer";
|
|||
import { emptyState } from "../reducer";
|
||||
import { resourceReady, newTaggedResource } from "../../sync/actions";
|
||||
import { chain } from "lodash";
|
||||
// import { Actions } from "../../constants";
|
||||
|
||||
const TOOL_ID = 99;
|
||||
const SLOT_ID = 100;
|
||||
|
@ -124,13 +123,6 @@ describe("findPointerByTypeAndId()", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("findToolSlot()", () => {
|
||||
it("throws error", () => {
|
||||
const find = () => Selector.findToolSlot(fakeIndex, "bad");
|
||||
expect(find).toThrow("ToolSlotPointer not found: bad");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPlant()", () => {
|
||||
it("throws error", () => {
|
||||
console.warn = jest.fn();
|
||||
|
@ -163,6 +155,7 @@ describe("getSequenceByUUID()", () => {
|
|||
expect(console.warn).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserAccountSettings", () => {
|
||||
it("throws exceptions when user is not loaded", () => {
|
||||
const boom = () => Selector
|
||||
|
@ -171,6 +164,7 @@ describe("getUserAccountSettings", () => {
|
|||
.toThrow("PROBLEM: Tried to fetch user before it was available.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeGetSequence", () => {
|
||||
it("returns undefined", () => {
|
||||
const i = buildResourceIndex([]);
|
||||
|
@ -243,6 +237,13 @@ describe("findRegimenById()", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("findFolderById()", () => {
|
||||
it("throws error", () => {
|
||||
const find = () => Selector.findFolderById(fakeIndex, 0);
|
||||
expect(find).toThrow("Bad folder id: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeFindPlantById()", () => {
|
||||
it("not found", () => {
|
||||
const result = Selector.maybeFindPlantById(fakeIndex, 0);
|
||||
|
|
|
@ -33,6 +33,6 @@ export const generalizedError = (payload: GeneralizedError) => {
|
|||
return { type: Actions._RESOURCE_NO, payload };
|
||||
};
|
||||
|
||||
export let destroyNO = generalizedError;
|
||||
export let createNO = generalizedError;
|
||||
export let updateNO = generalizedError;
|
||||
export const destroyNO = generalizedError;
|
||||
export const createNO = generalizedError;
|
||||
export const updateNO = generalizedError;
|
||||
|
|
|
@ -22,6 +22,7 @@ export type UsageKind =
|
|||
| "Sequence.Regimen"
|
||||
| "Sequence.FarmEvent"
|
||||
| "Sequence.Sequence"
|
||||
| "Sequence.PinBinding"
|
||||
| "Sequence.FbosConfig";
|
||||
|
||||
/** This variable ensures that `EVERY_USAGE_KIND` does not have typos and is
|
||||
|
@ -31,6 +32,7 @@ const values: Record<UsageKind, UsageKind> = {
|
|||
"Sequence.Regimen": "Sequence.Regimen",
|
||||
"Sequence.FarmEvent": "Sequence.FarmEvent",
|
||||
"Sequence.Sequence": "Sequence.Sequence",
|
||||
"Sequence.PinBinding": "Sequence.PinBinding",
|
||||
"Sequence.FbosConfig": "Sequence.FbosConfig"
|
||||
};
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import { HelpState } from "../help/reducer";
|
|||
import { UsageIndex } from "./in_use";
|
||||
import { SequenceMeta } from "./sequence_meta";
|
||||
import { AlertReducerState } from "../messages/interfaces";
|
||||
import { RootFolderNode, FolderMeta } from "../folders/constants";
|
||||
|
||||
export type UUID = string;
|
||||
export type VariableNameSet = Record<string, SequenceMeta | undefined>;
|
||||
|
@ -72,6 +73,14 @@ export interface ResourceIndex {
|
|||
* }
|
||||
*/
|
||||
inUse: UsageIndex;
|
||||
sequenceFolders: {
|
||||
folders: RootFolderNode;
|
||||
/** Local data about a `Folder` that is stored
|
||||
* out-of-band rather than in the API. */
|
||||
localMetaAttributes: Record<number, FolderMeta>;
|
||||
searchTerm?: string;
|
||||
filteredFolders?: RootFolderNode | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestResources {
|
||||
|
|
|
@ -7,7 +7,9 @@ import {
|
|||
indexRemove,
|
||||
initResourceReducer,
|
||||
afterEach,
|
||||
beforeEach
|
||||
beforeEach,
|
||||
folderIndexer,
|
||||
reindexFolders
|
||||
} from "./reducer_support";
|
||||
import { TaggedResource, SpecialStatus } from "farmbot";
|
||||
import { Actions } from "../constants";
|
||||
|
@ -22,6 +24,8 @@ import { farmwareState } from "../farmware/reducer";
|
|||
import { initialState as regimenState } from "../regimens/reducer";
|
||||
import { initialState as sequenceState } from "../sequences/reducer";
|
||||
import { initialState as alertState } from "../messages/reducer";
|
||||
import { ingest } from "../folders/data_transfer";
|
||||
import { searchFolderTree } from "../folders/search_folder_tree";
|
||||
|
||||
export const emptyState = (): RestResources => {
|
||||
return {
|
||||
|
@ -46,6 +50,7 @@ export const emptyState = (): RestResources => {
|
|||
FarmwareInstallation: {},
|
||||
FbosConfig: {},
|
||||
FirmwareConfig: {},
|
||||
Folder: {},
|
||||
Image: {},
|
||||
Log: {},
|
||||
Peripheral: {},
|
||||
|
@ -63,7 +68,6 @@ export const emptyState = (): RestResources => {
|
|||
User: {},
|
||||
WebAppConfig: {},
|
||||
WebcamFeed: {},
|
||||
Folder: {}
|
||||
},
|
||||
byKindAndId: {},
|
||||
references: {},
|
||||
|
@ -73,14 +77,22 @@ export const emptyState = (): RestResources => {
|
|||
"Sequence.FarmEvent": {},
|
||||
"Sequence.Regimen": {},
|
||||
"Sequence.Sequence": {},
|
||||
"Sequence.PinBinding": {},
|
||||
"Sequence.FbosConfig": {}
|
||||
},
|
||||
sequenceFolders: {
|
||||
localMetaAttributes: {},
|
||||
folders: {
|
||||
folders: [],
|
||||
noFolder: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/** Responsible for all RESTful resources. */
|
||||
export let resourceReducer =
|
||||
export const resourceReducer =
|
||||
generateReducer<RestResources>(emptyState())
|
||||
.beforeEach(beforeEach)
|
||||
.afterEach(afterEach)
|
||||
|
@ -109,12 +121,11 @@ export let resourceReducer =
|
|||
mutateSpecialStatus(uuid, s.index, specialStatus);
|
||||
return s;
|
||||
})
|
||||
.add<SyncBodyContents<TaggedResource>>(Actions.RESOURCE_READY,
|
||||
(s, { payload }) => {
|
||||
!s.loaded.includes(payload.kind) && s.loaded.push(payload.kind);
|
||||
indexUpsert(s.index, payload.body, "initial");
|
||||
return s;
|
||||
})
|
||||
.add<SyncBodyContents<TaggedResource>>(Actions.RESOURCE_READY, (s, { payload }) => {
|
||||
!s.loaded.includes(payload.kind) && s.loaded.push(payload.kind);
|
||||
indexUpsert(s.index, payload.body, "initial");
|
||||
return s;
|
||||
})
|
||||
.add<TaggedResource>(Actions.REFRESH_RESOURCE_OK, (s, { payload }) => {
|
||||
indexUpsert(s.index, [payload], "ongoing");
|
||||
mutateSpecialStatus(payload.uuid, s.index);
|
||||
|
@ -122,6 +133,7 @@ export let resourceReducer =
|
|||
})
|
||||
.add<TaggedResource>(Actions.DESTROY_RESOURCE_OK, (s, { payload }) => {
|
||||
indexRemove(s.index, payload);
|
||||
folderIndexer(payload, s.index);
|
||||
return s;
|
||||
})
|
||||
.add<GeneralizedError>(Actions._RESOURCE_NO, (s, { payload }) => {
|
||||
|
@ -149,4 +161,66 @@ export let resourceReducer =
|
|||
payload: resource
|
||||
});
|
||||
}, s);
|
||||
})
|
||||
.add<{ id: number }>(Actions.FOLDER_TOGGLE, (s, { payload }) => {
|
||||
const { localMetaAttributes } = s.index.sequenceFolders;
|
||||
const record = localMetaAttributes[parseInt("" + payload.id)];
|
||||
record.open = !(record.open ?? true);
|
||||
reindexFolders(s.index);
|
||||
return s;
|
||||
})
|
||||
.add<boolean>(Actions.FOLDER_TOGGLE_ALL, (s, { payload }) => {
|
||||
const { localMetaAttributes } = s.index.sequenceFolders;
|
||||
Object.keys(localMetaAttributes).map((x) => {
|
||||
localMetaAttributes[parseInt("" + x)].open = payload;
|
||||
});
|
||||
reindexFolders(s.index);
|
||||
return s;
|
||||
})
|
||||
.add<{ id: number }>(Actions.FOLDER_TOGGLE_EDIT, (s, { payload }) => {
|
||||
const { localMetaAttributes } = s.index.sequenceFolders;
|
||||
const record = localMetaAttributes[parseInt("" + payload.id)];
|
||||
record.editing = !record.editing;
|
||||
|
||||
reindexFolders(s.index);
|
||||
|
||||
return s;
|
||||
})
|
||||
.add<string | undefined>(Actions.FOLDER_SEARCH, (s, { payload }) => {
|
||||
s.index.sequenceFolders.searchTerm = payload;
|
||||
if (payload) {
|
||||
const folders = searchFolderTree({
|
||||
references: s.index.references,
|
||||
input: payload,
|
||||
root: s.index.sequenceFolders.folders
|
||||
});
|
||||
const { localMetaAttributes } = s.index.sequenceFolders;
|
||||
Object /** Expand all folders when searching. */
|
||||
.keys(localMetaAttributes)
|
||||
.map(x => {
|
||||
s
|
||||
.index
|
||||
.sequenceFolders
|
||||
.localMetaAttributes[x as unknown as number]
|
||||
.open = true;
|
||||
});
|
||||
const nextFolder = ingest({
|
||||
localMetaAttributes,
|
||||
folders
|
||||
});
|
||||
nextFolder.noFolder = nextFolder.noFolder.filter(uuid => {
|
||||
const sq = s.index.references[uuid];
|
||||
if (sq && sq.kind === "Sequence") {
|
||||
const n = sq.body.name.toLowerCase();
|
||||
return n.includes(payload);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
s.index.sequenceFolders.filteredFolders = nextFolder;
|
||||
} else {
|
||||
s.index.sequenceFolders.filteredFolders = undefined;
|
||||
}
|
||||
reindexFolders(s.index);
|
||||
return s;
|
||||
});
|
||||
|
|
|
@ -15,9 +15,12 @@ import {
|
|||
} from "../sequences/locals_list/sanitize_nodes";
|
||||
import {
|
||||
selectAllFarmEvents,
|
||||
selectAllPinBindings,
|
||||
findByKindAndId,
|
||||
selectAllLogs,
|
||||
selectAllRegimens,
|
||||
selectAllFolders,
|
||||
selectAllSequences,
|
||||
} from "./selectors_by_kind";
|
||||
import { ExecutableType } from "farmbot/dist/resources/api_resources";
|
||||
import { betterCompact, unpackUUID } from "../util";
|
||||
|
@ -29,6 +32,9 @@ import { ActionHandler } from "../redux/generate_reducer";
|
|||
import { get } from "lodash";
|
||||
import { Actions } from "../constants";
|
||||
import { getFbosConfig } from "./getters";
|
||||
import { ingest, PARENTLESS as NO_PARENT } from "../folders/data_transfer";
|
||||
import { FolderNode, FolderMeta, FolderNodeTerminal, FolderNodeMedial } from "../folders/constants";
|
||||
import { climb } from "../folders/climb";
|
||||
|
||||
export function findByUuid(index: ResourceIndex, uuid: string): TaggedResource {
|
||||
const x = index.references[uuid];
|
||||
|
@ -45,6 +51,82 @@ type IndexDirection =
|
|||
type IndexerCallback = (self: TaggedResource, index: ResourceIndex) => void;
|
||||
export interface Indexer extends Record<IndexDirection, IndexerCallback> { }
|
||||
|
||||
export const reindexFolders = (i: ResourceIndex) => {
|
||||
const folders = betterCompact(selectAllFolders(i)
|
||||
.map((x): FolderNode | undefined => {
|
||||
const { body } = x;
|
||||
if (typeof body.id === "number") {
|
||||
const fn: FolderNode = { id: body.id, ...body };
|
||||
return fn;
|
||||
}
|
||||
}));
|
||||
const allSequences = selectAllSequences(i);
|
||||
|
||||
const oldMeta = i.sequenceFolders.localMetaAttributes;
|
||||
const localMetaAttributes: Record<number, FolderMeta> = {};
|
||||
folders.map(x => {
|
||||
localMetaAttributes[x.id] = {
|
||||
...(oldMeta[x.id] || {}),
|
||||
sequences: [], // Clobber and re-init
|
||||
};
|
||||
});
|
||||
|
||||
allSequences.map((s) => {
|
||||
const { folder_id } = s.body;
|
||||
const parentId = folder_id || NO_PARENT;
|
||||
|
||||
if (!localMetaAttributes[parentId]) {
|
||||
localMetaAttributes[parentId] = {
|
||||
sequences: [],
|
||||
open: true,
|
||||
editing: false
|
||||
};
|
||||
}
|
||||
localMetaAttributes[parentId].sequences.push(s.uuid);
|
||||
});
|
||||
|
||||
const { searchTerm } = i.sequenceFolders;
|
||||
|
||||
/** Perform tree search for search term O(n)
|
||||
* complexity plz send help. */
|
||||
if (searchTerm) {
|
||||
const sequenceHits = new Set<string>();
|
||||
const folderHits = new Set<number>();
|
||||
|
||||
climb(i.sequenceFolders.folders, (node) => {
|
||||
node.content.map(x => {
|
||||
const s = i.references[x];
|
||||
if (s &&
|
||||
s.kind == "Sequence" &&
|
||||
s.body.name.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
sequenceHits.add(s.uuid);
|
||||
folderHits.add(node.id);
|
||||
}
|
||||
});
|
||||
const nodes: (FolderNodeMedial | FolderNodeTerminal)[] =
|
||||
node.children || [];
|
||||
nodes.map(_x => { });
|
||||
});
|
||||
}
|
||||
|
||||
i.sequenceFolders = {
|
||||
folders: ingest({ folders, localMetaAttributes }),
|
||||
localMetaAttributes,
|
||||
searchTerm: searchTerm,
|
||||
filteredFolders: searchTerm ?
|
||||
i.sequenceFolders.filteredFolders : undefined
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
export const folderIndexer: IndexerCallback = (r, i) => {
|
||||
if (r.kind === "Folder" || r.kind === "Sequence") {
|
||||
reindexFolders(i);
|
||||
}
|
||||
};
|
||||
|
||||
const SEQUENCE_FOLDERS: Indexer = { up: folderIndexer, down: () => { } };
|
||||
|
||||
const REFERENCES: Indexer = {
|
||||
up: (r, i) => i.references[r.uuid] = r,
|
||||
down: (r, i) => delete i.references[r.uuid],
|
||||
|
@ -137,6 +219,7 @@ export const INDEXERS: Indexer[] = [
|
|||
ALL,
|
||||
BY_KIND,
|
||||
BY_KIND_AND_ID,
|
||||
SEQUENCE_FOLDERS
|
||||
];
|
||||
|
||||
type IndexerHook = Partial<Record<TaggedResource["kind"], Reindexer>>;
|
||||
|
@ -212,6 +295,21 @@ const AFTER_HOOKS: IndexerHook = {
|
|||
i.inUse["Sequence.FbosConfig"] = {};
|
||||
}
|
||||
},
|
||||
PinBinding: (i) => {
|
||||
i.inUse["Sequence.PinBinding"] = {};
|
||||
const tracker = i.inUse["Sequence.PinBinding"];
|
||||
selectAllPinBindings(i)
|
||||
.map(pinBinding => {
|
||||
if (pinBinding.body.binding_type === "standard") {
|
||||
const { sequence_id } = pinBinding.body;
|
||||
const uuid = i.byKindAndId[joinKindAndId("Sequence", sequence_id)];
|
||||
if (uuid) {
|
||||
tracker[uuid] = tracker[uuid] || {};
|
||||
tracker[uuid][pinBinding.uuid] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
FarmEvent: reindexAllFarmEventUsage,
|
||||
Sequence: reindexAllSequences,
|
||||
Regimen: (i) => {
|
||||
|
|
|
@ -40,7 +40,7 @@ export * from "./selectors_for_indexing";
|
|||
* unless there is actually a reason for the resource to not have a UUID.
|
||||
* `findId()` is more appropriate 99% of the time because it can spot
|
||||
* referential integrity issues. */
|
||||
export let maybeDetermineUuid =
|
||||
export const maybeDetermineUuid =
|
||||
(index: ResourceIndex, kind: ResourceName, id: number) => {
|
||||
const kni = joinKindAndId(kind, id);
|
||||
const uuid = index.byKindAndId[kni];
|
||||
|
@ -50,7 +50,7 @@ export let maybeDetermineUuid =
|
|||
}
|
||||
};
|
||||
|
||||
export let findId = (index: ResourceIndex, kind: ResourceName, id: number): UUID => {
|
||||
export const findId = (index: ResourceIndex, kind: ResourceName, id: number): UUID => {
|
||||
const uuid = maybeDetermineUuid(index, kind, id);
|
||||
if (uuid) {
|
||||
return uuid;
|
||||
|
@ -59,7 +59,7 @@ export let findId = (index: ResourceIndex, kind: ResourceName, id: number): UUID
|
|||
}
|
||||
};
|
||||
|
||||
export let isKind = (name: ResourceName) => (tr: TaggedResource) => tr.kind === name;
|
||||
export const isKind = (name: ResourceName) => (tr: TaggedResource) => tr.kind === name;
|
||||
|
||||
export function groupPointsByType(index: ResourceIndex) {
|
||||
return chain(selectAllActivePoints(index))
|
||||
|
@ -106,15 +106,6 @@ export function selectAllToolSlotPointers(index: ResourceIndex):
|
|||
return betterCompact(toolSlotPointers);
|
||||
}
|
||||
|
||||
export function findToolSlot(i: ResourceIndex, uuid: string): TaggedToolSlotPointer {
|
||||
const ts = selectAllToolSlotPointers(i).filter(x => x.uuid === uuid)[0];
|
||||
if (ts) {
|
||||
return ts;
|
||||
} else {
|
||||
throw new Error("ToolSlotPointer not found: " + uuid);
|
||||
}
|
||||
}
|
||||
|
||||
export function findPlant(i: ResourceIndex, uuid: string):
|
||||
TaggedPlantPointer {
|
||||
const point = findPoints(i, uuid);
|
||||
|
@ -151,7 +142,7 @@ export function getSequenceByUUID(index: ResourceIndex,
|
|||
|
||||
/** GIVEN: a slot UUID.
|
||||
* FINDS: Tool in that slot (if any) */
|
||||
export let currentToolInSlot = (index: ResourceIndex) =>
|
||||
export const currentToolInSlot = (index: ResourceIndex) =>
|
||||
(toolSlotUUID: string): TaggedTool | undefined => {
|
||||
const currentSlot = selectCurrentToolSlot(index, toolSlotUUID);
|
||||
if (currentSlot
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue