Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into friday

pull/1082/head
Rick Carlino 2019-01-08 09:29:07 -06:00
commit f2704db2d8
59 changed files with 761 additions and 412 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
class DeviceSerialNumber < ApplicationRecord
belongs_to :device
# DO NOT USE THIS TABLE. IT IS DEPRECATED. DESTROY FEB 2019.
end

View File

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

View File

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

View File

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

View File

@ -9,6 +9,6 @@ class FarmEventSerializer < ApplicationSerializer
def body
f = object.fragment
f ? f.serialize.fetch(:body) : []
f ? f.serialize.fetch(:body, []) : []
end
end

View File

@ -0,0 +1,5 @@
class AddSerialNumberToDevice < ActiveRecord::Migration[5.2]
def change
add_column :devices, :serial_number, :string, limit: 32
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -52,6 +52,7 @@ export function FarmbotOsRow(props: FarmbotOsRowProps) {
<OsUpdateButton
bot={bot}
sourceFbosConfig={sourceFbosConfig}
shouldDisplay={props.shouldDisplay}
botOnline={botOnline} />
</Col>
</Row >;

View File

@ -68,4 +68,5 @@ export interface OsUpdateButtonProps {
bot: BotState;
sourceFbosConfig: SourceFbosConfig;
botOnline: boolean;
shouldDisplay: ShouldDisplay;
}

View File

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

View File

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

View File

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

View File

@ -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} />
&nbsp;{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} />
&nbsp;{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>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ const assignVariable = (props: ExecBlockParams) =>
sequence: currentSequence,
index: index,
executor(step) {
step.body = addOrEditVarDeclaration(declarations)(variable);
step.body = addOrEditVarDeclaration(declarations, variable);
}
}));
};

View File

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

View File

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