commit
d8da8b711e
2
Gemfile
2
Gemfile
|
@ -16,7 +16,7 @@ gem "mutations"
|
|||
gem "paperclip"
|
||||
gem "pg"
|
||||
gem "polymorphic_constraints"
|
||||
# gem "rack-attack" # RC 17 APR 19
|
||||
gem "rack-attack"
|
||||
gem "rack-cors"
|
||||
gem "rails_12factor"
|
||||
gem "rails"
|
||||
|
|
34
Gemfile.lock
34
Gemfile.lock
|
@ -59,12 +59,12 @@ GEM
|
|||
arel (9.0.0)
|
||||
bcrypt (3.1.12)
|
||||
builder (3.2.3)
|
||||
bunny (2.14.1)
|
||||
bunny (2.14.2)
|
||||
amq-protocol (~> 2.3, >= 2.3.0)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
childprocess (0.9.0)
|
||||
ffi (~> 1.0, >= 1.0.11)
|
||||
childprocess (1.0.1)
|
||||
rake (< 13.0)
|
||||
choice (0.2.0)
|
||||
climate_control (0.2.0)
|
||||
codecov (0.1.14)
|
||||
|
@ -95,11 +95,11 @@ GEM
|
|||
docile (1.3.1)
|
||||
erubi (1.8.0)
|
||||
eventmachine (1.2.7)
|
||||
excon (0.62.0)
|
||||
excon (0.64.0)
|
||||
factory_bot (5.0.2)
|
||||
activesupport (>= 4.2.0)
|
||||
factory_bot_rails (5.0.1)
|
||||
factory_bot (~> 5.0.0)
|
||||
factory_bot_rails (5.0.2)
|
||||
factory_bot (~> 5.0.2)
|
||||
railties (>= 4.2.0)
|
||||
faker (1.9.3)
|
||||
i18n (>= 0.7)
|
||||
|
@ -107,7 +107,6 @@ GEM
|
|||
multipart-post (>= 1.2, < 3)
|
||||
faraday_middleware (0.13.1)
|
||||
faraday (>= 0.7.4, < 1.0)
|
||||
ffi (1.10.0)
|
||||
figaro (1.1.1)
|
||||
thor (~> 0.14)
|
||||
fog-core (2.1.0)
|
||||
|
@ -126,8 +125,8 @@ GEM
|
|||
fog-xml (0.1.3)
|
||||
fog-core
|
||||
nokogiri (>= 1.5.11, < 2.0.0)
|
||||
font-awesome-rails (4.7.0.4)
|
||||
railties (>= 3.2, < 6.0)
|
||||
font-awesome-rails (4.7.0.5)
|
||||
railties (>= 3.2, < 6.1)
|
||||
foreman (0.85.0)
|
||||
thor (~> 0.19.1)
|
||||
formatador (0.2.5)
|
||||
|
@ -148,7 +147,7 @@ GEM
|
|||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.7)
|
||||
hashdiff (0.3.8)
|
||||
hashdiff (0.3.9)
|
||||
hashie (3.6.0)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.6.0)
|
||||
|
@ -160,7 +159,7 @@ GEM
|
|||
addressable (~> 2.3)
|
||||
letter_opener (1.7.0)
|
||||
launchy (~> 2.2)
|
||||
lol_dba (2.1.7)
|
||||
lol_dba (2.1.8)
|
||||
actionpack (>= 3.0, < 6.0)
|
||||
activerecord (>= 3.0, < 6.0)
|
||||
railties (>= 3.0, < 6.0)
|
||||
|
@ -185,10 +184,10 @@ GEM
|
|||
mutations (0.9.0)
|
||||
activesupport
|
||||
nio4r (2.3.1)
|
||||
nokogiri (1.10.2)
|
||||
nokogiri (1.10.3)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.0.0)
|
||||
os (1.0.1)
|
||||
paperclip (6.1.0)
|
||||
activemodel (>= 4.2.0)
|
||||
activesupport (>= 4.2.0)
|
||||
|
@ -213,6 +212,8 @@ GEM
|
|||
hashie (~> 3.6)
|
||||
multi_json (~> 1.13.1)
|
||||
rack (2.0.7)
|
||||
rack-attack (6.0.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-cors (1.0.3)
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
|
@ -270,7 +271,7 @@ GEM
|
|||
rspec-mocks (~> 3.8.0)
|
||||
rspec-core (3.8.0)
|
||||
rspec-support (~> 3.8.0)
|
||||
rspec-expectations (3.8.2)
|
||||
rspec-expectations (3.8.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.8.0)
|
||||
rspec-mocks (3.8.0)
|
||||
|
@ -291,8 +292,8 @@ GEM
|
|||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
secure_headers (6.1.0)
|
||||
selenium-webdriver (3.141.0)
|
||||
childprocess (~> 0.5)
|
||||
selenium-webdriver (3.142.0)
|
||||
childprocess (>= 0.5, < 2.0)
|
||||
rubyzip (~> 1.2, >= 1.2.2)
|
||||
signet (0.11.0)
|
||||
addressable (~> 2.3)
|
||||
|
@ -369,6 +370,7 @@ DEPENDENCIES
|
|||
pry
|
||||
pry-rails
|
||||
rabbitmq_http_api_client
|
||||
rack-attack
|
||||
rack-cors
|
||||
rails
|
||||
rails-erd
|
||||
|
|
17
app/controllers/api/alerts_controller.rb
Normal file
17
app/controllers/api/alerts_controller.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
module Api
|
||||
class AlertsController < Api::AbstractController
|
||||
def index
|
||||
render json: current_device.alerts
|
||||
end
|
||||
|
||||
def destroy
|
||||
mutate Alerts::Destroy.run(alert: alert)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def alert
|
||||
@alert ||= current_device.alerts.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -35,7 +35,7 @@ module Api
|
|||
end
|
||||
|
||||
def seed
|
||||
Devices::SeedData.delay.run!(params.as_json, device: current_device)
|
||||
Devices::CreateSeedData.delay.run!(params.as_json, device: current_device)
|
||||
render json: { done: "Loading resources now." }
|
||||
end
|
||||
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
module Api
|
||||
class EnigmasController < Api::AbstractController
|
||||
def index
|
||||
render json: current_device.enigmas
|
||||
end
|
||||
|
||||
def destroy
|
||||
mutate Enigmas::Destroy.run(enigma: enigma)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enigma
|
||||
@enigma ||= current_device.enigmas.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
16
app/controllers/api/global_bulletins_controller.rb
Normal file
16
app/controllers/api/global_bulletins_controller.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
module Api
|
||||
class GlobalBulletinsController < Api::AbstractController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_before_action :check_fbos_version
|
||||
|
||||
def show
|
||||
render json: search_results
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_results
|
||||
@search_results ||= GlobalBulletin.find_by(slug: params[:id])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,21 +3,21 @@
|
|||
# Also handles throttling.
|
||||
class LogService
|
||||
T = ThrottlePolicy::TimePeriod
|
||||
THROTTLE_POLICY = ThrottlePolicy.new T.new(1.minute) => 0.5 * 1_000,
|
||||
T.new(1.hour) => 0.5 * 10_000,
|
||||
T.new(1.day) => 0.5 * 100_000
|
||||
THROTTLE_POLICY = ThrottlePolicy.new T.new(1.minute) => 0.5 * 1_000,
|
||||
T.new(1.hour) => 0.5 * 10_000,
|
||||
T.new(1.day) => 0.5 * 100_000
|
||||
|
||||
def self.process(delivery_info, payload)
|
||||
params = { routing_key: delivery_info.routing_key, payload: payload }
|
||||
data = AmqpLogParser.run!(params)
|
||||
puts data.payload["message"] if Rails.env.production?
|
||||
THROTTLE_POLICY.track(data.device_id)
|
||||
maybe_deliver(data)
|
||||
m = AmqpLogParser.run!(params)
|
||||
puts "#{m.device_id}: #{m.payload["message"]}" if Rails.env.production?
|
||||
THROTTLE_POLICY.track(m.device_id)
|
||||
maybe_deliver(m)
|
||||
end
|
||||
|
||||
def self.maybe_deliver(data)
|
||||
violation = THROTTLE_POLICY.is_throttled(data.device_id)
|
||||
ok = data.valid? && !violation
|
||||
ok = data.valid? && !violation
|
||||
|
||||
data.device.auto_sync_transaction do
|
||||
ok ? deliver(data) : warn_user(data, violation)
|
||||
|
|
12
app/models/alert.rb
Normal file
12
app/models/alert.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
class Alert < ApplicationRecord
|
||||
belongs_to :device
|
||||
PROBLEM_TAGS = [
|
||||
SEED_DATA = "api.seed_data.missing",
|
||||
TOUR = "api.tour.not_taken",
|
||||
USER = "api.user.not_welcomed",
|
||||
DOCUMENTATION = "api.documentation.unread",
|
||||
BULLETIN = "api.bulletin.unread"
|
||||
]
|
||||
|
||||
validates_inclusion_of :problem_tag, in: PROBLEM_TAGS
|
||||
end
|
|
@ -20,7 +20,7 @@ module CeleryScriptSettingsBag
|
|||
"BoxLed3" => BoxLed,
|
||||
"BoxLed4" => BoxLed }
|
||||
ALLOWED_AXIS = %w(x y z all)
|
||||
ALLOWED_CHAGES = %w(add remove update)
|
||||
ALLOWED_CHANGES = %w(add remove update)
|
||||
ALLOWED_CHANNEL_NAMES = %w(ticker toast email espeak)
|
||||
ALLOWED_LHS_STRINGS = [*(0..69)].map { |x| "pin#{x}" }.concat(%w(x y z))
|
||||
ALLOWED_LHS_TYPES = [String, :named_pin]
|
||||
|
@ -94,7 +94,7 @@ module CeleryScriptSettingsBag
|
|||
ALLOWED_PIN_MODES: [ALLOWED_PIN_MODES, BAD_ALLOWED_PIN_MODES],
|
||||
AllowedPinTypes: [ALLOWED_PIN_TYPES, BAD_PIN_TYPE],
|
||||
Color: [Sequence::COLORS, MISC_ENUM_ERR],
|
||||
DataChangeType: [ALLOWED_CHAGES, MISC_ENUM_ERR],
|
||||
DataChangeType: [ALLOWED_CHANGES, MISC_ENUM_ERR],
|
||||
LegalSequenceKind: [ALLOWED_RPC_NODES.sort, MISC_ENUM_ERR],
|
||||
lhs: [ALLOWED_LHS_STRINGS, BAD_LHS],
|
||||
PlantStage: [PLANT_STAGES, MISC_ENUM_ERR],
|
||||
|
@ -211,7 +211,7 @@ module CeleryScriptSettingsBag
|
|||
blk: ->(node) do
|
||||
x = [ALLOWED_LHS_STRINGS, node, BAD_LHS]
|
||||
# This would never have happened if we hadn't allowed
|
||||
# heterogenus args :(
|
||||
# heterogenous args :(
|
||||
manual_enum(*x) unless node.is_a?(CeleryScript::AstNode)
|
||||
end,
|
||||
},
|
||||
|
|
|
@ -12,7 +12,7 @@ class Device < ApplicationRecord
|
|||
"Resuming log storage."
|
||||
CACHE_KEY = "devices.%s"
|
||||
|
||||
has_many :enigmas, dependent: :destroy
|
||||
has_many :alerts, dependent: :destroy
|
||||
has_many :farmware_envs, dependent: :destroy
|
||||
has_many :farm_events, dependent: :destroy
|
||||
has_many :farmware_installations, dependent: :destroy
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
class Enigma < ApplicationRecord
|
||||
belongs_to :device
|
||||
PROBLEM_TAGS = [
|
||||
SEED_DATA = "api.seed_data.missing",
|
||||
TOUR = "api.tour.not_taken",
|
||||
USER = "api.user.not_welcomed",
|
||||
DOCUMENTATION = "api.documentation.unread"
|
||||
]
|
||||
end
|
|
@ -7,10 +7,10 @@ class FarmwareEnv < ApplicationRecord
|
|||
PRIMITIVES_ONLY = "`value` must be a string, number or boolean"
|
||||
|
||||
def primitives_only
|
||||
errors.add(:value, PRIMITIVES_ONLY) unless is_primitve
|
||||
errors.add(:value, PRIMITIVES_ONLY) unless is_primitive
|
||||
end
|
||||
|
||||
def is_primitve
|
||||
def is_primitive
|
||||
[String, Integer, Float, TrueClass, FalseClass].include?(value.class)
|
||||
end
|
||||
end
|
||||
|
|
5
app/models/global_bulletin.rb
Normal file
5
app/models/global_bulletin.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class GlobalBulletin < ActiveRecord::Base
|
||||
self.inheritance_column = "none"
|
||||
validates_uniqueness_of :slug
|
||||
validates_presence_of :content, :slug, :type
|
||||
end
|
|
@ -1,4 +1,14 @@
|
|||
# A single organism living in the ground.
|
||||
class Plant < Point
|
||||
DEFAULT_ICON = "/app-resources/img/icons/generic-plant.svg"
|
||||
ATTRS = %w(meta
|
||||
name
|
||||
openfarm_slug
|
||||
plant_stage
|
||||
planted_at
|
||||
pointer_type
|
||||
radius
|
||||
x
|
||||
y
|
||||
z)
|
||||
end
|
||||
|
|
|
@ -49,6 +49,6 @@ class User < ApplicationRecord
|
|||
|
||||
Transport
|
||||
.current
|
||||
.raw_amqp_send("X", Api::RmqUtilsController::PUBLIC_BROADCAST)
|
||||
.raw_amqp_send({}.to_json, Api::RmqUtilsController::PUBLIC_BROADCAST)
|
||||
end
|
||||
end
|
||||
|
|
16
app/mutations/alerts/create.rb
Normal file
16
app/mutations/alerts/create.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
module Alerts
|
||||
class Create < Mutations::Command
|
||||
required do
|
||||
model :device
|
||||
string :problem_tag, in: Alert::PROBLEM_TAGS
|
||||
end
|
||||
|
||||
optional { string :slug }
|
||||
|
||||
def execute
|
||||
Alert.create!(device: device,
|
||||
problem_tag: problem_tag,
|
||||
slug: slug || SecureRandom.uuid)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,11 +1,11 @@
|
|||
module Enigmas
|
||||
module Alerts
|
||||
class Destroy < Mutations::Command
|
||||
required do
|
||||
model :enigma
|
||||
model :alert
|
||||
end
|
||||
|
||||
def execute
|
||||
enigma.destroy!
|
||||
alert.destroy!
|
||||
end
|
||||
end
|
||||
end
|
|
@ -13,8 +13,14 @@ module Devices
|
|||
def execute
|
||||
merge_default_values
|
||||
device = Device.create!({name: "Farmbot"}.merge(inputs.except(:user)))
|
||||
Enigmas::Create.run!(device: device,
|
||||
problem_tag: Enigma::SEED_DATA)
|
||||
Alerts::Create.run!(device: device,
|
||||
problem_tag: Alert::SEED_DATA)
|
||||
Alerts::Create.run!(device: device,
|
||||
problem_tag: Alert::TOUR)
|
||||
Alerts::Create.run!(device: device,
|
||||
problem_tag: Alert::USER)
|
||||
Alerts::Create.run!(device: device,
|
||||
problem_tag: Alert::DOCUMENTATION)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
# TODO: This is a really, really, really old
|
||||
|
|
35
app/mutations/devices/create_seed_data.rb
Normal file
35
app/mutations/devices/create_seed_data.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
module Devices
|
||||
class CreateSeedData < Mutations::Command
|
||||
PRODUCT_LINES = {
|
||||
"express_1.0" => Devices::Seeders::ExpressOneZero,
|
||||
"express_xl_1.0" => Devices::Seeders::ExpressXlOneZero,
|
||||
|
||||
"genesis_1.2" => Devices::Seeders::GenesisOneTwo,
|
||||
"genesis_1.3" => Devices::Seeders::GenesisOneThree,
|
||||
"genesis_1.4" => Devices::Seeders::GenesisOneFour,
|
||||
|
||||
"xl_1.4" => Devices::Seeders::XlOneFour,
|
||||
|
||||
"none" => Devices::Seeders::None,
|
||||
}
|
||||
|
||||
COMMANDS = Devices::Seeders::Abstract.instance_methods(false).sort
|
||||
|
||||
required do
|
||||
model :device
|
||||
string :product_line, in: PRODUCT_LINES.keys
|
||||
end
|
||||
|
||||
def execute
|
||||
run_seeds!
|
||||
end
|
||||
|
||||
def seeder
|
||||
@seeder ||= PRODUCT_LINES.fetch(product_line).new(device)
|
||||
end
|
||||
|
||||
def run_seeds!
|
||||
COMMANDS.map { |cmd| seeder.send(cmd) }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,24 +0,0 @@
|
|||
module Devices
|
||||
class SeedData < Mutations::Command
|
||||
PRODUCT_LINES = ["genesis"]
|
||||
required do
|
||||
model :device
|
||||
string :product_line, in: PRODUCT_LINES
|
||||
end
|
||||
|
||||
def execute
|
||||
add_plants
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_plants
|
||||
Plant.create!(device: device,
|
||||
x: rand(40...1500),
|
||||
y: rand(40...800),
|
||||
radius: rand(30...60),
|
||||
name: "Celery",
|
||||
openfarm_slug: "celery")
|
||||
end
|
||||
end
|
||||
end
|
69
app/mutations/devices/seeders/abstract.rb
Normal file
69
app/mutations/devices/seeders/abstract.rb
Normal file
|
@ -0,0 +1,69 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class Abstract
|
||||
SEED_EMAIL = "seed@farmbot.io"
|
||||
attr_reader :device
|
||||
|
||||
def initialize(device)
|
||||
@device = device
|
||||
end
|
||||
|
||||
def plants
|
||||
seed_device
|
||||
.plants
|
||||
.as_json
|
||||
.map do |x|
|
||||
Points::Create.run!(x
|
||||
.slice(*Plant::ATTRS)
|
||||
.merge({
|
||||
device: device,
|
||||
pointer_type: "Plant",
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
def peripherals_lighting; end
|
||||
def peripherals_peripheral_4; end
|
||||
def peripherals_peripheral_5; end
|
||||
def peripherals_vacuum; end
|
||||
def peripherals_water; end
|
||||
def pin_bindings_button_1; end
|
||||
def pin_bindings_button_2; end
|
||||
def sensors_soil_sensor; end
|
||||
def sensors_tool_verification; end
|
||||
def sequences_mount_tool; end
|
||||
def sequences_pick_up_seed; end
|
||||
def sequences_plant_seed; end
|
||||
def sequences_take_photo_of_plant; end
|
||||
def sequences_tool_error; end
|
||||
def sequences_unmount_tool; end
|
||||
def sequences_water_plant; end
|
||||
def settings_default_map_size_x; end
|
||||
def settings_default_map_size_y; end
|
||||
def settings_device_name; end
|
||||
def settings_enable_encoders; end
|
||||
def settings_firmware; end
|
||||
def tool_slots_slot_1; end
|
||||
def tool_slots_slot_2; end
|
||||
def tool_slots_slot_3; end
|
||||
def tool_slots_slot_4; end
|
||||
def tool_slots_slot_5; end
|
||||
def tool_slots_slot_6; end
|
||||
def tools_seed_bin; end
|
||||
def tools_seed_tray; end
|
||||
def tools_seed_trough_1; end
|
||||
def tools_seed_trough_2; end
|
||||
def tools_seed_trough_3; end
|
||||
def tools_seeder; end
|
||||
def tools_soil_sensor; end
|
||||
def tools_watering_nozzle; end
|
||||
def tools_weeder; end
|
||||
|
||||
private
|
||||
|
||||
def seed_device
|
||||
@seed_device ||= User.find_by(email: SEED_EMAIL).device
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/express_one_zero.rb
Normal file
6
app/mutations/devices/seeders/express_one_zero.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class ExpressOneZero < Abstract
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/express_xl_one_zero.rb
Normal file
6
app/mutations/devices/seeders/express_xl_one_zero.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class ExpressXlOneZero < Abstract
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/genesis_one_four.rb
Normal file
6
app/mutations/devices/seeders/genesis_one_four.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class GenesisOneFour < Abstract
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/genesis_one_three.rb
Normal file
6
app/mutations/devices/seeders/genesis_one_three.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class GenesisOneThree < Abstract
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/genesis_one_two.rb
Normal file
6
app/mutations/devices/seeders/genesis_one_two.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class GenesisOneTwo < Abstract
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/none.rb
Normal file
6
app/mutations/devices/seeders/none.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class None < Abstract
|
||||
end
|
||||
end
|
||||
end
|
6
app/mutations/devices/seeders/xl_one_four.rb
Normal file
6
app/mutations/devices/seeders/xl_one_four.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
module Devices
|
||||
module Seeders
|
||||
class XlOneFour < Abstract
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,14 +0,0 @@
|
|||
module Enigmas
|
||||
class Create < Mutations::Command
|
||||
required do
|
||||
model :device
|
||||
string :problem_tag, in: Enigma::PROBLEM_TAGS
|
||||
end
|
||||
|
||||
def execute
|
||||
Enigma.create!(device: device,
|
||||
problem_tag: problem_tag,
|
||||
uuid: SecureRandom.uuid)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -12,10 +12,10 @@ module Users
|
|||
end
|
||||
|
||||
def execute
|
||||
user.destroy!
|
||||
user.delay.destroy!
|
||||
end
|
||||
|
||||
private
|
||||
private
|
||||
|
||||
def confirm_password
|
||||
invalid = !user.valid_password?(password)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class EnigmaSerializer < ApplicationSerializer
|
||||
attributes :created_at, :id, :priority, :problem_tag, :updated_at, :uuid
|
||||
class AlertSerializer < ApplicationSerializer
|
||||
attributes :created_at, :id, :priority, :problem_tag, :updated_at, :slug
|
||||
|
||||
def created_at
|
||||
object.created_at.to_i
|
3
app/serializers/global_bulletin_serializer.rb
Normal file
3
app/serializers/global_bulletin_serializer.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class GlobalBulletinSerializer < ApplicationSerializer
|
||||
attributes :href, :href_label, :slug, :title, :type, :content
|
||||
end
|
|
@ -13,7 +13,7 @@ module FarmBot
|
|||
REDIS_ENV_KEY = ENV.fetch("WHERE_IS_REDIS_URL", "REDIS_URL")
|
||||
REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0")
|
||||
config.cache_store = :redis_cache_store, { url: REDIS_URL }
|
||||
# config.middleware.use Rack::Attack
|
||||
config.middleware.use Rack::Attack
|
||||
config.active_record.schema_format = :sql
|
||||
config.active_job.queue_adapter = :delayed_job
|
||||
config.action_dispatch.perform_deep_munge = false
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
# RC 17 APR 19
|
||||
# class Rack::Attack
|
||||
# ### Throttle Spammy Clients ###
|
||||
# throttle('req/ip', limit: 1000, period: 1.minutes) do |req|
|
||||
# req.ip
|
||||
# end
|
||||
class Rack::Attack
|
||||
### Throttle Spammy Clients ###
|
||||
throttle('req/ip', limit: 2000, period: 1.minutes) do |req|
|
||||
req.ip
|
||||
end
|
||||
|
||||
# ### Stop people from overusing the sync object. ###
|
||||
# throttle('sync_req/ip', limit: 5, period: 1.minutes) do |req|
|
||||
# req.ip if req.url.include?("/sync")
|
||||
# end
|
||||
# end
|
||||
### Stop people from overusing the sync object. ###
|
||||
throttle('sync_req/ip', limit: 10, period: 1.minutes) do |req|
|
||||
req.ip if req.url.include?("/sync")
|
||||
end
|
||||
end
|
||||
|
||||
# # Always allow requests from localhost
|
||||
# # (blacklist & throttles are skipped)
|
||||
# Rack::Attack.safelist('allow from localhost') do |req|
|
||||
# # Requests are allowed if the return value is truthy
|
||||
# '127.0.0.1' == req.ip || '::1' == req.ip
|
||||
# end
|
||||
# Always allow requests from localhost
|
||||
# (blacklist & throttles are skipped)
|
||||
Rack::Attack.safelist('allow from localhost') do |req|
|
||||
# Requests are allowed if the return value is truthy
|
||||
'127.0.0.1' == req.ip || '::1' == req.ip
|
||||
end
|
||||
|
|
|
@ -8,9 +8,10 @@ FarmBot::Application.routes.draw do
|
|||
# Standard API Resources:
|
||||
{
|
||||
diagnostic_dumps: [:create, :destroy, :index],
|
||||
enigmas: [:create, :destroy, :index],
|
||||
alerts: [:create, :destroy, :index],
|
||||
farm_events: [:create, :destroy, :index, :show, :update],
|
||||
farmware_envs: [:create, :destroy, :index, :show, :update],
|
||||
global_bulletins: [:show],
|
||||
images: [:create, :destroy, :index, :show],
|
||||
password_resets: [:create, :update],
|
||||
peripherals: [:create, :destroy, :index, :show, :update],
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
class StricterValidationForToolId < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
safety_assured do
|
||||
add_index :points, [:device_id, :tool_id], unique: true
|
||||
end
|
||||
end
|
||||
end
|
13
db/migrate/20190419052844_create_global_bulletin.rb
Normal file
13
db/migrate/20190419052844_create_global_bulletin.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
class CreateGlobalBulletin < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
create_table :global_bulletins do |t|
|
||||
t.string :href
|
||||
t.string :href_label
|
||||
t.string :slug
|
||||
t.string :title
|
||||
t.string :type
|
||||
t.text :content
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
11
db/migrate/20190419174728_drop_enigmas_table.rb
Normal file
11
db/migrate/20190419174728_drop_enigmas_table.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class DropEnigmasTable < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
drop_table :enigmas do |t|
|
||||
t.string :problem_tag, null: false
|
||||
t.integer :priority, default: 100, null: false
|
||||
t.string :uuid, null: false
|
||||
t.references :device, foreign_key: true, null: false
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
11
db/migrate/20190419174811_create_alerts_table.rb
Normal file
11
db/migrate/20190419174811_create_alerts_table.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class CreateAlertsTable < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
create_table :alerts do |t|
|
||||
t.string :problem_tag, null: false
|
||||
t.integer :priority, default: 100, null: false
|
||||
t.string :slug, null: false
|
||||
t.references :device, foreign_key: true, null: false
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
196
db/structure.sql
196
db/structure.sql
|
@ -42,6 +42,40 @@ SET default_tablespace = '';
|
|||
|
||||
SET default_with_oids = false;
|
||||
|
||||
--
|
||||
-- Name: alerts; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.alerts (
|
||||
id bigint NOT NULL,
|
||||
problem_tag character varying NOT NULL,
|
||||
priority integer DEFAULT 100 NOT NULL,
|
||||
slug character varying NOT NULL,
|
||||
device_id bigint NOT NULL,
|
||||
created_at timestamp without time zone NOT NULL,
|
||||
updated_at timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: alerts_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.alerts_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: alerts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.alerts_id_seq OWNED BY public.alerts.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -272,40 +306,6 @@ CREATE SEQUENCE public.edge_nodes_id_seq
|
|||
ALTER SEQUENCE public.edge_nodes_id_seq OWNED BY public.edge_nodes.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: enigmas; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.enigmas (
|
||||
id bigint NOT NULL,
|
||||
problem_tag character varying NOT NULL,
|
||||
priority integer DEFAULT 100 NOT NULL,
|
||||
uuid character varying NOT NULL,
|
||||
device_id bigint NOT NULL,
|
||||
created_at timestamp without time zone NOT NULL,
|
||||
updated_at timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: enigmas_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.enigmas_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: enigmas_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.enigmas_id_seq OWNED BY public.enigmas.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: farm_events; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -618,6 +618,42 @@ CREATE SEQUENCE public.fragments_id_seq
|
|||
ALTER SEQUENCE public.fragments_id_seq OWNED BY public.fragments.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_bulletins; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.global_bulletins (
|
||||
id bigint NOT NULL,
|
||||
href character varying,
|
||||
href_label character varying,
|
||||
slug character varying,
|
||||
title character varying,
|
||||
type character varying,
|
||||
content text,
|
||||
created_at timestamp without time zone NOT NULL,
|
||||
updated_at timestamp without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_bulletins_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.global_bulletins_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_bulletins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.global_bulletins_id_seq OWNED BY public.global_bulletins.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_configs; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -1511,7 +1547,8 @@ CREATE TABLE public.web_app_configs (
|
|||
show_dev_menu boolean DEFAULT false,
|
||||
internal_use text,
|
||||
time_format_24_hour boolean DEFAULT false,
|
||||
show_pins boolean DEFAULT false
|
||||
show_pins boolean DEFAULT false,
|
||||
disable_emergency_unlock_confirmation boolean DEFAULT false
|
||||
);
|
||||
|
||||
|
||||
|
@ -1567,6 +1604,13 @@ CREATE SEQUENCE public.webcam_feeds_id_seq
|
|||
ALTER SEQUENCE public.webcam_feeds_id_seq OWNED BY public.webcam_feeds.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: alerts id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.alerts ALTER COLUMN id SET DEFAULT nextval('public.alerts_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: arg_names id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -1609,13 +1653,6 @@ ALTER TABLE ONLY public.diagnostic_dumps ALTER COLUMN id SET DEFAULT nextval('pu
|
|||
ALTER TABLE ONLY public.edge_nodes ALTER COLUMN id SET DEFAULT nextval('public.edge_nodes_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: enigmas id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.enigmas ALTER COLUMN id SET DEFAULT nextval('public.enigmas_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: farm_events id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -1658,6 +1695,13 @@ ALTER TABLE ONLY public.firmware_configs ALTER COLUMN id SET DEFAULT nextval('pu
|
|||
ALTER TABLE ONLY public.fragments ALTER COLUMN id SET DEFAULT nextval('public.fragments_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_bulletins id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.global_bulletins ALTER COLUMN id SET DEFAULT nextval('public.global_bulletins_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_configs id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -1826,6 +1870,14 @@ ALTER TABLE ONLY public.web_app_configs ALTER COLUMN id SET DEFAULT nextval('pub
|
|||
ALTER TABLE ONLY public.webcam_feeds ALTER COLUMN id SET DEFAULT nextval('public.webcam_feeds_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: alerts alerts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.alerts
|
||||
ADD CONSTRAINT alerts_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -1882,14 +1934,6 @@ ALTER TABLE ONLY public.edge_nodes
|
|||
ADD CONSTRAINT edge_nodes_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: enigmas enigmas_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.enigmas
|
||||
ADD CONSTRAINT enigmas_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: farm_events farm_events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -1938,6 +1982,14 @@ ALTER TABLE ONLY public.fragments
|
|||
ADD CONSTRAINT fragments_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_bulletins global_bulletins_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.global_bulletins
|
||||
ADD CONSTRAINT global_bulletins_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: global_configs global_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -2145,6 +2197,13 @@ ALTER TABLE ONLY public.webcam_feeds
|
|||
CREATE INDEX delayed_jobs_priority ON public.delayed_jobs USING btree (priority, run_at);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_alerts_on_device_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_alerts_on_device_id ON public.alerts USING btree (device_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_arg_sets_on_fragment_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -2201,13 +2260,6 @@ CREATE INDEX index_edge_nodes_on_primary_node_id ON public.edge_nodes USING btre
|
|||
CREATE INDEX index_edge_nodes_on_sequence_id ON public.edge_nodes USING btree (sequence_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_enigmas_on_device_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_enigmas_on_device_id ON public.enigmas USING btree (device_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_farm_events_on_device_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -2404,6 +2456,13 @@ CREATE INDEX index_plant_templates_on_saved_garden_id ON public.plant_templates
|
|||
CREATE INDEX index_points_on_device_id ON public.points USING btree (device_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_points_on_device_id_and_tool_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX index_points_on_device_id_and_tool_id ON public.points USING btree (device_id, tool_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_points_on_discarded_at; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -2664,14 +2723,6 @@ ALTER TABLE ONLY public.sensor_readings
|
|||
ADD CONSTRAINT fk_rails_04297fb1ff FOREIGN KEY (device_id) REFERENCES public.devices(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: enigmas fk_rails_10ebd17bff; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.enigmas
|
||||
ADD CONSTRAINT fk_rails_10ebd17bff FOREIGN KEY (device_id) REFERENCES public.devices(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pin_bindings fk_rails_1f1c3b6979; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -2712,6 +2763,14 @@ ALTER TABLE ONLY public.farmware_envs
|
|||
ADD CONSTRAINT fk_rails_bdadc396eb FOREIGN KEY (device_id) REFERENCES public.devices(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: alerts fk_rails_c0132c78be; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.alerts
|
||||
ADD CONSTRAINT fk_rails_c0132c78be FOREIGN KEY (device_id) REFERENCES public.devices(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: diagnostic_dumps fk_rails_c5df7fdc83; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
@ -2893,6 +2952,11 @@ INSERT INTO "schema_migrations" (version) VALUES
|
|||
('20190411152319'),
|
||||
('20190411171401'),
|
||||
('20190411222900'),
|
||||
('20190416035406');
|
||||
('20190416035406'),
|
||||
('20190417165636'),
|
||||
('20190419001321'),
|
||||
('20190419052844'),
|
||||
('20190419174728'),
|
||||
('20190419174811');
|
||||
|
||||
|
||||
|
|
|
@ -52,7 +52,7 @@ export let bot: Everything["bot"] = {
|
|||
"process_info": {
|
||||
"farmwares": {}
|
||||
},
|
||||
"enigmas": {},
|
||||
"alerts": {},
|
||||
},
|
||||
"dirty": false,
|
||||
"currentOSVersion": "3.1.6",
|
||||
|
|
|
@ -24,7 +24,7 @@ import {
|
|||
TaggedToolSlotPointer,
|
||||
TaggedFarmwareEnv,
|
||||
TaggedFarmwareInstallation,
|
||||
TaggedEnigma,
|
||||
TaggedAlert,
|
||||
} from "farmbot";
|
||||
import { fakeResource } from "../fake_resource";
|
||||
import { ExecutableType, PinBindingType } from "farmbot/dist/resources/api_resources";
|
||||
|
@ -430,9 +430,9 @@ export function fakeFarmwareInstallation(): TaggedFarmwareInstallation {
|
|||
});
|
||||
}
|
||||
|
||||
export function fakeEnigma(): TaggedEnigma {
|
||||
return fakeResource("Enigma", {
|
||||
uuid: "uuid",
|
||||
export function fakeAlert(): TaggedAlert {
|
||||
return fakeResource("Alert", {
|
||||
slug: "slug",
|
||||
created_at: 123,
|
||||
problem_tag: "api.noun.verb",
|
||||
priority: 100,
|
||||
|
|
|
@ -364,7 +364,7 @@ const KIND_PRIORITY: ResourceLookupTable = {
|
|||
Point: 1,
|
||||
Sensor: 1,
|
||||
Tool: 1,
|
||||
Enigma: 1,
|
||||
Alert: 1,
|
||||
SensorReading: 2,
|
||||
Sequence: 2,
|
||||
Regimen: 3,
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { DeleteAccount } from "../components/delete_account";
|
||||
import { mount } from "enzyme";
|
||||
|
||||
describe("<DeleteAccount/>", () => {
|
||||
it("executes account deletion", () => {
|
||||
const fn = jest.fn();
|
||||
const el = mount(<DeleteAccount onClick={fn} />);
|
||||
el.setState({ password: "123" });
|
||||
el.find("button.red").last().simulate("click");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("123");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import * as React from "react";
|
||||
import { DeleteAccount } from "../delete_account";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import { DeleteAccountProps } from "../../interfaces";
|
||||
import { BlurablePassword } from "../../../ui/blurable_password";
|
||||
|
||||
describe("<DeleteAccount/>", () => {
|
||||
const fakeProps = (): DeleteAccountProps => ({
|
||||
onClick: jest.fn(),
|
||||
});
|
||||
|
||||
it("executes account deletion", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount(<DeleteAccount {...p} />);
|
||||
wrapper.setState({ password: "123" });
|
||||
wrapper.find("button.red").last().simulate("click");
|
||||
expect(p.onClick).toHaveBeenCalledTimes(1);
|
||||
expect(p.onClick).toHaveBeenCalledWith("123");
|
||||
});
|
||||
|
||||
it("enters password", () => {
|
||||
const wrapper = shallow<DeleteAccount>(<DeleteAccount {...fakeProps()} />);
|
||||
wrapper.find(BlurablePassword).simulate("commit", {
|
||||
currentTarget: { value: "password" }
|
||||
});
|
||||
expect(wrapper.state().password).toEqual("password");
|
||||
});
|
||||
|
||||
it("enters password", () => {
|
||||
const wrapper = mount<DeleteAccount>(<DeleteAccount {...fakeProps()} />);
|
||||
wrapper.setState({ password: "password" });
|
||||
wrapper.unmount();
|
||||
expect(wrapper).toEqual({});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { ExportAccountPanel } from "../components/export_account_panel";
|
||||
import { ExportAccountPanel } from "../export_account_panel";
|
||||
import { mount } from "enzyme";
|
||||
|
||||
describe("<ExportAccountPanel/>", () => {
|
|
@ -1,9 +1,9 @@
|
|||
import * as React from "react";
|
||||
import { ChangePassword } from "../components/index";
|
||||
import { ChangePassword } from "../change_password";
|
||||
import { mount } from "enzyme";
|
||||
import { SpecialStatus } from "farmbot";
|
||||
import * as moxios from "moxios";
|
||||
import { API } from "../../api/api";
|
||||
import { API } from "../../../api/api";
|
||||
import { error } from "farmbot-toastr";
|
||||
|
||||
describe("<ChangePassword/>", function () {
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import { Settings } from "../components";
|
||||
import { SettingsPropTypes } from "../interfaces";
|
||||
import { fakeUser } from "../../__test_support__/fake_state/resources";
|
||||
import { Settings } from "../settings";
|
||||
import { SettingsPropTypes } from "../../interfaces";
|
||||
import { fakeUser } from "../../../__test_support__/fake_state/resources";
|
||||
|
||||
describe("<Settings/>", function () {
|
||||
it("saves user settings", function () {
|
|
@ -9,11 +9,13 @@ describe("API", () => {
|
|||
API.setBaseUrl(BASE);
|
||||
[
|
||||
[API.current.pointSearchPath, BASE + "/api/points/search"],
|
||||
[API.current.allPointsPath, BASE + "/api/points/?filter=all"],
|
||||
[API.current.sensorReadingPath, BASE + "/api/sensor_readings"],
|
||||
[API.current.farmwareEnvPath, BASE + "/api/farmware_envs/"],
|
||||
[API.current.plantTemplatePath, BASE + "/api/plant_templates/"],
|
||||
[API.current.diagnosticDumpsPath, BASE + "/api/diagnostic_dumps/"],
|
||||
[API.current.farmwareInstallationPath, BASE + "/api/farmware_installations/"],
|
||||
[API.current.globalBulletinPath, BASE + "/api/global_bulletins/"],
|
||||
].map(x => expect(x[0]).toEqual(x[1]));
|
||||
});
|
||||
|
||||
|
|
|
@ -152,7 +152,9 @@ export class API {
|
|||
get farmwareInstallationPath() {
|
||||
return `${this.baseUrl}/api/farmware_installations/`;
|
||||
}
|
||||
/** /api/enigmas/:id */
|
||||
get enigmaPath() { return `${this.baseUrl}/api/enigmas/`; }
|
||||
/** /api/alerts/:id */
|
||||
get alertPath() { return `${this.baseUrl}/api/alerts/`; }
|
||||
/** /api/global_bulletins/:id */
|
||||
get globalBulletinPath() { return `${this.baseUrl}/api/global_bulletins/`; }
|
||||
get syncPatch() { return `${this.baseUrl}/api/device/sync/`; }
|
||||
}
|
||||
|
|
|
@ -261,7 +261,7 @@ export function urlFor(tag: ResourceName) {
|
|||
PlantTemplate: API.current.plantTemplatePath,
|
||||
FarmwareEnv: API.current.farmwareEnvPath,
|
||||
FarmwareInstallation: API.current.farmwareInstallationPath,
|
||||
Enigma: API.current.enigmaPath,
|
||||
Alert: API.current.alertPath,
|
||||
};
|
||||
const url = OPTIONS[tag];
|
||||
if (url) {
|
||||
|
|
|
@ -14,6 +14,7 @@ const BLACKLIST: ResourceName[] = [
|
|||
"User",
|
||||
"WebAppConfig",
|
||||
"WebcamFeed",
|
||||
"Alert",
|
||||
];
|
||||
|
||||
export function maybeStartTracking(uuid: string) {
|
||||
|
|
|
@ -316,12 +316,13 @@ export namespace ToolTips {
|
|||
|
||||
// Tools
|
||||
export const TOOL_LIST =
|
||||
trim(`This is a list of all your FarmBot tools and seed containers. Click the Edit button
|
||||
to add, edit, or delete tools or seed containers.`);
|
||||
trim(`This is a list of all your FarmBot tools and seed containers.
|
||||
Click the Edit button to add, edit, or delete tools or seed containers.`);
|
||||
|
||||
export const TOOLBAY_LIST =
|
||||
trim(`Tool slots are where you store your FarmBot tools and seed containers, which should be
|
||||
reflective of your real FarmBot hardware configuration.`);
|
||||
trim(`Tool slots are where you store your FarmBot tools and seed
|
||||
containers, which should be reflective of your real FarmBot hardware
|
||||
configuration.`);
|
||||
|
||||
// Logs
|
||||
export const LOGS =
|
||||
|
@ -420,6 +421,11 @@ export namespace Content {
|
|||
trim(`When you're finished with a message, press the x button in the
|
||||
top right of the card to dismiss it.`);
|
||||
|
||||
export const FIRMWARE_MISSING =
|
||||
trim(`Please choose a firmware version to install. Your choice should be
|
||||
based on the type of electronics in your FarmBot according to the reference
|
||||
table below.`);
|
||||
|
||||
// App Settings
|
||||
export const CONFIRM_STEP_DELETION =
|
||||
trim(`Show a confirmation dialog when deleting a sequence step.`);
|
||||
|
|
|
@ -176,6 +176,17 @@ fieldset {
|
|||
}
|
||||
}
|
||||
|
||||
.voltage-display {
|
||||
display: flex;
|
||||
.saucer {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
cursor: default;
|
||||
margin-left: 0.5rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wifi-strength-display {
|
||||
position: relative;
|
||||
.percent-bar {
|
||||
|
@ -793,7 +804,7 @@ ul {
|
|||
color: $panel_yellow;
|
||||
}
|
||||
.empty-state-graphic {
|
||||
filter: sepia() contrast(1.2) saturate(1.2);
|
||||
filter: sepia(1) contrast(1.2) saturate(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -939,7 +950,7 @@ ul {
|
|||
}
|
||||
|
||||
.messages-page {
|
||||
max-width: 600px;
|
||||
max-width: 785px;
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 2rem !important;
|
||||
.link-to-logs {
|
||||
|
@ -1104,7 +1115,7 @@ ul {
|
|||
|
||||
.tour-list {
|
||||
margin: auto;
|
||||
width: 75%;
|
||||
max-width: 300px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
@ -1171,9 +1182,23 @@ ul {
|
|||
box-shadow: 0px 2px 5px $medium_gray;
|
||||
background: $off_white;
|
||||
.problem-alert-title {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1rem;
|
||||
.fa-exclamation-triangle,
|
||||
.fa-check-square,
|
||||
.fa-info-circle {
|
||||
font-size: 1.6rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.fa-exclamation-triangle {
|
||||
color: $orange;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.fa-check-square {
|
||||
color: $green;
|
||||
}
|
||||
.fa-info-circle {
|
||||
color: $blue;
|
||||
}
|
||||
h3 {
|
||||
color: $dark_gray;
|
||||
|
@ -1182,19 +1207,36 @@ ul {
|
|||
}
|
||||
p {
|
||||
display: inline;
|
||||
padding: 1rem;
|
||||
color: $medium_gray;
|
||||
font-size: 1.2rem;
|
||||
margin-left: 2rem;
|
||||
white-space: pre;
|
||||
}
|
||||
.fa-times {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: $medium_light_gray;
|
||||
float: right;
|
||||
&:hover {
|
||||
color: $dark_gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
.problem-alert-content {
|
||||
.markdown {
|
||||
p {
|
||||
display: block;
|
||||
color: $dark_gray;
|
||||
text-overflow: inherit;
|
||||
overflow: inherit;
|
||||
width: inherit;
|
||||
white-space: inherit;
|
||||
}
|
||||
ul {
|
||||
list-style-type: disc !important;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin-bottom: 0.75rem !important;
|
||||
font-size: 1.4rem;
|
||||
|
@ -1215,5 +1257,32 @@ ul {
|
|||
float: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.fb-button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.firmware-alerts {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.firmware-hardware-choice-table {
|
||||
margin: 2rem;
|
||||
margin-top: 1rem;
|
||||
width: 93%;
|
||||
border: 1px solid $gray;
|
||||
font-size: 1.2rem;
|
||||
th {
|
||||
background: $light_gray;
|
||||
font-weight: normal;
|
||||
}
|
||||
td {
|
||||
background: $off_white;
|
||||
color: $medium_gray;
|
||||
}
|
||||
code {
|
||||
background: $light_gray;
|
||||
color: $dark_gray;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@ jest.mock("../../../../api/crud", () => ({
|
|||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { FbosDetails, colorFromTemp, betaReleaseOptIn } from "../fbos_details";
|
||||
import {
|
||||
FbosDetails, colorFromTemp, betaReleaseOptIn, colorFromThrottle, ThrottleType
|
||||
} from "../fbos_details";
|
||||
import { shallow, mount } from "enzyme";
|
||||
import { bot } from "../../../../__test_support__/fake_state/bot";
|
||||
import { FbosDetailsProps } from "../interfaces";
|
||||
|
@ -153,6 +155,20 @@ describe("<FbosDetails/>", () => {
|
|||
const wrapper = mount(<FbosDetails {...p} />);
|
||||
expect(wrapper.text()).toContain("2 days");
|
||||
});
|
||||
|
||||
it("doesn't display when throttled value is undefined", () => {
|
||||
const p = fakeProps();
|
||||
p.botInfoSettings.throttled = undefined;
|
||||
const wrapper = mount(<FbosDetails {...p} />);
|
||||
expect(wrapper.text().toLowerCase()).not.toContain("voltage");
|
||||
});
|
||||
|
||||
it("displays voltage indicator", () => {
|
||||
const p = fakeProps();
|
||||
p.botInfoSettings.throttled = "0x0";
|
||||
const wrapper = mount(<FbosDetails {...p} />);
|
||||
expect(wrapper.text().toLowerCase()).toContain("voltage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("betaReleaseOptIn()", () => {
|
||||
|
@ -219,3 +235,15 @@ describe("colorFromTemp()", () => {
|
|||
expect(colorFromTemp(-1)).toEqual("lightblue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("colorFromThrottle()", () => {
|
||||
it("is currently throttled", () => {
|
||||
expect(colorFromThrottle("0x40004", ThrottleType.Throttled)).toEqual("red");
|
||||
});
|
||||
it("was throttled", () => {
|
||||
expect(colorFromThrottle("0x40000", ThrottleType.Throttled)).toEqual("yellow");
|
||||
});
|
||||
it("hasn't been throttled", () => {
|
||||
expect(colorFromThrottle("0x0", ThrottleType.Throttled)).toEqual("green");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { DropDownItem, Row, Col, FBSelect } from "../../../ui/index";
|
||||
|
||||
import {
|
||||
CameraSelectionProps, CameraSelectionState
|
||||
} from "./interfaces";
|
||||
|
@ -69,7 +68,6 @@ export class CameraSelection
|
|||
allowEmpty={false}
|
||||
list={CAMERA_CHOICES()}
|
||||
selectedItem={this.selectedCamera()}
|
||||
placeholder="Select a camera..."
|
||||
onChange={this.sendOffConfig}
|
||||
extraClass={this.props.botOnline ? "" : "disabled"} />
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { Saucer } from "../../../ui/index";
|
||||
|
||||
import { ToggleButton } from "../../../controls/toggle_button";
|
||||
import { updateConfig } from "../../actions";
|
||||
import { last, isNumber } from "lodash";
|
||||
|
@ -37,7 +36,7 @@ export function ChipTemperatureDisplay({ chip, temperature }: {
|
|||
<b>{chip && chip.toUpperCase()} {t("CPU temperature")}: </b>
|
||||
{temperature ? <span>{temperature}°C</span> : t("unknown")}
|
||||
</p>
|
||||
{<Saucer color={colorFromTemp(temperature)} className={"small-inline"} />}
|
||||
<Saucer color={colorFromTemp(temperature)} className={"small-inline"} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
@ -65,6 +64,57 @@ export function WiFiStrengthDisplay({ wifiStrength }: {
|
|||
</div>;
|
||||
}
|
||||
|
||||
/** Available throttle info. */
|
||||
export enum ThrottleType {
|
||||
UnderVoltage = "UnderVoltage",
|
||||
ArmFrequencyCapped = "ArmFrequencyCapped",
|
||||
Throttled = "Throttled",
|
||||
SoftTempLimit = "SoftTempLimit",
|
||||
}
|
||||
|
||||
/** Bit positions for throttle flags. */
|
||||
const THROTTLE_BIT_LOOKUP:
|
||||
Record<ThrottleType, Record<"active" | "occurred", number>> = {
|
||||
[ThrottleType.UnderVoltage]: { active: 0, occurred: 16 },
|
||||
[ThrottleType.ArmFrequencyCapped]: { active: 1, occurred: 17 },
|
||||
[ThrottleType.Throttled]: { active: 2, occurred: 18 },
|
||||
[ThrottleType.SoftTempLimit]: { active: 3, occurred: 19 },
|
||||
};
|
||||
|
||||
/** Return a color based on throttle flag states. */
|
||||
export const colorFromThrottle =
|
||||
(throttled: string, throttleType: ThrottleType) => {
|
||||
const throttleCode = parseInt(throttled, 16);
|
||||
const bit = THROTTLE_BIT_LOOKUP[throttleType];
|
||||
// tslint:disable-next-line:no-bitwise
|
||||
const active = throttleCode & (1 << bit.active);
|
||||
// tslint:disable-next-line:no-bitwise
|
||||
const occurred = throttleCode & (1 << bit.occurred);
|
||||
if (active) {
|
||||
return "red";
|
||||
} else if (occurred) {
|
||||
return "yellow";
|
||||
} else {
|
||||
return "green";
|
||||
}
|
||||
};
|
||||
|
||||
interface VoltageDisplayProps {
|
||||
chip?: string;
|
||||
throttled: string | undefined;
|
||||
}
|
||||
|
||||
/** RPI throttle state display row: label, indicator. */
|
||||
export const VoltageDisplay = ({ chip, throttled }: VoltageDisplayProps) =>
|
||||
throttled
|
||||
? <div className="voltage-display">
|
||||
<p>
|
||||
<b>{chip && chip.toUpperCase()} {t("Voltage")}: </b>
|
||||
</p>
|
||||
<Saucer className={"small-inline"}
|
||||
color={colorFromThrottle(throttled, ThrottleType.UnderVoltage)} />
|
||||
</div> : <div className="voltage-display" />;
|
||||
|
||||
/** Get the first 8 characters of a commit. */
|
||||
const shortenCommit = (longCommit: string) => (longCommit || "").slice(0, 8);
|
||||
|
||||
|
@ -147,7 +197,7 @@ const BetaReleaseOptInButton =
|
|||
export function FbosDetails(props: FbosDetailsProps) {
|
||||
const {
|
||||
env, commit, target, node_name, firmware_version, firmware_commit,
|
||||
soc_temp, wifi_level, uptime, memory_usage, disk_usage
|
||||
soc_temp, wifi_level, uptime, memory_usage, disk_usage, throttled
|
||||
} = props.botInfoSettings;
|
||||
|
||||
return <div>
|
||||
|
@ -164,6 +214,7 @@ export function FbosDetails(props: FbosDetailsProps) {
|
|||
{isNumber(disk_usage) && <p><b>{t("Disk usage")}: </b>{disk_usage}%</p>}
|
||||
<ChipTemperatureDisplay chip={target} temperature={soc_temp} />
|
||||
<WiFiStrengthDisplay wifiStrength={wifi_level} />
|
||||
<VoltageDisplay chip={target} throttled={throttled} />
|
||||
<BetaReleaseOptInButton
|
||||
dispatch={props.dispatch}
|
||||
shouldDisplay={props.shouldDisplay}
|
||||
|
|
|
@ -39,6 +39,21 @@ export interface FirmwareHardwareStatusDetailsProps {
|
|||
dispatch: Function;
|
||||
}
|
||||
|
||||
export interface FlashFirmwareBtnProps {
|
||||
apiFirmwareValue: string | undefined;
|
||||
botOnline: boolean;
|
||||
}
|
||||
|
||||
export const FlashFirmwareBtn = (props: FlashFirmwareBtnProps) => {
|
||||
const { apiFirmwareValue } = props;
|
||||
return <button className="fb-button yellow"
|
||||
disabled={!apiFirmwareValue || !props.botOnline}
|
||||
onClick={() => isFwHardwareValue(apiFirmwareValue) &&
|
||||
flashFirmware(apiFirmwareValue)}>
|
||||
{t("flash firmware")}
|
||||
</button>;
|
||||
};
|
||||
|
||||
export interface FirmwareActionsProps {
|
||||
apiFirmwareValue: string | undefined;
|
||||
botOnline: boolean;
|
||||
|
@ -51,12 +66,7 @@ export const FirmwareActions = (props: FirmwareActionsProps) => {
|
|||
{trim(`${t("Flash the")} ${lookup(apiFirmwareValue) || ""}
|
||||
${t("firmware to your device")}:`)}
|
||||
</p>
|
||||
<button className="fb-button yellow"
|
||||
disabled={!apiFirmwareValue || !props.botOnline}
|
||||
onClick={() => isFwHardwareValue(apiFirmwareValue) &&
|
||||
flashFirmware(apiFirmwareValue)}>
|
||||
{t("flash firmware")}
|
||||
</button>
|
||||
<FlashFirmwareBtn {...props} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -98,7 +108,7 @@ export const FirmwareHardwareStatus = (props: FirmwareHardwareStatusProps) => {
|
|||
const { firmware_hardware } = props.bot.hardware.configuration;
|
||||
const status = props.apiFirmwareValue == firmware_hardware &&
|
||||
props.apiFirmwareValue == boardType(firmware_version);
|
||||
return <Popover position={Position.BOTTOM}>
|
||||
return <Popover position={Position.TOP}>
|
||||
<FirmwareHardwareStatusIcon
|
||||
firmwareHardware={firmware_hardware}
|
||||
status={status} />
|
||||
|
|
|
@ -5,7 +5,7 @@ import { ConnectivityRow, StatusRowProps } from "./connectivity_row";
|
|||
import { Row, Col } from "../../ui";
|
||||
import { ConnectivityDiagram } from "./diagram";
|
||||
import {
|
||||
ChipTemperatureDisplay, WiFiStrengthDisplay
|
||||
ChipTemperatureDisplay, WiFiStrengthDisplay, VoltageDisplay
|
||||
} from "../components/fbos_settings/fbos_details";
|
||||
import { t } from "../../i18next_wrapper";
|
||||
|
||||
|
@ -27,7 +27,8 @@ export class Connectivity
|
|||
() => this.setState({ hoveredConnection: name });
|
||||
|
||||
render() {
|
||||
const { soc_temp, wifi_level } = this.props.bot.hardware.informational_settings;
|
||||
const { informational_settings } = this.props.bot.hardware;
|
||||
const { soc_temp, wifi_level, throttled } = informational_settings;
|
||||
return <div className="connectivity">
|
||||
<Row>
|
||||
<Col md={12} lg={4}>
|
||||
|
@ -39,6 +40,7 @@ export class Connectivity
|
|||
<label>{t("Raspberry Pi Info")}</label>
|
||||
<ChipTemperatureDisplay temperature={soc_temp} />
|
||||
<WiFiStrengthDisplay wifiStrength={wifi_level} />
|
||||
<VoltageDisplay throttled={throttled} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={12} lg={8}>
|
||||
|
|
|
@ -67,7 +67,7 @@ export let initialState = (): BotState => ({
|
|||
process_info: {
|
||||
farmwares: {},
|
||||
},
|
||||
enigmas: {},
|
||||
alerts: {},
|
||||
},
|
||||
dirty: false,
|
||||
currentOSVersion: undefined,
|
||||
|
|
|
@ -41,6 +41,13 @@ describe("<MapImage />", () => {
|
|||
expect(wrapper.html()).toEqual("<image></image>");
|
||||
});
|
||||
|
||||
it("renders pre-calibration preview", () => {
|
||||
const p = fakeProps();
|
||||
p.image && (p.image.body.meta = { x: 0, y: 0, z: 0 });
|
||||
const wrapper = mount(<MapImage {...p} />);
|
||||
expect(wrapper.html()).toContain("image_url");
|
||||
});
|
||||
|
||||
interface ExpectedData {
|
||||
size: { width: number, height: number };
|
||||
sx: number;
|
||||
|
|
|
@ -6,6 +6,8 @@ import { transformXY } from "../../util";
|
|||
import { isNumber, round } from "lodash";
|
||||
|
||||
const PRECISION = 3; // Number of decimals for image placement coordinates
|
||||
/** Show all images roughly on map when no calibration values are present. */
|
||||
const PRE_CALIBRATION_PREVIEW = true;
|
||||
|
||||
/* Parse floats in camera calibration environment variables. */
|
||||
const parse = (str: string | undefined) => {
|
||||
|
@ -14,7 +16,8 @@ const parse = (str: string | undefined) => {
|
|||
};
|
||||
|
||||
/* Check if the image has been rotated according to the calibration value. */
|
||||
const isRotated = (name: string | undefined) => {
|
||||
const isRotated = (name: string | undefined, noCalib: boolean) => {
|
||||
if (PRE_CALIBRATION_PREVIEW && noCalib) { return true; }
|
||||
return name &&
|
||||
(name.includes("rotated")
|
||||
|| name.includes("marked")
|
||||
|
@ -24,6 +27,7 @@ const isRotated = (name: string | undefined) => {
|
|||
/* Check if the calibration data is valid for the image provided using z. */
|
||||
const cameraZCheck =
|
||||
(imageZ: number | undefined, calibZ: string | undefined) => {
|
||||
if (PRE_CALIBRATION_PREVIEW && !calibZ) { return true; }
|
||||
const calibrationZ = parse(calibZ);
|
||||
return isNumber(imageZ) && isNumber(calibrationZ) &&
|
||||
Math.abs(imageZ - calibrationZ) < 5;
|
||||
|
@ -41,8 +45,8 @@ const getImageSize = (url: string, size?: ImageSize): ImageSize => {
|
|||
const imageData = new Image();
|
||||
imageData.src = url;
|
||||
return {
|
||||
height: imageData.height,
|
||||
width: imageData.width
|
||||
height: imageData.height || 480,
|
||||
width: imageData.width || 640
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -121,13 +125,16 @@ export interface MapImageProps {
|
|||
* Assume the image that is provided from the Farmware is rotated correctly.
|
||||
* Require camera calibration data to display the image.
|
||||
*/
|
||||
// tslint:disable-next-line:cyclomatic-complexity
|
||||
export function MapImage(props: MapImageProps) {
|
||||
const { image, cameraCalibrationData, sizeOverride } = props;
|
||||
const { scale, offset, origin, calibrationZ } = cameraCalibrationData;
|
||||
const imageScale = parse(scale);
|
||||
const imageOffsetX = parse(offset.x);
|
||||
const imageOffsetY = parse(offset.y);
|
||||
const imageOrigin = origin ? origin.split("\"").join("") : undefined;
|
||||
const noCalib = PRE_CALIBRATION_PREVIEW && !parse(scale);
|
||||
const imageScale = noCalib ? 1.5 : parse(scale);
|
||||
const imageOffsetX = noCalib ? 0 : parse(offset.x);
|
||||
const imageOffsetY = noCalib ? 0 : parse(offset.y);
|
||||
const cleanOrigin = origin ? origin.split("\"").join("") : undefined;
|
||||
const imageOrigin = noCalib ? "BOTTOM_LEFT" : cleanOrigin;
|
||||
const { quadrant, xySwap } = props.mapTransformProps;
|
||||
|
||||
/* Check if the image exists. */
|
||||
|
@ -140,7 +147,7 @@ export function MapImage(props: MapImageProps) {
|
|||
/* Check for all necessary camera calibration and image data. */
|
||||
if (isNumber(x) && isNumber(y) && height > 0 && width > 0 &&
|
||||
isNumber(imageScale) && imageScale > 0 &&
|
||||
cameraZCheck(z, calibrationZ) && isRotated(imageAnnotation) &&
|
||||
cameraZCheck(z, calibrationZ) && isRotated(imageAnnotation, noCalib) &&
|
||||
isNumber(imageOffsetX) && isNumber(imageOffsetY) && imageOrigin) {
|
||||
/* Use pixel to coordinate scale to scale image. */
|
||||
const size = { x: width * imageScale, y: height * imageScale };
|
||||
|
|
|
@ -53,6 +53,13 @@ describe("<FarmwarePage />", () => {
|
|||
expect(wrapper.text()).toContain("Take Photo");
|
||||
});
|
||||
|
||||
it("renders photos page by default without farmware data", () => {
|
||||
const p = fakeProps();
|
||||
p.farmwares = {};
|
||||
const wrapper = mount(<FarmwarePage {...p} />);
|
||||
expect(wrapper.text()).toContain("Take Photo");
|
||||
});
|
||||
|
||||
const TEST_DATA = {
|
||||
"Photos": ["Take Photo"],
|
||||
"take-photo": ["Take Photo"],
|
||||
|
|
|
@ -37,6 +37,15 @@ describe("setActiveFarmwareByName", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("finds a farmware by name: other match", () => {
|
||||
mockLastUrlChunk = "weed_detector";
|
||||
setActiveFarmwareByName(["plant_detection"]);
|
||||
expect(store.dispatch).toHaveBeenCalledWith({
|
||||
type: Actions.SELECT_FARMWARE,
|
||||
payload: "plant_detection"
|
||||
});
|
||||
});
|
||||
|
||||
it("handles undefined farmware names", () => {
|
||||
mockLastUrlChunk = "some_farmware";
|
||||
setActiveFarmwareByName([undefined]);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { urlFriendly } from "../util";
|
||||
import { Actions } from "../constants";
|
||||
import { Farmwares } from "./interfaces";
|
||||
|
@ -13,6 +12,7 @@ import { ShouldDisplay, Feature } from "../devices/interfaces";
|
|||
import { initSave } from "../api/crud";
|
||||
import { TaggedFarmwareInstallation } from "farmbot";
|
||||
import { t } from "../i18next_wrapper";
|
||||
import { getFormattedFarmwareName } from "./index";
|
||||
|
||||
const DISPLAY_NAMES: Dictionary<string> = {
|
||||
"Photos": t("Photos"),
|
||||
|
@ -27,7 +27,7 @@ const farmwareListItem = (dispatch: Function, current: string | undefined) =>
|
|||
type: Actions.SELECT_FARMWARE,
|
||||
payload: farmwareName
|
||||
});
|
||||
const selected = (farmwareName == current)
|
||||
const selected = (farmwareName == getFormattedFarmwareName(current || ""))
|
||||
|| (!current && farmwareName == "Photos")
|
||||
? "selected" : "";
|
||||
const displayName = Object.keys(DISPLAY_NAMES).includes(farmwareName)
|
||||
|
@ -107,8 +107,7 @@ export class FarmwareList
|
|||
this.props.firstPartyFarmwareNames)} />
|
||||
</Popover>
|
||||
</div>
|
||||
{["Photos", "Camera Calibration", "Weed Detector"]
|
||||
.map(farmwareListItem(dispatch, current))}
|
||||
{Object.keys(DISPLAY_NAMES).map(farmwareListItem(dispatch, current))}
|
||||
<hr />
|
||||
<label>
|
||||
{t("My Farmware")}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { getDevice } from "../device";
|
|||
import { t } from "../i18next_wrapper";
|
||||
import { isBotOnline } from "../devices/must_be_online";
|
||||
import { BooleanSetting } from "../session_keys";
|
||||
import { Dictionary } from "farmbot";
|
||||
|
||||
/** Get the correct help text for the provided Farmware. */
|
||||
const getToolTipByFarmware =
|
||||
|
@ -75,6 +76,18 @@ const getFarmwareByName =
|
|||
}
|
||||
};
|
||||
|
||||
const FARMWARE_NAMES_1ST_PARTY: Dictionary<string> = {
|
||||
"take-photo": "Photos",
|
||||
"camera-calibration": "Camera Calibration",
|
||||
"plant-detection": "Weed Detector",
|
||||
};
|
||||
|
||||
export const getFormattedFarmwareName = (farmwareName: string) =>
|
||||
FARMWARE_NAMES_1ST_PARTY[farmwareName] || farmwareName;
|
||||
|
||||
export const farmwareUrlFriendly = (farmwareName: string) =>
|
||||
urlFriendly(farmwareName).replace(/-/g, "_");
|
||||
|
||||
/** Execute a Farmware. */
|
||||
const run = (farmwareName: string) => () => {
|
||||
getDevice().execScript(farmwareName)
|
||||
|
@ -116,7 +129,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
|
|||
type: Actions.SELECT_FARMWARE,
|
||||
payload: "Photos"
|
||||
});
|
||||
if (!this.current && Object.values(this.props.farmwares).length > 0) {
|
||||
if (Object.values(this.props.farmwares).length > 0) {
|
||||
const farmwareNames = Object.values(this.props.farmwares).map(x => x.name);
|
||||
setActiveFarmwareByName(farmwareNames);
|
||||
} else {
|
||||
|
@ -127,7 +140,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
|
|||
|
||||
/** Load Farmware input panel contents for 1st & 3rd party Farmware. */
|
||||
getPanelByFarmware(farmwareName: string) {
|
||||
switch (urlFriendly(farmwareName).replace("-", "_")) {
|
||||
switch (farmwareUrlFriendly(farmwareName)) {
|
||||
case "take_photo":
|
||||
case "photos":
|
||||
return <Photos
|
||||
|
@ -240,7 +253,7 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
|
|||
</LeftPanel>
|
||||
<CenterPanel
|
||||
className={`farmware-input-panel ${activeClasses}`}
|
||||
title={this.current || t("Photos")}
|
||||
title={getFormattedFarmwareName(this.current || "Photos")}
|
||||
helpText={getToolTipByFarmware(this.props.farmwares, this.current)
|
||||
|| ToolTips.PHOTOS}
|
||||
docPage={getDocLinkByFarmware(this.current)}>
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import { store } from "../redux/store";
|
||||
import { urlFriendly, lastUrlChunk } from "../util";
|
||||
import { lastUrlChunk } from "../util";
|
||||
import { Actions } from "../constants";
|
||||
import { farmwareUrlFriendly } from "./index";
|
||||
|
||||
export function setActiveFarmwareByName(farmwareNames: (string | undefined)[]) {
|
||||
const chunk = urlFriendly(lastUrlChunk());
|
||||
const chunk = farmwareUrlFriendly(lastUrlChunk());
|
||||
if (chunk == "farmware") { return; }
|
||||
|
||||
farmwareNames.map(payload => {
|
||||
if (payload) {
|
||||
const urlName = urlFriendly(payload);
|
||||
const match = chunk === urlName;
|
||||
const urlName = farmwareUrlFriendly(payload);
|
||||
const directMatch = chunk === urlName;
|
||||
const altMatch = chunk === "weed_detector" && urlName === "plant_detection";
|
||||
const match = directMatch || altMatch;
|
||||
match && store.dispatch({ type: Actions.SELECT_FARMWARE, payload });
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import { ImageWorkspace, ImageWorkspaceProps } from "../image_workspace";
|
||||
import { fakeImage } from "../../../__test_support__/fake_state/resources";
|
||||
import { TaggedImage } from "farmbot";
|
||||
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
|
||||
import { clickButton } from "../../../__test_support__/helpers";
|
||||
|
||||
describe("<Body/>", () => {
|
||||
describe("<ImageWorkspace />", () => {
|
||||
const fakeProps = (): ImageWorkspaceProps => ({
|
||||
onFlip: jest.fn(),
|
||||
onProcessPhoto: jest.fn(),
|
||||
|
@ -84,4 +87,21 @@ describe("<Body/>", () => {
|
|||
iw.maybeProcessPhoto();
|
||||
expect(p.onProcessPhoto).toHaveBeenCalledWith(photo2.body.id);
|
||||
});
|
||||
|
||||
it("scans image", () => {
|
||||
const image = fakeImage();
|
||||
const p = fakeProps();
|
||||
p.botOnline = true;
|
||||
p.images = [image];
|
||||
const wrapper = mount(<ImageWorkspace {...p} />);
|
||||
clickButton(wrapper, 0, "scan image");
|
||||
expect(p.onProcessPhoto).toHaveBeenCalledWith(image.body.id);
|
||||
});
|
||||
|
||||
it("disables scan image button when offline", () => {
|
||||
const p = fakeProps();
|
||||
p.botOnline = false;
|
||||
const wrapper = mount(<ImageWorkspace {...p} />);
|
||||
expect(wrapper.find("button").first().props().disabled).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BlurableInput,
|
||||
Row, Col,
|
||||
|
@ -69,8 +68,7 @@ export class WeedDetectorConfig extends React.Component<SettingsMenuProps, {}> {
|
|||
<FBSelect
|
||||
onChange={this.setDDI("CAMERA_CALIBRATION_calibration_along_axis")}
|
||||
selectedItem={this.find("CAMERA_CALIBRATION_calibration_along_axis")}
|
||||
list={CALIBRATION_DROPDOWNS}
|
||||
placeholder="Select..." />
|
||||
list={CALIBRATION_DROPDOWNS} />
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<this.NumberBox
|
||||
|
@ -89,8 +87,7 @@ export class WeedDetectorConfig extends React.Component<SettingsMenuProps, {}> {
|
|||
<FBSelect
|
||||
list={ORIGIN_DROPDOWNS}
|
||||
onChange={this.setDDI("CAMERA_CALIBRATION_image_bot_origin_location")}
|
||||
selectedItem={this.find("CAMERA_CALIBRATION_image_bot_origin_location")}
|
||||
placeholder="Select..." />
|
||||
selectedItem={this.find("CAMERA_CALIBRATION_image_bot_origin_location")} />
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<this.NumberBox
|
||||
|
|
|
@ -156,7 +156,7 @@ export class ImageWorkspace extends React.Component<ImageWorkspaceProps, {}> {
|
|||
className="green fb-button"
|
||||
title="Scan this image"
|
||||
onClick={this.maybeProcessPhoto}
|
||||
disabled={this.props.botOnline}
|
||||
disabled={!this.props.botOnline}
|
||||
hidden={!this.props.images.length} >
|
||||
{t("Scan image")}
|
||||
</button>
|
||||
|
|
|
@ -7,9 +7,9 @@ import {
|
|||
WidgetHeader,
|
||||
Row,
|
||||
} from "../ui/index";
|
||||
|
||||
import { BlurablePassword } from "../ui/blurable_password";
|
||||
import { t } from "../i18next_wrapper";
|
||||
import { updatePageInfo } from "../util";
|
||||
|
||||
export interface LoginProps {
|
||||
/** Attributes */
|
||||
|
@ -41,6 +41,7 @@ export class Login extends React.Component<LoginProps, {}> {
|
|||
onLoginPasswordChange,
|
||||
onToggleForgotPassword,
|
||||
} = this.props;
|
||||
updatePageInfo("login");
|
||||
return <Col xs={12} sm={5} smOffset={1} mdOffset={0}>
|
||||
<Widget>
|
||||
<WidgetHeader title={"Login"} />
|
||||
|
@ -65,6 +66,7 @@ export class Login extends React.Component<LoginProps, {}> {
|
|||
type="email"
|
||||
value={email || ""}
|
||||
name="login_email"
|
||||
autoFocus={true}
|
||||
onCommit={onEmailChange} />
|
||||
<label>
|
||||
{t("Password")}
|
||||
|
|
|
@ -4,6 +4,8 @@ import { Color } from "../ui";
|
|||
import { history } from "../history";
|
||||
import { TOUR_STEPS, tourPageNavigation } from "./tours";
|
||||
import { t } from "../i18next_wrapper";
|
||||
import { Actions } from "../constants";
|
||||
import { store } from "../redux/store";
|
||||
|
||||
const strings = () => ({
|
||||
back: t("Back"),
|
||||
|
@ -44,6 +46,7 @@ export class Tour extends React.Component<TourProps, TourState> {
|
|||
if (type === "tour:end") {
|
||||
this.setState({ run: false });
|
||||
history.push("/app/messages");
|
||||
store.dispatch({ type: Actions.START_TOUR, payload: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
15
frontend/messages/__tests__/actions_test.ts
Normal file
15
frontend/messages/__tests__/actions_test.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
jest.mock("axios", () => ({
|
||||
get: jest.fn(() => Promise.resolve({ data: { foo: "bar" } })),
|
||||
}));
|
||||
|
||||
jest.mock("../../api/api", () => ({
|
||||
API: { current: { globalBulletinPath: "/api/stub" } }
|
||||
}));
|
||||
|
||||
import { fetchBulletinContent } from "../actions";
|
||||
|
||||
describe("fetchBulletinContent()", () => {
|
||||
it("fetches data", async () => {
|
||||
expect(await fetchBulletinContent("slug")).toEqual({ foo: "bar" });
|
||||
});
|
||||
});
|
|
@ -9,28 +9,28 @@ const FIRMWARE_MISSING_ALERT: Alert = {
|
|||
created_at: 123,
|
||||
problem_tag: "farmbot_os.firmware.missing",
|
||||
priority: 100,
|
||||
uuid: "uuid",
|
||||
slug: "slug",
|
||||
};
|
||||
|
||||
const SEED_DATA_MISSING_ALERT: Alert = {
|
||||
created_at: 123,
|
||||
problem_tag: "api.seed_data.missing",
|
||||
priority: 300,
|
||||
uuid: "uuid",
|
||||
slug: "slug",
|
||||
};
|
||||
|
||||
const UNKNOWN_ALERT: Alert = {
|
||||
created_at: 123,
|
||||
problem_tag: "farmbot_os.firmware.alert",
|
||||
priority: 200,
|
||||
uuid: "uuid",
|
||||
slug: "slug",
|
||||
};
|
||||
|
||||
const UNKNOWN_ALERT_2: Alert = {
|
||||
created_at: 456,
|
||||
problem_tag: "farmbot_os.firmware.alert",
|
||||
priority: 100,
|
||||
uuid: "uuid",
|
||||
slug: "slug",
|
||||
};
|
||||
|
||||
describe("<Alerts />", () => {
|
||||
|
@ -53,7 +53,7 @@ describe("<Alerts />", () => {
|
|||
p.alerts = [FIRMWARE_MISSING_ALERT, SEED_DATA_MISSING_ALERT];
|
||||
const wrapper = mount(<Alerts {...p} />);
|
||||
expect(wrapper.text()).toContain("2");
|
||||
expect(wrapper.text()).toContain("Your device has no firmware installed");
|
||||
expect(wrapper.text()).toContain("Your device has no firmware");
|
||||
expect(wrapper.text()).toContain("Choose your FarmBot");
|
||||
});
|
||||
|
||||
|
@ -76,20 +76,20 @@ describe("<FirmwareAlerts />", () => {
|
|||
|
||||
it("renders no alerts", () => {
|
||||
const p = fakeProps();
|
||||
p.bot.hardware.enigmas = undefined;
|
||||
p.bot.hardware.alerts = undefined;
|
||||
const wrapper = mount(<FirmwareAlerts {...p} />);
|
||||
expect(wrapper.html()).toEqual(`<div class="firmware-alerts"></div>`);
|
||||
});
|
||||
|
||||
it("renders alerts", () => {
|
||||
const p = fakeProps();
|
||||
p.bot.hardware.enigmas = {
|
||||
"uuid1": FIRMWARE_MISSING_ALERT,
|
||||
"uuid2": UNKNOWN_ALERT
|
||||
p.bot.hardware.alerts = {
|
||||
"slug1": FIRMWARE_MISSING_ALERT,
|
||||
"slug2": UNKNOWN_ALERT
|
||||
};
|
||||
const wrapper = mount(<FirmwareAlerts {...p} />);
|
||||
expect(wrapper.text()).toContain("1");
|
||||
expect(wrapper.text()).toContain("Your device has no firmware installed");
|
||||
expect(wrapper.text()).toContain("Your device has no firmware");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,12 +1,29 @@
|
|||
jest.mock("../../devices/actions", () => ({ updateConfig: jest.fn() }));
|
||||
|
||||
jest.mock("../../api/crud", () => ({ destroy: jest.fn() }));
|
||||
|
||||
const fakeBulletin: Bulletin = {
|
||||
content: "Alert content.",
|
||||
href: "https://farm.bot",
|
||||
href_label: "See more",
|
||||
type: "info",
|
||||
slug: "slug",
|
||||
title: "Announcement",
|
||||
};
|
||||
|
||||
let mockData: Bulletin | undefined = fakeBulletin;
|
||||
jest.mock("../actions", () => ({
|
||||
fetchBulletinContent: jest.fn(() => Promise.resolve(mockData)),
|
||||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import { AlertCard } from "../cards";
|
||||
import { AlertCardProps } from "../interfaces";
|
||||
import { AlertCard, changeFirmwareHardware } from "../cards";
|
||||
import { AlertCardProps, Bulletin } from "../interfaces";
|
||||
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
|
||||
import { FBSelect } from "../../ui";
|
||||
import { destroy } from "../../api/crud";
|
||||
import { updateConfig } from "../../devices/actions";
|
||||
|
||||
describe("<AlertCard />", () => {
|
||||
const fakeProps = (): AlertCardProps => ({
|
||||
|
@ -14,7 +31,7 @@ describe("<AlertCard />", () => {
|
|||
created_at: 123,
|
||||
problem_tag: "author.noun.verb",
|
||||
priority: 100,
|
||||
uuid: "uuid",
|
||||
slug: "slug",
|
||||
},
|
||||
apiFirmwareValue: undefined,
|
||||
timeSettings: fakeTimeSettings(),
|
||||
|
@ -34,10 +51,13 @@ describe("<AlertCard />", () => {
|
|||
it("renders firmware card", () => {
|
||||
const p = fakeProps();
|
||||
p.alert.problem_tag = "farmbot_os.firmware.missing";
|
||||
p.alert.created_at = 1555555555;
|
||||
p.timeSettings.hour24 = false;
|
||||
p.timeSettings.utcOffset = 0;
|
||||
const wrapper = mount(<AlertCard {...p} />);
|
||||
expect(wrapper.text()).toContain("Firmware missing");
|
||||
wrapper.find(".fa-times").simulate("click");
|
||||
expect(destroy).not.toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain("Your device has no firmware");
|
||||
expect(wrapper.find(".fa-times").length).toEqual(0);
|
||||
expect(wrapper.text()).toContain("Apr");
|
||||
});
|
||||
|
||||
it("renders seed data card", () => {
|
||||
|
@ -68,4 +88,61 @@ describe("<AlertCard />", () => {
|
|||
const wrapper = mount(<AlertCard {...p} />);
|
||||
expect(wrapper.text()).toContain("Learn");
|
||||
});
|
||||
|
||||
it("renders loading bulletin card", () => {
|
||||
const p = fakeProps();
|
||||
p.alert.problem_tag = "api.bulletin.unread";
|
||||
const wrapper = mount(<AlertCard {...p} />);
|
||||
["Loading", "Slug"].map(string =>
|
||||
expect(wrapper.text()).toContain(string));
|
||||
});
|
||||
|
||||
it("has no content to load for bulletin card", async () => {
|
||||
mockData = undefined;
|
||||
const p = fakeProps();
|
||||
p.alert.problem_tag = "api.bulletin.unread";
|
||||
const wrapper = await mount(<AlertCard {...p} />);
|
||||
["Unable to load content.", "Slug"].map(string =>
|
||||
expect(wrapper.text()).toContain(string));
|
||||
});
|
||||
|
||||
it("renders loaded bulletin card", async () => {
|
||||
const p = fakeProps();
|
||||
p.alert.problem_tag = "api.bulletin.unread";
|
||||
mockData = fakeBulletin;
|
||||
mockData.href_label = "See more";
|
||||
mockData.type = "info";
|
||||
const wrapper = await mount(<AlertCard {...p} />);
|
||||
["Loading...", "Slug"].map(string =>
|
||||
expect(wrapper.text()).not.toContain(string));
|
||||
["Announcement", "Alert content.", "See more"].map(string =>
|
||||
expect(wrapper.text()).toContain(string));
|
||||
});
|
||||
|
||||
it("renders loaded bulletin card with missing fields", async () => {
|
||||
const p = fakeProps();
|
||||
p.alert.problem_tag = "api.bulletin.unread";
|
||||
mockData = fakeBulletin;
|
||||
mockData.href_label = undefined;
|
||||
mockData.type = "unknown";
|
||||
const wrapper = await mount(<AlertCard {...p} />);
|
||||
expect(wrapper.text()).toContain("Find out more");
|
||||
});
|
||||
|
||||
it("hides incorrect time", () => {
|
||||
const p = fakeProps();
|
||||
p.alert.problem_tag = "farmbot_os.firmware.missing";
|
||||
p.alert.created_at = 0;
|
||||
p.timeSettings.hour24 = false;
|
||||
p.timeSettings.utcOffset = 0;
|
||||
const wrapper = mount(<AlertCard {...p} />);
|
||||
expect(wrapper.text()).not.toContain("Jan 1, 12:00am");
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeFirmwareHardware()", () => {
|
||||
it("changes firmware hardware value", () => {
|
||||
changeFirmwareHardware(jest.fn())({ label: "Arduino", value: "arduino" });
|
||||
expect(updateConfig).toHaveBeenCalledWith({ firmware_hardware: "arduino" });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,7 +27,7 @@ describe("<Messages />", () => {
|
|||
created_at: 123,
|
||||
problem_tag: "author.noun.verb",
|
||||
priority: 100,
|
||||
uuid: "uuid",
|
||||
slug: "slug",
|
||||
}];
|
||||
const wrapper = mount(<Messages {...p} />);
|
||||
expect(wrapper.text()).toContain("Message Center");
|
||||
|
|
|
@ -7,22 +7,22 @@ import { fakeState } from "../../__test_support__/fake_state";
|
|||
import { mapStateToProps } from "../state_to_props";
|
||||
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
|
||||
import {
|
||||
fakeEnigma, fakeFbosConfig
|
||||
fakeAlert, fakeFbosConfig
|
||||
} from "../../__test_support__/fake_state/resources";
|
||||
|
||||
describe("mapStateToProps()", () => {
|
||||
it("handles undefined", () => {
|
||||
const state = fakeState();
|
||||
state.bot.hardware.enigmas = undefined;
|
||||
state.bot.hardware.alerts = undefined;
|
||||
const props = mapStateToProps(state);
|
||||
expect(props.alerts).toEqual([]);
|
||||
});
|
||||
|
||||
it("doesn't show API alerts", () => {
|
||||
const state = fakeState();
|
||||
const enigma = fakeEnigma();
|
||||
enigma.body.problem_tag = "api.seed_data.missing";
|
||||
state.resources = buildResourceIndex([enigma]);
|
||||
const alert = fakeAlert();
|
||||
alert.body.problem_tag = "api.seed_data.missing";
|
||||
state.resources = buildResourceIndex([alert]);
|
||||
mockDev = false;
|
||||
const props = mapStateToProps(state);
|
||||
expect(props.alerts).toEqual([]);
|
||||
|
@ -30,12 +30,12 @@ describe("mapStateToProps()", () => {
|
|||
|
||||
it("shows API alerts", () => {
|
||||
const state = fakeState();
|
||||
const enigma = fakeEnigma();
|
||||
enigma.body.problem_tag = "api.seed_data.missing";
|
||||
state.resources = buildResourceIndex([enigma]);
|
||||
const alert = fakeAlert();
|
||||
alert.body.problem_tag = "api.seed_data.missing";
|
||||
state.resources = buildResourceIndex([alert]);
|
||||
mockDev = true;
|
||||
const props = mapStateToProps(state);
|
||||
expect(props.alerts).toEqual([enigma.body]);
|
||||
expect(props.alerts).toEqual([alert.body]);
|
||||
});
|
||||
|
||||
it("returns firmware value", () => {
|
||||
|
@ -50,7 +50,7 @@ describe("mapStateToProps()", () => {
|
|||
|
||||
it("finds alert", () => {
|
||||
const state = fakeState();
|
||||
const alert = fakeEnigma();
|
||||
const alert = fakeAlert();
|
||||
alert.body.id = 1;
|
||||
state.resources = buildResourceIndex([alert]);
|
||||
const props = mapStateToProps(state);
|
||||
|
|
12
frontend/messages/actions.ts
Normal file
12
frontend/messages/actions.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import axios from "axios";
|
||||
import { API } from "../api";
|
||||
import { Bulletin } from "./interfaces";
|
||||
|
||||
const url = (slug: string) => `${API.current.globalBulletinPath}${slug}`;
|
||||
|
||||
export const fetchBulletinContent =
|
||||
(slug: string): Promise<Bulletin | undefined> => {
|
||||
return axios
|
||||
.get<Bulletin>(url(slug))
|
||||
.then(response => Promise.resolve(response.data));
|
||||
};
|
|
@ -15,29 +15,31 @@ export const sortAlerts = (alerts: Alert[]): Alert[] =>
|
|||
sortBy(alerts, "priority", "created_at");
|
||||
|
||||
export const FirmwareAlerts = (props: FirmwareAlertsProps) => {
|
||||
const alerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
|
||||
const alerts = betterCompact(Object.values(props.bot.hardware.alerts || {}));
|
||||
const firmwareAlerts = sortAlerts(alerts)
|
||||
.filter(x => x.problem_tag && x.priority && x.created_at)
|
||||
.filter(x => splitProblemTag(x.problem_tag).noun === "firmware");
|
||||
return <div className="firmware-alerts">
|
||||
{firmwareAlerts.filter(x => x.problem_tag && x.priority && x.created_at)
|
||||
.map((x, i) =>
|
||||
<AlertCard key={i}
|
||||
alert={x}
|
||||
dispatch={props.dispatch}
|
||||
apiFirmwareValue={props.apiFirmwareValue}
|
||||
timeSettings={props.timeSettings} />)}
|
||||
{firmwareAlerts.map((x, i) =>
|
||||
<AlertCard key={i}
|
||||
alert={x}
|
||||
dispatch={props.dispatch}
|
||||
apiFirmwareValue={props.apiFirmwareValue}
|
||||
timeSettings={props.timeSettings} />)}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const Alerts = (props: AlertsProps) =>
|
||||
<div className="problem-alerts">
|
||||
<div className="problem-alerts-content">
|
||||
{sortAlerts(props.alerts).map((x, i) =>
|
||||
<AlertCard key={i}
|
||||
alert={x}
|
||||
dispatch={props.dispatch}
|
||||
apiFirmwareValue={props.apiFirmwareValue}
|
||||
timeSettings={props.timeSettings}
|
||||
findApiAlertById={props.findApiAlertById} />)}
|
||||
{sortAlerts(props.alerts)
|
||||
.filter(x => x.problem_tag && x.priority && x.created_at)
|
||||
.map((x, i) =>
|
||||
<AlertCard key={i}
|
||||
alert={x}
|
||||
dispatch={props.dispatch}
|
||||
apiFirmwareValue={props.apiFirmwareValue}
|
||||
timeSettings={props.timeSettings}
|
||||
findApiAlertById={props.findApiAlertById} />)}
|
||||
</div>
|
||||
</div>;
|
||||
|
|
|
@ -4,17 +4,25 @@ import {
|
|||
AlertCardProps, AlertCardTemplateProps, FirmwareMissingProps,
|
||||
SeedDataMissingProps, SeedDataMissingState, TourNotTakenProps,
|
||||
CommonAlertCardProps,
|
||||
DismissAlertProps
|
||||
DismissAlertProps,
|
||||
Bulletin,
|
||||
BulletinAlertState
|
||||
} from "./interfaces";
|
||||
import { formatLogTime } from "../logs";
|
||||
import {
|
||||
FirmwareActions
|
||||
FlashFirmwareBtn
|
||||
} from "../devices/components/fbos_settings/firmware_hardware_status";
|
||||
import { DropDownItem, Row, Col, FBSelect, docLink } from "../ui";
|
||||
import { DropDownItem, Row, Col, FBSelect, docLink, Markdown } from "../ui";
|
||||
import { Content } from "../constants";
|
||||
import { TourList } from "../help/tour_list";
|
||||
import { splitProblemTag } from "./alerts";
|
||||
import { destroy } from "../api/crud";
|
||||
import {
|
||||
isFwHardwareValue
|
||||
} from "../devices/components/fbos_settings/board_type";
|
||||
import { updateConfig } from "../devices/actions";
|
||||
import { fetchBulletinContent } from "./actions";
|
||||
import { startCase } from "lodash";
|
||||
|
||||
export const AlertCard = (props: AlertCardProps) => {
|
||||
const { alert, timeSettings, findApiAlertById, dispatch } = props;
|
||||
|
@ -33,32 +41,85 @@ export const AlertCard = (props: AlertCardProps) => {
|
|||
return <UserNotWelcomed {...commonProps} />;
|
||||
case "api.documentation.unread":
|
||||
return <DocumentationUnread {...commonProps} />;
|
||||
case "api.bulletin.unread":
|
||||
return <BulletinAlert {...commonProps} />;
|
||||
default:
|
||||
return UnknownAlert(commonProps);
|
||||
return <UnknownAlert {...commonProps} />;
|
||||
}
|
||||
};
|
||||
const dismissAlert = (props: DismissAlertProps) => () =>
|
||||
(props.id && props.findApiAlertById && props.dispatch)
|
||||
? props.dispatch(destroy(props.findApiAlertById(props.id)))
|
||||
: () => { };
|
||||
(props.id && props.findApiAlertById && props.dispatch) &&
|
||||
props.dispatch(destroy(props.findApiAlertById(props.id)));
|
||||
|
||||
const timeOk = (timestamp: number) => timestamp > 1550000000;
|
||||
|
||||
const AlertCardTemplate = (props: AlertCardTemplateProps) => {
|
||||
const { alert, findApiAlertById, dispatch } = props;
|
||||
return <div className={`problem-alert ${props.className}`}>
|
||||
<div className="problem-alert-title">
|
||||
<i className="fa fa-exclamation-triangle" />
|
||||
<i className={`fa fa-${props.iconName || "exclamation-triangle"}`} />
|
||||
<h3>{t(props.title)}</h3>
|
||||
<p>{formatLogTime(alert.created_at, props.timeSettings)}</p>
|
||||
<i className="fa fa-times"
|
||||
onClick={dismissAlert({ id: alert.id, findApiAlertById, dispatch })} />
|
||||
{timeOk(alert.created_at) &&
|
||||
<p>{formatLogTime(alert.created_at, props.timeSettings)}</p>}
|
||||
{alert.id && <i className="fa fa-times"
|
||||
onClick={dismissAlert({ id: alert.id, findApiAlertById, dispatch })} />}
|
||||
</div>
|
||||
<div className="problem-alert-content">
|
||||
<p>{t(props.message)}</p>
|
||||
<Markdown>{t(props.message)}</Markdown>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const ICON_LOOKUP: { [x: string]: string } = {
|
||||
"info": "info-circle",
|
||||
"success": "check-square",
|
||||
"warn": "exclamation-triangle",
|
||||
};
|
||||
|
||||
class BulletinAlert
|
||||
extends React.Component<CommonAlertCardProps, BulletinAlertState> {
|
||||
state: BulletinAlertState = { bulletin: undefined, no_content: false };
|
||||
|
||||
componentDidMount() {
|
||||
fetchBulletinContent(this.props.alert.slug)
|
||||
.then(bulletin => bulletin
|
||||
? this.setState({ bulletin })
|
||||
: this.setState({ no_content: true }));
|
||||
}
|
||||
|
||||
get bulletinData(): Bulletin {
|
||||
return this.state.bulletin || {
|
||||
content: this.state.no_content ? t("Unable to load content.")
|
||||
: t("Loading..."),
|
||||
href: undefined,
|
||||
href_label: undefined,
|
||||
type: "info",
|
||||
slug: this.props.alert.slug,
|
||||
title: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { content, href, href_label, type, title } = this.bulletinData;
|
||||
return <AlertCardTemplate
|
||||
alert={this.props.alert}
|
||||
className={"bulletin-alert"}
|
||||
title={title || startCase(this.props.alert.slug)}
|
||||
iconName={ICON_LOOKUP[type] || "info-circle"}
|
||||
message={t(content)}
|
||||
timeSettings={this.props.timeSettings}
|
||||
dispatch={this.props.dispatch}
|
||||
findApiAlertById={this.props.findApiAlertById}>
|
||||
{href && <a className="link-button fb-button green"
|
||||
href={href} target="_blank"
|
||||
title={t("Open link in a new tab")}>
|
||||
{href_label || t("Find out more")}
|
||||
</a>}
|
||||
</AlertCardTemplate>;
|
||||
}
|
||||
}
|
||||
|
||||
const UnknownAlert = (props: CommonAlertCardProps) => {
|
||||
const { problem_tag, created_at, priority } = props.alert;
|
||||
const { author, noun, verb } = splitProblemTag(problem_tag);
|
||||
|
@ -74,18 +135,77 @@ const UnknownAlert = (props: CommonAlertCardProps) => {
|
|||
findApiAlertById={props.findApiAlertById} />;
|
||||
};
|
||||
|
||||
const FIRMWARE_CHOICES: DropDownItem[] = [
|
||||
{ label: "Arduino/RAMPS (Genesis v1.2)", value: "arduino" },
|
||||
{ label: "Farmduino (Genesis v1.3)", value: "farmduino" },
|
||||
{ label: "Farmduino (Genesis v1.4)", value: "farmduino_k14" },
|
||||
];
|
||||
|
||||
const FIRMWARE_CHOICES_DDI: { [x: string]: DropDownItem } = {};
|
||||
FIRMWARE_CHOICES.map(x => FIRMWARE_CHOICES_DDI[x.value] = x);
|
||||
|
||||
const FirmwareChoiceTable = () =>
|
||||
<table className="firmware-hardware-choice-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("FarmBot Version")}</th>
|
||||
<th>{t("Electronics Board")}</th>
|
||||
<th>{t("Firmware Name")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{"Genesis v1.2"}</td>
|
||||
<td>{"RAMPS"}</td>
|
||||
<td><code>{FIRMWARE_CHOICES_DDI["arduino"].label}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{"Genesis v1.3"}</td>
|
||||
<td>{"Farmduino"}</td>
|
||||
<td><code>{FIRMWARE_CHOICES_DDI["farmduino"].label}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{"Genesis v1.4"}</td>
|
||||
<td>{"Farmduino"}</td>
|
||||
<td><code>{FIRMWARE_CHOICES_DDI["farmduino_k14"].label}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>;
|
||||
|
||||
export const changeFirmwareHardware = (dispatch: Function | undefined) =>
|
||||
(ddi: DropDownItem) => {
|
||||
if (isFwHardwareValue(ddi.value)) {
|
||||
dispatch && dispatch(updateConfig({ firmware_hardware: ddi.value }));
|
||||
}
|
||||
};
|
||||
|
||||
const FirmwareMissing = (props: FirmwareMissingProps) =>
|
||||
<AlertCardTemplate
|
||||
alert={props.alert}
|
||||
className={"firmware-missing-alert"}
|
||||
title={t("Firmware missing")}
|
||||
message={t("Your device has no firmware installed.")}
|
||||
title={t("Your device has no firmware")}
|
||||
message={t(Content.FIRMWARE_MISSING)}
|
||||
timeSettings={props.timeSettings}
|
||||
dispatch={props.dispatch}
|
||||
findApiAlertById={props.findApiAlertById}>
|
||||
<FirmwareActions
|
||||
apiFirmwareValue={props.apiFirmwareValue}
|
||||
botOnline={true} />
|
||||
<Row>
|
||||
<FirmwareChoiceTable />
|
||||
<Col xs={4}>
|
||||
<label>{t("Choose Firmware")}</label>
|
||||
</Col>
|
||||
<Col xs={5}>
|
||||
<FBSelect
|
||||
key={props.apiFirmwareValue}
|
||||
list={FIRMWARE_CHOICES}
|
||||
selectedItem={FIRMWARE_CHOICES_DDI[props.apiFirmwareValue || "arduino"]}
|
||||
onChange={changeFirmwareHardware(props.dispatch)} />
|
||||
</Col>
|
||||
<Col xs={3}>
|
||||
<FlashFirmwareBtn
|
||||
apiFirmwareValue={props.apiFirmwareValue}
|
||||
botOnline={true} />
|
||||
</Col>
|
||||
</Row>
|
||||
</AlertCardTemplate>;
|
||||
|
||||
const SEED_DATA_OPTIONS: DropDownItem[] = [
|
||||
|
@ -107,7 +227,8 @@ class SeedDataMissing
|
|||
message={t(Content.SEED_DATA_SELECTION)}
|
||||
timeSettings={this.props.timeSettings}
|
||||
dispatch={this.props.dispatch}
|
||||
findApiAlertById={this.props.findApiAlertById}>
|
||||
findApiAlertById={this.props.findApiAlertById}
|
||||
iconName={"check-square"}>
|
||||
<Row>
|
||||
<Col xs={4}>
|
||||
<label>{t("Choose your FarmBot")}</label>
|
||||
|
@ -132,7 +253,8 @@ const TourNotTaken = (props: TourNotTakenProps) =>
|
|||
message={t(Content.TAKE_A_TOUR)}
|
||||
timeSettings={props.timeSettings}
|
||||
dispatch={props.dispatch}
|
||||
findApiAlertById={props.findApiAlertById}>
|
||||
findApiAlertById={props.findApiAlertById}
|
||||
iconName={"info-circle"}>
|
||||
<p>{t("Choose a tour to begin")}:</p>
|
||||
<TourList dispatch={props.dispatch} />
|
||||
</AlertCardTemplate>;
|
||||
|
@ -145,10 +267,11 @@ const UserNotWelcomed = (props: CommonAlertCardProps) =>
|
|||
message={t(Content.WELCOME)}
|
||||
timeSettings={props.timeSettings}
|
||||
dispatch={props.dispatch}
|
||||
findApiAlertById={props.findApiAlertById}>
|
||||
findApiAlertById={props.findApiAlertById}
|
||||
iconName={"info-circle"}>
|
||||
<p>
|
||||
{t("You're currently viewing the")} <b>{t("Message Center")}</b>.
|
||||
{t(Content.MESSAGE_CENTER_WELCOME)}
|
||||
{" "}{t(Content.MESSAGE_CENTER_WELCOME)}
|
||||
</p>
|
||||
<p>
|
||||
{t(Content.MESSAGE_DISMISS)}
|
||||
|
@ -163,7 +286,8 @@ const DocumentationUnread = (props: CommonAlertCardProps) =>
|
|||
message={t(Content.READ_THE_DOCS)}
|
||||
timeSettings={props.timeSettings}
|
||||
dispatch={props.dispatch}
|
||||
findApiAlertById={props.findApiAlertById}>
|
||||
findApiAlertById={props.findApiAlertById}
|
||||
iconName={"info-circle"}>
|
||||
<p>
|
||||
{t("Head over to")}
|
||||
<a href={docLink()} target="_blank"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FirmwareHardware, Enigma } from "farmbot";
|
||||
import { FirmwareHardware, Alert as Enigma } from "farmbot";
|
||||
import { TimeSettings } from "../interfaces";
|
||||
import { BotState } from "../devices/interfaces";
|
||||
import { UUID } from "../resources/interfaces";
|
||||
|
@ -58,6 +58,7 @@ export interface AlertCardTemplateProps {
|
|||
children?: React.ReactNode;
|
||||
findApiAlertById?(id: number): UUID;
|
||||
dispatch?: Function;
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export interface DismissAlertProps {
|
||||
|
@ -81,3 +82,17 @@ export interface SeedDataMissingState {
|
|||
export interface TourNotTakenProps extends CommonAlertCardProps {
|
||||
dispatch: Function;
|
||||
}
|
||||
|
||||
export interface Bulletin {
|
||||
content: string;
|
||||
href: string | undefined;
|
||||
href_label: string | undefined;
|
||||
type: string;
|
||||
slug: string;
|
||||
title: string | undefined;
|
||||
}
|
||||
|
||||
export interface BulletinAlertState {
|
||||
bulletin: Bulletin | undefined;
|
||||
no_content: boolean;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { getFbosConfig } from "../resources/getters";
|
|||
import { sourceFbosConfigValue } from "../devices/components/source_config_value";
|
||||
import { DevSettings } from "../account/dev/dev_support";
|
||||
import {
|
||||
selectAllEnigmas, maybeGetTimeSettings, findResourceById
|
||||
selectAllAlerts, maybeGetTimeSettings, findResourceById
|
||||
} from "../resources/selectors";
|
||||
import { isFwHardwareValue } from "../devices/components/fbos_settings/board_type";
|
||||
import { ResourceIndex, UUID } from "../resources/interfaces";
|
||||
|
@ -18,7 +18,7 @@ export const mapStateToProps = (props: Everything): MessagesProps => {
|
|||
sourceFbosConfigValue(fbosConfig, hardware.configuration);
|
||||
const apiFirmwareValue = sourceFbosConfig("firmware_hardware").value;
|
||||
const findApiAlertById = (id: number): UUID =>
|
||||
findResourceById(props.resources.index, "Enigma", id);
|
||||
findResourceById(props.resources.index, "Alert", id);
|
||||
return {
|
||||
alerts: getAlerts(props.resources.index, props.bot),
|
||||
apiFirmwareValue: isFwHardwareValue(apiFirmwareValue)
|
||||
|
@ -31,8 +31,8 @@ export const mapStateToProps = (props: Everything): MessagesProps => {
|
|||
|
||||
export const getAlerts =
|
||||
(resourceIndex: ResourceIndex, bot: BotState): Alert[] => {
|
||||
const botAlerts = betterCompact(Object.values(bot.hardware.enigmas || {}));
|
||||
const apiAlerts = selectAllEnigmas(resourceIndex).map(x => x.body)
|
||||
const botAlerts = betterCompact(Object.values(bot.hardware.alerts || {}));
|
||||
const apiAlerts = selectAllAlerts(resourceIndex).map(x => x.body)
|
||||
.filter(x => DevSettings.futureFeaturesEnabled() ||
|
||||
x.problem_tag !== "api.seed_data.missing");
|
||||
return botAlerts.concat(apiAlerts);
|
||||
|
|
|
@ -45,8 +45,7 @@ export class BulkScheduler extends React.Component<BulkEditorProps, {}> {
|
|||
<label>{t("Sequence")}</label>
|
||||
<FBSelect onChange={this.onChange}
|
||||
selectedItem={this.selected()}
|
||||
list={this.all()}
|
||||
placeholder="Pick a sequence (or save a new one)" />
|
||||
list={this.all()} />
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ export const emptyState = (): RestResources => {
|
|||
PlantTemplate: {},
|
||||
SavedGarden: {},
|
||||
DiagnosticDump: {},
|
||||
Enigma: {},
|
||||
Alert: {},
|
||||
},
|
||||
byKindAndId: {},
|
||||
references: {},
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
TaggedPlantTemplate,
|
||||
TaggedFarmwareEnv,
|
||||
TaggedFarmwareInstallation,
|
||||
TaggedEnigma,
|
||||
TaggedAlert,
|
||||
} from "farmbot";
|
||||
import {
|
||||
isTaggedResource,
|
||||
|
@ -100,8 +100,8 @@ export const selectAllWebcamFeeds =
|
|||
(i: ResourceIndex) => findAll<TaggedWebcamFeed>(i, "WebcamFeed");
|
||||
export const selectAllSavedPeripherals =
|
||||
(input: ResourceIndex) => selectAllPeripherals(input).filter(isSaved);
|
||||
export const selectAllEnigmas =
|
||||
(i: ResourceIndex) => findAll<TaggedEnigma>(i, "Enigma");
|
||||
export const selectAllAlerts =
|
||||
(i: ResourceIndex) => findAll<TaggedAlert>(i, "Alert");
|
||||
|
||||
export const findByKindAndId = <T extends TaggedResource>(
|
||||
i: ResourceIndex, kind: T["kind"], id: number | undefined): T => {
|
||||
|
|
|
@ -204,7 +204,7 @@ export class SequenceEditorMiddleActive extends
|
|||
farmwareInfo: this.props.farmwareInfo,
|
||||
shouldDisplay: this.props.shouldDisplay,
|
||||
confirmStepDeletion: !!getConfig(BooleanSetting.confirm_step_deletion),
|
||||
showPins: !!getConfig(BooleanSetting.confirm_step_deletion),
|
||||
showPins: !!getConfig(BooleanSetting.show_pins),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -40,6 +40,5 @@ export function SequenceSelectBox(props: SequenceSelectBoxProps) {
|
|||
return <FBSelect
|
||||
onChange={props.onChange}
|
||||
selectedItem={selectedSequence()}
|
||||
list={sequenceDropDownList()}
|
||||
placeholder="Pick a sequence (or save a new one)" />;
|
||||
list={sequenceDropDownList()} />;
|
||||
}
|
||||
|
|
|
@ -131,6 +131,18 @@ describe("Pin and Peripheral support files", () => {
|
|||
expect(JSON.stringify(result)).toContain("displayed peripheral");
|
||||
expect(JSON.stringify(result)).not.toContain("not displayed");
|
||||
});
|
||||
|
||||
it("doesn't display pins", () => {
|
||||
const ri = buildResourceIndex([]);
|
||||
const result = pinsAsDropDownsWritePin(ri.index, () => false, false);
|
||||
expect(JSON.stringify(result)).not.toContain("Pin 13");
|
||||
});
|
||||
|
||||
it("displays pins", () => {
|
||||
const ri = buildResourceIndex([]);
|
||||
const result = pinsAsDropDownsWritePin(ri.index, () => true, true);
|
||||
expect(JSON.stringify(result)).toContain("Pin 13");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinsAsDropDownsReadPin()", () => {
|
||||
|
@ -154,6 +166,18 @@ describe("Pin and Peripheral support files", () => {
|
|||
expect(JSON.stringify(result)).toContain("displayed sensor");
|
||||
expect(JSON.stringify(result)).toContain("displayed peripheral");
|
||||
});
|
||||
|
||||
it("doesn't display pins", () => {
|
||||
const ri = buildResourceIndex([]);
|
||||
const result = pinsAsDropDownsReadPin(ri.index, () => false, false);
|
||||
expect(JSON.stringify(result)).not.toContain("Pin 13");
|
||||
});
|
||||
|
||||
it("displays pins", () => {
|
||||
const ri = buildResourceIndex([]);
|
||||
const result = pinsAsDropDownsReadPin(ri.index, () => true, true);
|
||||
expect(JSON.stringify(result)).toContain("Pin 13");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByPinNumber", () => {
|
||||
|
|
|
@ -24,7 +24,8 @@ describe("<TileIf/>", () => {
|
|||
dispatch={jest.fn()}
|
||||
index={0}
|
||||
resources={emptyState().index}
|
||||
confirmStepDeletion={false} />)
|
||||
confirmStepDeletion={false}
|
||||
showPins={true} />)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -37,16 +37,16 @@ const BOX_LED_LABELS: { [x: string]: string } = {
|
|||
};
|
||||
|
||||
export const PERIPHERAL_HEADING: DropDownItem =
|
||||
({ heading: true, label: t("Peripherals"), value: 0 });
|
||||
({ heading: true, label: t("Peripherals"), value: 0, headingId: PinGroupName.Peripheral });
|
||||
|
||||
export const SENSOR_HEADING: DropDownItem =
|
||||
({ heading: true, label: t("Sensors"), value: 0 });
|
||||
({ heading: true, label: t("Sensors"), value: 0, headingId: PinGroupName.Sensor });
|
||||
|
||||
export const BOX_LED_HEADING: DropDownItem =
|
||||
({ heading: true, label: t("Box LEDs"), value: 0 });
|
||||
({ heading: true, label: t("Box LEDs"), value: 0, headingId: PinGroupName.BoxLed });
|
||||
|
||||
export const PIN_HEADING: DropDownItem =
|
||||
({ heading: true, label: t("Pins"), value: 0 });
|
||||
({ heading: true, label: t("Pins"), value: 0, headingId: PinGroupName.Pin });
|
||||
|
||||
/** Pass it the number X and it will generate a DropDownItem for `pin x`. */
|
||||
export const pinNumber2DropDown =
|
||||
|
|
|
@ -11,7 +11,8 @@ export function TileIf(props: StepParams) {
|
|||
index={props.index}
|
||||
resources={props.resources}
|
||||
shouldDisplay={props.shouldDisplay}
|
||||
confirmStepDeletion={props.confirmStepDeletion} />;
|
||||
confirmStepDeletion={props.confirmStepDeletion}
|
||||
showPins={props.showPins} />;
|
||||
} else {
|
||||
return <p> Expected "_if" node</p>;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
jest.mock("../../../../api/crud", () => ({ overwrite: jest.fn() }));
|
||||
|
||||
import * as React from "react";
|
||||
import { If_ } from "../if";
|
||||
import { mount } from "enzyme";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import { fakeSequence } from "../../../../__test_support__/fake_state/resources";
|
||||
import { If } from "farmbot/dist";
|
||||
import { IfParams } from "../index";
|
||||
import { emptyState } from "../../../../resources/reducer";
|
||||
import { FBSelect } from "../../../../ui";
|
||||
import { overwrite } from "../../../../api/crud";
|
||||
|
||||
describe("<If_/>", () => {
|
||||
function fakeProps(): IfParams {
|
||||
|
@ -26,6 +30,7 @@ describe("<If_/>", () => {
|
|||
resources: emptyState().index,
|
||||
shouldDisplay: jest.fn(),
|
||||
confirmStepDeletion: false,
|
||||
showPins: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -36,4 +41,15 @@ describe("<If_/>", () => {
|
|||
expect(wrapper.find("button").length).toEqual(2);
|
||||
expect(wrapper.find("input").length).toEqual(1);
|
||||
});
|
||||
|
||||
it("updates op", () => {
|
||||
const wrapper = shallow(<If_ {...fakeProps()} />);
|
||||
wrapper.find(FBSelect).last().simulate("change", {
|
||||
label: "is not", value: "not"
|
||||
});
|
||||
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
|
||||
expect.objectContaining({
|
||||
body: [{ kind: "_if", args: expect.objectContaining({ op: "not" }) }]
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -47,6 +47,7 @@ function fakeProps(): IfParams {
|
|||
resources: fakeResourceIndex,
|
||||
shouldDisplay: jest.fn(),
|
||||
confirmStepDeletion: false,
|
||||
showPins: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -60,16 +61,16 @@ describe("seqDropDown()", () => {
|
|||
});
|
||||
|
||||
describe("LHSOptions()", () => {
|
||||
it("returns positions and pins", () => {
|
||||
it("returns positions", () => {
|
||||
const s = fakeSensor();
|
||||
const p = fakePeripheral();
|
||||
s.body.label = "not displayed";
|
||||
p.body.label = "not displayed";
|
||||
const ri = buildResourceIndex([s, p]);
|
||||
const result = JSON.stringify(LHSOptions(ri.index, () => false));
|
||||
const result = JSON.stringify(LHSOptions(ri.index, () => false, false));
|
||||
expect(result).not.toContain("not displayed");
|
||||
expect(result).toContain("X position");
|
||||
expect(result).toContain("Pin 25");
|
||||
expect(result).not.toContain("Pin 25");
|
||||
});
|
||||
|
||||
it("returns positions, peripherals, sensors, pins", () => {
|
||||
|
@ -78,8 +79,9 @@ describe("LHSOptions()", () => {
|
|||
s.body.label = "displayed";
|
||||
p.body.label = "displayed";
|
||||
const ri = buildResourceIndex([s, p]);
|
||||
const result = JSON.stringify(LHSOptions(ri.index, () => true));
|
||||
const result = JSON.stringify(LHSOptions(ri.index, () => true, true));
|
||||
expect(result).toContain("displayed");
|
||||
expect(result).toContain("Pin 25");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,15 +1,9 @@
|
|||
import * as React from "react";
|
||||
import { IfParams, LHSOptions, operatorOptions } from "./index";
|
||||
|
||||
import { StepInputBox } from "../../inputs/step_input_box";
|
||||
import { defensiveClone } from "../../../util";
|
||||
import { overwrite } from "../../../api/crud";
|
||||
import {
|
||||
Col,
|
||||
Row,
|
||||
FBSelect,
|
||||
DropDownItem
|
||||
} from "../../../ui/index";
|
||||
import { Col, Row, FBSelect, DropDownItem } from "../../../ui";
|
||||
import { ALLOWED_OPS } from "farmbot/dist";
|
||||
import { updateLhs } from "./update_lhs";
|
||||
import { displayLhs } from "./display_lhs";
|
||||
|
@ -25,31 +19,25 @@ const label_ops: Record<ALLOWED_OPS, string> = {
|
|||
"not": t("is not")
|
||||
};
|
||||
|
||||
// tslint:disable-next-line:no-any
|
||||
const isOp = (x: any): x is ALLOWED_OPS => Object.keys(label_ops).includes(x);
|
||||
|
||||
const updateOp = (props: IfParams) => (ddi: DropDownItem) => {
|
||||
const stepCopy = defensiveClone(props.currentStep);
|
||||
const seqCopy = defensiveClone(props.currentSequence).body;
|
||||
const val = ddi.value;
|
||||
seqCopy.body = seqCopy.body || [];
|
||||
if (isString(val) && isOp(val)) { stepCopy.args.op = val; }
|
||||
seqCopy.body[props.index] = stepCopy;
|
||||
props.dispatch(overwrite(props.currentSequence, seqCopy));
|
||||
};
|
||||
|
||||
export function If_(props: IfParams) {
|
||||
const {
|
||||
dispatch,
|
||||
currentStep,
|
||||
index,
|
||||
resources
|
||||
} = props;
|
||||
const step = props.currentStep;
|
||||
const { currentStep, resources } = props;
|
||||
const sequence = props.currentSequence;
|
||||
const { op } = currentStep.args;
|
||||
const cb = props.shouldDisplay || (() => false);
|
||||
const lhsOptions = LHSOptions(props.resources, cb);
|
||||
function updateField(field: "lhs" | "op") {
|
||||
return (e: DropDownItem) => {
|
||||
const stepCopy = defensiveClone(step);
|
||||
const seqCopy = defensiveClone(sequence).body;
|
||||
const val = e.value;
|
||||
seqCopy.body = seqCopy.body || [];
|
||||
if (isString(val)) {
|
||||
stepCopy.args[field] = val;
|
||||
}
|
||||
seqCopy.body[index] = stepCopy;
|
||||
dispatch(overwrite(sequence, seqCopy));
|
||||
};
|
||||
}
|
||||
const lhsOptions = LHSOptions(resources, cb, !!props.showPins);
|
||||
|
||||
return <Row>
|
||||
<Col xs={12}>
|
||||
|
@ -58,27 +46,25 @@ export function If_(props: IfParams) {
|
|||
<Col xs={4}>
|
||||
<label>{t("Variable")}</label>
|
||||
<FBSelect
|
||||
key={JSON.stringify(props.currentSequence)}
|
||||
key={JSON.stringify(sequence)}
|
||||
list={lhsOptions}
|
||||
placeholder="Left hand side"
|
||||
onChange={updateLhs(props)}
|
||||
selectedItem={displayLhs({ currentStep, resources, lhsOptions })} />
|
||||
</Col>
|
||||
<Col xs={4}>
|
||||
<label>{t("Operator")}</label>
|
||||
<FBSelect
|
||||
key={JSON.stringify(props.currentSequence)}
|
||||
key={JSON.stringify(sequence)}
|
||||
list={operatorOptions}
|
||||
placeholder="Operation"
|
||||
onChange={updateField("op")}
|
||||
selectedItem={{ label: label_ops[op as ALLOWED_OPS] || op, value: op }} />
|
||||
onChange={updateOp(props)}
|
||||
selectedItem={{ label: label_ops[op] || op, value: op }} />
|
||||
</Col>
|
||||
<Col xs={4} hidden={op === IS_UNDEFINED}>
|
||||
<label>{t("Value")}</label>
|
||||
<StepInputBox dispatch={dispatch}
|
||||
<StepInputBox dispatch={props.dispatch}
|
||||
step={currentStep}
|
||||
sequence={sequence}
|
||||
index={index}
|
||||
index={props.index}
|
||||
field="rhs" />
|
||||
</Col>
|
||||
</Row>;
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { DropDownItem, NULL_CHOICE } from "../../../ui/index";
|
||||
import { TaggedSequence, ParameterApplication } from "farmbot";
|
||||
import { If, Execute, Nothing } from "farmbot/dist";
|
||||
import { ResourceIndex } from "../../../resources/interfaces";
|
||||
import { selectAllSequences, findSequenceById } from "../../../resources/selectors";
|
||||
import {
|
||||
selectAllSequences, findSequenceById
|
||||
} from "../../../resources/selectors";
|
||||
import { isRecursive } from "../index";
|
||||
import { If_ } from "./if";
|
||||
import { ThenElse } from "./then_else";
|
||||
|
@ -13,11 +14,13 @@ import { overwrite } from "../../../api/crud";
|
|||
import { ToolTips } from "../../../constants";
|
||||
import { StepWrapper, StepHeader, StepContent } from "../../step_ui/index";
|
||||
import {
|
||||
sensorsAsDropDowns, peripheralsAsDropDowns, pinDropdowns
|
||||
sensorsAsDropDowns, peripheralsAsDropDowns, pinDropdowns, PinGroupName
|
||||
} from "../pin_and_peripheral_support";
|
||||
import { ShouldDisplay, Feature } from "../../../devices/interfaces";
|
||||
import { isNumber, isString } from "lodash";
|
||||
import { addOrEditParamApps, variableList } from "../../locals_list/variable_support";
|
||||
import {
|
||||
addOrEditParamApps, variableList
|
||||
} from "../../locals_list/variable_support";
|
||||
import { t } from "../../../i18next_wrapper";
|
||||
|
||||
export interface IfParams {
|
||||
|
@ -28,6 +31,7 @@ export interface IfParams {
|
|||
resources: ResourceIndex;
|
||||
shouldDisplay?: ShouldDisplay;
|
||||
confirmStepDeletion: boolean;
|
||||
showPins?: boolean;
|
||||
}
|
||||
|
||||
export interface ThenElseParams extends IfParams {
|
||||
|
@ -41,15 +45,15 @@ export type Operator = "lhs"
|
|||
| "_else";
|
||||
|
||||
export const LHSOptions =
|
||||
(resources: ResourceIndex, shouldDisplay: ShouldDisplay
|
||||
(resources: ResourceIndex, shouldDisplay: ShouldDisplay, showPins: boolean
|
||||
): DropDownItem[] => [
|
||||
{ heading: true, label: t("Positions"), value: 0 },
|
||||
{ heading: true, label: t("Positions"), value: 0, headingId: PinGroupName.Position },
|
||||
{ value: "x", label: t("X position"), headingId: "Position" },
|
||||
{ value: "y", label: t("Y position"), headingId: "Position" },
|
||||
{ value: "z", label: t("Z position"), headingId: "Position" },
|
||||
...(shouldDisplay(Feature.named_pins) ? peripheralsAsDropDowns(resources) : []),
|
||||
...(shouldDisplay(Feature.named_pins) ? sensorsAsDropDowns(resources) : []),
|
||||
...pinDropdowns(n => `pin${n}`),
|
||||
...(showPins ? pinDropdowns(n => `pin${n}`) : []),
|
||||
];
|
||||
|
||||
export const operatorOptions: DropDownItem[] = [
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { ThenElseParams, seqDropDown, IfBlockDropDownHandler } from "./index";
|
||||
|
||||
import { Row, Col, FBSelect } from "../../../ui";
|
||||
import { LocalsList } from "../../locals_list/locals_list";
|
||||
import { AllowedVariableNodes } from "../../locals_list/locals_list_support";
|
||||
|
@ -21,7 +20,6 @@ export function ThenElse(props: ThenElseParams) {
|
|||
key={JSON.stringify(props.currentSequence)}
|
||||
allowEmpty={true}
|
||||
list={seqDropDown(props.resources)}
|
||||
placeholder="Sequence..."
|
||||
onChange={onChange}
|
||||
selectedItem={selectedItem()} />
|
||||
{!!calledSequenceVariableData &&
|
||||
|
|
|
@ -76,7 +76,7 @@ export async function fetchSyncData(dispatch: Function) {
|
|||
get("Point", API.current.allPointsPath),
|
||||
get("Sensor", API.current.sensorPath),
|
||||
get("Tool", API.current.toolsPath),
|
||||
get("Enigma", API.current.enigmaPath),
|
||||
get("Alert", API.current.alertPath),
|
||||
]),
|
||||
2: () => Promise.all<{}>([
|
||||
get("SensorReading", API.current.sensorReadingPath),
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Button, Classes, MenuItem, Alignment } from "@blueprintjs/core";
|
||||
import { Select, ItemRenderer } from "@blueprintjs/select";
|
||||
import { DropDownItem } from "./fb_select";
|
||||
|
@ -33,7 +32,7 @@ export class FilterSearch extends React.Component<Props, Partial<State>> {
|
|||
return <SelectComponent
|
||||
{...flags}
|
||||
items={this.props.items}
|
||||
itemPredicate={this.filter}
|
||||
itemPredicate={this.filter(this.props.items)}
|
||||
itemRenderer={this.default}
|
||||
noResults={<MenuItem disabled text={t("No results.")} />}
|
||||
onItemSelect={this.handleValueChange}
|
||||
|
@ -67,12 +66,13 @@ export class FilterSearch extends React.Component<Props, Partial<State>> {
|
|||
text={`${i.label}`} />;
|
||||
}
|
||||
|
||||
private filter(query: string, item: DropDownItem) {
|
||||
if (item.heading) { return true; }
|
||||
const itemHeadingId = item.headingId ? item.headingId : "";
|
||||
const itemSearchLabel = `${itemHeadingId}: ${item.label}`;
|
||||
return itemSearchLabel.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
}
|
||||
private filter = (items: DropDownItem[]) =>
|
||||
(query: string, item: DropDownItem): boolean => {
|
||||
const matchedItems = allMatchedItems(items, query);
|
||||
return item.heading
|
||||
? sectionHasItems(item.headingId, matchedItems)
|
||||
: isMatch(item, query);
|
||||
}
|
||||
|
||||
private handleValueChange = (item: DropDownItem | undefined) => {
|
||||
if (item) {
|
||||
|
@ -82,3 +82,17 @@ export class FilterSearch extends React.Component<Props, Partial<State>> {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
const isMatch = (item: DropDownItem, query: string): boolean =>
|
||||
`${item.headingId || ""}: ${item.label}`
|
||||
.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
|
||||
const allMatchedItems =
|
||||
(allItems: DropDownItem[], query: string): DropDownItem[] =>
|
||||
allItems.filter(x => !x.heading).filter(x => isMatch(x, query));
|
||||
|
||||
const sectionHasItems =
|
||||
(headingId: string | undefined, matchedItems: DropDownItem[]): boolean => {
|
||||
const sectionItems = matchedItems.filter(x => x.headingId === headingId);
|
||||
return sectionItems.length > 0;
|
||||
};
|
||||
|
|
|
@ -12,8 +12,6 @@ export interface FBSelectProps {
|
|||
list: DropDownItem[];
|
||||
/** Allow user to select no value. */
|
||||
allowEmpty?: boolean;
|
||||
/** Text shown before user selection. */
|
||||
placeholder?: string | undefined;
|
||||
/** Extra class names to add. */
|
||||
extraClass?: string;
|
||||
/** Custom label for NULL_CHOICE instead of "None". */
|
||||
|
|
|
@ -8,10 +8,10 @@ def check_for_digests
|
|||
.pluck(:device_id)
|
||||
.uniq
|
||||
.map do |id|
|
||||
device = Device.find(id)
|
||||
puts "Sending log digest to device \##{id} (#{device.name})"
|
||||
LogDeliveryMailer.log_digest(device).deliver
|
||||
end
|
||||
device = Device.find(id)
|
||||
puts "Sending log digest to device \##{id} (#{device.name})"
|
||||
LogDeliveryMailer.log_digest(device).deliver
|
||||
end
|
||||
sleep 10.minutes
|
||||
end
|
||||
|
||||
|
@ -38,7 +38,7 @@ def user_typed?(word)
|
|||
end
|
||||
|
||||
namespace :api do
|
||||
desc "Runs pending email digests. "\
|
||||
desc "Runs pending email digests. " \
|
||||
"Use the `FOREVER` ENV var to continually check."
|
||||
task log_digest: :environment do
|
||||
puts "Running log digest loop..."
|
||||
|
@ -51,14 +51,13 @@ namespace :api do
|
|||
end
|
||||
|
||||
def parcel(cmd, opts = " ")
|
||||
intro = [ "node_modules/parcel-bundler/bin/cli.js",
|
||||
cmd,
|
||||
DashboardController::PARCEL_ASSET_LIST,
|
||||
"--out-dir",
|
||||
DashboardController::PUBLIC_OUTPUT_DIR,
|
||||
"--public-url",
|
||||
DashboardController::OUTPUT_URL,
|
||||
].join(" ")
|
||||
intro = ["node_modules/parcel-bundler/bin/cli.js",
|
||||
cmd,
|
||||
DashboardController::PARCEL_ASSET_LIST,
|
||||
"--out-dir",
|
||||
DashboardController::PUBLIC_OUTPUT_DIR,
|
||||
"--public-url",
|
||||
DashboardController::OUTPUT_URL].join(" ")
|
||||
sh [intro, opts].join(" ")
|
||||
end
|
||||
|
||||
|
@ -67,8 +66,7 @@ namespace :api do
|
|||
# Clear out cache and previous builds on initial load.
|
||||
sh ["rm -rf",
|
||||
DashboardController::CACHE_DIR,
|
||||
DashboardController::PUBLIC_OUTPUT_DIR
|
||||
].join(" ")
|
||||
DashboardController::PUBLIC_OUTPUT_DIR].join(" ")
|
||||
parcel "watch", DashboardController::PARCEL_HMR_OPTS
|
||||
end
|
||||
|
||||
|
@ -79,7 +77,7 @@ namespace :api do
|
|||
|
||||
desc "Reset _everything_, including your database"
|
||||
task :reset do
|
||||
puts "This is going to destroy _ALL_ of your local Farmbot SQL data and "\
|
||||
puts "This is going to destroy _ALL_ of your local Farmbot SQL data and " \
|
||||
"configs. Type 'destroy' to continue, enter to abort."
|
||||
if user_typed?("destroy")
|
||||
hard_reset_api
|
||||
|
@ -88,38 +86,38 @@ namespace :api do
|
|||
end
|
||||
end
|
||||
|
||||
VERSION = "tag_name"
|
||||
VERSION = "tag_name"
|
||||
TIMESTAMP = "created_at"
|
||||
|
||||
desc "Update GlobalConfig to deprecate old FBOS versions"
|
||||
task deprecate: :environment do
|
||||
# Get current version
|
||||
version_str = GlobalConfig.dump.fetch("FBOS_END_OF_LIFE_VERSION")
|
||||
version_str = GlobalConfig.dump.fetch("FBOS_END_OF_LIFE_VERSION")
|
||||
# Convert it to Gem::Version for easy comparisons (>, <, ==, etc)
|
||||
current_version = Gem::Version::new(version_str)
|
||||
# 60 days is the current policy.
|
||||
cutoff = 60.days.ago
|
||||
cutoff = 60.days.ago
|
||||
# Download release data from github
|
||||
stringio = open("https://api.github.com/repos/farmbot/farmbot_os/releases")
|
||||
string = stringio.read
|
||||
data = JSON
|
||||
.parse(string)
|
||||
.map { |x| x.slice(VERSION, TIMESTAMP) } # Only grab keys that matter
|
||||
.reject { |x| x.fetch(VERSION).include?("-") } # Remove RC/Beta releases
|
||||
.map do |x|
|
||||
string = stringio.read
|
||||
data = JSON
|
||||
.parse(string)
|
||||
.map { |x| x.slice(VERSION, TIMESTAMP) } # Only grab keys that matter
|
||||
.reject { |x| x.fetch(VERSION).include?("-") } # Remove RC/Beta releases
|
||||
.map do |x|
|
||||
# Convert string-y version/timestamps to Real ObjectsTM
|
||||
version = Gem::Version::new(x.fetch(VERSION).gsub("v", ""))
|
||||
time = DateTime.parse(x.fetch(TIMESTAMP))
|
||||
time = DateTime.parse(x.fetch(TIMESTAMP))
|
||||
Pair.new(version, time)
|
||||
end
|
||||
.select do |pair|
|
||||
.select do |pair|
|
||||
# Grab versions that are > current version and outside of cutoff window
|
||||
(pair.head > current_version) && (pair.tail < cutoff)
|
||||
end
|
||||
.sort_by { |p| p.tail } # Sort by release date
|
||||
.last(2) # Grab 2 latest versions (closest to cuttof)
|
||||
.first # Give 'em some leeway, grabbing the 2nd most outdated version.
|
||||
.try(:head) # We might already be up-to-date?
|
||||
.sort_by { |p| p.tail } # Sort by release date
|
||||
.last(2) # Grab 2 latest versions (closest to cutoff)
|
||||
.first # Give 'em some leeway, grabbing the 2nd most outdated version.
|
||||
.try(:head) # We might already be up-to-date?
|
||||
if data # ...or not
|
||||
puts "Setting new support target to #{data.to_s}"
|
||||
GlobalConfig # Set the new oldest support version.
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue