Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into friday
commit
f2704db2d8
2
Gemfile
2
Gemfile
|
@ -35,8 +35,6 @@ gem "redis", "~> 4.0"
|
|||
|
||||
group :development, :test do
|
||||
gem "thin"
|
||||
gem "capybara"
|
||||
# gem "deep-cover", "~> 0.4", require: false
|
||||
gem "codecov", require: false
|
||||
gem "simplecov"
|
||||
gem "database_cleaner"
|
||||
|
|
126
Gemfile.lock
126
Gemfile.lock
|
@ -7,25 +7,25 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (5.2.1)
|
||||
actionpack (= 5.2.1)
|
||||
actioncable (5.2.2)
|
||||
actionpack (= 5.2.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailer (5.2.1)
|
||||
actionpack (= 5.2.1)
|
||||
actionview (= 5.2.1)
|
||||
activejob (= 5.2.1)
|
||||
actionmailer (5.2.2)
|
||||
actionpack (= 5.2.2)
|
||||
actionview (= 5.2.2)
|
||||
activejob (= 5.2.2)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (5.2.1)
|
||||
actionview (= 5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
actionpack (5.2.2)
|
||||
actionview (= 5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
rack (~> 2.0)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.0.2)
|
||||
actionview (5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
actionview (5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
|
@ -35,20 +35,20 @@ GEM
|
|||
activemodel (>= 4.1, < 6)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
activejob (5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
activerecord (5.2.1)
|
||||
activemodel (= 5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
activemodel (5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
activerecord (5.2.2)
|
||||
activemodel (= 5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
arel (>= 9.0)
|
||||
activestorage (5.2.1)
|
||||
actionpack (= 5.2.1)
|
||||
activerecord (= 5.2.1)
|
||||
activestorage (5.2.2)
|
||||
actionpack (= 5.2.2)
|
||||
activerecord (= 5.2.2)
|
||||
marcel (~> 0.3.1)
|
||||
activesupport (5.2.1)
|
||||
activesupport (5.2.2)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 0.7, < 2)
|
||||
minitest (~> 5.1)
|
||||
|
@ -56,21 +56,13 @@ GEM
|
|||
addressable (2.5.2)
|
||||
public_suffix (>= 2.0.2, < 4.0)
|
||||
amq-protocol (2.3.0)
|
||||
appsignal (2.7.2)
|
||||
appsignal (2.8.1)
|
||||
rack
|
||||
arel (9.0.0)
|
||||
bcrypt (3.1.12)
|
||||
builder (3.2.3)
|
||||
bunny (2.12.0)
|
||||
bunny (2.13.0)
|
||||
amq-protocol (~> 2.3, >= 2.3.0)
|
||||
capybara (3.11.0)
|
||||
addressable
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (~> 1.8)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (~> 1.2)
|
||||
xpath (~> 3.2)
|
||||
case_transform (0.2)
|
||||
activesupport
|
||||
childprocess (0.9.0)
|
||||
|
@ -82,7 +74,7 @@ GEM
|
|||
simplecov
|
||||
url
|
||||
coderay (1.1.2)
|
||||
concurrent-ruby (1.1.3)
|
||||
concurrent-ruby (1.1.4)
|
||||
crass (1.0.4)
|
||||
daemons (1.2.6)
|
||||
database_cleaner (1.7.0)
|
||||
|
@ -103,8 +95,7 @@ GEM
|
|||
discard (1.0.0)
|
||||
activerecord (>= 4.2, < 6)
|
||||
docile (1.3.1)
|
||||
effin_utf8 (1.0)
|
||||
erubi (1.7.1)
|
||||
erubi (1.8.0)
|
||||
eventmachine (1.2.7)
|
||||
excon (0.62.0)
|
||||
factory_bot (4.11.1)
|
||||
|
@ -159,17 +150,17 @@ GEM
|
|||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.7)
|
||||
hashdiff (0.3.7)
|
||||
hashdiff (0.3.8)
|
||||
hashie (3.6.0)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.1.1)
|
||||
i18n (1.4.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.1.0)
|
||||
jsonapi-renderer (0.2.0)
|
||||
jwt (2.1.0)
|
||||
launchy (2.4.3)
|
||||
addressable (~> 2.3)
|
||||
letter_opener (1.6.0)
|
||||
letter_opener (1.7.0)
|
||||
launchy (~> 2.2)
|
||||
lol_dba (2.1.5)
|
||||
actionpack (>= 3.0)
|
||||
|
@ -187,17 +178,17 @@ GEM
|
|||
mime-types (3.2.2)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2018.0812)
|
||||
mimemagic (0.3.2)
|
||||
mimemagic (0.3.3)
|
||||
mini_mime (1.0.1)
|
||||
mini_portile2 (2.3.0)
|
||||
mini_portile2 (2.4.0)
|
||||
minitest (5.11.3)
|
||||
multi_json (1.13.1)
|
||||
multipart-post (2.0.0)
|
||||
mutations (0.8.3)
|
||||
activesupport
|
||||
nio4r (2.3.1)
|
||||
nokogiri (1.8.5)
|
||||
mini_portile2 (~> 2.3.0)
|
||||
nokogiri (1.9.1)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.0.0)
|
||||
paperclip (6.1.0)
|
||||
|
@ -206,7 +197,7 @@ GEM
|
|||
mime-types
|
||||
mimemagic (~> 0.3.0)
|
||||
terrapin (~> 0.6.0)
|
||||
passenger (5.3.7)
|
||||
passenger (6.0.0)
|
||||
rack
|
||||
rake (>= 0.8.1)
|
||||
pg (1.1.3)
|
||||
|
@ -215,11 +206,10 @@ GEM
|
|||
pry (0.12.2)
|
||||
coderay (~> 1.1.0)
|
||||
method_source (~> 0.9.0)
|
||||
pry-rails (0.3.7)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (3.0.3)
|
||||
rabbitmq_http_api_client (1.9.1)
|
||||
effin_utf8 (~> 1.0.0)
|
||||
rabbitmq_http_api_client (1.11.0)
|
||||
faraday (~> 0.13.0)
|
||||
faraday_middleware (~> 0.12.0)
|
||||
hashie (~> 3.5)
|
||||
|
@ -230,18 +220,18 @@ GEM
|
|||
rack-cors (1.0.2)
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rails (5.2.1)
|
||||
actioncable (= 5.2.1)
|
||||
actionmailer (= 5.2.1)
|
||||
actionpack (= 5.2.1)
|
||||
actionview (= 5.2.1)
|
||||
activejob (= 5.2.1)
|
||||
activemodel (= 5.2.1)
|
||||
activerecord (= 5.2.1)
|
||||
activestorage (= 5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
rails (5.2.2)
|
||||
actioncable (= 5.2.2)
|
||||
actionmailer (= 5.2.2)
|
||||
actionpack (= 5.2.2)
|
||||
actionview (= 5.2.2)
|
||||
activejob (= 5.2.2)
|
||||
activemodel (= 5.2.2)
|
||||
activerecord (= 5.2.2)
|
||||
activestorage (= 5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
bundler (>= 1.3.0)
|
||||
railties (= 5.2.1)
|
||||
railties (= 5.2.2)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
|
@ -258,15 +248,14 @@ GEM
|
|||
rails_stdout_logging
|
||||
rails_serve_static_assets (0.0.5)
|
||||
rails_stdout_logging (0.0.5)
|
||||
railties (5.2.1)
|
||||
actionpack (= 5.2.1)
|
||||
activesupport (= 5.2.1)
|
||||
railties (5.2.2)
|
||||
actionpack (= 5.2.2)
|
||||
activesupport (= 5.2.2)
|
||||
method_source
|
||||
rake (>= 0.8.7)
|
||||
thor (>= 0.19.0, < 2.0)
|
||||
rake (12.3.1)
|
||||
redis (4.0.3)
|
||||
regexp_parser (1.3.0)
|
||||
rake (12.3.2)
|
||||
redis (4.1.0)
|
||||
representable (3.0.4)
|
||||
declarative (< 0.1.0)
|
||||
declarative-option (< 0.2.0)
|
||||
|
@ -277,7 +266,7 @@ GEM
|
|||
actionpack (>= 4.2.0, < 5.3)
|
||||
railties (>= 4.2.0, < 5.3)
|
||||
retriable (3.1.2)
|
||||
rollbar (2.18.0)
|
||||
rollbar (2.18.2)
|
||||
multi_json
|
||||
rspec (3.8.0)
|
||||
rspec-core (~> 3.8.0)
|
||||
|
@ -319,9 +308,9 @@ GEM
|
|||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.2)
|
||||
skylight (3.1.1)
|
||||
skylight-core (= 3.1.1)
|
||||
skylight-core (3.1.1)
|
||||
skylight (3.1.2)
|
||||
skylight-core (= 3.1.2)
|
||||
skylight-core (3.1.2)
|
||||
activesupport (>= 4.2.0)
|
||||
sprockets (3.7.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
|
@ -352,8 +341,6 @@ GEM
|
|||
websocket-driver (0.7.0)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zero_downtime_migrations (0.0.7)
|
||||
activerecord
|
||||
|
||||
|
@ -364,7 +351,6 @@ DEPENDENCIES
|
|||
active_model_serializers
|
||||
appsignal
|
||||
bunny
|
||||
capybara
|
||||
climate_control
|
||||
codecov
|
||||
database_cleaner
|
||||
|
|
|
@ -2,9 +2,9 @@ class SendNervesHubInfoJob < ApplicationJob
|
|||
queue_as :default
|
||||
|
||||
def perform(device_id:, serial_number:, tags:)
|
||||
device = Device.find(device_id)
|
||||
DeviceSerialNumber.transaction do
|
||||
DeviceSerialNumber.create!(device_id: device_id,
|
||||
serial_number: serial_number)
|
||||
device.update_attributes!(serial_number: serial_number)
|
||||
resp_data = NervesHub.create_or_update(serial_number, tags)
|
||||
certs = NervesHub.sign_device(resp_data.fetch(:identifier))
|
||||
Transport.current.amqp_send(certs.to_json, device_id, "nerves_hub")
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
require "net/http"
|
||||
require "openssl"
|
||||
require "base64"
|
||||
|
||||
class NervesHub
|
||||
class NervesHubHTTPError < StandardError; end
|
||||
# There is a lot of configuration available in this class to support:
|
||||
|
@ -68,12 +67,28 @@ class NervesHub
|
|||
raise NervesHubHTTPError, NERVES_HUB_ERROR % [code, body]
|
||||
end
|
||||
|
||||
APPLICATION = "application"
|
||||
CHANNEL = "channel"
|
||||
|
||||
def self.update_channel(serial_number, channel)
|
||||
dev = device(serial_number)
|
||||
return unless dev
|
||||
# ["application:prod", "channel:stable"]
|
||||
# Becomes: {"application"=>"prod", "channel"=>"stable"}
|
||||
# NEVER DUPLICATE TAG PREFIXES (thing before ":"). Must be unique!
|
||||
tag_map = dev.fetch(:tags).map { |x| x.split(":") }.to_h
|
||||
tag_map[CHANNEL] = channel
|
||||
next_tags = tag_map.to_a.map { |x| x.join(":") }
|
||||
update(serial_number, next_tags)
|
||||
end
|
||||
|
||||
# Checks if a deivce exists in NervesHub
|
||||
# if it does -> does a PUT request updating the tags.
|
||||
# if it does not -> does a POST request creating the device with given tags.
|
||||
def self.create_or_update(serial_number, tags)
|
||||
current_nerves_hub_devcice = device(serial_number)
|
||||
if current_nerves_hub_devcice
|
||||
# Hash | nil
|
||||
current_nerves_hub_device = device(serial_number)
|
||||
if current_nerves_hub_device
|
||||
update(serial_number, tags)
|
||||
else
|
||||
new_device(serial_number, tags)
|
||||
|
@ -161,7 +176,7 @@ class NervesHub
|
|||
|
||||
# HTTP connection.
|
||||
def self.conn
|
||||
active? && !@conn ? set_conn : @conn
|
||||
(active? && !@conn) ? set_conn : @conn
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -41,7 +41,11 @@ class ApplicationRecord < ActiveRecord::Base
|
|||
|
||||
def force_serialization
|
||||
serializer = ActiveModel::Serializer.serializer_for(self)
|
||||
return (serializer ? serializer.new(self) : self).as_json
|
||||
if serializer
|
||||
serializer.new(self).as_json
|
||||
else
|
||||
self.as_json
|
||||
end
|
||||
end
|
||||
|
||||
def broadcast_payload(label)
|
||||
|
|
|
@ -32,6 +32,7 @@ class Device < ApplicationRecord
|
|||
has_many :diagnostic_dumps, dependent: :destroy
|
||||
has_many :fragments, dependent: :destroy
|
||||
has_one :fbos_config, dependent: :destroy
|
||||
has_one :device_serial_number, dependent: :destroy
|
||||
has_many :in_use_tools
|
||||
has_many :in_use_points
|
||||
has_many :users
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
class DeviceSerialNumber < ApplicationRecord
|
||||
belongs_to :device
|
||||
# DO NOT USE THIS TABLE. IT IS DEPRECATED. DESTROY FEB 2019.
|
||||
end
|
||||
|
|
|
@ -1,4 +1,27 @@
|
|||
# An API backup of user options for Farmbot OS.
|
||||
class FbosConfig < ApplicationRecord
|
||||
class MissingSerial < StandardError; end
|
||||
|
||||
belongs_to :device
|
||||
after_save :maybe_sync_nerves, on: [:create, :update]
|
||||
|
||||
NERVES_FIELD = "update_channel"
|
||||
|
||||
def push_changes_to_nerves_hub(serial_number, channel)
|
||||
NervesHub.update_channel(serial_number, channel)
|
||||
end
|
||||
|
||||
def sync_nerves
|
||||
serial = device.serial_number
|
||||
return unless serial
|
||||
self.delay.push_changes_to_nerves_hub(serial, update_channel)
|
||||
end
|
||||
|
||||
def nerves_info_changed?
|
||||
the_changes.keys.include?(NERVES_FIELD)
|
||||
end
|
||||
|
||||
def maybe_sync_nerves
|
||||
sync_nerves if nerves_info_changed?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,7 +13,7 @@ class Image < ApplicationRecord
|
|||
end
|
||||
|
||||
PROTO = ENV["FORCE_SSL"] ? "https:" : "http:"
|
||||
PLACEHOLDER = "/app-resources/img/placeholder.png\?text=Processing..."
|
||||
PLACEHOLDER = "/placeholder_farmbot.jpg\?text=Processing..."
|
||||
CONFIG = {
|
||||
default_url: "#{PROTO}#{$API_URL}#{PLACEHOLDER}",
|
||||
styles: { x1280: "1280x1280>",
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class DeviceSerializer < ApplicationSerializer
|
||||
attributes :name, :timezone, :last_saw_api, :last_saw_mq, :tz_offset_hrs,
|
||||
:fbos_version, :throttled_until, :throttled_at, :mounted_tool_id
|
||||
attributes :fbos_version, :last_saw_api, :last_saw_mq,
|
||||
:mounted_tool_id, :name, :serial_number,
|
||||
:throttled_at, :throttled_until, :timezone,
|
||||
:tz_offset_hrs
|
||||
end
|
||||
|
|
|
@ -9,6 +9,6 @@ class FarmEventSerializer < ApplicationSerializer
|
|||
|
||||
def body
|
||||
f = object.fragment
|
||||
f ? f.serialize.fetch(:body) : []
|
||||
f ? f.serialize.fetch(:body, []) : []
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class AddSerialNumberToDevice < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :devices, :serial_number, :string, limit: 32
|
||||
end
|
||||
end
|
|
@ -0,0 +1,8 @@
|
|||
class DeprecateDeviceSerialNumberTable < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
DeviceSerialNumber.preload(:devices) do |x|
|
||||
x.device
|
||||
.update_attributes!(serial_number: x.serial_number)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -51,12 +51,13 @@ if Rails.env == "development"
|
|||
end
|
||||
|
||||
PLANT_COUNT.times do
|
||||
veggie = Faker::Food.vegetables
|
||||
Plant.create(device: u.device,
|
||||
x: rand(40...970),
|
||||
y: rand(40...470),
|
||||
radius: rand(10...50),
|
||||
name: Faker::Food.vegetables,
|
||||
openfarm_slug: ["tomato", "carrot", "radish", "garlic"].sample)
|
||||
name: veggie,
|
||||
openfarm_slug: veggie.downcase.gsub(" ", "-"))
|
||||
end
|
||||
|
||||
Device.all.map { |device| SavedGardens::Snapshot.run!(device: device) }
|
||||
|
|
|
@ -216,7 +216,8 @@ CREATE TABLE public.devices (
|
|||
throttled_at timestamp without time zone,
|
||||
mounted_tool_id bigint,
|
||||
created_at timestamp without time zone,
|
||||
updated_at timestamp without time zone
|
||||
updated_at timestamp without time zone,
|
||||
serial_number character varying(32)
|
||||
);
|
||||
|
||||
|
||||
|
@ -2885,6 +2886,8 @@ INSERT INTO "schema_migrations" (version) VALUES
|
|||
('20181112010427'),
|
||||
('20181126175951'),
|
||||
('20181204005038'),
|
||||
('20181208035706');
|
||||
('20181208035706'),
|
||||
('20190103211708'),
|
||||
('20190103213956');
|
||||
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ services:
|
|||
- ./docker_volumes/rabbit:/farmbot
|
||||
webpack: # ====================
|
||||
<<: *base_config
|
||||
image: node:8.12.0
|
||||
image: node:10.15.0
|
||||
working_dir: /farmbot
|
||||
command: ./node_modules/.bin/webpack-dev-server --config config/webpack.config.js
|
||||
volumes:
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
"@types/react": "^16.7.13",
|
||||
"@types/react-color": "2.13.6",
|
||||
"@types/react-dom": "^16.0.11",
|
||||
"@types/react-joyride": "^2.0.1",
|
||||
"@types/react-joyride": "2.0.1",
|
||||
"@types/react-redux": "^6.0.10",
|
||||
"axios": "^0.18.0",
|
||||
"boxed_value": "^1.0.0",
|
||||
|
@ -75,7 +75,7 @@
|
|||
"react-addons-test-utils": "^15.6.2",
|
||||
"react-color": "2.14.1",
|
||||
"react-dom": "^16.6.3",
|
||||
"react-joyride": "^2.0.0-15",
|
||||
"react-joyride": "2.0.0-15",
|
||||
"react-redux": "^5.1.1",
|
||||
"react-test-renderer": "^16.6.3",
|
||||
"react-transition-group": "2.5.0",
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB |
|
@ -58,7 +58,7 @@ describe Api::DeviceCertsController do
|
|||
end
|
||||
expect(response.status).to eq(200)
|
||||
expect(json).to eq({})
|
||||
expect(DeviceSerialNumber.count).to be > old_count
|
||||
expect(DeviceSerialNumber.count).to eq(old_count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -74,7 +74,7 @@ describe Api::ImagesController do
|
|||
expect(Image.count).to be > before_count
|
||||
expect(json[:device_id]).to eq(user.device.id)
|
||||
expect(json.key?(:attachment_processed_at)).to be_truthy
|
||||
expect(json[:attachment_url]).to include("placeholder.png")
|
||||
expect(json[:attachment_url]).to include("placeholder_farmbot.jpg")
|
||||
expect(json.dig :meta, :x).to eq(1)
|
||||
expect(json.dig :meta, :y).to eq(nil)
|
||||
expect(json.dig :meta, :z).to eq(3)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
FactoryBot.define do
|
||||
factory :device_serial_number do
|
||||
device { nil }
|
||||
serial_number { "" }
|
||||
serial_number { raise "Stop using this model" }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,5 +2,6 @@ FactoryBot.define do
|
|||
factory :device do
|
||||
name { Faker::Food.vegetables }
|
||||
timezone { Device::TIMEZONES.sample }
|
||||
serial_number { SecureRandom.hex(16)}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,14 +1,6 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe NervesHub do
|
||||
class StubResp
|
||||
attr_accessor :code, :body
|
||||
|
||||
def initialize(code, body)
|
||||
@code, @body = code, body
|
||||
end
|
||||
end
|
||||
|
||||
def stub_connection
|
||||
double(SecureRandom.hex.first(6), :ca_file= => nil,
|
||||
:cert_store => nil,
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe FbosConfig do
|
||||
let(:device) { FactoryBot.create(:device) }
|
||||
let(:config) { FbosConfig.create!(device: device) }
|
||||
|
||||
it 'triggers callbacks' do
|
||||
conn = double("Create a cert", :ca_file= => nil,
|
||||
:cert_store => nil,
|
||||
:cert_store= => nil,
|
||||
:use_ssl => nil,
|
||||
:use_ssl= => nil,
|
||||
:cert= => nil,
|
||||
:key= => nil)
|
||||
NervesHub.set_conn(conn)
|
||||
url = "/orgs/farmbot/devices/#{device.serial_number}"
|
||||
resp = StubResp.new("200", { "data" => { "tags": [] } }.to_json)
|
||||
resp2 = StubResp.new("201", { "data" => { "tags": [] } }.to_json)
|
||||
params = [ url,
|
||||
{"tags": ["channel:beta"]}.to_json,
|
||||
{"Content-Type"=>"application/json"} ]
|
||||
expect(NervesHub.conn).to(receive(:get).with(url).and_return(resp))
|
||||
expect(NervesHub.conn).to(receive(:put).with(*params).and_return(resp2))
|
||||
|
||||
run_jobs_now do
|
||||
config.update_attributes!(update_channel: "beta")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -119,6 +119,14 @@ def const_reassign(target, const, value)
|
|||
target.const_set(const, value)
|
||||
end
|
||||
|
||||
class StubResp
|
||||
attr_accessor :code, :body
|
||||
|
||||
def initialize(code, body)
|
||||
@code, @body = code, body
|
||||
end
|
||||
end
|
||||
|
||||
class NiceResponse
|
||||
attr_reader :r, :body
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { Coordinate } from "farmbot";
|
||||
import { VariableNameSet } from "../resources/interfaces";
|
||||
|
||||
export const fakeVariableNameSet = (label = "parent"): VariableNameSet => {
|
||||
const data_value: Coordinate = {
|
||||
kind: "coordinate", args: { x: 0, y: 0, z: 0 }
|
||||
};
|
||||
return {
|
||||
[label]: {
|
||||
celeryNode: {
|
||||
kind: "variable_declaration",
|
||||
args: { label, data_value }
|
||||
},
|
||||
dropdown: { label: "", value: "" },
|
||||
vector: { x: 0, y: 0, z: 0 },
|
||||
}
|
||||
};
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
jest.mock("../../config_storage/actions", () => {
|
||||
jest.mock("../../../config_storage/actions", () => {
|
||||
return { setWebAppConfigValue: jest.fn() };
|
||||
});
|
||||
|
||||
|
@ -8,7 +8,7 @@ import * as React from "react";
|
|||
import { range } from "lodash";
|
||||
import {
|
||||
setWebAppConfigValue
|
||||
} from "../../config_storage/actions";
|
||||
} from "../../../config_storage/actions";
|
||||
import { warning } from "farmbot-toastr";
|
||||
|
||||
describe("<DevMode/>", () => {
|
|
@ -1,49 +1,48 @@
|
|||
jest.mock("../../config_storage/actions", () => ({
|
||||
jest.mock("../../../config_storage/actions", () => ({
|
||||
setWebAppConfigValue: jest.fn()
|
||||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import {
|
||||
DevWidget, FUTURE_FE_FEATURES, FBOS_VERSION_OVERRIDE,
|
||||
DevWidgetFERow, DevWidgetFBOSRow
|
||||
} from "../dev_widget";
|
||||
import { setWebAppConfigValue } from "../../config_storage/actions";
|
||||
import { DevWidget, DevWidgetFERow, DevWidgetFBOSRow } from "../dev_widget";
|
||||
import { DevSettings } from "../dev_support";
|
||||
import { setWebAppConfigValue } from "../../../config_storage/actions";
|
||||
|
||||
describe("<DevWidget />", () => {
|
||||
const fakeProps = () => ({ dispatch: jest.fn() });
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<DevWidget {...fakeProps()} />);
|
||||
localStorage[FUTURE_FE_FEATURES] = "true";
|
||||
localStorage[FBOS_VERSION_OVERRIDE] = "1.0.0";
|
||||
localStorage[DevSettings.FUTURE_FE_FEATURES] = "true";
|
||||
localStorage[DevSettings.FBOS_VERSION_OVERRIDE] = "1.0.0";
|
||||
wrapper.find("button").first().simulate("click");
|
||||
expect(setWebAppConfigValue).toHaveBeenCalledWith("show_dev_menu", false);
|
||||
expect(localStorage[FUTURE_FE_FEATURES]).toEqual(undefined);
|
||||
expect(localStorage[FBOS_VERSION_OVERRIDE]).toEqual(undefined);
|
||||
expect(localStorage[DevSettings.FUTURE_FE_FEATURES]).toEqual(undefined);
|
||||
expect(localStorage[DevSettings.FBOS_VERSION_OVERRIDE]).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("changes override value", () => {
|
||||
const wrapper = shallow(<DevWidgetFBOSRow />);
|
||||
wrapper.find("BlurableInput").simulate("commit",
|
||||
{ currentTarget: { value: "1.2.3" } });
|
||||
expect(localStorage[FBOS_VERSION_OVERRIDE]).toEqual("1.2.3");
|
||||
expect(localStorage[DevSettings.FBOS_VERSION_OVERRIDE]).toEqual("1.2.3");
|
||||
wrapper.find(".fa-times").simulate("click");
|
||||
expect(localStorage[FBOS_VERSION_OVERRIDE]).toEqual(undefined);
|
||||
expect(localStorage[DevSettings.FBOS_VERSION_OVERRIDE]).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("increases override value", () => {
|
||||
const wrapper = mount(<DevWidgetFBOSRow />);
|
||||
wrapper.find(".fa-angle-double-up").simulate("click");
|
||||
expect(localStorage[FBOS_VERSION_OVERRIDE]).toEqual("1000.0.0");
|
||||
expect(localStorage[DevSettings.FBOS_VERSION_OVERRIDE])
|
||||
.toEqual("1000.0.0");
|
||||
wrapper.find(".fa-times").simulate("click");
|
||||
expect(localStorage[FBOS_VERSION_OVERRIDE]).toEqual(undefined);
|
||||
expect(localStorage[DevSettings.FBOS_VERSION_OVERRIDE]).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("toggles unstable FE features", () => {
|
||||
localStorage[FUTURE_FE_FEATURES] = "true";
|
||||
localStorage[DevSettings.FUTURE_FE_FEATURES] = "true";
|
||||
const wrapper = mount(<DevWidgetFERow />);
|
||||
wrapper.find("button").simulate("click");
|
||||
expect(localStorage[FUTURE_FE_FEATURES]).toEqual(undefined);
|
||||
expect(localStorage[DevSettings.FUTURE_FE_FEATURES]).toEqual(undefined);
|
||||
});
|
||||
});
|
|
@ -1,7 +1,8 @@
|
|||
import * as React from "react";
|
||||
import { warning } from "farmbot-toastr";
|
||||
import { setWebAppConfigValue } from "../config_storage/actions";
|
||||
import { setWebAppConfigValue } from "../../config_storage/actions";
|
||||
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
|
||||
import { DevSettings } from "./dev_support";
|
||||
|
||||
interface S { count: number; }
|
||||
interface P { dispatch: Function; }
|
||||
|
@ -11,7 +12,7 @@ const clicksLeft =
|
|||
const key = "show_dev_menu" as BooleanConfigKey;
|
||||
|
||||
const activateDevMode = (dispatch: Function) => {
|
||||
localStorage.setItem("IM_A_DEVELOPER", "1000.0.0");
|
||||
DevSettings.setMaxFbosVersionOverride();
|
||||
dispatch(setWebAppConfigValue(key, true));
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
export namespace DevSettings {
|
||||
|
||||
export const FUTURE_FE_FEATURES = "FUTURE_FEATURES";
|
||||
/** Unstable FE features enabled? */
|
||||
export const futureFeaturesEnabled = () =>
|
||||
!!localStorage.getItem(FUTURE_FE_FEATURES);
|
||||
/** Show unstable FE features for development purposes. */
|
||||
export const enableFutureFeatures = () =>
|
||||
localStorage.setItem(FUTURE_FE_FEATURES, "true");
|
||||
export const disableFutureFeatures = () =>
|
||||
localStorage.removeItem(FUTURE_FE_FEATURES);
|
||||
|
||||
export const FBOS_VERSION_OVERRIDE = "IM_A_DEVELOPER";
|
||||
export const MAX_FBOS_VERSION_OVERRIDE = "1000.0.0";
|
||||
/**
|
||||
* Escape hatch for platform developers doing offline development.
|
||||
* Use `setFbosVersionOverride` or `setMaxFbosVersionOverride`
|
||||
* to adjust override level.
|
||||
*/
|
||||
export const overriddenFbosVersion = () =>
|
||||
localStorage.getItem(FBOS_VERSION_OVERRIDE);
|
||||
export const resetFbosVersionOverride = () =>
|
||||
localStorage.removeItem(FBOS_VERSION_OVERRIDE);
|
||||
export const setFbosVersionOverride = (override: string) =>
|
||||
localStorage.setItem(FBOS_VERSION_OVERRIDE, override);
|
||||
export const setMaxFbosVersionOverride = () =>
|
||||
localStorage.setItem(FBOS_VERSION_OVERRIDE, MAX_FBOS_VERSION_OVERRIDE);
|
||||
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
Widget, WidgetHeader, WidgetBody, Row, Col, BlurableInput
|
||||
} from "../../ui";
|
||||
import { ToggleButton } from "../../controls/toggle_button";
|
||||
import { setWebAppConfigValue } from "../../config_storage/actions";
|
||||
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
|
||||
import { DevSettings } from "./dev_support";
|
||||
|
||||
export const DevWidgetFERow = () =>
|
||||
<Row>
|
||||
<Col xs={8}>
|
||||
<label>
|
||||
{"Enable unstable FE features"}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={4}>
|
||||
<ToggleButton
|
||||
toggleValue={DevSettings.futureFeaturesEnabled()}
|
||||
toggleAction={DevSettings.futureFeaturesEnabled()
|
||||
? DevSettings.disableFutureFeatures
|
||||
: DevSettings.enableFutureFeatures} />
|
||||
</Col>
|
||||
</Row>;
|
||||
|
||||
export const DevWidgetFBOSRow = () => {
|
||||
return <Row>
|
||||
<Col xs={6}>
|
||||
<label>
|
||||
{"Change FBOS version"}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={1}>
|
||||
<button className="fb-button red fa fa-times"
|
||||
onClick={DevSettings.resetFbosVersionOverride} />
|
||||
</Col>
|
||||
<Col xs={1}>
|
||||
<button className="fb-button green fa fa-angle-double-up"
|
||||
onClick={DevSettings.setMaxFbosVersionOverride} />
|
||||
</Col>
|
||||
<Col xs={4}>
|
||||
<BlurableInput type="text"
|
||||
value={DevSettings.overriddenFbosVersion() || ""}
|
||||
onCommit={e =>
|
||||
DevSettings.setFbosVersionOverride(e.currentTarget.value)} />
|
||||
</Col>
|
||||
</Row>;
|
||||
};
|
||||
|
||||
export const DevWidget = ({ dispatch }: { dispatch: Function }) =>
|
||||
<Widget>
|
||||
<WidgetHeader title={"Dev options"}>
|
||||
<button className="fb-button red"
|
||||
onClick={() => {
|
||||
DevSettings.disableFutureFeatures();
|
||||
DevSettings.resetFbosVersionOverride();
|
||||
dispatch(setWebAppConfigValue(
|
||||
"show_dev_menu" as BooleanConfigKey, false));
|
||||
}}>
|
||||
{"Reset all and remove this widget"}
|
||||
</button>
|
||||
</WidgetHeader>
|
||||
<WidgetBody>
|
||||
<DevWidgetFERow />
|
||||
<DevWidgetFBOSRow />
|
||||
</WidgetBody>
|
||||
</Widget>;
|
|
@ -1,85 +0,0 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
Widget, WidgetHeader, WidgetBody, Row, Col, BlurableInput
|
||||
} from "../ui";
|
||||
import { ToggleButton } from "../controls/toggle_button";
|
||||
import { setWebAppConfigValue } from "../config_storage/actions";
|
||||
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
|
||||
|
||||
export const FUTURE_FE_FEATURES = "FUTURE_FEATURES";
|
||||
/** Unstable FE features enabled? */
|
||||
export const futureFeaturesEnabled = () =>
|
||||
!!localStorage.getItem(FUTURE_FE_FEATURES);
|
||||
/** Show unstable FE features for development purposes. */
|
||||
export const enableFutureFeatures = () =>
|
||||
localStorage.setItem(FUTURE_FE_FEATURES, "true");
|
||||
const disableFutureFeatures = () =>
|
||||
localStorage.removeItem(FUTURE_FE_FEATURES);
|
||||
|
||||
export const FBOS_VERSION_OVERRIDE = "IM_A_DEVELOPER";
|
||||
/** Escape hatch for platform developers doing offline development. */
|
||||
const overriddenFbosVersion = () =>
|
||||
localStorage.getItem(FBOS_VERSION_OVERRIDE);
|
||||
const resetFbosVersionOverride = () =>
|
||||
localStorage.removeItem(FBOS_VERSION_OVERRIDE);
|
||||
const setFbosVersionOverride = (override: string) =>
|
||||
localStorage.setItem(FBOS_VERSION_OVERRIDE, override);
|
||||
|
||||
export const DevWidgetFERow = () =>
|
||||
<Row>
|
||||
<Col xs={8}>
|
||||
<label>
|
||||
{"Enable unstable FE features"}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={4}>
|
||||
<ToggleButton
|
||||
toggleValue={futureFeaturesEnabled()}
|
||||
toggleAction={futureFeaturesEnabled()
|
||||
? disableFutureFeatures
|
||||
: enableFutureFeatures} />
|
||||
</Col>
|
||||
</Row>;
|
||||
|
||||
export const DevWidgetFBOSRow = () => {
|
||||
return <Row>
|
||||
<Col xs={6}>
|
||||
<label>
|
||||
{"Change FBOS version"}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={1}>
|
||||
<button className="fb-button red fa fa-times"
|
||||
onClick={resetFbosVersionOverride} />
|
||||
</Col>
|
||||
<Col xs={1}>
|
||||
<button className="fb-button green fa fa-angle-double-up"
|
||||
onClick={() => setFbosVersionOverride("1000.0.0")} />
|
||||
</Col>
|
||||
<Col xs={4}>
|
||||
<BlurableInput type="text"
|
||||
value={overriddenFbosVersion() || ""}
|
||||
onCommit={e =>
|
||||
setFbosVersionOverride(e.currentTarget.value)} />
|
||||
</Col>
|
||||
</Row>;
|
||||
};
|
||||
|
||||
export const DevWidget = ({ dispatch }: { dispatch: Function }) =>
|
||||
<Widget>
|
||||
<WidgetHeader title={"Dev options"}>
|
||||
<button className="fb-button red"
|
||||
onClick={() => {
|
||||
disableFutureFeatures();
|
||||
resetFbosVersionOverride();
|
||||
dispatch(setWebAppConfigValue(
|
||||
"show_dev_menu" as BooleanConfigKey, false));
|
||||
}}>
|
||||
{"Reset all and remove this widget"}
|
||||
</button>
|
||||
</WidgetHeader>
|
||||
<WidgetBody>
|
||||
<DevWidgetFERow />
|
||||
<DevWidgetFBOSRow />
|
||||
</WidgetBody>
|
||||
</Widget>;
|
|
@ -13,9 +13,9 @@ import { success } from "farmbot-toastr/dist";
|
|||
import { LabsFeatures } from "./labs/labs_features";
|
||||
import { ExportAccountPanel } from "./components/export_account_panel";
|
||||
import { requestAccountExport } from "./request_account_export";
|
||||
import { DevWidget } from "./dev_widget";
|
||||
import { DevWidget } from "./dev/dev_widget";
|
||||
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
|
||||
import { DevMode } from "./dev_mode";
|
||||
import { DevMode } from "./dev/dev_mode";
|
||||
|
||||
const KEYS: (keyof User)[] = ["id", "name", "email", "created_at", "updated_at"];
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ jest.mock("../../api/api", () => ({
|
|||
|
||||
jest.mock("../../devices/actions", () => ({
|
||||
fetchReleases: jest.fn(),
|
||||
fetchLatestGHBetaRelease: jest.fn(),
|
||||
fetchMinOsFeatureData: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -29,7 +30,7 @@ import { didLogin } from "../actions";
|
|||
import { Actions } from "../../constants";
|
||||
import { API } from "../../api/api";
|
||||
import { AuthState } from "../interfaces";
|
||||
import { fetchReleases } from "../../devices/actions";
|
||||
import { fetchReleases, fetchLatestGHBetaRelease } from "../../devices/actions";
|
||||
|
||||
const mockToken = (): AuthState => ({
|
||||
token: {
|
||||
|
@ -56,7 +57,7 @@ describe("didLogin()", () => {
|
|||
mockAuth.token.unencoded.beta_os_update_server = "beta_os_update_server";
|
||||
didLogin(mockAuth, dispatch);
|
||||
expect(fetchReleases).toHaveBeenCalledWith("os_update_server");
|
||||
expect(fetchReleases).toHaveBeenCalledWith("beta_os_update_server",
|
||||
{ beta: true });
|
||||
expect(fetchLatestGHBetaRelease)
|
||||
.toHaveBeenCalledWith("beta_os_update_server");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import axios from "axios";
|
||||
import {
|
||||
fetchReleases, fetchMinOsFeatureData, FEATURE_MIN_VERSIONS_URL
|
||||
fetchReleases, fetchMinOsFeatureData, FEATURE_MIN_VERSIONS_URL,
|
||||
fetchLatestGHBetaRelease
|
||||
} from "../devices/actions";
|
||||
import { AuthState } from "./interfaces";
|
||||
import { ReduxAction } from "../redux/interfaces";
|
||||
|
@ -20,7 +21,7 @@ export function didLogin(authState: AuthState, dispatch: Function) {
|
|||
const { os_update_server, beta_os_update_server } = authState.token.unencoded;
|
||||
dispatch(fetchReleases(os_update_server));
|
||||
beta_os_update_server && beta_os_update_server != "NOT_SET" &&
|
||||
dispatch(fetchReleases(beta_os_update_server, { beta: true }));
|
||||
dispatch(fetchLatestGHBetaRelease(beta_os_update_server));
|
||||
dispatch(getFirstPartyFarmwareList());
|
||||
dispatch(fetchMinOsFeatureData(FEATURE_MIN_VERSIONS_URL));
|
||||
dispatch(setToken(authState));
|
||||
|
|
|
@ -2,7 +2,7 @@ import * as React from "react";
|
|||
import { mount } from "enzyme";
|
||||
import { BooleanSetting } from "../../../session_keys";
|
||||
import { moveWidgetSetting, MoveWidgetSettingsMenu } from "../settings_menu";
|
||||
import { enableFutureFeatures } from "../../../account/dev_widget";
|
||||
import { DevSettings } from "../../../account/dev/dev_support";
|
||||
|
||||
describe("moveWidgetSetting()", () => {
|
||||
it("renders setting", () => {
|
||||
|
@ -24,7 +24,7 @@ describe("<MoveWidgetSettingsMenu />", () => {
|
|||
it("displays motor plot toggle", () => {
|
||||
const noToggle = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
|
||||
expect(noToggle.text()).not.toContain("Motor position plot");
|
||||
enableFutureFeatures();
|
||||
DevSettings.enableFutureFeatures();
|
||||
const wrapper = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
|
||||
expect(wrapper.text()).toContain("Motor position plot");
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@ import { BooleanSetting } from "../../session_keys";
|
|||
import { ToggleButton } from "../toggle_button";
|
||||
import { ToggleWebAppBool, GetWebAppBool } from "./interfaces";
|
||||
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
|
||||
import { futureFeaturesEnabled } from "../../account/dev_widget";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
export const moveWidgetSetting = (toggle: ToggleWebAppBool, getValue: GetWebAppBool) =>
|
||||
({ label, setting }: { label: string, setting: BooleanConfigKey }) =>
|
||||
|
@ -44,7 +44,7 @@ export const MoveWidgetSettingsMenu = ({ toggle, getValue }: {
|
|||
label={t("perform homing (find home)")}
|
||||
setting={BooleanSetting.home_button_homing} />
|
||||
|
||||
{futureFeaturesEnabled() &&
|
||||
{DevSettings.futureFeaturesEnabled() &&
|
||||
<div>
|
||||
<p>{t("Motor position plot")}</p>
|
||||
<Setting
|
||||
|
|
|
@ -410,6 +410,32 @@ describe("fetchReleases()", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("fetchLatestGHBetaRelease()", () => {
|
||||
it("fetches latest beta OS release version", async () => {
|
||||
mockGetRelease = Promise.resolve({ data: [{ tag_name: "v1.0.0-beta" }] });
|
||||
const dispatch = jest.fn();
|
||||
await actions.fetchLatestGHBetaRelease("url/001")(dispatch);
|
||||
expect(axios.get).toHaveBeenCalledWith("url");
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { version: "1.0.0-beta", commit: undefined },
|
||||
type: Actions.FETCH_BETA_OS_UPDATE_INFO_OK
|
||||
});
|
||||
});
|
||||
|
||||
it("fails to fetches latest beta OS release version", async () => {
|
||||
mockGetRelease = Promise.reject("error");
|
||||
const dispatch = jest.fn();
|
||||
await actions.fetchLatestGHBetaRelease("url/001")(dispatch);
|
||||
await expect(axios.get).toHaveBeenCalledWith("url");
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: "error",
|
||||
type: Actions.FETCH_BETA_OS_UPDATE_INFO_ERROR
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchMinOsFeatureData()", () => {
|
||||
afterEach(() =>
|
||||
jest.restoreAllMocks());
|
||||
|
|
|
@ -21,6 +21,7 @@ jest.mock("../../resources/getters", () => ({
|
|||
import { mapStateToProps } from "../state_to_props";
|
||||
import { fakeState } from "../../__test_support__/fake_state";
|
||||
import { TaggedFbosConfig, TaggedImage } from "farmbot";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
describe("mapStateToProps()", () => {
|
||||
it("uses the API as the source of FBOS settings", () => {
|
||||
|
@ -60,7 +61,8 @@ describe("mapStateToProps()", () => {
|
|||
it("returns API Farmware env vars", () => {
|
||||
const state = fakeState();
|
||||
state.bot.hardware.user_env = {};
|
||||
state.bot.hardware.informational_settings.controller_version = "1000.0.0";
|
||||
state.bot.hardware.informational_settings.controller_version =
|
||||
DevSettings.MAX_FBOS_VERSION_OVERRIDE;
|
||||
const props = mapStateToProps(state);
|
||||
expect(props.env).toEqual({
|
||||
fake_FarmwareEnv_key: "fake_FarmwareEnv_value"
|
||||
|
|
|
@ -151,6 +151,37 @@ export function requestDiagnostic() {
|
|||
return getDevice().dumpInfo().then(commandOK(noun), commandErr(noun));
|
||||
}
|
||||
|
||||
const tagNameToVersionString = (tagName: string): string =>
|
||||
tagName.toLowerCase().replace("v", "");
|
||||
|
||||
/**
|
||||
* Fetch FarmBot OS beta release data.
|
||||
* Ignores a specific release provided by the url (i.e., `releases/1234`)
|
||||
* in favor of the latest `-beta` release.
|
||||
*/
|
||||
export const fetchLatestGHBetaRelease =
|
||||
(url: string) =>
|
||||
(dispatch: Function) => {
|
||||
const urlArray = url.split("/");
|
||||
const releasesURL = urlArray.splice(0, urlArray.length - 1).join("/");
|
||||
axios
|
||||
.get<GithubRelease[]>(releasesURL)
|
||||
.then(resp => {
|
||||
const latestBeta = resp.data
|
||||
.filter(x => x.tag_name.includes("beta"))[0];
|
||||
const { tag_name, target_commitish } = latestBeta;
|
||||
const version = tagNameToVersionString(tag_name);
|
||||
dispatch({
|
||||
type: Actions.FETCH_BETA_OS_UPDATE_INFO_OK,
|
||||
payload: { version, commit: target_commitish }
|
||||
});
|
||||
})
|
||||
.catch(ferror => dispatch({
|
||||
type: "FETCH_BETA_OS_UPDATE_INFO_ERROR",
|
||||
payload: ferror
|
||||
}));
|
||||
};
|
||||
|
||||
/** Fetch FarmBot OS release data. */
|
||||
export const fetchReleases =
|
||||
(url: string, options = { beta: false }) =>
|
||||
|
@ -159,7 +190,7 @@ export const fetchReleases =
|
|||
.get<GithubRelease>(url)
|
||||
.then(resp => {
|
||||
const { tag_name, target_commitish } = resp.data;
|
||||
const version = tag_name.toLowerCase().replace("v", "");
|
||||
const version = tagNameToVersionString(tag_name);
|
||||
dispatch({
|
||||
type: options.beta
|
||||
? Actions.FETCH_BETA_OS_UPDATE_INFO_OK
|
||||
|
|
|
@ -11,6 +11,7 @@ import { mount } from "enzyme";
|
|||
import { bot } from "../../../../__test_support__/fake_state/bot";
|
||||
import { OsUpdateButton } from "../os_update_button";
|
||||
import { OsUpdateButtonProps } from "../interfaces";
|
||||
import { ShouldDisplay } from "../../../interfaces";
|
||||
|
||||
describe("<OsUpdateButton/>", () => {
|
||||
beforeEach(() => {
|
||||
|
@ -23,6 +24,7 @@ describe("<OsUpdateButton/>", () => {
|
|||
sourceFbosConfig: x =>
|
||||
({ value: bot.hardware.configuration[x], consistent: true }),
|
||||
botOnline: true,
|
||||
shouldDisplay: () => false,
|
||||
});
|
||||
|
||||
interface TestProps {
|
||||
|
@ -34,6 +36,8 @@ describe("<OsUpdateButton/>", () => {
|
|||
betaOptIn: boolean | undefined;
|
||||
onBeta: boolean | undefined;
|
||||
update_available?: boolean | undefined;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
update_channel: string;
|
||||
}
|
||||
|
||||
const defaultTestProps = (): TestProps => ({
|
||||
|
@ -44,6 +48,8 @@ describe("<OsUpdateButton/>", () => {
|
|||
availableBetaCommit: undefined,
|
||||
betaOptIn: false,
|
||||
onBeta: false,
|
||||
shouldDisplay: () => false,
|
||||
update_channel: "stable",
|
||||
});
|
||||
|
||||
interface Results {
|
||||
|
@ -90,7 +96,8 @@ describe("<OsUpdateButton/>", () => {
|
|||
expected: Results) => {
|
||||
const {
|
||||
installedVersion, installedCommit, onBeta, update_available,
|
||||
availableVersion, availableBetaVersion, availableBetaCommit, betaOptIn
|
||||
availableVersion, availableBetaVersion, availableBetaCommit, betaOptIn,
|
||||
shouldDisplay, update_channel,
|
||||
} = testProps;
|
||||
bot.hardware.informational_settings.controller_version = installedVersion;
|
||||
bot.hardware.informational_settings.commit = installedCommit;
|
||||
|
@ -101,8 +108,12 @@ describe("<OsUpdateButton/>", () => {
|
|||
bot.currentBetaOSVersion = availableBetaVersion;
|
||||
bot.currentBetaOSCommit = availableBetaCommit;
|
||||
bot.hardware.configuration.beta_opt_in = betaOptIn;
|
||||
// tslint:disable-next-line:no-any
|
||||
(bot.hardware.configuration as any).update_channel = update_channel;
|
||||
|
||||
const buttons = mount(<OsUpdateButton {...fakeProps()} />);
|
||||
const p = fakeProps();
|
||||
p.shouldDisplay = shouldDisplay;
|
||||
const buttons = mount(<OsUpdateButton {...p} />);
|
||||
const osUpdateButton = buttons.find("button").first();
|
||||
expect(osUpdateButton.text()).toBe(expected.text);
|
||||
expect(osUpdateButton.props().title).toBe(expected.title);
|
||||
|
@ -253,6 +264,36 @@ describe("<OsUpdateButton/>", () => {
|
|||
testButtonState(testProps, expectedResults);
|
||||
});
|
||||
|
||||
it("uses update_channel value", () => {
|
||||
const testProps = defaultTestProps();
|
||||
testProps.installedVersion = "3.1.6";
|
||||
testProps.shouldDisplay = () => true;
|
||||
testProps.update_channel = "stable";
|
||||
testProps.availableBetaVersion = "3.1.7-beta";
|
||||
const expectedResults = upToDate("3.1.6");
|
||||
testButtonState(testProps, expectedResults);
|
||||
});
|
||||
|
||||
it("uses update_channel value: beta", () => {
|
||||
const testProps = defaultTestProps();
|
||||
testProps.installedVersion = "3.1.6";
|
||||
testProps.shouldDisplay = () => true;
|
||||
testProps.update_channel = "beta";
|
||||
testProps.availableBetaVersion = "3.1.7-beta";
|
||||
const expectedResults = updateNeeded("3.1.7-beta");
|
||||
testButtonState(testProps, expectedResults);
|
||||
});
|
||||
|
||||
it("doesn't use update_channel value", () => {
|
||||
const testProps = defaultTestProps();
|
||||
testProps.installedVersion = "3.1.6";
|
||||
testProps.shouldDisplay = () => false;
|
||||
testProps.update_channel = "beta";
|
||||
testProps.availableBetaVersion = "3.1.7-beta";
|
||||
const expectedResults = upToDate("3.1.6");
|
||||
testButtonState(testProps, expectedResults);
|
||||
});
|
||||
|
||||
it("calls checkUpdates", () => {
|
||||
const buttons = mount(<OsUpdateButton {...fakeProps()} />);
|
||||
const osUpdateButton = buttons.find("button").first();
|
||||
|
@ -260,6 +301,14 @@ describe("<OsUpdateButton/>", () => {
|
|||
expect(mockDevice.checkUpdates).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles undefined jobs", () => {
|
||||
// tslint:disable-next-line:no-any
|
||||
bot.hardware.jobs = undefined as any;
|
||||
const buttons = mount(<OsUpdateButton {...fakeProps()} />);
|
||||
const osUpdateButton = buttons.find("button").first();
|
||||
expect(osUpdateButton.text()).toEqual("UP TO DATE");
|
||||
});
|
||||
|
||||
function bytesProgressTest(unit: string, progress: number, text: string) {
|
||||
it(`shows update progress: ${unit}`, () => {
|
||||
bot.hardware.jobs = {
|
||||
|
|
|
@ -52,6 +52,7 @@ export function FarmbotOsRow(props: FarmbotOsRowProps) {
|
|||
<OsUpdateButton
|
||||
bot={bot}
|
||||
sourceFbosConfig={sourceFbosConfig}
|
||||
shouldDisplay={props.shouldDisplay}
|
||||
botOnline={botOnline} />
|
||||
</Col>
|
||||
</Row >;
|
||||
|
|
|
@ -68,4 +68,5 @@ export interface OsUpdateButtonProps {
|
|||
bot: BotState;
|
||||
sourceFbosConfig: SourceFbosConfig;
|
||||
botOnline: boolean;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import { JobProgress } from "farmbot/dist";
|
||||
import { JobProgress, ConfigurationName } from "farmbot/dist";
|
||||
import { SemverResult, semverCompare } from "../../../util";
|
||||
import { OsUpdateButtonProps } from "./interfaces";
|
||||
import { checkControllerUpdates } from "../../actions";
|
||||
import { isString } from "lodash";
|
||||
import { BotState } from "../../interfaces";
|
||||
import { BotState, Feature } from "../../interfaces";
|
||||
|
||||
/** FBOS update button states. */
|
||||
enum UpdateButton { upToDate, needsUpdate, unknown, none }
|
||||
|
@ -151,7 +151,9 @@ export const OsUpdateButton = (props: OsUpdateButtonProps) => {
|
|||
const { bot, sourceFbosConfig, botOnline } = props;
|
||||
|
||||
/** FBOS beta release opt-in setting. */
|
||||
const betaOptIn = !!sourceFbosConfig("beta_opt_in").value;
|
||||
const betaOptIn = props.shouldDisplay(Feature.use_update_channel)
|
||||
? sourceFbosConfig("update_channel" as ConfigurationName).value !== "stable"
|
||||
: !!sourceFbosConfig("beta_opt_in").value;
|
||||
/** FBOS update availability. */
|
||||
const buttonStatusProps = buttonVersionStatus({ bot, betaOptIn });
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "../../__test_support__/fake_state/resources";
|
||||
import { WebAppConfig } from "farmbot/dist/resources/configs/web_app";
|
||||
import { generateUuid } from "../../resources/util";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
describe("mapStateToProps()", () => {
|
||||
const DISCARDED_AT = "2018-01-01T00:00:00.000Z";
|
||||
|
@ -111,7 +112,8 @@ describe("getPlants()", () => {
|
|||
it("returns API farmware env", () => {
|
||||
const state = fakeState();
|
||||
state.bot.hardware.user_env = {};
|
||||
state.bot.hardware.informational_settings.controller_version = "1000.0.0";
|
||||
state.bot.hardware.informational_settings.controller_version =
|
||||
DevSettings.MAX_FBOS_VERSION_OVERRIDE;
|
||||
const fwEnv = fakeFarmwareEnv();
|
||||
fwEnv.body.key = "CAMERA_CALIBRATION_total_rotation_angle";
|
||||
fwEnv.body.value = 15;
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "../edit_fe_form";
|
||||
import { isString } from "lodash";
|
||||
import { repeatOptions } from "../map_state_to_props_add_edit";
|
||||
import { SpecialStatus } from "farmbot";
|
||||
import { SpecialStatus, VariableDeclaration } from "farmbot";
|
||||
import { success, error } from "farmbot-toastr";
|
||||
import * as moment from "moment";
|
||||
import { fakeState } from "../../../__test_support__/fake_state";
|
||||
|
@ -27,6 +27,7 @@ import { history } from "../../../history";
|
|||
import {
|
||||
buildResourceIndex
|
||||
} from "../../../__test_support__/resource_index_builder";
|
||||
import { fakeVariableNameSet } from "../../../__test_support__/fake_variables";
|
||||
|
||||
const mockSequence = fakeSequence();
|
||||
|
||||
|
@ -60,10 +61,10 @@ describe("<FarmEventForm/>", () => {
|
|||
|
||||
it("determines if it is a one time event", () => {
|
||||
const i = instance(props());
|
||||
expect(i.isOneTime).toBe(true);
|
||||
expect(i.repeats).toBe(false);
|
||||
i.mergeState("timeUnit", "daily");
|
||||
i.forceUpdate();
|
||||
expect(i.isOneTime).toBe(false);
|
||||
expect(i.repeats).toBe(true);
|
||||
});
|
||||
|
||||
it("has a dispatch", () => {
|
||||
|
@ -401,6 +402,82 @@ describe("<FarmEventForm/>", () => {
|
|||
const reject = instance(p).maybeRejectStartTime(p.farmEvent.body, fakeNow);
|
||||
expect(reject).toBeFalsy();
|
||||
});
|
||||
|
||||
it("edits a declaration", () => {
|
||||
const p = props();
|
||||
const oldDeclaration: VariableDeclaration = {
|
||||
kind: "variable_declaration",
|
||||
args: {
|
||||
label: "foo",
|
||||
data_value: {
|
||||
kind: "point", args: {
|
||||
pointer_id: 1, pointer_type: "Plant"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const newDeclaration: VariableDeclaration = {
|
||||
kind: "variable_declaration",
|
||||
args: {
|
||||
label: "foo",
|
||||
data_value: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }
|
||||
}
|
||||
};
|
||||
const inst = instance(p);
|
||||
inst.setState({ fe: { body: [oldDeclaration] } });
|
||||
expect(inst.state.fe.body).toEqual([oldDeclaration]);
|
||||
expect(inst.state.specialStatusLocal).toEqual(SpecialStatus.SAVED);
|
||||
inst.editDeclaration([oldDeclaration])(newDeclaration);
|
||||
expect(inst.state.fe.body).toEqual([newDeclaration]);
|
||||
expect(inst.state.specialStatusLocal).toEqual(SpecialStatus.DIRTY);
|
||||
});
|
||||
|
||||
it("saves an updated declaration", () => {
|
||||
const p = props();
|
||||
const oldDeclaration: VariableDeclaration = {
|
||||
kind: "variable_declaration",
|
||||
args: {
|
||||
label: "foo",
|
||||
data_value: {
|
||||
kind: "point", args: {
|
||||
pointer_id: 1, pointer_type: "Plant"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
p.farmEvent.body.body = [oldDeclaration];
|
||||
const newDeclaration: VariableDeclaration = {
|
||||
kind: "variable_declaration",
|
||||
args: {
|
||||
label: "foo",
|
||||
data_value: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }
|
||||
}
|
||||
};
|
||||
const inst = instance(p);
|
||||
inst.setState({ fe: { body: [newDeclaration] } });
|
||||
expect(inst.updatedFarmEvent.body).toEqual([newDeclaration]);
|
||||
});
|
||||
|
||||
it("saves the current declaration", () => {
|
||||
const p = props();
|
||||
const sequence = fakeSequence();
|
||||
p.findExecutable = () => sequence;
|
||||
p.resources.sequenceMetas[sequence.uuid] = fakeVariableNameSet("foo");
|
||||
const oldDeclaration: VariableDeclaration = {
|
||||
kind: "variable_declaration",
|
||||
args: {
|
||||
label: "foo",
|
||||
data_value: {
|
||||
kind: "point", args: {
|
||||
pointer_id: 1, pointer_type: "Plant"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
p.farmEvent.body.body = [oldDeclaration];
|
||||
const inst = instance(p);
|
||||
expect(inst.updatedFarmEvent.body).toEqual([oldDeclaration]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destructureFarmEvent", () => {
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
SaveBtn,
|
||||
FBSelect,
|
||||
DropDownItem
|
||||
} from "../../ui/index";
|
||||
} from "../../ui";
|
||||
import { destroy, save, overwrite } from "../../api/crud";
|
||||
import { history } from "../../history";
|
||||
// TIL: https://stackoverflow.com/a/24900248/1064917
|
||||
|
@ -41,7 +41,9 @@ import { ShouldDisplay, Feature } from "../../devices/interfaces";
|
|||
import {
|
||||
addOrEditVarDeclaration, declarationList
|
||||
} from "../../sequences/locals_list/declaration_support";
|
||||
import { AllowedDeclaration } from "../../sequences/locals_list/locals_list_support";
|
||||
import {
|
||||
AllowedDeclaration
|
||||
} from "../../sequences/locals_list/locals_list_support";
|
||||
|
||||
type FormEvent = React.SyntheticEvent<HTMLInputElement>;
|
||||
export const NEVER: TimeUnit = "never";
|
||||
|
@ -61,6 +63,7 @@ export interface FarmEventViewModel {
|
|||
timeOffset: number;
|
||||
body?: VariableDeclaration[];
|
||||
}
|
||||
|
||||
/** Breaks up a TaggedFarmEvent into a structure that can easily be used
|
||||
* by the edit form.
|
||||
* USE CASE EXAMPLE: We have a "date" and "time" field that are created from
|
||||
|
@ -83,12 +86,19 @@ export function destructureFarmEvent(
|
|||
};
|
||||
}
|
||||
|
||||
type recombineOptions = { forceRegimensToMidnight: boolean };
|
||||
const startTimeWarning = () => {
|
||||
const message =
|
||||
t("FarmEvent start time needs to be in the future, not the past.");
|
||||
const title = t("Unable to save farm event.");
|
||||
error(message, title);
|
||||
};
|
||||
|
||||
type RecombineOptions = { forceRegimensToMidnight: boolean };
|
||||
|
||||
/** Take a FormViewModel and recombine the fields into a FarmEvent
|
||||
* that can be used to apply updates (such as a PUT request to the API). */
|
||||
export function recombine(vm: FarmEventViewModel,
|
||||
options: recombineOptions): TaggedFarmEvent["body"] {
|
||||
options: RecombineOptions): TaggedFarmEvent["body"] {
|
||||
// Make sure that `repeat` is set to `never` when dealing with regimens.
|
||||
const isReg = vm.executable_type === "Regimen";
|
||||
const startTime = isReg && options.forceRegimensToMidnight
|
||||
|
@ -129,7 +139,9 @@ export interface EditFEProps {
|
|||
}
|
||||
|
||||
interface State {
|
||||
/** Hold a partial FarmEvent locally */
|
||||
/**
|
||||
* Hold a partial FarmEvent locally containing only updates made by the form.
|
||||
*/
|
||||
fe: Partial<FarmEventViewModel>;
|
||||
/**
|
||||
* This form has local state and does not cause any global state changes when
|
||||
|
@ -143,10 +155,11 @@ interface State {
|
|||
export class EditFEForm extends React.Component<EditFEProps, State> {
|
||||
state: State = { fe: {}, specialStatusLocal: SpecialStatus.SAVED };
|
||||
|
||||
get isOneTime() { return this.fieldGet("timeUnit") === NEVER; }
|
||||
get repeats() { return this.fieldGet("timeUnit") !== NEVER; }
|
||||
|
||||
get dispatch() { return this.props.dispatch; }
|
||||
|
||||
/** API data for the FarmEvent to which form updates can be applied. */
|
||||
get viewModel() {
|
||||
return destructureFarmEvent(this.props.farmEvent, this.props.timeOffset);
|
||||
}
|
||||
|
@ -161,6 +174,16 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
|
|||
}
|
||||
}
|
||||
|
||||
get isReg() {
|
||||
return this.fieldGet("executable_type") === "Regimen";
|
||||
}
|
||||
|
||||
/** Executable requires variables or a user has manually added declarations. */
|
||||
get needsVariables() {
|
||||
const varData = this.props.resources.sequenceMetas[this.executable.uuid];
|
||||
return Object.keys(varData || {}).length > 0;
|
||||
}
|
||||
|
||||
get variableData() {
|
||||
return this.props.resources.sequenceMetas[this.executable.uuid];
|
||||
}
|
||||
|
@ -169,12 +192,18 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
|
|||
return this.state.fe.body || this.props.farmEvent.body.body;
|
||||
}
|
||||
|
||||
overwriteStateFEBody = (newBody: VariableDeclaration[]) => {
|
||||
const state = this.state;
|
||||
state.fe.body = newBody;
|
||||
this.setState(state);
|
||||
}
|
||||
|
||||
editDeclaration = (declarations: VariableDeclaration[]) =>
|
||||
(declaration: VariableDeclaration) =>
|
||||
this.setState(betterMerge(this.state, {
|
||||
fe: { body: addOrEditVarDeclaration(declarations)(declaration) },
|
||||
specialStatusLocal: SpecialStatus.DIRTY
|
||||
}));
|
||||
(declaration: VariableDeclaration) => {
|
||||
const body = addOrEditVarDeclaration(declarations, declaration);
|
||||
this.overwriteStateFEBody(body);
|
||||
this.setState({ specialStatusLocal: SpecialStatus.DIRTY });
|
||||
}
|
||||
|
||||
LocalsList = () => <LocalsList
|
||||
declarations={this.declarations}
|
||||
|
@ -200,10 +229,10 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
|
|||
fe: {
|
||||
executable_type: executableType(e.headingId),
|
||||
executable_id: (e.value || "").toString(),
|
||||
body: declarationList(varData),
|
||||
},
|
||||
specialStatusLocal: SpecialStatus.DIRTY
|
||||
};
|
||||
this.overwriteStateFEBody(declarationList(varData) || []);
|
||||
this.setState(betterMerge(this.state, update));
|
||||
}
|
||||
}
|
||||
|
@ -285,6 +314,36 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
|
|||
return newEvent && (inThePast && (sequenceEvent || unsupportedOS));
|
||||
}
|
||||
|
||||
/** Merge and recombine FarmEvent form updates into and updated FarmEvent. */
|
||||
get updatedFarmEvent() {
|
||||
/** ViewModel with INVALID `body` (must be replaced). */
|
||||
const vm = betterMerge(this.viewModel, this.state.fe);
|
||||
const oldBodyData = this.needsVariables ? this.viewModel.body : [];
|
||||
vm.body = this.state.fe.body || oldBodyData;
|
||||
const opts: RecombineOptions = {
|
||||
forceRegimensToMidnight: this.allowRegimenBackscheduling
|
||||
};
|
||||
return recombine(vm, opts);
|
||||
}
|
||||
|
||||
/** Use the next item run time to display toast messages and return to
|
||||
* the form if necessary. */
|
||||
nextRunTimeActions = (editFEPath: string, now = moment()) => {
|
||||
const nextRun = this.nextItemTime(this.props.farmEvent.body, now);
|
||||
if (nextRun) {
|
||||
const nextRunText = this.props.autoSyncEnabled
|
||||
? t(`The next item in this Farm Event will run {{timeFromNow}}.`,
|
||||
{ timeFromNow: nextRun.from(now) })
|
||||
: t(`The next item in this Farm Event will run {{timeFromNow}}, but
|
||||
you must first SYNC YOUR DEVICE. If you do not sync, the event will
|
||||
not run.`.replace(/\s+/g, " "), { timeFromNow: nextRun.from(now) });
|
||||
success(nextRunText);
|
||||
} else {
|
||||
history.push(editFEPath);
|
||||
error(t(Content.INVALID_RUN_TIME), t("Warning"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Once saved, if
|
||||
* - Regimen Farm Event:
|
||||
* * Return to calendar view.
|
||||
|
@ -297,55 +356,94 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
|
|||
* * If auto-sync is disabled, prompt the user to sync.
|
||||
*/
|
||||
commitViewModel = (now = moment()) => {
|
||||
const viewModel = this.viewModel;
|
||||
/** Replace farmEvent body with form updates. */
|
||||
delete viewModel.body;
|
||||
const updatedFarmEvent = recombine(betterMerge(viewModel, this.state.fe),
|
||||
{ forceRegimensToMidnight: this.allowRegimenBackscheduling });
|
||||
if (this.maybeRejectStartTime(updatedFarmEvent)) {
|
||||
error(t("FarmEvent start time needs to be in the future, not the past."),
|
||||
t("Unable to save farm event."));
|
||||
return;
|
||||
if (this.maybeRejectStartTime(this.updatedFarmEvent)) {
|
||||
return startTimeWarning();
|
||||
}
|
||||
this.dispatch(overwrite(this.props.farmEvent, updatedFarmEvent));
|
||||
const EditFEPath = window.location.pathname;
|
||||
this
|
||||
.dispatch(save(this.props.farmEvent.uuid))
|
||||
this.dispatch(overwrite(this.props.farmEvent, this.updatedFarmEvent));
|
||||
const editFEPath = window.location.pathname;
|
||||
this.dispatch(save(this.props.farmEvent.uuid))
|
||||
.then(() => {
|
||||
this.setState({ specialStatusLocal: SpecialStatus.SAVED });
|
||||
history.push("/app/designer/farm_events");
|
||||
const frmEvnt = this.props.farmEvent;
|
||||
this.props.dispatch(maybeWarnAboutMissedTasks(frmEvnt, function () {
|
||||
alert(t(Content.REGIMEN_TODAY_SKIPPED_ITEM_RISK));
|
||||
}, now));
|
||||
const nextRun = this.nextItemTime(frmEvnt.body, now);
|
||||
if (nextRun) {
|
||||
const nextRunText = this.props.autoSyncEnabled
|
||||
? t(`The next item in this Farm Event will run {{timeFromNow}}.`,
|
||||
{ timeFromNow: nextRun.from(now) })
|
||||
: t(`The next item in this Farm Event will run {{timeFromNow}}, but
|
||||
you must first SYNC YOUR DEVICE. If you do not sync, the event will
|
||||
not run.`.replace(/\s+/g, " "), { timeFromNow: nextRun.from(now) });
|
||||
success(nextRunText);
|
||||
} else {
|
||||
history.push(EditFEPath);
|
||||
error(t(Content.INVALID_RUN_TIME), t("Warning"));
|
||||
}
|
||||
this.dispatch(maybeWarnAboutMissedTasks(this.props.farmEvent,
|
||||
() => alert(t(Content.REGIMEN_TODAY_SKIPPED_ITEM_RISK)), now));
|
||||
this.nextRunTimeActions(editFEPath, now);
|
||||
})
|
||||
.catch(() => {
|
||||
error(t("Unable to save farm event."));
|
||||
this.setState({ specialStatusLocal: SpecialStatus.DIRTY });
|
||||
});
|
||||
}
|
||||
get isReg() {
|
||||
return this.fieldGet("executable_type") === "Regimen";
|
||||
|
||||
StartTimeForm = () => {
|
||||
const forceMidnight = this.isReg && this.allowRegimenBackscheduling;
|
||||
return <div>
|
||||
<label>
|
||||
{t("Starts")}
|
||||
</label>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<BlurableInput
|
||||
type="date"
|
||||
className="add-event-start-date"
|
||||
name="start_date"
|
||||
value={this.fieldGet("startDate")}
|
||||
onCommit={this.fieldSet("startDate")} />
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<EventTimePicker
|
||||
className="add-event-start-time"
|
||||
name="start_time"
|
||||
tzOffset={this.props.timeOffset}
|
||||
value={this.fieldGet("startTime")}
|
||||
onCommit={this.fieldSet("startTime")}
|
||||
disabled={forceMidnight}
|
||||
hidden={forceMidnight} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>;
|
||||
}
|
||||
|
||||
RepeatCheckbox = ({ allowRepeat }: { allowRepeat: boolean }) =>
|
||||
!this.isReg ?
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
onChange={this.toggleRepeat}
|
||||
disabled={this.isReg}
|
||||
checked={allowRepeat} />
|
||||
{t("Repeats?")}
|
||||
</label> : <div />
|
||||
|
||||
RepeatForm = () => {
|
||||
const allowRepeat = !this.isReg && this.repeats;
|
||||
return <div>
|
||||
<this.RepeatCheckbox allowRepeat={allowRepeat} />
|
||||
<FarmEventRepeatForm
|
||||
tzOffset={this.props.timeOffset}
|
||||
disabled={!allowRepeat}
|
||||
hidden={!allowRepeat}
|
||||
onChange={this.mergeState}
|
||||
timeUnit={this.fieldGet("timeUnit") as TimeUnit}
|
||||
repeat={this.fieldGet("repeat")}
|
||||
endDate={this.fieldGet("endDate")}
|
||||
endTime={this.fieldGet("endTime")} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
FarmEventDeleteButton = () =>
|
||||
<button className="fb-button red" hidden={!this.props.deleteBtn}
|
||||
onClick={() => {
|
||||
this.dispatch(destroy(this.props.farmEvent.uuid))
|
||||
.then(() => {
|
||||
history.push("/app/designer/farm_events");
|
||||
success(t("Deleted farm event."), t("Deleted"));
|
||||
});
|
||||
}}>
|
||||
{t("Delete")}
|
||||
</button>
|
||||
|
||||
render() {
|
||||
const { farmEvent } = this.props;
|
||||
const repeats = this.fieldGet("timeUnit") !== NEVER;
|
||||
const allowRepeat = (!this.isReg && repeats);
|
||||
const forceMidnight = this.isReg && this.allowRegimenBackscheduling;
|
||||
return <DesignerPanel panelName={"add-farm-event"} panelColor={"magenta"}>
|
||||
<DesignerPanelHeader
|
||||
panelName={"add-farm-event"}
|
||||
|
@ -364,59 +462,13 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
|
|||
onChange={this.executableSet}
|
||||
selectedItem={this.executableGet()} />
|
||||
<this.LocalsList />
|
||||
<label>
|
||||
{t("Starts")}
|
||||
</label>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<BlurableInput
|
||||
type="date"
|
||||
className="add-event-start-date"
|
||||
name="start_date"
|
||||
value={this.fieldGet("startDate")}
|
||||
onCommit={this.fieldSet("startDate")} />
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<EventTimePicker
|
||||
className="add-event-start-time"
|
||||
name="start_time"
|
||||
tzOffset={this.props.timeOffset}
|
||||
value={this.fieldGet("startTime")}
|
||||
onCommit={this.fieldSet("startTime")}
|
||||
disabled={forceMidnight}
|
||||
hidden={forceMidnight} />
|
||||
</Col>
|
||||
</Row>
|
||||
{!this.isReg &&
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
onChange={this.toggleRepeat}
|
||||
disabled={this.isReg}
|
||||
checked={repeats && !this.isReg} />
|
||||
{t("Repeats?")}
|
||||
</label>}
|
||||
<FarmEventRepeatForm
|
||||
tzOffset={this.props.timeOffset}
|
||||
disabled={!allowRepeat}
|
||||
hidden={!allowRepeat}
|
||||
onChange={this.mergeState}
|
||||
timeUnit={this.fieldGet("timeUnit") as TimeUnit}
|
||||
repeat={this.fieldGet("repeat")}
|
||||
endDate={this.fieldGet("endDate")}
|
||||
endTime={this.fieldGet("endTime")} />
|
||||
<this.StartTimeForm />
|
||||
<this.RepeatForm />
|
||||
<SaveBtn
|
||||
status={farmEvent.specialStatus || this.state.specialStatusLocal}
|
||||
color="magenta"
|
||||
onClick={() => this.commitViewModel()} />
|
||||
<button className="fb-button red" hidden={!this.props.deleteBtn}
|
||||
onClick={() => {
|
||||
this.dispatch(destroy(farmEvent.uuid)).then(() => {
|
||||
history.push("/app/designer/farm_events");
|
||||
success(t("Deleted farm event."), t("Deleted"));
|
||||
});
|
||||
}}>
|
||||
{t("Delete")}
|
||||
</button>
|
||||
<this.FarmEventDeleteButton />
|
||||
<TzWarning deviceTimezone={this.props.deviceTimezone} />
|
||||
</DesignerPanelContent>
|
||||
</DesignerPanel>;
|
||||
|
|
|
@ -32,54 +32,50 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
|
|||
const { disabled, onChange, repeat, endDate, endTime, timeUnit } = props;
|
||||
const changeHandler =
|
||||
(key: Key) => (e: Ev) => onChange(key, e.currentTarget.value);
|
||||
if (props.hidden) {
|
||||
return <div />;
|
||||
} else {
|
||||
return <div>
|
||||
<label>
|
||||
{t("Every")}
|
||||
</label>
|
||||
<Row>
|
||||
<Col xs={4}>
|
||||
<BlurableInput
|
||||
disabled={disabled}
|
||||
placeholder="(Number)"
|
||||
type="number"
|
||||
className="add-event-repeat-frequency"
|
||||
name="repeat"
|
||||
value={repeat}
|
||||
onCommit={changeHandler("repeat")} />
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<FBSelect
|
||||
list={repeatOptions}
|
||||
onChange={(e) => onChange("timeUnit", "" + e.value)}
|
||||
selectedItem={OPTN_LOOKUP[timeUnit] || OPTN_LOOKUP["daily"]} />
|
||||
</Col>
|
||||
</Row>
|
||||
<label>
|
||||
{t("Until")}
|
||||
</label>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<BlurableInput
|
||||
disabled={disabled}
|
||||
type="date"
|
||||
className="add-event-end-date"
|
||||
name="endDate"
|
||||
value={endDate}
|
||||
onCommit={changeHandler("endDate")} />
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<EventTimePicker
|
||||
disabled={disabled}
|
||||
className="add-event-end-time"
|
||||
name="endTime"
|
||||
tzOffset={props.tzOffset}
|
||||
value={endTime}
|
||||
onCommit={changeHandler("endTime")} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>;
|
||||
}
|
||||
return props.hidden ? <div /> : <div>
|
||||
<label>
|
||||
{t("Every")}
|
||||
</label>
|
||||
<Row>
|
||||
<Col xs={4}>
|
||||
<BlurableInput
|
||||
disabled={disabled}
|
||||
placeholder="(Number)"
|
||||
type="number"
|
||||
className="add-event-repeat-frequency"
|
||||
name="repeat"
|
||||
value={repeat}
|
||||
onCommit={changeHandler("repeat")} />
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<FBSelect
|
||||
list={repeatOptions}
|
||||
onChange={(e) => onChange("timeUnit", "" + e.value)}
|
||||
selectedItem={OPTN_LOOKUP[timeUnit] || OPTN_LOOKUP["daily"]} />
|
||||
</Col>
|
||||
</Row>
|
||||
<label>
|
||||
{t("Until")}
|
||||
</label>
|
||||
<Row>
|
||||
<Col xs={6}>
|
||||
<BlurableInput
|
||||
disabled={disabled}
|
||||
type="date"
|
||||
className="add-event-end-date"
|
||||
name="endDate"
|
||||
value={endDate}
|
||||
onCommit={changeHandler("endDate")} />
|
||||
</Col>
|
||||
<Col xs={6}>
|
||||
<EventTimePicker
|
||||
disabled={disabled}
|
||||
className="add-event-end-time"
|
||||
name="endTime"
|
||||
tzOffset={props.tzOffset}
|
||||
value={endTime}
|
||||
onCommit={changeHandler("endTime")} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import { GardenMapLegendProps } from "../../interfaces";
|
|||
import { clickButton } from "../../../../__test_support__/helpers";
|
||||
import { history } from "../../../../history";
|
||||
import { BooleanSetting } from "../../../../session_keys";
|
||||
import { enableFutureFeatures } from "../../../../account/dev_widget";
|
||||
import { DevSettings } from "../../../../account/dev/dev_support";
|
||||
|
||||
describe("<GardenMapLegend />", () => {
|
||||
const fakeProps = (): GardenMapLegendProps => ({
|
||||
|
@ -48,7 +48,7 @@ describe("<GardenMapLegend />", () => {
|
|||
});
|
||||
|
||||
it("shows submenu", () => {
|
||||
enableFutureFeatures();
|
||||
DevSettings.enableFutureFeatures();
|
||||
const wrapper = mount(<GardenMapLegend {...fakeProps()} />);
|
||||
expect(wrapper.html()).toContain("filter");
|
||||
expect(wrapper.html()).toContain("extras");
|
||||
|
|
|
@ -11,7 +11,7 @@ import { MoveModeLink } from "../../plants/move_to";
|
|||
import { SavedGardensLink } from "../../saved_gardens/saved_gardens";
|
||||
import { GetWebAppConfigValue } from "../../../config_storage/actions";
|
||||
import { BooleanSetting } from "../../../session_keys";
|
||||
import { futureFeaturesEnabled } from "../../../account/dev_widget";
|
||||
import { DevSettings } from "../../../account/dev/dev_support";
|
||||
|
||||
const OriginSelector = ({ quadrant, update }: {
|
||||
quadrant: BotOriginQuadrant,
|
||||
|
@ -79,7 +79,7 @@ const LayerToggles = (props: GardenMapLegendProps) => {
|
|||
label={t("Points?")}
|
||||
onClick={toggle("show_points")}
|
||||
submenuTitle={t("extras")}
|
||||
popover={futureFeaturesEnabled()
|
||||
popover={DevSettings.futureFeaturesEnabled()
|
||||
? <PointsSubMenu toggle={toggle} getConfigValue={getConfigValue} />
|
||||
: undefined} />
|
||||
<LayerToggle
|
||||
|
@ -100,7 +100,7 @@ const LayerToggles = (props: GardenMapLegendProps) => {
|
|||
dispatch={props.dispatch}
|
||||
getConfigValue={getConfigValue}
|
||||
imageAgeInfo={props.imageAgeInfo} />} />
|
||||
{futureFeaturesEnabled() &&
|
||||
{DevSettings.futureFeaturesEnabled() &&
|
||||
<LayerToggle
|
||||
value={props.showSensorReadings}
|
||||
label={t("Readings?")}
|
||||
|
|
|
@ -16,7 +16,7 @@ import { Content } from "../../constants";
|
|||
import {
|
||||
DesignerPanel, DesignerPanelHeader, DesignerPanelContent
|
||||
} from "../plants/designer_panel";
|
||||
import { futureFeaturesEnabled } from "../../account/dev_widget";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
export const mapStateToProps = (props: Everything): SavedGardensProps => ({
|
||||
savedGardens: selectAllSavedGardens(props.resources.index),
|
||||
|
@ -64,7 +64,7 @@ export class SavedGardens extends React.Component<SavedGardensProps, {}> {
|
|||
/** Link to SavedGardens panel for garden map legend. */
|
||||
export const SavedGardensLink = () =>
|
||||
<button className="fb-button green"
|
||||
hidden={!futureFeaturesEnabled()}
|
||||
hidden={!DevSettings.futureFeaturesEnabled()}
|
||||
onClick={() => history.push("/app/designer/saved_gardens")}>
|
||||
{t("Saved Gardens")}
|
||||
</button>;
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { edit, initSave, save } from "../../api/crud";
|
||||
import { fakeFarmware } from "../../__test_support__/fake_farmwares";
|
||||
import { JobProgress } from "farmbot";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
describe("mapStateToProps()", () => {
|
||||
|
||||
|
@ -48,7 +49,8 @@ describe("mapStateToProps()", () => {
|
|||
it("returns API farmware env", () => {
|
||||
const state = fakeState();
|
||||
state.bot.hardware.user_env = {};
|
||||
state.bot.hardware.informational_settings.controller_version = "1000.0.0";
|
||||
state.bot.hardware.informational_settings.controller_version =
|
||||
DevSettings.MAX_FBOS_VERSION_OVERRIDE;
|
||||
state.resources = buildResourceIndex([fakeFarmwareEnv()]);
|
||||
const props = mapStateToProps(state);
|
||||
expect(props.user_env).toEqual({
|
||||
|
@ -58,7 +60,8 @@ describe("mapStateToProps()", () => {
|
|||
|
||||
it("includes API FarmwareInstallations", () => {
|
||||
const state = fakeState();
|
||||
state.bot.hardware.informational_settings.controller_version = "1000.0.0";
|
||||
state.bot.hardware.informational_settings.controller_version =
|
||||
DevSettings.MAX_FBOS_VERSION_OVERRIDE;
|
||||
const farmware1 = fakeFarmwareInstallation();
|
||||
farmware1.body.id = 2;
|
||||
const farmware2 = fakeFarmwareInstallation();
|
||||
|
@ -118,6 +121,14 @@ describe("mapStateToProps()", () => {
|
|||
time: "2018-11-15 18:13:21.167440Z"
|
||||
}]);
|
||||
});
|
||||
|
||||
it("handles undefined jobs", () => {
|
||||
const state = fakeState();
|
||||
// tslint:disable-next-line:no-any
|
||||
state.bot.hardware.jobs = undefined as any;
|
||||
const props = mapStateToProps(state);
|
||||
expect(props.imageJobs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOrEditFarmwareEnv()", () => {
|
||||
|
|
|
@ -99,7 +99,7 @@ export function mapStateToProps(props: Everything): FarmwareProps {
|
|||
}
|
||||
});
|
||||
|
||||
const { jobs } = props.bot.hardware;
|
||||
const jobs = props.bot.hardware.jobs || {};
|
||||
const imageJobNames = Object.keys(jobs).filter(x => x != "FBOS_OTA");
|
||||
const imageJobs: JobProgress[] =
|
||||
_(betterCompact(imageJobNames.map(x => jobs[x])))
|
||||
|
|
|
@ -4,7 +4,7 @@ import { AccountMenuProps } from "./interfaces";
|
|||
import { docLink } from "../ui/doc_link";
|
||||
import { Link } from "../link";
|
||||
import { shortRevision } from "../util";
|
||||
import { futureFeaturesEnabled } from "../account/dev_widget";
|
||||
import { DevSettings } from "../account/dev/dev_support";
|
||||
|
||||
export const AdditionalMenu = (props: AccountMenuProps) => {
|
||||
return <div className="nav-additional-menu">
|
||||
|
@ -14,7 +14,7 @@ export const AdditionalMenu = (props: AccountMenuProps) => {
|
|||
{t("Account Settings")}
|
||||
</Link>
|
||||
</div>
|
||||
{futureFeaturesEnabled() &&
|
||||
{DevSettings.futureFeaturesEnabled() &&
|
||||
<Link to="/app/help" onClick={props.close("accountMenuOpen")}>
|
||||
<i className="fa fa-question-circle"></i>
|
||||
{t("Help")}
|
||||
|
|
|
@ -37,12 +37,12 @@ import { destroy, save, edit } from "../../api/crud";
|
|||
import {
|
||||
fakeHardwareFlags
|
||||
} from "../../__test_support__/sequence_hardware_settings";
|
||||
import { SpecialStatus, Coordinate } from "farmbot";
|
||||
import { SpecialStatus } from "farmbot";
|
||||
import { move, splice } from "../step_tiles";
|
||||
import { copySequence, editCurrentSequence } from "../actions";
|
||||
import { execSequence } from "../../devices/actions";
|
||||
import { clickButton } from "../../__test_support__/helpers";
|
||||
import { VariableNameSet } from "../../resources/interfaces";
|
||||
import { fakeVariableNameSet } from "../../__test_support__/fake_variables";
|
||||
|
||||
describe("<SequenceEditorMiddleActive/>", () => {
|
||||
const sequence = fakeSequence();
|
||||
|
@ -116,23 +116,6 @@ describe("<SequenceEditorMiddleActive/>", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const fakeVariableNameSet = (): VariableNameSet => {
|
||||
const label = "parent";
|
||||
const data_value: Coordinate = {
|
||||
kind: "coordinate", args: { x: 0, y: 0, z: 0 }
|
||||
};
|
||||
return {
|
||||
[label]: {
|
||||
celeryNode: {
|
||||
kind: "variable_declaration",
|
||||
args: { label, data_value }
|
||||
},
|
||||
dropdown: { label: "", value: "" },
|
||||
vector: { x: 0, y: 0, z: 0 },
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
it("has correct height with variable form", () => {
|
||||
const p = fakeProps();
|
||||
p.resources.sequenceMetas = { [p.sequence.uuid]: fakeVariableNameSet() };
|
||||
|
|
|
@ -25,8 +25,9 @@ export const declarationList = (variableData: VariableNameSet | undefined):
|
|||
};
|
||||
|
||||
/** Add a new var declaration or replace an existing one with the same label. */
|
||||
export const addOrEditVarDeclaration = (declarations: VariableDeclaration[]) =>
|
||||
(updatedItem: VariableDeclaration): VariableDeclaration[] => {
|
||||
export const addOrEditVarDeclaration =
|
||||
(declarations: VariableDeclaration[],
|
||||
updatedItem: VariableDeclaration): VariableDeclaration[] => {
|
||||
const items = reduceVarDeclarations(declarations);
|
||||
items[updatedItem.args.label] = updatedItem;
|
||||
return Object.values(items);
|
||||
|
|
|
@ -29,7 +29,7 @@ const assignVariable = (props: ExecBlockParams) =>
|
|||
sequence: currentSequence,
|
||||
index: index,
|
||||
executor(step) {
|
||||
step.body = addOrEditVarDeclaration(declarations)(variable);
|
||||
step.body = addOrEditVarDeclaration(declarations, variable);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
|
|
@ -99,7 +99,14 @@ export function sortResourcesById<T extends TaggedResource>(input: T[]): T[] {
|
|||
return _.sortBy(input, (x) => x.body.id || Infinity);
|
||||
}
|
||||
|
||||
/** Light wrapper around _.merge() to prevent common type errors / mistakes. */
|
||||
/**
|
||||
* Light wrapper around _.merge() to prevent common type errors / mistakes.
|
||||
*
|
||||
* NOTE: If you rely solely on `betterMerge()` to combine array-bearing
|
||||
* CeleryScript nodes, the API will reject them because they contain
|
||||
* extra properties. The CS Corpus does not allow extra nodes for
|
||||
* safety reasons.
|
||||
*/
|
||||
export function betterMerge<T, U>(target: T, update: U): T & U {
|
||||
return _.merge({}, target, update);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { isString, isUndefined } from "lodash";
|
||||
import { BotState, Feature, MinOsFeatureLookup } from "../devices/interfaces";
|
||||
import { TaggedDevice } from "farmbot";
|
||||
import { DevSettings } from "../account/dev/dev_support";
|
||||
|
||||
/**
|
||||
* for semverCompare()
|
||||
|
@ -102,9 +103,7 @@ export enum MinVersionOverride {
|
|||
export function shouldDisplay(
|
||||
current: string | undefined, lookupData: MinOsFeatureLookup | undefined) {
|
||||
return function (feature: Feature): boolean {
|
||||
/** Escape hatch for platform developers doing offline development.
|
||||
* COPY/PASTE: `localStorage.IM_A_DEVELOPER = "1000.0.0"` */
|
||||
const override = localStorage.getItem("IM_A_DEVELOPER");
|
||||
const override = DevSettings.overriddenFbosVersion();
|
||||
const target = override || current;
|
||||
if (isString(target)) {
|
||||
const min = (lookupData || {})[feature] || MinVersionOverride.NEVER;
|
||||
|
|
Loading…
Reference in New Issue