Merge pull request #1640 from FarmBot/staging

v9.0.2 - Jolly Juniper
deleteme2 v9.0.2
Rick Carlino 2019-12-26 10:48:09 -06:00 committed by GitHub
commit a810743fa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
126 changed files with 3436 additions and 669 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.cache
.env
.vscode
.idea
*.log
*.pem
coverage_api

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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: {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 || {},

View File

@ -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

View File

@ -0,0 +1,3 @@
class FolderSerializer < ApplicationSerializer
attributes :id, :parent_id, :color, :name
end

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -0,0 +1,5 @@
class AddReadAtToSensorReadings < ActiveRecord::Migration[6.0]
def change
add_column :sensor_readings, :read_at, :datetime, default: nil
end
end

View File

@ -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');

View File

@ -1,6 +1,6 @@
import { Everything } from "../../interfaces";
export let bot: Everything["bot"] = {
export const bot: Everything["bot"] = {
"consistent": true,
"stepSize": 100,
"controlPanelState": {

View File

@ -1,6 +1,6 @@
import { Everything } from "../../interfaces";
export let config: Everything["config"] = {
export const config: Everything["config"] = {
"host": "localhost",
"port": "3000"
};

View File

@ -1,5 +1,5 @@
import { Everything } from "../../interfaces";
export let draggable: Everything["draggable"] = {
export const draggable: Everything["draggable"] = {
"dataTransfer": {}
};

View File

@ -1,6 +1,6 @@
import { TaggedImage, SpecialStatus } from "farmbot";
export let fakeImages: TaggedImage[] = [
export const fakeImages: TaggedImage[] = [
{
"kind": "Image",
"specialStatus": SpecialStatus.SAVED,

View File

@ -1,5 +1,5 @@
import { Everything } from "../../interfaces";
export let peripherals: Everything["Peripheral"] = {
export const peripherals: Everything["Peripheral"] = {
"isEditing": true
};

View File

@ -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,

View File

@ -1,6 +1,6 @@
import { AuthState } from "../../auth/interfaces";
export let auth: AuthState = {
export const auth: AuthState = {
"token": {
"unencoded": {
"jti": "xyz",

View File

@ -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,

View File

@ -328,7 +328,7 @@ const log: TaggedLog = {
uuid: "Log.1091396.70"
};
export let FAKE_RESOURCES: TaggedResource[] = [
export const FAKE_RESOURCES: TaggedResource[] = [
tr1,
fakeDevice(),
tr2,

View File

@ -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,

View File

@ -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/`; }
}

View File

@ -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) {

View File

@ -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;
});

View File

@ -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;

View File

@ -14,4 +14,4 @@ const change = (state: "up" | "down") =>
export const networkUp = change("up");
export let networkDown = change("down");
export const networkDown = change("down");

View File

@ -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);

View File

@ -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);

View File

@ -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) };

View File

@ -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 });

View File

@ -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"
}

View File

@ -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} />

View File

@ -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)}

View File

@ -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;
}
}

View File

@ -394,7 +394,7 @@
a {
margin-top: 0.5rem;
}
i {
i:not(.fa-stack-2x) {
font-size: 1.5rem;
}
}

View File

@ -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;

View File

@ -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,

View File

@ -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()]
}));

View File

@ -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)

View File

@ -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}

View File

@ -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;

View File

@ -1,4 +1,4 @@
export let list = ["Africa/Abidjan",
export const list = ["Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",

View File

@ -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,
}
};
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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>;
}

View File

@ -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";

View File

@ -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);

View File

@ -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);

View File

@ -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" },

View File

@ -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)");
});
});

View File

@ -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>

View File

@ -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}

View File

@ -110,6 +110,7 @@ export interface MapBackgroundProps {
export interface GridProps {
mapTransformProps: MapTransformProps;
zoomLvl: number;
onClick(): void;
onMouseDown(e: React.MouseEvent<SVGElement>): void;
}

View File

@ -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 {

View File

@ -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);

View File

@ -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)

View File

@ -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;

View File

@ -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));
};

View File

@ -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 });
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
}
});
});

View File

@ -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"]
}
]
}
});

View File

@ -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 }); }
};

View File

@ -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);
};

View File

@ -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>;

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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),
};
}

View File

@ -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))
};
});
};

View File

@ -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;

View File

@ -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. */

View File

@ -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>

View File

@ -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">

View File

@ -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 {

View File

@ -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,

View File

@ -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);
}

View File

@ -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. */

View File

@ -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, "*"];

View File

@ -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.

View File

@ -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:

View File

@ -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));

View File

@ -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", () => {

View File

@ -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);

View File

@ -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;

View File

@ -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"
};

View File

@ -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 {

View File

@ -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;
});

View File

@ -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) => {

View File

@ -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