Merge pull request #988 from FarmBot/staging

v6.6.2
pull/997/head v6.6.2
Rick Carlino 2018-09-18 07:23:55 -05:00 committed by GitHub
commit 1a35dfb136
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
257 changed files with 4955 additions and 2382 deletions

4
.gitignore vendored
View File

@ -25,3 +25,7 @@ public/webpack
public/webpack/*
scratchpad.rb
tmp
# Ignore master key for decrypting credentials and more.
/config/master.key
config/credentials.yml.enc

48
.sass-lint.yml 100644
View File

@ -0,0 +1,48 @@
#########################
## Sass Lint Config File
#########################
# Linter Options
options:
# Don't merge default rules
merge-default-rules: false
# Raise an error if warnings are generated
max-warnings: 0
# File Options
files:
include: 'webpack/css/**/*.s+(a|c)ss'
# Rule Configuration
rules:
extends-before-mixins: 2
extends-before-declarations: 2
placeholder-in-extend: 2
mixins-before-declarations: 2
no-warn: 1
no-debug: 1
no-ids: 2
hex-notation:
- 2
-
style: lowercase
indentation:
- 2
-
size: 2
property-sort-order:
- 1
-
order:
- content
- display
- position
- top
- left
- bottom
- right
- z-index
- margin
ignore-custom-properties: true
variable-for-property:
- 2
-
properties:
- color

View File

@ -32,6 +32,8 @@ before_script:
- bundle exec rake db:create db:migrate
script:
- bundle exec rspec --fail-fast=3
- npm run tslint
- npm run sass-lint
- npm run typecheck
- npm run test-slow
- npm run coverage

View File

@ -31,6 +31,7 @@ gem "webpack-rails"
# Still working out the bugs. - RC 5 Jul 18
gem "rabbitmq_http_api_client"
gem "zero_downtime_migrations"
# gem "digest-murmurhash"
group :development, :test do
gem "thin"

View File

@ -7,25 +7,25 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (5.2.0)
actionpack (= 5.2.0)
actioncable (5.2.1)
actionpack (= 5.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.0)
actionpack (= 5.2.0)
actionview (= 5.2.0)
activejob (= 5.2.0)
actionmailer (5.2.1)
actionpack (= 5.2.1)
actionview (= 5.2.1)
activejob (= 5.2.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.0)
actionview (= 5.2.0)
activesupport (= 5.2.0)
actionpack (5.2.1)
actionview (= 5.2.1)
activesupport (= 5.2.1)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.0)
activesupport (= 5.2.0)
actionview (5.2.1)
activesupport (= 5.2.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
@ -35,20 +35,20 @@ GEM
activemodel (>= 4.1, < 6)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (5.2.0)
activesupport (= 5.2.0)
activejob (5.2.1)
activesupport (= 5.2.1)
globalid (>= 0.3.6)
activemodel (5.2.0)
activesupport (= 5.2.0)
activerecord (5.2.0)
activemodel (= 5.2.0)
activesupport (= 5.2.0)
activemodel (5.2.1)
activesupport (= 5.2.1)
activerecord (5.2.1)
activemodel (= 5.2.1)
activesupport (= 5.2.1)
arel (>= 9.0)
activestorage (5.2.0)
actionpack (= 5.2.0)
activerecord (= 5.2.0)
activestorage (5.2.1)
actionpack (= 5.2.1)
activerecord (= 5.2.1)
marcel (~> 0.3.1)
activesupport (5.2.0)
activesupport (5.2.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@ -58,14 +58,14 @@ GEM
amq-protocol (2.3.0)
arel (9.0.0)
ast (2.4.0)
backports (3.11.3)
backports (3.11.4)
bcrypt (3.1.12)
binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1)
builder (3.2.3)
bunny (2.11.0)
amq-protocol (~> 2.3.0)
capybara (3.4.2)
capybara (3.7.1)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -90,24 +90,25 @@ GEM
debug_inspector (0.0.3)
declarative (0.0.10)
declarative-option (0.1.0)
deep-cover (0.6.2)
backports (>= 3.11.0)
binding_of_caller
deep-cover (0.6.4)
bundler
deep-cover-core (= 0.6.4)
highline
parser (~> 2.5.0)
pry
sass
slop (~> 4.0)
term-ansicolor
terminal-table
with_progress
deep-cover-core (0.6.4)
backports (>= 3.11.0)
binding_of_caller
parser (~> 2.5.0)
pry
terminal-table
delayed_job (4.1.5)
activesupport (>= 3.0, < 5.3)
delayed_job_active_record (4.1.3)
activerecord (>= 3.0, < 5.3)
delayed_job (>= 3.0, < 5)
devise (4.4.3)
devise (4.5.0)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0, < 6.0)
@ -121,10 +122,10 @@ GEM
erubi (1.7.1)
eventmachine (1.2.7)
excon (0.62.0)
factory_bot (4.10.0)
factory_bot (4.11.0)
activesupport (>= 3.0.0)
factory_bot_rails (4.10.0)
factory_bot (~> 4.10.0)
factory_bot_rails (4.11.0)
factory_bot (~> 4.11.0)
railties (>= 3.0.0)
faker (1.9.1)
i18n (>= 0.7)
@ -135,12 +136,12 @@ GEM
ffi (1.9.25)
figaro (1.1.1)
thor (~> 0.14)
fog-core (2.1.0)
fog-core (2.1.2)
builder
excon (~> 0.58)
formatador (~> 0.2)
mime-types
fog-google (1.6.0)
fog-google (1.7.1)
fog-core
fog-json
fog-xml
@ -158,26 +159,26 @@ GEM
formatador (0.2.5)
globalid (0.4.1)
activesupport (>= 4.2.0)
google-api-client (0.23.4)
google-api-client (0.23.8)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.5, < 0.7.0)
httpclient (>= 2.8.1, < 3.0)
mime-types (~> 3.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
googleauth (0.6.2)
signet (~> 0.9)
googleauth (0.6.6)
faraday (~> 0.12)
jwt (>= 1.4, < 3.0)
logging (~> 2.0)
memoist (~> 0.12)
multi_json (~> 1.11)
os (~> 0.9)
os (>= 0.9, < 2.0)
signet (~> 0.7)
hashdiff (0.3.7)
hashie (3.5.7)
highline (2.0.0)
httpclient (2.8.3)
i18n (1.0.1)
i18n (1.1.0)
concurrent-ruby (~> 1.0)
json (2.1.0)
jsonapi-renderer (0.2.0)
@ -186,10 +187,6 @@ GEM
addressable (~> 2.3)
letter_opener (1.6.0)
launchy (~> 2.2)
little-plugger (1.1.4)
logging (2.2.2)
little-plugger (~> 1.1)
multi_json (~> 1.10)
lol_dba (2.1.5)
actionpack (>= 3.0)
activerecord (>= 3.0)
@ -203,23 +200,23 @@ GEM
mimemagic (~> 0.3.2)
memoist (0.16.0)
method_source (0.9.0)
mime-types (3.1)
mime-types (3.2.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2016.0521)
mime-types-data (3.2018.0812)
mimemagic (0.3.2)
mini_mime (1.0.0)
mini_mime (1.0.1)
mini_portile2 (2.3.0)
minitest (5.11.3)
multi_json (1.13.1)
multipart-post (2.0.0)
mutations (0.8.2)
mutations (0.8.3)
activesupport
nio4r (2.3.1)
nokogiri (1.8.4)
mini_portile2 (~> 2.3.0)
orm_adapter (0.5.0)
os (0.9.6)
paperclip (6.0.0)
os (1.0.0)
paperclip (6.1.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
mime-types
@ -227,10 +224,10 @@ GEM
terrapin (~> 0.6.0)
parser (2.5.1.2)
ast (~> 2.4.0)
passenger (5.3.3)
passenger (5.3.4)
rack
rake (>= 0.8.1)
pg (1.0.0)
pg (1.1.2)
polymorphic_constraints (1.0.0)
rails
pry (0.11.3)
@ -238,7 +235,7 @@ GEM
method_source (~> 0.9.0)
pry-rails (0.3.6)
pry (>= 0.10.4)
public_suffix (3.0.2)
public_suffix (3.0.3)
rabbitmq_http_api_client (1.9.1)
effin_utf8 (~> 1.0.0)
faraday (~> 0.13.0)
@ -251,18 +248,18 @@ GEM
rack-cors (1.0.2)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.0)
actioncable (= 5.2.0)
actionmailer (= 5.2.0)
actionpack (= 5.2.0)
actionview (= 5.2.0)
activejob (= 5.2.0)
activemodel (= 5.2.0)
activerecord (= 5.2.0)
activestorage (= 5.2.0)
activesupport (= 5.2.0)
rails (5.2.1)
actioncable (= 5.2.1)
actionmailer (= 5.2.1)
actionpack (= 5.2.1)
actionview (= 5.2.1)
activejob (= 5.2.1)
activemodel (= 5.2.1)
activerecord (= 5.2.1)
activestorage (= 5.2.1)
activesupport (= 5.2.1)
bundler (>= 1.3.0)
railties (= 5.2.0)
railties (= 5.2.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
@ -279,16 +276,13 @@ GEM
rails_stdout_logging
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.2.0)
actionpack (= 5.2.0)
activesupport (= 5.2.0)
railties (5.2.1)
actionpack (= 5.2.1)
activesupport (= 5.2.1)
method_source
rake (>= 0.8.7)
thor (>= 0.18.1, < 2.0)
thor (>= 0.19.0, < 2.0)
rake (12.3.1)
rb-fsevent (0.10.3)
rb-inotify (0.9.10)
ffi (>= 0.5.0, < 2)
representable (3.0.4)
declarative (< 0.1.0)
declarative-option (< 0.2.0)
@ -299,45 +293,40 @@ GEM
actionpack (>= 4.2.0, < 5.3)
railties (>= 4.2.0, < 5.3)
retriable (3.1.2)
rollbar (2.16.3)
rollbar (2.17.0)
multi_json
rspec (3.7.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
rspec-core (3.7.1)
rspec-support (~> 3.7.0)
rspec-expectations (3.7.0)
rspec (3.8.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-core (3.8.0)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.7.0)
rspec-mocks (3.7.0)
rspec-support (~> 3.8.0)
rspec-mocks (3.8.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.7.0)
rspec-rails (3.7.2)
rspec-support (~> 3.8.0)
rspec-rails (3.8.0)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
rspec-core (~> 3.7.0)
rspec-expectations (~> 3.7.0)
rspec-mocks (~> 3.7.0)
rspec-support (~> 3.7.0)
rspec-support (3.7.1)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.0)
ruby-graphviz (1.2.3)
ruby-progressbar (1.10.0)
rubyzip (1.2.1)
sass (3.5.7)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
rubyzip (1.2.2)
scenic (1.4.1)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
secure_headers (6.0.0)
selenium-webdriver (3.13.1)
selenium-webdriver (3.14.0)
childprocess (~> 0.5)
rubyzip (~> 1.2)
signet (0.8.1)
signet (0.9.1)
addressable (~> 2.3)
faraday (~> 0.9)
jwt (>= 1.5, < 3.0)
@ -347,9 +336,9 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
skylight (2.0.2)
skylight-core (= 2.0.2)
skylight-core (2.0.2)
skylight (3.0.0)
skylight-core (= 3.0.0)
skylight-core (3.0.0)
activesupport (>= 4.2.0)
slop (4.6.2)
sprockets (3.7.2)

View File

@ -2,10 +2,7 @@
# Mostly used for creation of jwt.pem- which is used to verify authenticity of
# JSON Web Tokens
class KeyGen
PROD_KEY_FILE = "/keys/production.pem"
KEY_FILE = "jwt.#{Rails.env}.pem"
SAVE_PATH = (Rails.env == "production") ? PROD_KEY_FILE : KEY_FILE
# SAVE_PATH = KEY_FILE
SAVE_PATH = "jwt.#{Rails.env}.pem"
def self.try_file
OpenSSL::PKey::RSA.new(File.read(SAVE_PATH)) if File.file?(SAVE_PATH)

View File

@ -30,9 +30,8 @@ module CeleryScriptSettingsBag
set_servo_angle change_ownership dump_info)
ALLOWED_PACKAGES = %w(farmbot_os arduino_firmware)
ALLOWED_CHAGES = %w(add remove update)
RESOURCE_NAME = %w(images plants regimens peripherals
corpuses logs sequences farm_events
tool_slots tools points tokens users device)
RESOURCE_NAME = %w(Device FarmEvent Image Log Peripheral Plant Point
Regimen Sequence Tool ToolSlot User GenericPointer)
ALLOWED_MESSAGE_TYPES = %w(success busy warn error info fun debug)
ALLOWED_CHANNEL_NAMES = %w(ticker toast email espeak)
ALLOWED_POINTER_TYPE = %w(GenericPointer ToolSlot Plant)
@ -45,7 +44,7 @@ module CeleryScriptSettingsBag
read_status reboot sync take_photo)
STEPS = %w(_if execute execute_script find_home move_absolute
move_relative read_pin send_message take_photo wait
write_pin )
write_pin resource_update)
BAD_ALLOWED_PIN_MODES = '"%s" is not a valid pin_mode. Allowed values: %s'
BAD_LHS = 'Can not put "%s" into a left hand side (LHS) '\
'argument. Allowed values: %s'
@ -58,12 +57,13 @@ module CeleryScriptSettingsBag
BAD_DATA_TYPE = '"%s" is not a valid data_type. Allowed values: %s'
BAD_MESSAGE_TYPE = '"%s" is not a valid message_type. Allowed values: %s'
BAD_MESSAGE = "Messages must be between 1 and 300 characters"
BAD_RESOURCE_TYPE = '"%s" is not a valid resource_type. Allowed values: %s'
BAD_TOOL_ID = 'Tool #%s does not exist.'
BAD_PERIPH_ID = 'Peripheral #%s does not exist.'
BAD_PACKAGE = '"%s" is not a valid package. Allowed values: %s'
BAD_AXIS = '"%s" is not a valid axis. Allowed values: %s'
BAD_POINTER_ID = "Bad point ID: %s"
BAD_PIN_ID = "Can't find %s with id of %s"
BAD_RESOURCE_ID = "Can't find %s with id of %s"
NO_PIN_ID = "%s requires a valid pin number"
BAD_POINTER_TYPE = '"%s" is not a type of point. Allowed values: %s'
BAD_PIN_TYPE = '"%s" is not a type of pin. Allowed values: %s'
@ -74,9 +74,9 @@ module CeleryScriptSettingsBag
"BoxLed4" => BoxLed }
CANT_ANALOG = "Analog modes are not supported for Box LEDs"
ALLOWED_PIN_TYPES = PIN_TYPE_MAP.keys
KLASS_LOOKUP = Point::POINTER_KINDS.reduce({}) do |acc, val|
(acc[val] = Kernel.const_get(val)) && acc
end
# KLASS_LOOKUP =
# Point::POINTER_KINDS.reduce({}) { |a, v| (a[v] = Kernel.const_get(v)) && a }
RESOURCE_UPDATE_ARGS = [:resource_type, :resource_id, :label, :value]
Corpus = CeleryScript::Corpus
.new
@ -180,13 +180,19 @@ module CeleryScriptSettingsBag
BAD_DATA_TYPE % [v.to_s, ALLOWED_DATA_TYPES.inspect]
end
end
.arg(:resource_id, [Integer])
.arg(:resource_type, [String]) do |n|
within(RESOURCE_NAME, n) do |v|
BAD_RESOURCE_TYPE % [v.to_s, RESOURCE_NAME]
end
end
.node(:named_pin, [:pin_type, :pin_id]) do |node|
args = HashWithIndifferentAccess.new(node.args)
klass = PIN_TYPE_MAP.fetch(args[:pin_type].value)
id = args[:pin_id].value
node.invalidate!(NO_PIN_ID % [klass.name]) if (id == 0)
bad_node = !klass.exists?(id)
node.invalidate!(BAD_PIN_ID % [klass.name, id]) if bad_node
no_resource(node, klass, id) if bad_node
end
.node(:nothing, [])
.node(:tool, [:tool_id])
@ -240,15 +246,37 @@ module CeleryScriptSettingsBag
.node(:set_servo_angle, [:pin_number, :pin_value], [])
.node(:change_ownership, [], [:pair])
.node(:dump_info, [], [])
.node(:resource_update, RESOURCE_UPDATE_ARGS) do |x|
resource_type = x.args.fetch("resource_type").value
resource_id = x.args.fetch("resource_id").value
check_resource_type(x, resource_type, resource_id)
end
.node(:install_first_party_farmware, [])
ANY_ARG_NAME = Corpus.as_json[:args].pluck("name").map(&:to_s)
ANY_NODE_NAME = Corpus.as_json[:nodes].pluck("name").map(&:to_s)
def self.no_resource(node, klass, resource_id)
node.invalidate!(BAD_RESOURCE_ID % [klass.name, resource_id])
end
def self.check_resource_type(node, resource_type, resource_id)
case resource_type # <= Security critical code (for const_get'ing)
when "Device"
# When "resource_type" is "Device", resource_id always refers to
# the current_device.
# For convinience, we try to set it here, defaulting to 0
node.args["resource_id"].instance_variable_set("@value", 0)
when *RESOURCE_NAME.without("Device")
klass = Kernel.const_get(resource_type)
resource_ok = klass.exists?(resource_id)
no_resource(node, klass, resource_id) unless resource_ok
end
end
# Given an array of allowed values and a CeleryScript AST node, will DETERMINE
# if the node contains a legal value. Throws exception and invalidates if not.
def self.within(array, node)
val = node&.value
val = node.try(:value)
node.invalidate!(yield(val)) if !array.include?(val)
end

View File

@ -18,9 +18,9 @@ class GlobalConfig < ApplicationRecord
"FBOS_END_OF_LIFE_VERSION" => "0.0.0",
"MINIMUM_FBOS_VERSION" => "6.0.0"
}.map do |(key, value)|
self.find_or_create_by(key: key) do |conf|
conf.assign_attributes(key: key, value: value)
end
self
.find_or_create_by(key: key)
.update_attributes(key: key, value: value)
end
# Memoized version of every GlobalConfig, with key/values layed out in a hash.

View File

@ -1,5 +1,7 @@
module Devices
class Update < Mutations::Command
BAD_TOOL_ID = "Can't mount to tool #%s because it does not exist."
required do
model :device, class: Device
end
@ -8,11 +10,35 @@ module Devices
string :name
string :timezone#, in: Device::TIMEZONES
time :last_saw_mq
integer :mounted_tool_id, nils: true
end
def validate
validate_tool_id if better_tool_id
end
def execute
device.update_attributes!(inputs.except(:device))
p = inputs.except(:device).merge(mounted_tool_data)
device.update_attributes!(p)
device
end
private
def bad_tool_id
add_error :mounted_tool_id, :mounted_tool_id, BAD_TOOL_ID % better_tool_id
end
def validate_tool_id
bad_tool_id unless device.tools.pluck(:id).include?(better_tool_id)
end
def better_tool_id
@better_tool_id ||= ((mounted_tool_id || 0) > 0) ? mounted_tool_id : nil
end
def mounted_tool_data
mounted_tool_id_present? ? {mounted_tool_id: better_tool_id} : {}
end
end
end

View File

@ -31,8 +31,8 @@ module Sequences
def validate
validate_sequence
regimens_cant_have_parameters
farm_events_cant_have_parameters
# regimens_cant_have_parameters
# farm_events_cant_have_parameters
raise Errors::Forbidden unless device.sequences.include?(sequence)
end
@ -55,26 +55,29 @@ module Sequences
Regimen => BASE + "the following Regimen(s) are using it: %{items}",
}
def regimens_cant_have_parameters
maybe_stop_parameter_use(resource: Regimen,
items: Regimen
.includes(:regimen_items)
.where(regimen_items: {sequence_id: sequence.id})
.map(&:fancy_name))
end
# TODO: Bring this back after "sequence variables" rollout. - RC 12 SEP 2018
# def regimens_cant_have_parameters
# maybe_stop_parameter_use(resource: Regimen,
# items: Regimen
# .includes(:regimen_items)
# .where(regimen_items: {sequence_id: sequence.id})
# .map(&:fancy_name))
# end
def farm_events_cant_have_parameters
maybe_stop_parameter_use(resource: FarmEvent,
items: FarmEvent
.where(executable: sequence)
.map(&:fancy_name))
end
# TODO: Bring this back after "sequence variables" rollout. - RC 12 SEP 2018
# def farm_events_cant_have_parameters
# maybe_stop_parameter_use(resource: FarmEvent,
# items: FarmEvent
# .where(executable: sequence)
# .map(&:fancy_name))
# end
def maybe_stop_parameter_use(resource:, items:)
add_error :sequence, :sequence, EXPL.fetch(resource) % {
resource: resource,
items: items.join(", ")
} if items.present?
end
# TODO: Bring this back after "sequence variables" rollout. - RC 12 SEP 2018
# def maybe_stop_parameter_use(resource:, items:)
# add_error :sequence, :sequence, EXPL.fetch(resource) % {
# resource: resource,
# items: items.join(", ")
# } if items.present?
# end
end
end

View File

@ -1,4 +1,4 @@
class DeviceSerializer < ActiveModel::Serializer
attributes :id, :name, :timezone, :last_saw_api, :last_saw_mq, :tz_offset_hrs,
:fbos_version, :throttled_until, :throttled_at
:fbos_version, :throttled_until, :throttled_at, :mounted_tool_id
end

View File

@ -86,7 +86,8 @@ EXTRA_DOMAINS: staging.farm.bot,whatever.farm.bot
# Most users will not want this enabled.
RUN_CAPYBARA: "true"
# Set this to "production" in most cases.
# If you need help debugging issues, please delete this line.
# Setting this line to "production" will disable debug backtraces.
# Please delete this line if you are submitting a bug report on the forum/Github
RAILS_ENV: "production"
# Every server has a superuser.
# Set this to something SECURE.

View File

@ -1,5 +1,5 @@
Devise.setup do |config|
config.secret_key = ENV['DEVISE_SECRET']
config.secret_key = ENV["DEVISE_SECRET"]
config.mailer_sender = 'do-not-reply@farmbot.io'
require 'devise/orm/active_record'
config.case_insensitive_keys = [ :email ]

View File

@ -0,0 +1,9 @@
class AddShowMotorPlotToWebAppConfigs < ActiveRecord::Migration[5.2]
safety_assured
def change
add_column :web_app_configs,
:show_motor_plot,
:boolean,
default: false
end
end

View File

@ -0,0 +1,8 @@
class AddMountedToolIdToDevice < ActiveRecord::Migration[5.2]
def change
add_reference :devices,
:mounted_tool,
null: true,
foreign_key: { to_table: :tools }
end
end

View File

@ -122,7 +122,8 @@ CREATE TABLE public.devices (
last_saw_mq timestamp without time zone,
fbos_version character varying(15),
throttled_until timestamp without time zone,
throttled_at timestamp without time zone
throttled_at timestamp without time zone,
mounted_tool_id bigint
);
@ -1216,7 +1217,8 @@ CREATE TABLE public.web_app_configs (
photo_filter_end character varying,
discard_unsaved boolean DEFAULT false,
xy_swap boolean DEFAULT false,
home_button_homing boolean DEFAULT false
home_button_homing boolean DEFAULT false,
show_motor_plot boolean DEFAULT false
);
@ -1715,6 +1717,13 @@ ALTER TABLE ONLY public.webcam_feeds
CREATE INDEX delayed_jobs_priority ON public.delayed_jobs USING btree (priority, run_at);
--
-- Name: index_devices_on_mounted_tool_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_devices_on_mounted_tool_id ON public.devices USING btree (mounted_tool_id);
--
-- Name: index_devices_on_timezone; Type: INDEX; Schema: public; Owner: -
--
@ -2195,6 +2204,14 @@ ALTER TABLE ONLY public.token_issuances
ADD CONSTRAINT fk_rails_e202a61188 FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: devices fk_rails_eef5afaff7; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.devices
ADD CONSTRAINT fk_rails_eef5afaff7 FOREIGN KEY (mounted_tool_id) REFERENCES public.tools(id);
--
-- Name: pin_bindings fk_rails_f72ee24d98; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -2305,6 +2322,8 @@ INSERT INTO "schema_migrations" (version) VALUES
('20180726165546'),
('20180727152741'),
('20180813185430'),
('20180815143819');
('20180815143819'),
('20180829211322'),
('20180910143055');

View File

@ -1,10 +1,12 @@
class CorpusEmitter
PIPE = "\n | "
PIPE = "\n | "
class CSArg
TRANSLATIONS = {"integer" => "number",
"string" => "string",
"float" => "number" }
"float" => "number",
"boolean" => "boolean" }
attr_reader :name, :allowed_values
def initialize(name:, allowed_values:)
@ -141,7 +143,7 @@ class CorpusEmitter
result.push(enum_type :PlantStage, CeleryScriptSettingsBag::PLANT_STAGES)
File.open("latest_corpus.ts", "w") do |f|
f.write(result.join.gsub("\n\n\n", "\n").gsub("\n\n", "\n").strip)
f.write(result.join.gsub("\n\n\n", "\n").gsub("\n\n", "\n").gsub("\n\n", "\n").strip)
end
end
end

View File

@ -17,7 +17,8 @@
"test-slow": "jest --coverage --no-cache -w 4",
"test": "jest --no-coverage --cache -w 5",
"typecheck": "./node_modules/.bin/tsc --noEmit --jsx preserve",
"tslint": "./node_modules/tslint/bin/tslint --project ."
"tslint": "./node_modules/tslint/bin/tslint --project .",
"sass-lint": "./node_modules/sass-lint/bin/sass-lint.js -c .sass-lint.yml -v -q"
},
"keywords": [
"farmbot"
@ -25,21 +26,21 @@
"author": "farmbot.io",
"license": "MIT",
"optionalDependencies": {
"webpack-dev-server": "3.1.5"
"webpack-dev-server": "3.1.7"
},
"dependencies": {
"@blueprintjs/core": "2.3.1",
"@blueprintjs/datetime": "2.0.3",
"@blueprintjs/select": "^2.0.1",
"@types/enzyme": "3.1.12",
"@types/enzyme": "3.1.13",
"@types/fastclick": "^1.0.28",
"@types/history": "^4.6.1",
"@types/i18next": "^8.4.2",
"@types/history": "4.7.0",
"@types/i18next": "8.4.5",
"@types/jest": "23.3.1",
"@types/lodash": "4.14.114",
"@types/markdown-it": "^0.0.4",
"@types/lodash": "4.14.116",
"@types/markdown-it": "0.0.5",
"@types/moxios": "^0.4.5",
"@types/node": "10.5.3",
"@types/node": "10.9.4",
"@types/react": "16.3.14",
"@types/react-color": "2.13.5",
"@types/react-dom": "16.0.5",
@ -50,51 +51,52 @@
"browser-speech": "1.1.1",
"coveralls": "3.0.2",
"css-loader": "1.0.0",
"enzyme": "^3.1.0",
"enzyme-adapter-react-16": "^1.1.0",
"farmbot": "6.5.0-rc4",
"enzyme": "3.6.0",
"enzyme-adapter-react-16": "1.5.0",
"farmbot": "6.5.0",
"farmbot-toastr": "^1.0.3",
"fastclick": "^1.0.6",
"file-loader": "1.1.11",
"i18next": "11.5.0",
"file-loader": "2.0.0",
"i18next": "11.7.0",
"imports-loader": "0.8.0",
"jest": "23.4.1",
"jest": "23.5.0",
"json-loader": "0.5.7",
"lodash": "4.17.10",
"markdown-it": "^8.4.0",
"markdown-it-emoji": "^1.4.0",
"moment": "2.22.2",
"moxios": "^0.4.0",
"node-sass": "4.9.2",
"optimize-css-assets-webpack-plugin": "5.0.0",
"node-sass": "4.9.3",
"optimize-css-assets-webpack-plugin": "5.0.1",
"raf": "^3.4.0",
"react": "16.4.1",
"react": "16.4.2",
"react-addons-css-transition-group": "^15.6.2",
"react-addons-test-utils": "^15.6.2",
"react-color": "2.14.1",
"react-dom": "16.4.1",
"react-dom": "16.4.2",
"react-redux": "^5.0.6",
"react-router": "^3",
"react-test-renderer": "16.4.1",
"react-test-renderer": "16.4.2",
"react-transition-group": "^2.3.1",
"redux": "4.0.0",
"redux-immutable-state-invariant": "^2.1.0",
"redux-thunk": "2.3.0",
"rollbar-sourcemap-webpack-plugin": "^2.3.0",
"sass-lint": "^1.12.1",
"sass-loader": "7.1.0",
"stats-webpack-plugin": "0.6.2",
"style-loader": "0.21.0",
"ts-jest": "23.0.1",
"stats-webpack-plugin": "0.7.0",
"style-loader": "0.23.0",
"ts-jest": "23.1.4",
"ts-lint": "^4.5.1",
"ts-loader": "4.4.2",
"ts-loader": "5.0.0",
"tslint": "5.11.0",
"typescript": "3.0.1",
"url-loader": "1.0.1",
"webpack": "4.16.4",
"typescript": "3.0.3",
"url-loader": "1.1.1",
"webpack": "4.17.2",
"webpack-uglify-js-plugin": "1.1.9",
"weinre": "^2.0.0-pre-I0Z7U9OV",
"which": "1.3.1",
"yarn": "^1.9.2"
"yarn": "1.9.4"
},
"devDependencies": {
"jscpd": "0.6.22",
@ -124,7 +126,7 @@
"./webpack/__test_support__/additional_mocks.ts"
],
"transform": {
".(ts|tsx)": "<rootDir>/node_modules/ts-jest/preprocessor.js"
".(ts|tsx)": "ts-jest"
},
"testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
"moduleFileExtensions": [

View File

@ -1,20 +1,22 @@
require 'spec_helper'
require "spec_helper"
# Api::DevicesController is the RESTful endpoint for managing device related
# settings. Consumed by the Angular SPA on the front end.
describe Api::DevicesController do
include Devise::Test::ControllerHelpers
describe "#update" do
describe '#update' do
let(:user) { FactoryBot.create(:user) }
let(:user2) { FactoryBot.create(:user) }
let(:device) { user.device }
let(:tool) { FactoryBot.create(:tool, device: user.device) }
let(:user) { FactoryBot.create(:user) }
let(:user2) { FactoryBot.create(:user) }
it 'updates a Device' do
it "updates a Device" do
sign_in user
fake_name = Faker::Name.name
put :update, params: {id: user.device.id, name: fake_name}, session: { format: :json }
put :update,
params: {id: user.device.id, name: fake_name},
session: { format: :json }
# put path, params, options
user.reload
device = user.reload.device.reload
@ -22,7 +24,7 @@ describe Api::DevicesController do
expect(response.status).to eq(200)
end
it 'updates a Device Timezone wrong' do
it "updates a Device Timezone wrong" do
sign_in user
before = user.device.timezone
put :update,
@ -36,7 +38,7 @@ describe Api::DevicesController do
expect(user.device.timezone).to eq(before)
end
it 'updates a Device timezone correctly' do
it "updates a Device timezone correctly" do
sign_in user
fake_tz = Device::TIMEZONES.sample
put :update, params: {id: user.device.id, timezone: fake_tz}, session: { format: :json }
@ -45,5 +47,42 @@ describe Api::DevicesController do
expect(device.timezone).to eq(fake_tz)
expect(response.status).to eq(200)
end
it "mounts a tool" do
sign_in user
put :update,
params: {
id: user.device.id,
mounted_tool_id: tool.id
},
session: { format: :json }
user.reload
device = user.reload.device.reload
expect(device.mounted_tool_id).to eq(tool.id)
expect(response.status).to eq(200)
end
it "performs referential integrity checks on mounted_tool_id" do
sign_in user
put :update,
params: {
id: user.device.id,
mounted_tool_id: (FactoryBot.create(:tool).id + 1)
},
session: { format: :json }
expect(response.status).to eq(422)
expect(json[:mounted_tool_id]).to include("Can't mount to tool")
end
it "dismounts a tool" do
sign_in user
device.update_attributes!(mounted_tool_id: tool.id)
expect(device.mounted_tool_id).to be
put :update,
params: { id: user.device.id, mounted_tool_id: 0 },
session: { format: :json }
expect(device.reload.mounted_tool_id).not_to be
expect(json[:mounted_tool_id]).to be(nil)
end
end
end

View File

@ -35,6 +35,7 @@ describe Api::SequencesController do
end
it 'disallows adding `parent` to sequences used as executable' do
pending "Possibly broke"
sign_in user
sequence = FakeSequence.create(device: user.device)
farm_ev = FactoryBot.create(:farm_event, device: user.device, executable: sequence)
@ -46,6 +47,7 @@ describe Api::SequencesController do
it 'disallows adding `parent` to sequences used in a regimen' do
pending "Possibly broke"
sign_in user
sequence = FakeSequence.create(device: user.device)
regimen = Regimens::Create.run!(device: user.device,

View File

@ -1,12 +1,12 @@
FactoryBot.define do
factory :diagnostic_dump do
device
fbos_version "123_fbos_version"
fbos_commit "123_fbos_commit"
firmware_commit "123_firmware_commit"
network_interface "123_network_interface"
fbos_dmesg_dump "123_fbos_dmesg_dump"
firmware_state "123_firmware_state"
fbos_version { "123_fbos_version" }
fbos_commit { "123_fbos_commit" }
firmware_commit { "123_firmware_commit" }
network_interface { "123_network_interface" }
fbos_dmesg_dump { "123_fbos_dmesg_dump" }
firmware_state { "123_firmware_state" }
ticket_identifier { rand(36**5).to_s(36) }
end
end

View File

@ -1,11 +1,11 @@
FactoryBot.define do
factory :generic_pointer do
radius 1.5
radius { 1.5 }
x { rand(1...550) }
y { rand(1...550) }
z { rand(1...550) }
meta ({})
meta {({})}
device
pointer_type GenericPointer.name
pointer_type { GenericPointer.name }
end
end

View File

@ -1,6 +1,6 @@
FactoryBot.define do
factory :global_config do
key "MyString"
value "MyText"
key { "MyString" }
value { "MyText" }
end
end

View File

@ -2,7 +2,7 @@
FactoryBot.define do
factory :image do
attachment { StringIO.new(File.open("./spec/fixture.jpg").read) }
meta ({x: 1, y: 2, z: 3})
meta {({x: 1, y: 2, z: 3})}
device
end
end

View File

@ -2,6 +2,6 @@ FactoryBot.define do
factory :log_dispatch do
device
log
sent_at "2017-05-25 06:16:55"
sent_at { "2017-05-25 06:16:55" }
end
end

View File

@ -4,6 +4,6 @@ FactoryBot.define do
sequence(:created_at) { |n| n.minutes.ago.utc }
message { Faker::Company.bs }
type { Log::TYPES.sample }
channels ["toast"]
channels { ["toast"] }
end
end

View File

@ -3,6 +3,6 @@ FactoryBot.define do
factory :peripheral do
device
pin { count = (count + 1) % 50 }
label "MyString"
label { "MyString" }
end
end

View File

@ -1,7 +1,7 @@
FactoryBot.define do
factory :plant_template do
openfarm_slug "lettuce"
radius 1.5
openfarm_slug { "lettuce" }
radius { 1.5 }
x { rand(1...550) }
y { rand(1...550) }
z { rand(1...550) }

View File

@ -1,12 +1,12 @@
FactoryBot.define do
factory :plant do
radius 1.5
radius { 1.5 }
x { rand(1...550) }
y { rand(1...550) }
z { rand(1...550) }
meta ({})
meta {({})}
device
openfarm_slug "lettuce"
pointer_type "Plant"
openfarm_slug { "lettuce" }
pointer_type { "Plant" }
end
end

View File

@ -3,7 +3,7 @@ FactoryBot.define do
factory :sensor do
device
pin { count = (count + 1) % 50 }
label "MyString"
mode 1
label { "MyString" }
mode { 1 }
end
end

View File

@ -3,9 +3,9 @@ FactoryBot.define do
x { rand(1...550) }
y { rand(1...550) }
z { rand(1...550) }
meta ({})
meta {({})}
device
tool
pointer_type("ToolSlot")
pointer_type { "ToolSlot" }
end
end

View File

@ -3,9 +3,9 @@
FactoryBot.define do
factory :user do
device
name { Faker::Name.name }
email { Faker::Internet.email }
password { Faker::Internet.password(8) }
name { Faker::Name.name }
email { Faker::Internet.email }
password { Faker::Internet.password(8) }
confirmed_at { Time.now }
after(:create) do |user|
user.device ||= Devices::Create.run!(user: resp[:user])

View File

@ -1,6 +1,6 @@
FactoryBot.define do
factory :webcam_feed do
device
url "http://placehold.it/320x240"
url { "http://placehold.it/320x240" }
end
end

View File

@ -27,7 +27,7 @@ describe CeleryScript::Corpus do
speed: 100
}
})
check1 = CeleryScript::Checker.new(ok1, Sequence::Corpus, device)
check1 = CeleryScript::Checker.new(ok1, corpus, device)
expect(check1.valid?).to be_truthy
ok2 = CeleryScript::AstNode.new({
@ -48,7 +48,7 @@ describe CeleryScript::Corpus do
speed: 100
}
})
check2 = CeleryScript::Checker.new(ok2, Sequence::Corpus, device)
check2 = CeleryScript::Checker.new(ok2, corpus, device)
expect(check2.valid?).to be_truthy
end
@ -68,7 +68,7 @@ describe CeleryScript::Corpus do
},
}
})
check = CeleryScript::Checker.new(bad, Sequence::Corpus, device)
check = CeleryScript::Checker.new(bad, corpus, device)
expect(check.valid?).to be_falsey
expect(check.error.message).to include("but got Integer")
expect(check.error.message).to include("'location' within 'move_absolute'")
@ -94,7 +94,7 @@ describe CeleryScript::Corpus do
speed: 100
}
})
check = CeleryScript::Checker.new(bad, Sequence::Corpus, device)
check = CeleryScript::Checker.new(bad, corpus, device)
expect(check.valid?).to be_falsey
expect(check.error.message).to include("but got String")
end
@ -123,9 +123,7 @@ describe CeleryScript::Corpus do
},
"body": []
})
checker = CeleryScript::Checker.new(tree,
CeleryScriptSettingsBag::Corpus,
device)
checker = CeleryScript::Checker.new(tree, corpus, device)
expect(checker.error.message).to include("not a valid message_type")
end
@ -145,9 +143,57 @@ describe CeleryScript::Corpus do
}
]
})
checker = CeleryScript::Checker.new(tree,
CeleryScriptSettingsBag::Corpus,
device)
checker = CeleryScript::Checker.new(tree, corpus, device)
expect(checker.error.message).to include("not a valid channel_name")
end
it "validates tool_ids" do
ast = { "kind": "tool", "args": { "tool_id": 0 } };
checker = CeleryScript::Checker.new(CeleryScript::AstNode.new(ast),
corpus,
device)
expect(checker.valid?).to be(false)
expect(checker.error.message).to include("Tool #0 does not exist.")
end
it "Validates resource_update nodes" do
ast = { "kind": "resource_update",
"args": { "resource_type" => "Device",
"resource_id" => 23, # Mutated to "0" later..
"label" => "mounted_tool_id",
"value" => 1 } }
checker = CeleryScript::Checker.new(CeleryScript::AstNode.new(ast), corpus, device)
expect(checker.valid?).to be(true)
expect(checker.tree.args["resource_id"].value).to eq(0)
end
it "rejects bogus resource_updates" do
fake_id = FakeSequence.create().id + 1
expect(Sequence.exists?(fake_id)).to be(false)
ast = { "kind": "resource_update",
"args": { "resource_type" => "Sequence",
"resource_id" => fake_id,
"label" => "foo",
"value" => "Should Fail" } }
hmm = CeleryScript::AstNode.new(ast)
expect(hmm.args.fetch("resource_id").value).to eq(fake_id)
checker = CeleryScript::Checker.new(hmm, corpus, device)
expect(checker.valid?).to be(false)
expect(checker.error.message)
.to eq("Can't find Sequence with id of #{fake_id}")
end
it "rejects bogus resource_types" do
ast = { "kind": "resource_update",
"args": { "resource_type" => "CanOpener",
"resource_id" => 0,
"label" => "foo",
"value" => "Should Fail" } }
checker = CeleryScript::Checker.new(CeleryScript::AstNode.new(ast),
corpus,
device)
expect(checker.valid?).to be(false)
expect(checker.error.message)
.to include('"CanOpener" is not a valid resource_type.')
end
end

View File

@ -20,7 +20,8 @@ end
describe "Pin Binding updates" do
it "enforces mutual exclusivity" do
[Point, Tool, PinBinding, Sequence, Device].map(&:destroy_all)
Device.update_all(mounted_tool_id: nil)
[Point, Tool, PinBinding, Sequence].map(&:destroy_all)
device = FactoryBot.create(:device)
PinBinding.create!(device: device)
Sequence.create!(device: device, name: "test")

View File

@ -0,0 +1,9 @@
describe Sequences::Update do
it "does not allow you to modify other peoples sequences" do
theirs = FakeSequence.create()
you = FactoryBot.create(:device)
K = Sequences::Update
evil_params = {sequence: theirs, device: you, name: "impossible", body: []}
expect { K.run!(evil_params) }.to raise_error(Errors::Forbidden)
end
end

View File

@ -1,10 +1,8 @@
import { ReactWrapper, ShallowWrapper } from "enzyme";
import { range } from "lodash";
// tslint:disable-next-line:no-any
export function getProp(i: ReactWrapper<any, {}>, key: string): any {
return i.props()[key];
}
export const getProp =
<T, K extends keyof T>(i: ReactWrapper<T, {}>, key: K): T[K] => i.props()[key];
/** Simulate a click and check button text for a button in a wrapper. */
export function clickButton(

View File

@ -30,6 +30,7 @@ const fakeProps = (): AppProps => {
firmwareConfig: undefined,
xySwap: false,
animate: false,
getConfigValue: jest.fn(),
};
};

View File

@ -7,13 +7,13 @@ jest.mock("farmbot", () => {
import { fetchNewDevice } from "../device";
import { auth } from "../__test_support__/fake_state/token";
import { get } from "lodash";
describe("fetchNewDevice", () => {
it("returns an instance of FarmBot", async () => {
const bot = await fetchNewDevice(auth);
expect(bot).toBeInstanceOf(mockFarmbot);
// We use this for debugging in local dev env
// tslint:disable-next-line:no-any
expect((global as any)["current_bot"]).toBeDefined();
expect(get(global, "current_bot")).toBeDefined();
});
});

View File

@ -8,14 +8,7 @@ jest.mock("axios", () => ({
}
}));
jest.mock("../session", () => {
return {
Session: {
clear: jest.fn(),
deprecatedGetBool: jest.fn(),
}
};
});
jest.mock("../session", () => ({ Session: { clear: jest.fn(), } }));
import { maybeRefreshToken } from "../refresh_token";
import { API } from "../api/index";

View File

@ -6,8 +6,6 @@ let mockAuth: AuthState | undefined = undefined;
jest.mock("../session", () => ({
Session: {
fetchStoredToken: jest.fn(() => mockAuth),
deprecatedGetNum: () => undefined,
deprecatedGetBool: () => undefined,
getAll: () => undefined,
clear: jest.fn()
}

View File

@ -1,24 +1,3 @@
import { fakeWebAppConfig } from "../__test_support__/fake_state/resources";
import { fakeState } from "../__test_support__/fake_state";
const mockConfig = fakeWebAppConfig();
jest.mock("../resources/selectors_by_kind", () => ({
getWebAppConfig: () => mockConfig
}));
jest.mock("../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
const mockState = fakeState();
jest.mock("../redux/store", () => ({
store: {
dispatch: jest.fn(),
getState: () => mockState,
}
}));
import {
isNumericSetting,
isBooleanSetting,
@ -27,7 +6,6 @@ import {
Session,
} from "../session";
import { auth } from "../__test_support__/fake_state/token";
import { edit, save } from "../api/crud";
describe("fetchStoredToken", () => {
it("can't fetch token", () => {
@ -61,26 +39,6 @@ describe("safeBooleanSetting", () => {
});
});
describe("setBool", () => {
it("sets bool", () => {
Session.setBool("x_axis_inverted", false);
expect(edit).toHaveBeenCalledWith(expect.any(Object), {
x_axis_inverted: false
});
expect(save).toHaveBeenCalledWith(mockConfig.uuid);
});
});
describe("invertBool", () => {
it("inverts bool", () => {
Session.invertBool("x_axis_inverted");
expect(edit).toHaveBeenCalledWith(expect.any(Object), {
x_axis_inverted: true
});
expect(save).toHaveBeenCalledWith(mockConfig.uuid);
});
});
describe("safeNumericSetting", () => {
it("safely returns num", () => {
expect(() => safeNumericSetting("no")).toThrow();

View File

@ -1,7 +1,6 @@
const mock = {
response: {
// tslint:disable-next-line:no-any
data: (undefined as any) // Mutable
data: (undefined as undefined | {}) // Mutable
}
};
@ -35,8 +34,7 @@ describe("requestAccountExport", () => {
it("downloads the data synchronously (when API has no email support)", async () => {
mock.response.data = {};
// tslint:disable-next-line:no-any
window.URL = window.URL || ({} as any);
window.URL = window.URL || ({} as typeof window.URL);
window.URL.createObjectURL = jest.fn();
window.URL.revokeObjectURL = jest.fn();
const a = await requestAccountExport();

View File

@ -79,7 +79,9 @@ export class Account extends React.Component<Props, State> {
<ChangePassword />
</Row>
<Row>
<LabsFeatures />
<LabsFeatures
dispatch={this.props.dispatch}
getConfigValue={this.props.getConfigValue} />
</Row>
<Row>
<DeleteAccount onClick={deleteAcct} />

View File

@ -1,9 +1,11 @@
import { User } from "../auth/interfaces";
import { TaggedUser } from "farmbot";
import { GetWebAppConfigValue } from "../config_storage/actions";
export interface Props {
user: TaggedUser;
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
}
/** JSON form that gets POSTed to the API when user updates their info. */

View File

@ -3,7 +3,7 @@ import { fetchLabFeatures } from "../labs_features_list_data";
describe("fetchLabFeatures", () => {
window.location.reload = jest.fn();
it("basically just initializes stuff", () => {
const val = fetchLabFeatures();
const val = fetchLabFeatures(jest.fn());
expect(val.length).toBe(9);
expect(val[0].value).toBeFalsy();
const { callback } = val[0];

View File

@ -1,20 +1,5 @@
const mockStorj: Dictionary<boolean> = {};
jest.mock("../../../session", () => {
return {
Session: {
deprecatedGetBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
},
invertBool: (k: string) => {
mockStorj[k] = !mockStorj[k];
return mockStorj[k];
}
}
};
});
import { Dictionary } from "farmbot";
import { maybeToggleFeature, LabsFeature } from "../labs_features_list_data";
import { BooleanSetting } from "../../../session_keys";
@ -29,7 +14,7 @@ describe("maybeToggleFeature()", () => {
storageKey: BooleanSetting.stub_config,
confirmationMessage: "are you sure?"
};
const out = maybeToggleFeature(data);
const out = maybeToggleFeature(x => mockStorj[x], jest.fn())(data);
expect(data.value).toBeFalsy();
expect(out).toBeUndefined();
expect(window.confirm).toHaveBeenCalledWith(data.confirmationMessage);
@ -44,7 +29,7 @@ describe("maybeToggleFeature()", () => {
storageKey: BooleanSetting.stub_config,
confirmationMessage: "are you sure?"
};
const out = maybeToggleFeature(data);
const out = maybeToggleFeature(x => mockStorj[x], jest.fn())(data);
out ?
expect(out.value).toBeTruthy() : fail("out === undefined. Thats bad");
expect(out).toBeTruthy();
@ -52,7 +37,7 @@ describe("maybeToggleFeature()", () => {
it("Does not require consent when going from true to false", () => {
window.confirm = jest.fn(() => true);
const output = maybeToggleFeature({
const output = maybeToggleFeature(x => mockStorj[x], jest.fn())({
name: "Example",
value: (mockStorj[BooleanSetting.stub_config] = true),
description: "I stub this.",
@ -71,7 +56,7 @@ describe("maybeToggleFeature()", () => {
description: "I stub this.",
storageKey: BooleanSetting.stub_config
};
const out = maybeToggleFeature(data);
const out = maybeToggleFeature(x => mockStorj[x], jest.fn())(data);
out ?
expect(out.value).toBeTruthy() : fail("out === undefined. Thats bad");
expect(out).toBeTruthy();

View File

@ -9,7 +9,7 @@ const mockFeatures = [
];
const mocks = {
"maybeToggleFeature": jest.fn(),
"maybeToggleFeature": jest.fn(() => jest.fn()),
"fetchLabFeatures": jest.fn(() => mockFeatures)
};
@ -21,7 +21,9 @@ import { LabsFeatures } from "../labs_features";
describe("<LabsFeatures/>", () => {
it("triggers the correct callback on click", () => {
const el = mount(<LabsFeatures />);
const el = mount(<LabsFeatures
dispatch={jest.fn()}
getConfigValue={jest.fn()} />);
expect(mocks.fetchLabFeatures.mock.calls.length).toBeGreaterThan(0);
el.find("button").simulate("click");
expect(mockFeatures[0].callback).toHaveBeenCalled();

View File

@ -4,20 +4,29 @@ import { LabsFeaturesList } from "./labs_features_list_ui";
import { maybeToggleFeature } from "./labs_features_list_data";
import { t } from "i18next";
import { ToolTips } from "../../constants";
import { GetWebAppConfigValue } from "../../config_storage/actions";
export class LabsFeatures extends React.Component<{}, {}> {
interface LabsFeaturesProps {
getConfigValue: GetWebAppConfigValue;
dispatch: Function;
}
export class LabsFeatures extends React.Component<LabsFeaturesProps, {}> {
state = {};
render() {
const { getConfigValue, dispatch } = this.props;
return <Widget className="peripherals-widget">
<WidgetHeader title={t("App Settings")}
helpText={ToolTips.LABS}>
</WidgetHeader>
<WidgetBody>
<LabsFeaturesList onToggle={(x) => {
maybeToggleFeature(x);
this.forceUpdate();
}} />
<LabsFeaturesList
getConfigValue={getConfigValue}
onToggle={x => {
maybeToggleFeature(getConfigValue, dispatch)(x);
this.forceUpdate();
}} />
</WidgetBody>
</Widget>;
}

View File

@ -1,8 +1,9 @@
import { Session } from "../../session";
import { t } from "i18next";
import { BooleanConfigKey } from "../../config_storage/web_app_configs";
import { BooleanSetting } from "../../session_keys";
import { Content } from "../../constants";
import { VirtualTrail } from "../../farm_designer/map/virtual_farmbot/bot_trail";
import { GetWebAppConfigValue, setWebAppConfigValue } from "../../config_storage/actions";
export interface LabsFeature {
/** Toggle label. */
@ -21,88 +22,91 @@ export interface LabsFeature {
callback?(): void;
}
export const fetchLabFeatures = (): LabsFeature[] => ([
{
name: t("Internationalize Web App"),
description: t("Turn off to set Web App to English."),
storageKey: BooleanSetting.disable_i18n,
value: false,
displayInvert: true,
callback: () => window.location.reload()
},
{
name: t("Confirm Sequence step deletion"),
description: t(Content.CONFIRM_STEP_DELETION),
storageKey: BooleanSetting.confirm_step_deletion,
value: false
},
{
name: t("Hide Webcam widget"),
description: t(Content.HIDE_WEBCAM_WIDGET),
storageKey: BooleanSetting.hide_webcam_widget,
value: false
},
{
name: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
storageKey: BooleanSetting.dynamic_map,
value: false
},
{
name: t("Double default map dimensions"),
description: t(Content.DOUBLE_MAP_DIMENSIONS),
storageKey: BooleanSetting.map_xl,
value: false
},
{
name: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
storageKey: BooleanSetting.disable_animations,
value: false,
displayInvert: true
},
{
name: t("Read speak logs in browser"),
description: t(Content.BROWSER_SPEAK_LOGS),
storageKey: BooleanSetting.enable_browser_speak,
value: false
},
{
name: t("Discard Unsaved Changes"),
description: t(Content.DISCARD_UNSAVED_CHANGES),
storageKey: BooleanSetting.discard_unsaved,
value: false,
confirmationMessage: t(Content.DISCARD_UNSAVED_CHANGES_CONFIRM)
},
{
name: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
storageKey: BooleanSetting.display_trail,
value: false,
callback: () => sessionStorage.setItem("virtualTrailRecords", "[]")
},
].map(fetchRealValue));
export const fetchLabFeatures =
(getConfigValue: GetWebAppConfigValue): LabsFeature[] => ([
{
name: t("Internationalize Web App"),
description: t("Turn off to set Web App to English."),
storageKey: BooleanSetting.disable_i18n,
value: false,
displayInvert: true,
callback: () => window.location.reload()
},
{
name: t("Confirm Sequence step deletion"),
description: t(Content.CONFIRM_STEP_DELETION),
storageKey: BooleanSetting.confirm_step_deletion,
value: false
},
{
name: t("Hide Webcam widget"),
description: t(Content.HIDE_WEBCAM_WIDGET),
storageKey: BooleanSetting.hide_webcam_widget,
value: false
},
{
name: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
storageKey: BooleanSetting.dynamic_map,
value: false
},
{
name: t("Double default map dimensions"),
description: t(Content.DOUBLE_MAP_DIMENSIONS),
storageKey: BooleanSetting.map_xl,
value: false
},
{
name: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
storageKey: BooleanSetting.disable_animations,
value: false,
displayInvert: true
},
{
name: t("Read speak logs in browser"),
description: t(Content.BROWSER_SPEAK_LOGS),
storageKey: BooleanSetting.enable_browser_speak,
value: false
},
{
name: t("Discard Unsaved Changes"),
description: t(Content.DISCARD_UNSAVED_CHANGES),
storageKey: BooleanSetting.discard_unsaved,
value: false,
confirmationMessage: t(Content.DISCARD_UNSAVED_CHANGES_CONFIRM)
},
{
name: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
storageKey: BooleanSetting.display_trail,
value: false,
callback: () => sessionStorage.setItem(VirtualTrail.records, "[]")
},
].map(fetchSettingValue(getConfigValue)));
/** Always allow toggling from true => false (deactivate).
* Require a disclaimer when going from false => true (activate). */
export const maybeToggleFeature =
(x: LabsFeature): LabsFeature | undefined => {
return (x.value
|| !x.confirmationMessage
|| window.confirm(x.confirmationMessage)) ?
toggleFeatureValue(x) : undefined;
};
/** Stub this when testing if need be. */
const fetchVal = (k: BooleanConfigKey) => !!Session.deprecatedGetBool(k);
(getConfigValue: GetWebAppConfigValue, dispatch: Function) =>
(x: LabsFeature): LabsFeature | undefined =>
(x.value
|| !x.confirmationMessage
|| window.confirm(x.confirmationMessage)) ?
toggleFeatureValue(getConfigValue, dispatch)(x) : undefined;
/** Takes a `LabFeature` (probably one with an uninitialized fallback / default
* value) and sets it to the _real_ value that's in the API. */
const fetchRealValue = (x: LabsFeature): LabsFeature => {
return { ...x, value: fetchVal(x.storageKey) };
};
const fetchSettingValue = (getConfigValue: GetWebAppConfigValue) =>
(x: LabsFeature): LabsFeature => {
return { ...x, value: !!getConfigValue(x.storageKey) };
};
/** Toggle the `.value` of a `LabsToggle` object */
const toggleFeatureValue = (x: LabsFeature) => {
return { ...x, value: Session.invertBool(x.storageKey) };
};
const toggleFeatureValue =
(getConfigValue: GetWebAppConfigValue, dispatch: Function) =>
(x: LabsFeature) => {
const value = !getConfigValue(x.storageKey);
dispatch(setWebAppConfigValue(x.storageKey, value));
return { ...x, value };
};

View File

@ -1,14 +1,16 @@
import * as React from "react";
import { fetchLabFeatures, LabsFeature } from "./labs_features_list_data";
import { KeyValShowRow } from "../../controls/key_val_show_row";
import { GetWebAppConfigValue } from "../../config_storage/actions";
interface LabsFeaturesListProps {
onToggle(feature: LabsFeature): void;
getConfigValue: GetWebAppConfigValue;
}
export function LabsFeaturesList(props: LabsFeaturesListProps) {
return <div>
{fetchLabFeatures().map((p, i) => {
{fetchLabFeatures(props.getConfigValue).map((p, i) => {
const displayValue = p.displayInvert ? !p.value : p.value;
return <KeyValShowRow key={i}
label={p.name}

View File

@ -1,12 +1,14 @@
import { Everything } from "../interfaces";
import { Props } from "./interfaces";
import { getUserAccountSettings } from "../resources/selectors";
import { getWebAppConfigValue } from "../config_storage/actions";
export function mapStateToProps(props: Everything): Props {
const user = getUserAccountSettings(props.resources.index);
return {
user,
dispatch: () => { throw new Error("NEVER SHOULD HAPPEN"); }
dispatch: () => { throw new Error("NEVER SHOULD HAPPEN"); },
getConfigValue: getWebAppConfigValue(() => props),
};
}

View File

@ -11,7 +11,7 @@ describe("API", () => {
[API.current.pointSearchPath, BASE + "/api/points/search"],
[API.current.sensorReadingPath, BASE + "/api/sensor_readings"],
[API.current.farmwareEnvPath, BASE + "/api/farmware_envs"],
[API.current.plantTemplatePath, BASE + "/api/plant_templates"],
[API.current.plantTemplatePath, BASE + "/api/plant_templates/"],
[API.current.diagnosticDumpsPath, BASE + "/api/diagnostic_dumps/"],
[API.current.farmwareInstallationPath, BASE + "/api/farmware_installations"],
].map(x => expect(x[0]).toEqual(x[1]));

View File

@ -144,7 +144,7 @@ export class API {
(gardenId: number) => `${this.savedGardensPath}/${gardenId}/apply`;
get exportDataPath() { return `${this.baseUrl}/api/export_data`; }
/** /api/plant_templates/:id */
get plantTemplatePath() { return `${this.baseUrl}/api/plant_templates`; }
get plantTemplatePath() { return `${this.baseUrl}/api/plant_templates/`; }
/** /api/diagnostic_dumps/:id */
get diagnosticDumpsPath() { return `${this.baseUrl}/api/diagnostic_dumps/`; }
/** /api/farmware_installations/:id */

View File

@ -13,7 +13,7 @@ import {
import { UnsafeError } from "../interfaces";
import { findByUuid } from "../resources/reducer";
import { generateUuid } from "../resources/util";
import { defensiveClone } from "../util";
import { defensiveClone, unpackUUID } from "../util";
import { EditResourceParams } from "./interfaces";
import { ResourceIndex } from "../resources/interfaces";
import { SequenceBodyItem } from "farmbot/dist";
@ -225,6 +225,7 @@ export function urlFor(tag: ResourceName) {
FirmwareConfig: API.current.firmwareConfigPath,
DiagnosticDump: API.current.diagnosticDumpsPath,
SavedGarden: API.current.savedGardensPath,
PlantTemplate: API.current.plantTemplatePath,
};
const url = OPTIONS[tag];
if (url) {
@ -247,7 +248,7 @@ export function updateViaAjax(payl: AjaxUpdatePayload) {
let url = urlFor(kind);
if (body.id) {
verb = "put";
if (!SINGULAR_RESOURCE.includes(payl.uuid.split(".")[0] as ResourceName)) {
if (!SINGULAR_RESOURCE.includes(unpackUUID(payl.uuid).kind)) {
url += body.id;
}
} else {
@ -276,7 +277,8 @@ const MUST_CONFIRM_LIST: ResourceName[] = [
"Point",
"Sequence",
"Regimen",
"Image"
"Image",
"SavedGarden",
];
const confirmationChecker = (resource: TaggedResource, force = false) =>

View File

@ -1,5 +1,6 @@
import { ResourceName } from "farmbot";
import { startTracking } from "../connectivity/data_consistency";
import { unpackUUID } from "../util";
const BLACKLIST: ResourceName[] = [
"DiagnosticDump",
@ -16,6 +17,6 @@ const BLACKLIST: ResourceName[] = [
];
export function maybeStartTracking(uuid: string) {
const ignore = BLACKLIST.includes(uuid.split(".")[0] as ResourceName);
const ignore = BLACKLIST.includes(unpackUUID(uuid).kind);
ignore || startTracking(uuid);
}

View File

@ -22,7 +22,7 @@ import { validBotLocationData, validFwConfig } from "./util";
import { BooleanSetting } from "./session_keys";
import { getPathArray } from "./history";
import { FirmwareConfig } from "./config_storage/firmware_configs";
import { getWebAppConfigValue } from "./config_storage/actions";
import { getWebAppConfigValue, GetWebAppConfigValue } from "./config_storage/actions";
import { takeSortedLogs } from "./logs/state_to_props";
/** Remove 300ms delay on touch devices - https://github.com/ftlabs/fastclick */
@ -44,6 +44,7 @@ export interface AppProps {
xySwap: boolean;
firmwareConfig: FirmwareConfig | undefined;
animate: boolean;
getConfigValue: GetWebAppConfigValue;
}
function mapStateToProps(props: Everything): AppProps {
@ -64,6 +65,7 @@ function mapStateToProps(props: Everything): AppProps {
xySwap: !!webAppConfigValue(BooleanSetting.xy_swap),
firmwareConfig: validFwConfig(getFirmwareConfig(props.resources.index)),
animate: !webAppConfigValue(BooleanSetting.disable_animations),
getConfigValue: webAppConfigValue,
};
}
/** Time at which the app gives up and asks the user to refresh */
@ -111,7 +113,8 @@ export class App extends React.Component<AppProps, {}> {
user={this.props.user}
bot={this.props.bot}
dispatch={this.props.dispatch}
logs={this.props.logs} />
logs={this.props.logs}
getConfigValue={this.props.getConfigValue} />
{!syncLoaded && <LoadingPlant animate={this.props.animate} />}
{syncLoaded && this.props.children}
{!(["controls", "account", "regimens"].includes(currentPage)) &&

View File

@ -19,8 +19,6 @@ jest.mock("axios", () => ({
jest.mock("../../session", () => ({
Session: {
fetchStoredToken: jest.fn(),
deprecatedGetNum: () => undefined,
deprecatedGetBool: () => undefined,
getAll: () => undefined,
clear: jest.fn()
}

View File

@ -1,52 +0,0 @@
import { store } from "../redux/store";
import { BooleanConfigKey, NumberConfigKey } from "../config_storage/web_app_configs";
import { edit, save } from "../api/crud";
import { getWebAppConfig } from "../resources/selectors_by_kind";
/**
* HISTORICAL CONTEXT: We once stored user settings (like map zoom level) in
* localStorage and would retrieve values via `Session.getBool("zoom_level")`
*
* PROBLEM: localStorage is no longer used. Many parts of the app were accessing
* values in places that did not have access to the Redux store.
*
* SOLUTION: Create a temporary shim that will "cheat" and directly call Redux
* store without a lot of boilerplate props passing.
*
* WHY NOT JUST INLINE THESE FUNCTIONS?: It's easier to stub out calls in tests
* that already exist.
*/
/** Avoid using this function in new places. Pass props instead. */
export function getBoolViaRedux(key: BooleanConfigKey): boolean | undefined {
const conf = getWebAppConfig(store.getState().resources.index);
return conf && conf.body[key];
}
/** Avoid using this function in new places. Pass props instead. */
export function setBoolViaRedux(key: BooleanConfigKey, val: boolean) {
const conf = getWebAppConfig(store.getState().resources.index);
if (conf) {
store.dispatch(edit(conf, { [key]: val }));
// tslint:disable-next-line:no-any
store.dispatch(save(conf.uuid) as any);
}
return val;
}
/** Avoid using this function in new places. Pass props instead. */
export function getNumViaRedux(key: NumberConfigKey): number | undefined {
const conf = getWebAppConfig(store.getState().resources.index);
return conf && conf.body[key];
}
/** Avoid using this function in new places. Pass props instead. */
export function setNumViaRedux(key: NumberConfigKey, val: number): number {
const conf = getWebAppConfig(store.getState().resources.index);
if (conf) {
store.dispatch(edit(conf, { [key]: val }));
// tslint:disable-next-line:no-any
store.dispatch(save(conf.uuid) as any);
}
return val;
}

View File

@ -1,7 +1,8 @@
import {
BooleanConfigKey as BooleanWebAppConfigKey,
NumberConfigKey as NumberWebAppConfigKey,
StringConfigKey as StringWebAppConfigKey
StringConfigKey as StringWebAppConfigKey,
WebAppConfig
} from "./web_app_configs";
import { GetState } from "../redux/interfaces";
import { edit, save } from "../api/crud";
@ -12,7 +13,7 @@ export function toggleWebAppBool(key: BooleanWebAppConfigKey) {
return (dispatch: Function, getState: GetState) => {
const conf = getWebAppConfig(getState().resources.index);
if (conf) {
const val = !conf.body[key];
const val = !(conf.body as WebAppConfig)[key];
dispatch(edit(conf, { [key]: val }));
dispatch(save(conf.uuid));
} else {
@ -33,7 +34,7 @@ export type GetWebAppConfigValue = (k: WebAppConfigKey) => WebAppConfigValue;
export function getWebAppConfigValue(getState: GetState) {
return (key: WebAppConfigKey): WebAppConfigValue => {
const conf = getWebAppConfig(getState().resources.index);
return conf && conf.body[key];
return conf && (conf.body as WebAppConfig)[key];
};
}

View File

@ -46,6 +46,7 @@ export interface WebAppConfig {
discard_unsaved: boolean;
xy_swap: boolean;
home_button_homing: boolean;
show_motor_plot: boolean;
}
export type NumberConfigKey = "id"
@ -89,4 +90,5 @@ export type BooleanConfigKey = "confirm_step_deletion"
|"show_images"
|"discard_unsaved"
|"xy_swap"
|"home_button_homing";
|"home_button_homing"
|"show_motor_plot";

View File

@ -14,6 +14,7 @@ import {
} from "../auto_sync";
import { destroyOK } from "../../resources/actions";
import { SkipMqttData, BadMqttData, UpdateMqttData, DeleteMqttData } from "../interfaces";
import { unpackUUID } from "../../util";
describe("handleInbound()", () => {
const dispatch = jest.fn();
@ -50,7 +51,7 @@ describe("handleInbound()", () => {
it("handles DELETE when the record is in system", () => {
const i = getState().resources.index.byKind.Sequence;
// Pick an ID that we know will be in the DB
const id = parseInt(Object.values(i)[0].split(".")[1], 10);
const id = unpackUUID(Object.values(i)[0]).remoteId || -1;
const fixtr: DeleteMqttData = { status: "DELETE", kind: "Sequence", id };
handleInbound(dispatch, getState, fixtr);
expect(dispatch).toHaveBeenCalled();

View File

@ -12,6 +12,7 @@ import { fakeState } from "../../__test_support__/fake_state";
import { GetState } from "../../redux/interfaces";
import { SyncPayload, UpdateMqttData, Reason } from "../interfaces";
import { storeUUID } from "../data_consistency";
import { unpackUUID } from "../../util";
function toBinary(input: object): Buffer {
return Buffer.from(JSON.stringify(input), "utf8");
@ -62,8 +63,10 @@ describe("handleCreateOrUpdate", () => {
const dispatch = jest.fn();
const getState = jest.fn(fakeState) as GetState;
const { index } = getState().resources;
const fakeId = Object.values(index.byKind.Sequence)[0].split(".")[1];
myPayload.id = parseInt(fakeId, 10);
const fakeId =
unpackUUID(Object.values(index.byKind.Sequence)[0]).remoteId || -1;
myPayload.id = fakeId;
myPayload.kind = "Sequence";
handleCreateOrUpdate(dispatch, getState, myPayload);
expect(dispatch).toHaveBeenCalled();

View File

@ -31,7 +31,7 @@ set(window, "outstanding_requests", outstandingRequests);
const PLACEHOLDER = "placeholder";
/** Max wait in MS before clearing out. */
const MAX_WAIT = 3500;
const MAX_WAIT = 11000;
/**
* PROBLEM: You save a sequence and click "RUN" very fast. The remote device
@ -71,7 +71,7 @@ export function startTracking(uuid = PLACEHOLDER) {
}
storeUUID(cleanID);
getDevice().on(cleanID, () => stopTracking(cleanID));
setTimeout(stop, MAX_WAIT);
setTimeout(() => stopTracking(uuid), MAX_WAIT);
}
export function stopTracking(uuid: string) {

View File

@ -37,7 +37,8 @@ export namespace ToolTips {
few sequences to verify that everything works as expected.`);
export const PIN_BINDINGS =
trim(`Assign a sequence to execute when a Raspberry Pi GPIO pin is activated.`);
trim(`Assign a sequence to execute when a Raspberry Pi GPIO pin is
activated.`);
export const PIN_BINDING_WARNING =
trim(`Warning: Binding to a pin without a physical button and
@ -49,10 +50,10 @@ export namespace ToolTips {
// Hardware Settings: Homing and Calibration
export const HOMING =
trim(`(Alpha) If encoders or end-stops are enabled, home axis (find zero).`);
trim(`If encoders or end-stops are enabled, home axis (find zero).`);
export const CALIBRATION =
trim(`(Alpha) If encoders or end-stops are enabled, home axis and determine
trim(`If encoders or end-stops are enabled, home axis and determine
maximum.`);
export const SET_ZERO_POSITION =
@ -107,8 +108,8 @@ export namespace ToolTips {
trim(`The number of motor steps required to move the axis one millimeter.`);
export const ALWAYS_POWER_MOTORS =
trim(`Keep power applied to motors. Prevents slipping from gravity in certain
situations.`);
trim(`Keep power applied to motors. Prevents slipping from gravity in
certain situations.`);
export const INVERT_MOTORS =
trim(`Invert direction of motor during calibration.`);
@ -118,23 +119,23 @@ export namespace ToolTips {
// Hardware Settings: Encoders and Endstops
export const ENABLE_ENCODERS =
trim(`(Alpha) Enable use of rotary encoders during calibration and homing.`);
trim(`Enable use of rotary encoders during calibration and homing.`);
export const ENCODER_POSITIONING =
trim(`[EXPERIMENTAL] Use encoders for positioning.`);
trim(`Use encoders for positioning.`);
export const INVERT_ENCODERS =
trim(`(Alpha) Reverse the direction of encoder position reading.`);
trim(`Reverse the direction of encoder position reading.`);
export const MAX_MISSED_STEPS =
trim(`(Alpha) Number of steps missed (determined by encoder) before motor is
trim(`Number of steps missed (determined by encoder) before motor is
considered to have stalled.`);
export const ENCODER_MISSED_STEP_DECAY =
trim(`(Alpha) Reduction to missed step total for every good step.`);
trim(`Reduction to missed step total for every good step.`);
export const ENCODER_SCALING =
trim(`(Alpha) encoder scaling factor = 10000 * (motor resolution * microsteps)
trim(`encoder scaling factor = 10000 * (motor resolution * microsteps)
/ (encoder resolution).`);
export const ENABLE_ENDSTOPS =
@ -254,6 +255,12 @@ export namespace ToolTips {
trim(`Snaps a photo using the device camera. Select the camera type on the
Device page.`);
export const MARK_AS =
trim(`The Mark As step allows FarmBot to programmatically edit the
properties of the UTM, plants, and weeds from within a sequence.
For example, you can mark a plant as "planted" during a seeding
sequence or delete a weed after removing it.`);
// Regimens
export const BULK_SCHEDULER =
trim(`Add sequences to your regimen by selecting a sequence from the
@ -522,8 +529,8 @@ export namespace Content {
click "+" in the Regimens panel to create a new one.`);
export const NO_PARAMETERS = trim(`Can't directly use this sequence in a
regimen. Consider wrapping it in a parent sequence that calls it via "execute"
instead."`);
regimen. Consider wrapping it in a parent sequence that calls it via
"execute" instead.`);
// Farm Designer
export const OUTSIDE_PLANTING_AREA =
@ -538,6 +545,10 @@ export namespace Content {
trim(`Click and drag to draw a point or use the inputs and press
update. Press CREATE POINT to save, or the back arrow to exit.`);
export const BOX_SELECT_DESCRIPTION =
trim(`Drag a box around the plants you would like to select.
Press the back arrow to exit.`);
// Farm Events
export const REGIMEN_TODAY_SKIPPED_ITEM_RISK =
trim(`You are scheduling a regimen to run today. Be aware that
@ -634,6 +645,7 @@ export enum Actions {
OF_SEARCH_RESULTS_OK = "OF_SEARCH_RESULTS_OK",
CHOOSE_LOCATION = "CHOOSE_LOCATION",
SET_CURRENT_POINT_DATA = "SET_CURRENT_POINT_DATA",
CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN",
// Regimens
PUSH_WEEK = "PUSH_WEEK",

View File

@ -0,0 +1,51 @@
jest.mock("moment", () => () => ({ unix: () => 1020 }));
import * as React from "react";
import { mount } from "enzyme";
import { MotorPositionPlot, MotorPositionHistory } from "../motor_position_plot";
import { BotLocationData, BotPosition } from "../../../devices/interfaces";
describe("<MotorPositionPlot />", () => {
const fakePosition = (): BotPosition => ({ x: 0, y: 0, z: 0 });
const fakeLocationData = (): BotLocationData => ({
position: fakePosition(),
scaled_encoders: fakePosition(),
raw_encoders: fakePosition()
});
const fakeProps = () => ({
locationData: fakeLocationData(),
});
it("renders", () => {
const wrapper = mount(<MotorPositionPlot {...fakeProps()} />);
["x", "y", "z", "position", "seconds ago", "120", "100"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
});
it("renders motor position", () => {
const location1 = fakeLocationData();
const location2 = fakeLocationData();
location2.position.x = 100;
sessionStorage.setItem(MotorPositionHistory.array, JSON.stringify([
{ timestamp: 1000, locationData: location1 },
{ timestamp: 1010, locationData: location2 },
]));
const wrapper = mount(<MotorPositionPlot {...fakeProps()} />);
expect(wrapper.html()).toContain("M 120,0 L 120,0 L 110,-12.5 L 100,0");
expect(wrapper.html()).toContain("M 120,0 L 120,0 L 110,0 L 100,0");
});
it("handles undefined data", () => {
const location1 = fakeLocationData();
const location2 = fakeLocationData();
location2.position.x = undefined;
sessionStorage.setItem(MotorPositionHistory.array, JSON.stringify([
{ timestamp: 1000, locationData: location1 },
{ timestamp: 1010, locationData: location2 },
]));
const wrapper = mount(<MotorPositionPlot {...fakeProps()} />);
expect(wrapper.html()).not.toContain("M 120,0 L 120,0 L 110,-12.5 L 100,0");
expect(wrapper.html()).toContain("M 120,0 L 120,0 L 110,0 L 100,0");
});
});

View File

@ -13,7 +13,7 @@ jest.mock("../../../config_storage/actions", () => {
});
import * as React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import { Move } from "../move";
import { bot } from "../../../__test_support__/fake_state/bot";
import { MoveProps } from "../interfaces";
@ -81,4 +81,10 @@ describe("<Move />", () => {
payload: 1
});
});
it("displays motor position plot", () => {
mockConfig.show_motor_plot = true;
const wrapper = shallow(<Move {...fakeProps()} />);
expect(wrapper.html()).toContain("motor-position-plot");
});
});

View File

@ -1,7 +1,7 @@
import * as React from "react";
import { mount } from "enzyme";
import { BooleanSetting } from "../../../session_keys";
import { moveWidgetSetting } from "../settings_menu";
import { moveWidgetSetting, MoveWidgetSettingsMenu } from "../settings_menu";
describe("moveWidgetSetting()", () => {
it("renders setting", () => {
@ -13,3 +13,18 @@ describe("moveWidgetSetting()", () => {
expect(wrapper.text().toLowerCase()).toContain(string));
});
});
describe("<MoveWidgetSettingsMenu />", () => {
const fakeProps = () => ({
toggle: jest.fn(),
getValue: jest.fn(),
});
it("displays motor plot toggle", () => {
const noToggle = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
expect(noToggle.text()).not.toContain("Motor position plot");
localStorage.setItem("FUTURE_FEATURES", "true");
const wrapper = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
expect(wrapper.text()).toContain("Motor position plot");
});
});

View File

@ -0,0 +1,186 @@
import * as React from "react";
import * as _ from "lodash";
import { Xyz, LocationName, Dictionary } from "farmbot";
import * as moment from "moment";
import { BotLocationData, BotPosition } from "../../devices/interfaces";
import { trim } from "../../util";
import { t } from "i18next";
const HEIGHT = 50;
const HISTORY_LENGTH_SECONDS = 120;
const BORDER_WIDTH = 15;
const BORDERS = BORDER_WIDTH * 2;
const MAX_X = HISTORY_LENGTH_SECONDS;
const DEFAULT_Y_MAX = 100;
const COLOR_LOOKUP: Dictionary<string> = {
x: "red", y: "green", z: "blue"
};
const LINEWIDTH_LOOKUP: Dictionary<number> = {
position: 0.5, scaled_encoders: 0.25
};
export enum MotorPositionHistory {
array = "motorPositionHistoryArray",
}
type Entry = {
timestamp: number,
locationData: Record<LocationName, BotPosition>
};
type Paths = Record<LocationName, Record<Xyz, string>>;
const getArray = (): Entry[] =>
JSON.parse(_.get(sessionStorage, MotorPositionHistory.array, "[]"));
const getReversedArray = (): Entry[] => _.cloneDeep(getArray()).reverse();
const getLastEntry = (): Entry | undefined => {
const array = getArray();
return array[array.length - 1];
};
const findYLimit = (): number => {
const array = getArray();
const arrayAbsMax = _.max(array.map(entry =>
_.max(["position", "scaled_encoders"].map((name: LocationName) =>
_.max(["x", "y", "z"].map((axis: Xyz) =>
Math.abs(entry.locationData[name][axis] || 0) + 1))))));
return Math.max(_.ceil(arrayAbsMax || 0, -2), DEFAULT_Y_MAX);
};
const updateArray = (update: Entry): Entry[] => {
const arr = getArray();
const last = getLastEntry();
if (update && _.isNumber(update.locationData.position.x) &&
(!last || !_.isEqual(last.timestamp, update.timestamp))) {
arr.push(update);
}
const newArray = _.takeRight(arr, 100)
.filter(x => {
const entryAge = (last ? last.timestamp : moment().unix()) - x.timestamp;
return entryAge <= HISTORY_LENGTH_SECONDS;
});
sessionStorage.setItem(MotorPositionHistory.array, JSON.stringify(newArray));
return newArray;
};
const newPaths = (): Paths => ({
position: { x: "", y: "", z: "" },
scaled_encoders: { x: "", y: "", z: "" },
raw_encoders: { x: "", y: "", z: "" }
});
const getPaths = (): Paths => {
const last = getLastEntry();
const maxY = findYLimit();
const paths = newPaths();
if (last) {
getReversedArray().map(entry => {
["position", "scaled_encoders"].map((name: LocationName) => {
["x", "y", "z"].map((axis: Xyz) => {
const lastPos = last.locationData[name][axis];
const pos = entry.locationData[name][axis];
if (_.isNumber(lastPos) && _.isFinite(lastPos)
&& _.isNumber(maxY) && _.isNumber(pos)) {
if (!paths[name][axis].startsWith("M")) {
const yStart = -lastPos / maxY * HEIGHT / 2;
paths[name][axis] = `M ${MAX_X},${yStart} `;
}
const x = MAX_X - (last.timestamp - entry.timestamp);
const y = -pos / maxY * HEIGHT / 2;
paths[name][axis] += `L ${x},${y} `;
}
});
});
});
}
return paths;
};
const TitleLegend = () => {
const titleY = -(HEIGHT + BORDER_WIDTH) / 2;
const legendX = HISTORY_LENGTH_SECONDS / 4;
return <g id="title_with_legend">
<text fill={COLOR_LOOKUP.x} fontWeight={"bold"}
x={legendX - 10} y={titleY}>{"X"}</text>
<text fill={COLOR_LOOKUP.y} fontWeight={"bold"}
x={legendX} y={titleY}>{"Y"}</text>
<text fill={COLOR_LOOKUP.z} fontWeight={"bold"}
x={legendX + 10} y={titleY}>{"Z"}</text>
<text fontWeight={"bold"}
x={HISTORY_LENGTH_SECONDS / 2} y={titleY}>{t("Position (mm)")}</text>
</g>;
};
const YAxisLabels = () => {
const maxY = findYLimit();
return <g id="y_axis_labels">
{[maxY, maxY / 2, 0, -maxY / 2, -maxY].map(yPosition =>
<g key={"y_axis_label_" + yPosition}>
<text x={MAX_X + BORDER_WIDTH / 2} y={-yPosition / maxY * HEIGHT / 2}>
{yPosition}
</text>
<text x={-BORDER_WIDTH / 2} y={-yPosition / maxY * HEIGHT / 2}>
{yPosition}
</text>
</g>)}
</g>;
};
const XAxisLabels = () =>
<g id="x_axis_labels">
<text x={HISTORY_LENGTH_SECONDS / 2} y={HEIGHT / 2 + BORDER_WIDTH / 1.25}
fontStyle={"italic"}>
{t("seconds ago")}
</text>
{_.range(0, HISTORY_LENGTH_SECONDS + 1, 20).map(secondsAgo =>
<text key={"x_axis_label_" + secondsAgo}
x={MAX_X - secondsAgo} y={HEIGHT / 2 + BORDER_WIDTH / 3}>
{secondsAgo}
</text>)}
</g>;
const PlotBackground = () =>
<g id="plot_background">
<rect fill="white" x={0} y={-HEIGHT / 2} width={"100%"} height={"100%"} />
<line x1={0} y1={0} x2={MAX_X} y2={0} strokeWidth={0.25} stroke={"grey"} />
</g>;
const PlotLines = ({ locationData }: { locationData: BotLocationData }) => {
updateArray({ timestamp: moment().unix(), locationData });
const paths = getPaths();
return <g id="plot_lines">
{["position", "scaled_encoders"].map((name: LocationName) =>
["x", "y", "z"].map((axis: Xyz) =>
<path key={name + axis} fill={"none"}
stroke={COLOR_LOOKUP[axis]} strokeWidth={LINEWIDTH_LOOKUP[name]}
strokeLinecap={"round"} strokeLinejoin={"round"}
d={paths[name][axis]} />))}
</g>;
};
export const MotorPositionPlot = (props: { locationData: BotLocationData }) => {
return <svg
className="motor-position-plot-border"
style={{ marginTop: "2rem" }}
width="100%"
height="100%"
viewBox={trim(`${-BORDER_WIDTH} ${-HEIGHT / 2 - BORDER_WIDTH}
${HISTORY_LENGTH_SECONDS + BORDERS} ${HEIGHT + BORDERS}`)}>
<TitleLegend />
<YAxisLabels />
<XAxisLabels />
<svg
className="motor-position-plot"
width={HISTORY_LENGTH_SECONDS}
height={HEIGHT}
x={0}
y={-HEIGHT / 2}
viewBox={`0 ${-HEIGHT / 2} ${HISTORY_LENGTH_SECONDS} ${HEIGHT}`}>
<PlotBackground />
<PlotLines locationData={props.locationData} />
</svg>
</svg>;
};

View File

@ -14,6 +14,8 @@ import { MoveProps } from "./interfaces";
import { MoveWidgetSettingsMenu } from "./settings_menu";
import { JogControlsGroup } from "./jog_controls_group";
import { BotPositionRows } from "./bot_position_rows";
import { MotorPositionPlot } from "./motor_position_plot";
import { Popover, Position } from "@blueprintjs/core";
export class Move extends React.Component<MoveProps, {}> {
@ -30,9 +32,12 @@ export class Move extends React.Component<MoveProps, {}> {
<WidgetHeader
title={t("Move")}
helpText={ToolTips.MOVE}>
<MoveWidgetSettingsMenu
toggle={this.toggle}
getValue={this.getValue} />
<Popover position={Position.BOTTOM_RIGHT}>
<i className="fa fa-gear" />
<MoveWidgetSettingsMenu
toggle={this.toggle}
getValue={this.getValue} />
</Popover>
<EStopButton
bot={this.props.bot}
user={this.props.user} />
@ -55,6 +60,8 @@ export class Move extends React.Component<MoveProps, {}> {
arduinoBusy={this.props.arduinoBusy}
firmware_version={informational_settings.firmware_version} />
</MustBeOnline>
{this.props.getWebAppConfigVal(BooleanSetting.show_motor_plot) &&
<MotorPositionPlot locationData={locationData} />}
</WidgetBody>
</Widget>;
}

View File

@ -1,6 +1,5 @@
import * as React from "react";
import { t } from "i18next";
import { Popover, Position } from "@blueprintjs/core";
import {
BooleanConfigKey as BooleanWebAppConfigKey
} from "../../config_storage/web_app_configs";
@ -24,29 +23,34 @@ export const MoveWidgetSettingsMenu = ({ toggle, getValue }: {
getValue: GetWebAppBool
}) => {
const Setting = moveWidgetSetting(toggle, getValue);
return <Popover position={Position.BOTTOM_RIGHT}>
<i className="fa fa-gear" />
<div className="move-settings-menu">
<p>{t("Invert Jog Buttons")}</p>
<Setting label={t("X Axis")} setting={BooleanSetting.x_axis_inverted} />
<Setting label={t("Y Axis")} setting={BooleanSetting.y_axis_inverted} />
<Setting label={t("Z Axis")} setting={BooleanSetting.z_axis_inverted} />
return <div className="move-settings-menu">
<p>{t("Invert Jog Buttons")}</p>
<Setting label={t("X Axis")} setting={BooleanSetting.x_axis_inverted} />
<Setting label={t("Y Axis")} setting={BooleanSetting.y_axis_inverted} />
<Setting label={t("Z Axis")} setting={BooleanSetting.z_axis_inverted} />
<p>{t("Display Encoder Data")}</p>
<Setting
label={t("Scaled encoder position")}
setting={BooleanSetting.scaled_encoders} />
<Setting
label={t("Raw encoder position")}
setting={BooleanSetting.raw_encoders} />
<p>{t("Display Encoder Data")}</p>
<Setting
label={t("Scaled encoder position")}
setting={BooleanSetting.scaled_encoders} />
<Setting
label={t("Raw encoder position")}
setting={BooleanSetting.raw_encoders} />
<p>{t("Swap jog buttons (and rotate map)")}</p>
<Setting label={t("x and y axis")} setting={BooleanSetting.xy_swap} />
<p>{t("Swap jog buttons (and rotate map)")}</p>
<Setting label={t("x and y axis")} setting={BooleanSetting.xy_swap} />
<p>{t("Home button behavior")}</p>
<Setting
label={t("perform homing (find home)")}
setting={BooleanSetting.home_button_homing} />
</div>
</Popover>;
<p>{t("Home button behavior")}</p>
<Setting
label={t("perform homing (find home)")}
setting={BooleanSetting.home_button_homing} />
{localStorage.getItem("FUTURE_FEATURES") &&
<div>
<p>{t("Motor position plot")}</p>
<Setting
label={t("show")}
setting={BooleanSetting.show_motor_plot} />
</div>}
</div>;
};

View File

@ -97,7 +97,7 @@ export class Peripherals extends React.Component<PeripheralsProps, PeripheralSta
<button
className="fb-button gray"
onClick={this.toggle}
hidden={!!status && isEditing}>
disabled={!!status && isEditing}>
{!isEditing && t("Edit")}
{isEditing && t("Back")}
</button>

View File

@ -85,7 +85,7 @@ export class Sensors extends React.Component<SensorsProps, SensorState> {
<button
className="fb-button gray"
onClick={this.toggle}
hidden={!!status && isEditing}>
disabled={!!status && isEditing}>
{!isEditing && t("Edit")}
{isEditing && t("Back")}
</button>

View File

@ -27,7 +27,7 @@ describe("<WebcamPanel/>", () => {
wrapper.setState({ activeMenu: "edit" });
const text = allButtonText(wrapper);
expect(text.toLowerCase()).not.toContain("edit");
clickButton(wrapper, 2, "view");
clickButton(wrapper, 0, "back");
expect(wrapper.instance().state.activeMenu).toEqual("show");
});
});

View File

@ -30,9 +30,10 @@ export function Edit(props: WebcamPanelProps) {
return <Widget>
<WidgetHeader title="Edit" helpText={ToolTips.WEBCAM}>
<button
className="fb-button green"
onClick={props.init}>
<i className="fa fa-plus" />
className="fb-button gray"
disabled={unsaved.length > 0}
onClick={props.onToggle}>
{t("Back")}
</button>
<button
className="fb-button green"
@ -40,9 +41,9 @@ export function Edit(props: WebcamPanelProps) {
{t("Save")}{unsaved.length > 0 ? "*" : ""}
</button>
<button
className="fb-button gray"
onClick={props.onToggle}>
{t("View")}
className="fb-button green"
onClick={props.init}>
<i className="fa fa-plus" />
</button>
</WidgetHeader>
<div className="widget-body">

View File

@ -68,19 +68,19 @@
&:hover {
background: $white !important;
}
:first-child {
:first-child {
display: inline-block;
white-space: nowrap;
width: 73%;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
padding-right: 1rem;
}
}
.pt-popover-wrapper {
position: relative;
display: block;
position: relative;
* {
text-align: left;
}

View File

@ -12,9 +12,9 @@
color: $white;
}
.mobile-menu {
z-index: 9999;
background: $dark_gray;
color: $white;
z-index: 9999;
height: 100vh;
outline: none;
animation: slide-in 0.2s;

View File

@ -1,4 +1,5 @@
.fb-button {
position: relative;
font-size: 1rem;
float: right;
text-transform: uppercase;
@ -7,7 +8,6 @@
padding: .2rem .8rem;
border-radius: 3px;
border: none;
position: relative;
color: $off_white;
transition: all 0.1s ease-in-out !important;
&.disabled,
@ -25,6 +25,7 @@
pointer-events: none;
}
.btn-spinner {
margin: 0.2rem 0px 0 0.6rem;
width: 0.8rem;
height: 0.8rem;
animation: rotate 0.8s infinite linear;
@ -32,7 +33,6 @@
border-right-color: transparent;
border-radius: 50%;
float: right;
margin: 0.2rem 0px 0 0.6rem;
&.sync {
border-color: $dark_gray;
border-right-color: transparent;
@ -200,40 +200,39 @@
}
.fb-toggle-button {
height: 1.8rem !important;
position: relative;
height: 1.8rem !important;
border-bottom: none !important;
box-shadow: none !important;
padding: 0.3rem 0.10rem;
border-radius: 10px;
width: 5rem;
border-bottom: none;
position: relative;
transition: all 0.4s ease;
&.yellow {
&:after {
content: "";
position: absolute;
top: 0.1rem;
left: 0;
right: 0;
margin: 0 auto;
height: 1.6rem;
width: 1.6rem;
background: $white;
border-radius: 50%;
margin: 0 auto;
left: 0;
right: 0;
top: 0.1rem;
box-shadow: inset rgba(0, 0, 0, 0.03) 0px 0px 3px 3px;
}
&:before {
content: "";
position: absolute;
top: 0.1rem;
left: 0;
right: 0;
margin: 0 auto;
height: 1.6rem;
width: 1.6rem;
border-radius: 50%;
top: 0.1rem;
margin: 0 auto;
left: 0;
right: 0;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 0px 0 rgba(0, 0, 0, 0.04), 0 4px 9px rgba(0, 0, 0, 0.13), 0 3px 3px rgba(0, 0, 0, 0.05);
}
}
@ -243,12 +242,12 @@
&:after {
content: "";
position: absolute;
top: 0.1rem;
right: 0.2rem;
height: 1.6rem;
width: 1.6rem;
background: $white;
border-radius: 50%;
top: 0.1rem;
right: 0.2rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 0px 0 rgba(0, 0, 0, 0.04), 0 4px 9px rgba(0, 0, 0, 0.13), 0 3px 3px rgba(0, 0, 0, 0.05);
}
&:hover {
@ -264,12 +263,12 @@
&:after {
content: "";
position: absolute;
top: 0.1rem;
left: 0.2rem;
height: 1.6rem;
width: 1.6rem;
background: white;
border-radius: 50%;
top: 0.1rem;
left: 0.2rem;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1), 0 4px 0px 0 rgba(0, 0, 0, 0.04), 0 4px 9px rgba(0, 0, 0, 0.13), 0 3px 3px rgba(0, 0, 0, 0.05);
}
&.dim {
@ -282,9 +281,12 @@
}
.plus-button {
position: fixed;
left: 0;
bottom: 5rem;
right: 0;
z-index: 3;
border-radius: 50%;
bottom: 5rem;
width: 5rem;
height: 5rem;
color: $off_white;
@ -292,9 +294,6 @@
text-align: center;
cursor: pointer;
box-shadow: 0 3px 0px 0px $dark_green;
position: fixed;
left: 0;
right: 0;
i {
line-height: 5rem;
font-size: 2.8rem;

View File

@ -4,9 +4,10 @@ $translucent2: rgba(0, 0, 0, 0.6);
$white: #fff;
$off_white: #f4f4f4;
$light_gray: #ddd;
$gray: #cccccc;
$gray: #ccc;
$medium_light_gray: #bcbcbc;
$medium_gray: #666666;
$medium_gray: #666;
$placeholder_gray: #999;
$dark_gray: #434343;
$black: #000;
$light_blue: #cdf;
@ -22,13 +23,13 @@ $yellow: #fd6;
$gold: #b90;
$beige: #fec;
$light_brown: #e9d5c3;
$brown: #CA8;
$brown: #ca8;
$dark_brown: #783f04;
$light_orange: #fc9;
$orange: #fa0;
$dark_orange: #e93;
$light_purple: #bad;
$purple: #C68ED2;
$purple: #c68ed2;
$light_magenta: #ead1dc;
$magenta: #a64d79;
$pink: #ebb;
@ -114,4 +115,4 @@ $darkest_red: #900;
.fun,
.saucer-fun {
background: $dark_blue !important;
}
}

View File

@ -1,12 +1,12 @@
.farm-designer {
position: relative;
height: 100vh;
overflow-y: hidden;
position: relative;
}
.farm-designer-map {
min-width: 100%;
display: inline-block;
min-width: 100%;
padding: 16rem 2rem 2rem 2rem; // at zoom = 1.0: 160px 20px 20px 20px
height: 100%;
overflow: auto;
@ -25,11 +25,11 @@
}
text::selection {
background: none;
}
}
}
}
#drop-area-svg {
.drop-area-svg {
width: 100%;
height: 100%;
overflow: visible;
@ -70,27 +70,27 @@
margin-bottom: 2rem;
}
label {
position: absolute;
left: 15px;
bottom: -5px;
right: 15px;
margin-top: 0 !important;
padding: 0.4rem 0.6rem 0.2rem;
bottom: -5px;
background: rgba(0, 0, 0, 0.5);
color: $white;
position: absolute;
font-size: 1.2rem !important;
}
}
}
.crop-info-overlay {
display: flex;
position: relative;
bottom: 3rem;
z-index: 2;
background-color: $dark_gray;
font-style: italic;
color: $off_white;
display: flex;
justify-content: center;
/* align horizontal */
align-items: center;
@ -101,17 +101,17 @@
.thin-search-wrapper {
.text-input-wrapper {
margin: 1rem;
position: relative;
margin: 1rem;
border-bottom: 1px solid #000;
&:before,
&:after {
content: "";
position: absolute;
bottom: 0;
background: #000;
width: 1px;
height: 10px;
bottom: 0;
}
&:before {
left: 0;
@ -135,7 +135,7 @@
background: transparent !important;
}
&::-webkit-input-placeholder {
color: #999;
color: $placeholder_gray;
}
}
.select-results-container {
@ -160,14 +160,15 @@
.plant-search-item {
cursor: pointer;
padding: 0.5rem 1rem;
&:hover, &.hovered {
&:hover,
&.hovered {
background: darken($light_green, 10%);
transition: background 0.2s ease;
}
img {
margin: 0 1rem 0 0;
height: 4rem;
width: 4rem;
margin: 0 1rem 0 0;
}
}
.plant-search-item-age {
@ -178,12 +179,12 @@
float: right;
}
.plant-search-item-name {
display: inline-block;
vertical-align: middle;
white-space: nowrap;
width: 8em;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
margin-left: 1rem;
}
}
@ -270,9 +271,17 @@
}
@keyframes water-spray-animation {
0% { transform: scale(0.7) rotate(0deg); opacity: 0;}
50% { opacity: 1;}
100% { transform: scale(1.1) rotate(10deg); opacity: 0;}
0% {
transform: scale(0.7) rotate(0deg);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: scale(1.1) rotate(10deg);
opacity: 0;
}
}
.vacuum {
@ -287,8 +296,56 @@
}
@keyframes vacuum-animation {
0% { transform: scale(1); opacity: 0;}
100% { transform: scale(0); opacity: 1;}
0% {
transform: scale(1);
opacity: 0;
}
100% {
transform: scale(0);
opacity: 1;
}
}
.saved-garden-indicator {
position: fixed;
top: 80px;
left: 50%;
z-index: 3;
padding: 2rem;
background: rgba(256, 256, 256, .75);
border-radius: 5px;
box-shadow: 0px 1px 4px #555;
text-align: center;
label {
display: block;
}
button {
margin: 0.5rem;
float: unset;
}
}
.saved-garden-list {
margin: -15px;
.saved-garden-row {
padding: 0.25rem;
button {
margin-bottom: 1rem;
}
.saved-garden-info div {
cursor: pointer;
padding-right: 0;
}
&:hover {
background: $light_gray;
}
&.selected {
background: $gray;
p {
font-weight: bold;
}
}
}
}
.garden-map-legend {
@ -303,12 +360,12 @@
transform: translateX(-140px);
}
.content {
background: rgba(256, 256, 256, .75);
padding: 10px;
border-radius: 5px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
background: rgba(256, 256, 256, .75);
padding: 10px;
border-radius: 5px;
>*+* {
margin-top: 1rem;
}
@ -347,27 +404,26 @@
}
.farmbot-origin {
.quadrants {
border: 1px solid $dark_gray;
display: flex;
flex-wrap: wrap;
border: 1px solid $dark_gray;
}
.quadrant {
display: inline-block;
position: relative;
background-image: linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(rgba(0, 0, 0, 0.05) 2px, transparent 2px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 2px, transparent 2px);
background-size: 4px 4px, 4px 4px, 100px 100px, 100px 100px;
cursor: pointer;
border: 1px solid $dark_gray;
width: 50%;
height: 24px;
display: inline-block;
position: relative;
transition: all 0.2s ease-in-out;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
&.selected {
box-shadow: inset 0 0 8px $dark_gray;
}
// Quadrant 1
} // Quadrant 1
&:nth-child(2) {
&:before {
top: 0;
@ -377,8 +433,7 @@
top: 8px;
right: 16px;
}
}
// Quadrant 2
} // Quadrant 2
&:nth-child(1) {
&:before {
top: 0;
@ -388,19 +443,17 @@
top: 8px;
left: 16px;
}
}
// Quadrant 3
} // Quadrant 3
&:nth-child(3) {
&:before {
bottom: 0;
left: 0;
bottom: 0;
}
&:after {
bottom: 8px;
left: 16px;
bottom: 8px;
}
}
// Quadrant 4
} // Quadrant 4
&:nth-child(4) {
&:before {
bottom: 0;
@ -440,10 +493,10 @@
}
}
.menu-pullout {
color: $white;
cursor: pointer;
position: absolute;
left: -4.5rem;
color: $white;
cursor: pointer;
transition: all 0.4s ease;
text-shadow: 0px 1px 1px #555;
&.active {
@ -464,12 +517,12 @@
}
}
span {
position: absolute;
top: 0.6rem;
left: -4.6rem;
transition-delay: 0.6s;
transition: all 0.4s ease;
opacity: 0;
position: absolute;
left: -4.6rem;
top: 0.6rem;
}
}
}

View File

@ -1,8 +1,8 @@
.panel-header,
.farm-designer-panels {
width: 30rem;
position: fixed;
top: 8.9rem;
width: 30rem;
}
@keyframes panel-pullout {
@ -74,6 +74,7 @@
.panel-tabs {
display: flex;
a {
display: block;
flex: 1;
text-align: center;
text-transform: uppercase;
@ -81,7 +82,6 @@
line-height: 5rem;
height: 5rem;
color: $light_gray;
display: block;
&.active {
border-bottom: 3px solid $white;
font-weight: bold;
@ -106,17 +106,18 @@
padding-left: 1.4rem;
padding-right: 2rem;
.back-arrow {
display: inline-block;
color: $off_white;
margin-right: 1rem;
font-size: 1.8rem;
margin-top: -1.8rem;
display: inline-block;
vertical-align: middle;
&:hover {
color: $white;
}
}
.title {
display: inline-block;
color: $white;
font-size: 1.8rem;
margin-top: 0.4rem;
@ -124,7 +125,6 @@
width: 10em;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
height: 2rem;
}
.right-button {
@ -245,8 +245,8 @@
.panel-header {
position: fixed;
width: 300px;
z-index: 2;
width: 300px;
.panel-header-description,
.crop-info-description {
font-size: 1.2rem;
@ -261,10 +261,10 @@
.crop-info-panel {
.panel-header {
position: inherit;
background-size: 144% !important;
background-repeat: no-repeat !important;
background-position: top center !important;
position: inherit;
.back-arrow {
margin-top: -0.2rem;
vertical-align: bottom;

View File

@ -1,7 +1,7 @@
.farm-event-panel {
.fa-calendar {
font-size: 2rem;
margin: 0.5rem 0 0 1.6rem;
font-size: 2rem;
}
.panel-content {
padding: 6rem 1rem 6rem 0;
@ -48,8 +48,8 @@
.farm-event-data-block {
display: flex;
padding: 0.8rem;
position: relative;
padding: 0.8rem;
font-size: 1.2rem;
align-items: center;
background: $white;
@ -61,8 +61,8 @@
}
i {
position: absolute;
right: 0.8rem;
top: 1rem;
right: 0.8rem;
opacity: 0;
}
&:after {

View File

@ -1,6 +1,6 @@
// Google Fonts
@import url(https://fonts.googleapis.com/css?family=Roboto:400,400italic,700,700italic,100,100italic);
@import 'colors';
@import url("https://fonts.googleapis.com/css?family=Roboto:400,400italic,700,700italic,100,100italic");
@import "colors";
// Font Variables
$roboto: 'Roboto',
Arial,
@ -8,8 +8,8 @@ Helvetica,
sans-serif;
body,
html {
font-family: $roboto;
font-weight: 400;
font-family: $roboto;
font-weight: 400;
}
h1,
@ -20,13 +20,13 @@ h5,
h6,
p,
fieldset {
font-family: $roboto;
font-weight: 400;
font-family: $roboto;
font-weight: 400;
}
p {
font-size: 1.1rem;
color: $dark_gray;
line-height: 1.4rem;
margin-bottom: 0!important;
}
font-size: 1.1rem;
color: $dark_gray;
line-height: 1.4rem;
margin-bottom: 0 !important;
}

View File

@ -64,12 +64,12 @@ input[type=time] {
.markdown {
p {
color: #fff;
display: inline-block;
color: $white;
text-overflow: ellipsis;
overflow: hidden;
width: calc(100% - 13rem);
white-space: nowrap;
display: inline-block;
text-transform: none;
margin-top: 0.3rem;
}
@ -82,12 +82,12 @@ fieldset {
}
.saucer {
position: relative;
z-index: 2;
height: 2rem;
width: 2rem;
background: $dark_gray;
border-radius: 50%;
position: relative;
z-index: 2;
cursor: pointer;
&.active {
border: 2px solid white;
@ -104,10 +104,10 @@ fieldset {
}
.saucer-connector {
height: 3rem;
width: 1rem;
position: absolute;
z-index: 1;
height: 3rem;
width: 1rem;
margin-left: 0.5rem;
margin-top: -1rem;
&.last {
@ -134,11 +134,11 @@ fieldset {
.chip-temp-display {
position: relative;
.saucer {
position: absolute;
top: 2px;
right: 1rem;
height: 1rem;
width: 1rem;
position: absolute;
right: 1rem;
top: 2px;
cursor: default;
}
}
@ -146,11 +146,11 @@ fieldset {
.wifi-strength-display {
position: relative;
.percent-bar {
width: 25%;
position: absolute;
height: 1rem;
left: 12rem;
top: 2px;
left: 12rem;
height: 1rem;
width: 25%;
clip-path: polygon(0 85%, 100% 0, 100% 100%, 0% 100%);
background-color: $light_gray;
.percent-bar-fill {
@ -194,6 +194,9 @@ a {
cursor: pointer !important;
&.fa-gear {
color: $white;
&.dark {
color: $dark_gray;
}
}
}
@ -209,10 +212,10 @@ a {
&:after {
content: "\25BE";
position: absolute;
top: 0.2rem;
right: 1rem;
color: $dark_gray;
font-size: 2rem;
right: 1rem;
top: 0.2rem;
line-height: initial;
pointer-events: none;
}
@ -225,13 +228,13 @@ a {
}
.drag-drop-area {
margin: 0.75rem 0;
margin-right: 15px;
border-style: dashed;
border-width: 2px;
border-color: $light_gray;
color: $gray;
font-weight: bold;
margin: 0.75rem 0;
margin-right: 15px;
padding: 1.25rem;
background: $off_white;
text-align: center;
@ -306,9 +309,9 @@ a {
color: $orange;
}
.fa-th-large {
color: $dark_gray;
margin-top: 0.5rem;
margin-left: 0.5rem;
color: $dark_gray;
margin-top: 0.5rem;
margin-left: 0.5rem;
}
.fb-button {
margin-top: 0.5rem;
@ -336,7 +339,8 @@ a {
.sensor-history-table {
font-size: 1.2rem;
th, td {
th,
td {
width: 1%;
}
tr {
@ -404,15 +408,15 @@ fieldset {
.webcam-stream-valid {
img {
display: flex;
max-width: 100%;
margin: auto;
max-width: 100%;
max-height: 650px;
min-height: 300px;
}
iframe {
display: flex;
width: 100%;
margin: auto;
width: 100%;
max-height: 650px;
min-height: 300px;
border: none;
@ -420,10 +424,10 @@ fieldset {
}
.webcam-stream-unavailable p {
width: 100%;
position: absolute;
top: 50%;
left: 50%;
width: 100%;
transform: translate(-50%, -50%);
vertical-align: middle;
text-align: center;
@ -432,8 +436,8 @@ fieldset {
.clear-webcam-url-btn {
position: absolute !important;
left: 2rem;
top: 6.2rem;
left: 2rem;
}
h3 {
@ -534,19 +538,19 @@ ul {
}
.coming-soon {
opacity: 0.50;
position: relative;
opacity: 0.50;
width: 100%;
height: 100%;
}
.coming-soon:after {
content: "Coming Soon!";
background-color: $red;
width: 100%;
position: absolute;
top: 25%;
left: 0;
background-color: $red;
width: 100%;
vertical-align: middle;
text-align: center;
font-size: 2.5rem;
@ -554,26 +558,26 @@ ul {
.unavailable {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
opacity: 0.40;
pointer-events: none;
z-index: 10;
&.banner {
&:after {
content: "Not available when device is offline.";
position: absolute;
top: 25%;
left: -2.5%;
z-index: 10;
width: 105%;
padding: 0.5rem;
background-color: $dark_gray;
opacity: 0.90;
color: $off_white;
font-size: 1.8rem;
position: absolute;
vertical-align: middle;
text-align: center;
top: 25%;
left: -2.5%;
width: 105%;
z-index: 10;
}
}
}
@ -583,8 +587,8 @@ ul {
}
.button-group {
float: right;
margin: -2rem 0 2rem;
float: right;
button:not(:first-of-type) {
margin-right: 1rem;
}
@ -607,40 +611,48 @@ ul {
}
}
.motor-position-plot-border {
text {
font-size: 0.4rem;
text-anchor: middle;
dominant-baseline: middle;
}
}
.controls-popup,
.controls-popup-menu-outer {
position: fixed;
bottom: 3rem;
right: 4rem;
z-index: 2;
background: $dark_gray;
border-radius: 3rem;
height: 6rem;
width: 6rem;
right: 4rem;
bottom: 3rem;
cursor: pointer;
z-index: 2;
}
.controls-popup {
color: $off_white;
i {
position: fixed;
bottom: 3rem;
z-index: 3;
width: 6rem;
height: 6rem;
bottom: 3rem;
border-radius: 3rem;
padding: 18px 20px;
position: fixed;
font-size: 2.4rem;
transition: all 0.25s ease-in-out;
&:hover {
background-color: rgba(0,0,0,0.2);
background-color: rgba(0, 0, 0, 0.2);
}
}
&.open {
i {
transform: rotate(-135deg);
&:hover {
background-color: rgba(0,0,0,0);
background-color: rgba(0, 0, 0, 0);
}
}
.controls-popup-menu-outer {
@ -649,7 +661,7 @@ ul {
padding: 0.6rem 5rem 0rem 0rem;
}
.controls-popup-menu-inner {
transition-delay: 0.25s!important;
transition-delay: 0.25s !important;
opacity: 1;
}
}
@ -666,7 +678,7 @@ ul {
.controls-popup-menu-outer {
transition: all 0.1 s ease-in-out;
transition-delay: 0.2s!important;
transition-delay: 0.2s !important;
}
@keyframes page-transition {
@ -679,10 +691,10 @@ ul {
}
.empty-state-graphic {
width: 50%;
display: flex;
margin: auto;
margin-top: 10%;
width: 50%;
}
.farmware-selection-panel {
@ -701,7 +713,8 @@ ul {
.farmware-input-panel,
.farmware-info-panel {
label, h4 {
label,
h4 {
margin-top: 2rem;
}
}
@ -716,7 +729,8 @@ ul {
label {
cursor: pointer;
}
&:hover, &.selected {
&:hover,
&.selected {
background: $medium_light_gray;
p {
font-weight: bold;
@ -726,10 +740,10 @@ ul {
.farmware-button,
.farmware-settings-menu {
float: right !important;
position: absolute !important;
right: 3rem;
top: 1rem;
right: 3rem;
float: right !important;
}
.logs {
@ -781,7 +795,6 @@ ul {
td:nth-child(1),
td:nth-child(4) {
min-width: 106px;
}
td:nth-child(2),
td:nth-child(3) {
@ -798,7 +811,8 @@ ul {
max-width: 250px;
h1 {
font-weight: 300;
font-size: 1.5rem;
font-size: 1.4rem;
line-height: 2rem;
}
li:before {
content: "";
@ -857,9 +871,9 @@ ul {
width: 100%;
margin-top: 0.5rem;
.indicator {
position: relative;
background: $dark_gray;
height: 2rem;
position: relative;
span {
position: relative;
top: 0.15rem;
@ -889,21 +903,22 @@ ul {
}
.fb-checkbox {
display: inline;
margin-right: 1rem;
position: relative;
margin-right: 1rem;
&.partial:after {
content: "";
position: absolute;
left: 0.75rem;
bottom: 1.2rem;
border: solid $dark_gray;
border-width: 0 0 3px 0;
bottom: 1.2rem;
left: 0.75rem;
padding: 0.6rem 0.3rem;
}
}
.pt-popover-wrapper, .pt-popover-target {
margin-left: 1rem;
.pt-popover-wrapper,
.pt-popover-target {
display: inline-block !important;
margin-left: 1rem;
margin-top: 0.4rem;
}
}
@ -913,8 +928,8 @@ ul {
margin-left: 3rem;
i {
position: absolute;
right: 1rem;
top: 0.55rem;
right: 1rem;
color: $light_gray;
&:hover {
color: $dark_gray;

View File

@ -1,21 +1,21 @@
.hotkey-guide {
z-index: 99;
position: relative;
top: 24vh;
margin: 0 auto;
left: 0;
right: 0;
z-index: 99;
margin: 0 auto;
width: 32rem;
position: relative;
outline: none;
h3 {
text-align: center;
display: block;
text-align: center;
margin-bottom: 3rem;
}
i {
position: absolute;
font-size: 2rem;
top: 1rem;
left: 1rem;
font-size: 2rem;
}
}
}

View File

@ -3,16 +3,16 @@
}
.image-flipper-image {
max-width: 100%;
margin: auto;
max-width: 100%;
max-height: 650px;
min-height: 200px;
}
.image-flipper-left {
position: absolute;
left: -5px;
top: -5px;
left: -5px;
bottom: -5px;
background-color: rgba(0, 0, 0, 0.5);
&:hover {
@ -22,9 +22,9 @@
.image-flipper-right {
position: absolute;
right: -5px;
top: -5px;
bottom: -5px;
right: -5px;
background-color: rgba(0, 0, 0, 0.5);
&:hover {
background-color: rgba(0, 0, 0, 0.7);
@ -45,13 +45,13 @@
}
p {
position: absolute;
left: 0;
right: 0;
z-index: 1;
font-size: 1.5rem;
text-align: center;
padding: 20% 1rem 0;
line-height: 2.4rem;
left: 0;
right: 0;
z-index: 1;
margin-left: 50px;
margin-right: 50px;
}
@ -59,13 +59,13 @@
.photos {
.photos-footer {
padding-left: 0.5rem;
padding-right: 0.5rem;
display: flex;
position: relative;
left: 2.5rem;
bottom: -1rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
width: 90%;
display: flex;
justify-content: space-between;
label {
font-weight: normal;
@ -93,9 +93,9 @@
.index-indicator {
position: absolute;
background: $white;
height: 3px;
top: 3rem;
bottom: 0;
right: 0;
background: $white;
height: 3px;
}

View File

@ -17,8 +17,8 @@ input:not([role="combobox"]) {
&.bulk-day-selector {
width: 10%;
box-shadow: none;
margin-bottom: 0.75rem!important;
margin-top: 0.75rem!important;
margin-bottom: 0.75rem !important;
margin-top: 0.75rem !important;
height: 1.5rem;
vertical-align: top;
&.margin-left {
@ -34,9 +34,9 @@ input:not([role="combobox"]) {
}
.day-selector-wrapper {
display: inline-block;
width: 10%;
height: 3rem;
display: inline-block;
}
.week-row {
@ -56,8 +56,8 @@ select {
}
i {
position: absolute;
right: 0.65rem;
top: 30%;
right: 0.65rem;
color: $dark_gray
}
&.dim {
@ -77,7 +77,7 @@ select {
}
.filter-search-item-none::after {
content: "*";
content: "*";
}
.filter-search-heading-item {
@ -92,23 +92,23 @@ select {
.fb-checkbox {
input[type="checkbox"] {
position: relative;
border-radius: 0;
-webkit-appearance: none;
border: 0.5px solid $gray;
width: 2rem;
height: 2rem;
background: $white;
position: relative;
margin-top: 0;
cursor: pointer;
&:checked:after {
content: "";
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
border: solid $dark_gray;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
bottom: 0.5rem;
left: 0.5rem;
padding: 0.6rem 0.3rem;
}
}
@ -119,11 +119,11 @@ select {
&:checked:after {
content: "";
position: absolute;
left: 0.9rem;
bottom: 0.5rem;
border: solid $dark_gray;
border-width: 0 4px 4px 0;
transform: rotate(45deg);
bottom: 0.5rem;
left: 0.9rem;
padding: 1rem 0.3rem;
}
}

View File

@ -1,13 +1,12 @@
$duration: 2;
.loading-plant-div-container {
width: 100vw;
height: 100vh;
position: fixed;
display: flex;
padding-top: 10%;
justify-content: center;
z-index: 1;
display: flex;
position: fixed;
z-index: 1;
width: 100vw;
height: 100vh;
padding-top: 10%;
justify-content: center;
}
.loading-plant-svg-container {
@ -56,7 +55,7 @@ $duration: 2;
transform-box: fill-box;
transform: scale(1);
opacity: 1;
animation: loading-plant-accent-pop $duration*0.5s cubic-bezier(0, 0, 0, 1.4) 1 ;
animation: loading-plant-accent-pop $duration*0.5s cubic-bezier(0, 0, 0, 1.4) 1;
}
@keyframes loading-plant-accent-pop {
@ -77,9 +76,7 @@ $duration: 2;
.loading-plant-circle {
transform-origin: center;
transform-box: fill-box;
animation:
loading-plant-circle-pop $duration*0.3s cubic-bezier(0, 0, 0, 1.4) 1,
loading-plant-circle $duration*3s linear infinite $duration*0.3s;
animation: loading-plant-circle-pop $duration*0.3s cubic-bezier(0, 0, 0, 1.4) 1, loading-plant-circle $duration*3s linear infinite $duration*0.3s;
}
@keyframes loading-plant-circle-pop {

View File

@ -1,15 +1,15 @@
.nav-wrapper {
background: $dark_gray;
box-shadow: 0 4px 10px rgba(0, 0, 0, .2);
position: fixed;
left: 0;
right: 0;
z-index: 99;
background: $dark_gray;
box-shadow: 0 4px 10px rgba(0, 0, 0, .2);
}
nav {
max-width: 140rem;
margin: 3.4rem auto 0;
max-width: 140rem;
button {
margin: 1.8rem 1.8rem 0 0;
font-size: 1.3rem !important;
@ -35,12 +35,12 @@ nav {
display: none;
}
.nav-links a {
display: inline-block;
position: relative;
font-size: 1.2rem;
white-space: nowrap;
display: inline-block;
padding: 2rem 1rem;
letter-spacing: 1.2px;
position: relative;
transition: font-weight 0.2s ease;
&:hover {
font-weight: bold;
@ -57,19 +57,19 @@ nav {
}
&:before {
content: "";
bottom: 0;
left: 0;
right: 0;
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: $white;
}
&:after {
content: "";
bottom: 0;
left: 0;
right: 0;
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: $dark_gray;
}
@ -95,16 +95,16 @@ nav {
}
}
.pt-popover-content {
width: 22rem;
position: relative;
width: 22rem;
a:not(.app-version) {
margin-bottom: 0.6rem;
display: inline-block;
margin-bottom: 0.6rem;
}
.app-version {
margin: 1rem -1rem -1rem;
background: $dark_gray;
color: $white;
margin: 1rem -1rem -1rem;
padding: 0.5rem 0 0 1rem;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;

View File

@ -1,9 +1,9 @@
.farmware-input-panel,
.sequence-editor-panel,
.regimen-editor-panel {
margin: -3rem -1.5rem -6rem;
height: calc(100vh - 5rem);
background: $light_gray;
margin: -3rem -1.5rem -6rem;
@media screen and (max-width: 768px) {
margin-bottom: 3rem;
}
@ -14,7 +14,8 @@
margin-top: 0.4rem;
}
@media screen and (max-width: 974px) {
h3, p {
h3,
p {
margin-left: 15px;
margin-right: 15px;
}

View File

@ -1,63 +1,63 @@
$offset: 187;
$duration: 1.3s;
.spinner-container {
width: 100vw;
height: 100vh;
position: fixed;
display: flex;
padding-top: 16%;
justify-content: center;
z-index: 1;
display: flex;
position: fixed;
z-index: 1;
width: 100vw;
height: 100vh;
padding-top: 16%;
justify-content: center;
}
.spinner {
animation: rotator $duration linear infinite;
animation: rotator $duration linear infinite;
}
@keyframes rotator {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
}
.spinner-path {
stroke-dasharray: $offset;
stroke-dashoffset: 0;
transform-origin: center;
animation: dash $duration ease-in-out infinite, colors ($duration * 3) ease-in-out infinite;
stroke-dasharray: $offset;
stroke-dashoffset: 0;
transform-origin: center;
animation: dash $duration ease-in-out infinite, colors ($duration * 3) ease-in-out infinite;
}
@keyframes colors {
0% {
stroke: #783F04;
}
25% {
stroke: #EE6666;
}
50% {
stroke: #66AA44;
}
75% {
stroke: #274E13;
}
100% {
stroke: #666666;
}
0% {
stroke: #783f04;
}
25% {
stroke: #ee6666;
}
50% {
stroke: #66aa44;
}
75% {
stroke: #274e13;
}
100% {
stroke: #666666;
}
}
@keyframes dash {
0% {
stroke-dashoffset: $offset;
}
50% {
stroke-dashoffset: $offset / 4;
transform: rotate(135deg);
}
100% {
stroke-dashoffset: $offset;
transform: rotate(450deg);
}
}
0% {
stroke-dashoffset: $offset;
}
50% {
stroke-dashoffset: $offset / 4;
transform: rotate(135deg);
}
100% {
stroke-dashoffset: $offset;
transform: rotate(450deg);
}
}

View File

@ -4,7 +4,7 @@
* {
margin: 0 auto;
}
background: linear-gradient(-135deg, #6DB1EC, #35A274);
background: linear-gradient(-135deg, #6db1ec, #35a274);
padding-top: 4rem;
h1,
h2 {
@ -37,8 +37,8 @@
max-width: 1024px;
}
.forgot-password {
color: $blue;
display: inline-block;
color: $blue;
}
.tos {
margin-bottom: 1.5rem;

View File

@ -17,8 +17,8 @@
}
&:after {
content: "";
bottom: 0;
left: 0;
bottom: 0;
right: 0;
pointer-events: none;
}
@ -48,11 +48,11 @@
background: rgba(255, 255, 255, 0.2);
}
.saucer {
margin: 0.2rem 0.6rem 0rem 0;
float: left;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
margin: 0.2rem 0.6rem 0rem 0;
}
}

View File

@ -15,9 +15,9 @@
flex: 1;
input,
label {
display: inline-block;
width: auto;
cursor: pointer;
display: inline-block;
}
input {
margin-left: 1rem;
@ -36,17 +36,17 @@
float: right;
margin-top: 0.1rem;
}
::-webkit-input-placeholder {
::-webkit-input-placeholder {
color: $dark_gray;
font-weight: bold;
text-transform: uppercase;
}
::-moz-placeholder {
::-moz-placeholder {
color: $dark_gray;
font-weight: bold;
text-transform: uppercase;
}
:-ms-input-placeholder {
:-ms-input-placeholder {
color: $dark_gray;
font-weight: bold;
text-transform: uppercase;
@ -108,6 +108,12 @@
}
}
.step-up-down-arrows {
display: inline-block;
text-align: center;
font-size: 2rem;
}
.step-control {
margin-top: 0.3rem;
margin-left: 2rem;

View File

@ -1,18 +1,18 @@
// Styles for TABLES
table {
width: 100%;
margin-bottom: 0;
width: 100%;
margin-bottom: 0;
}
table.plain tr:nth-of-type(1n) {
background: $off-white;
border: .5rem solid $off-white;
background: $off-white;
border: .5rem solid $off-white;
}
table tr td {
padding: .5rem;
padding: .5rem;
}
table thead tr th {
padding: .5rem;
}
padding: .5rem;
}

View File

@ -1,18 +1,18 @@
.toast-container {
position: fixed;
bottom: 0px;
width: 100%;
z-index: 999999;
width: 100%;
pointer-events: none;
}
.toast {
position: relative;
box-shadow: 0px 1px 4px #555;
pointer-events: all;
padding: 1.8rem;
cursor: pointer;
min-height: 6rem;
position: relative;
margin-top: 10px;
margin-left: auto;
margin-right: auto;
@ -39,14 +39,16 @@
pointer-events: none;
}
&.yellow {
.toast-title, .toast-message {
.toast-title,
.toast-message {
color: $dark_gray;
}
}
&.blue,
&.green,
&.red {
.toast-title, .toast-message {
.toast-title,
.toast-message {
color: $off_white;
}
}
@ -66,19 +68,19 @@
.toast-loader {
position: absolute;
top: 1rem;
right: 1rem;
width: 1.6rem;
height: 1.6rem;
overflow: hidden;
transform: rotate(180deg);
top: 1rem;
right: 1rem;
}
.toast-loader-left,
.toast-loader-right,
.toast-loader-spinner {
top: 0;
position: absolute;
top: 0;
width: 50%;
height: 100%;
}
@ -93,8 +95,8 @@
.toast-loader-right {
right: 0;
background: #666 !important;
z-index: 1;
background: #666 !important;
opacity: 0;
animation: show-hide 7s steps(1, end) reverse;
border-radius: 0 100% 100% 0/ 0 50% 50% 0;
@ -102,10 +104,10 @@
.toast-loader-spinner {
left: 0;
z-index: 2;
background: #666 !important;
animation: spin 7s linear;
transform-origin: center right;
z-index: 2;
border-radius: 100% 0 0 100%/ 50% 0 0 50%;
}

View File

@ -7,7 +7,10 @@
&:hover,
&:active {
.help-text {
display: block;
position: absolute;
left: 17rem;
bottom: -0.8rem;
z-index: 999;
padding: 2px;
font-style: normal;
@ -16,9 +19,6 @@
border-radius: 3px;
padding: .5rem .8rem;
width: 250px;
left: 17rem;
bottom: -0.8rem;
display: block;
}
}
}

View File

@ -11,6 +11,7 @@
}
.move-amount {
margin: 0;
background-color: $white;
border-right: 2px solid $off-white;
color: $medium_gray;
@ -22,7 +23,6 @@
padding-right: 0;
text-align: center;
width: 20%;
margin: 0;
float: left;
&:hover {
background-color: darken($white, 40%);
@ -41,7 +41,7 @@
border-right: 0px none;
}
&.move-amount-selected {
background-color: $medium_gray!important;
background-color: $medium_gray !important;
color: $off_white;
}
}
@ -55,12 +55,12 @@
}
.arrow-button {
margin: 0;
background-color: $medium_gray;
border-bottom: 2px solid $dark_gray;
color: $off_white;
font-size: 16px!important;
font-size: 16px !important;
height: 40px;
margin: 0;
padding: 12px;
text-align: center;
width: 40px;

View File

@ -16,9 +16,9 @@
*/
.widget-wrapper {
position: relative;
box-shadow: 0px 0px 10px $gray;
margin-bottom: 3rem;
position: relative;
}
.widget-header {
@ -60,17 +60,17 @@
}
a:hover {
font-weight: 600;
color: white;
color: $white;
}
a:active {
color: white;
color: $white;
}
}
h5 {
display: inline;
color: $gray;
font-size: 1.2rem;
text-transform: uppercase;
display: inline;
margin-right: 1rem;
}
}

View File

@ -39,7 +39,7 @@ describe("<FarmbotOsSettings/>", () => {
it("renders settings", () => {
const osSettings = mount(<FarmbotOsSettings {...fakeProps()} />);
expect(osSettings.find("input").length).toBe(1);
expect(osSettings.find("button").length).toBe(6);
expect(osSettings.find("button").length).toBe(7);
["NAME", "TIME ZONE", "LAST SEEN", "FARMBOT OS", "CAMERA", "FIRMWARE"]
.map(string => expect(osSettings.text()).toContain(string));
});
@ -50,8 +50,10 @@ describe("<FarmbotOsSettings/>", () => {
{...fakeProps()} />);
await expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining("RELEASE_NOTES.md"));
expect(osSettings.instance().state.osReleaseNotesHeading)
.toEqual("FarmBot OS v6");
expect(osSettings.instance().state.osReleaseNotes)
.toEqual("# FarmBot OS v6\n* note");
.toEqual("* note");
});
it("doesn't fetch OS release notes", async () => {

Some files were not shown because too many files have changed in this diff Show More