Merge pull request #1159 from RickCarlino/release_candidate

Release candidate
pull/1160/head
Rick Carlino 2019-04-17 14:16:12 -07:00 committed by GitHub
commit 14f19e76e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 266 additions and 133 deletions

View File

@ -16,7 +16,7 @@ gem "mutations"
gem "paperclip"
gem "pg"
gem "polymorphic_constraints"
gem "rack-attack"
# gem "rack-attack" # RC 17 APR 19
gem "rack-cors"
gem "rails_12factor"
gem "rails"

View File

@ -1,37 +1,38 @@
require_relative "../app/models/transport.rb"
require File.expand_path('../boot', __FILE__)
require File.expand_path("../boot", __FILE__)
require_relative "../app/lib/celery_script/csheap"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(:default, Rails.env)
module FarmBot
class Application < Rails::Application
Delayed::Worker.max_attempts = 4
REDIS_ENV_KEY = ENV.fetch("WHERE_IS_REDIS_URL", "REDIS_URL")
REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0")
config.cache_store = :redis_cache_store, { url: REDIS_URL }
config.middleware.use Rack::Attack
REDIS_URL = ENV.fetch(REDIS_ENV_KEY, "redis://redis:6379/0")
config.cache_store = :redis_cache_store, { url: REDIS_URL }
# config.middleware.use Rack::Attack
config.active_record.schema_format = :sql
config.active_job.queue_adapter = :delayed_job
config.active_job.queue_adapter = :delayed_job
config.action_dispatch.perform_deep_munge = false
I18n.enforce_available_locales = false
LOCAL_API_HOST = ENV.fetch("API_HOST", "parcel")
PARCELJS_URL = "http://#{LOCAL_API_HOST}:3808"
PARCELJS_URL = "http://#{LOCAL_API_HOST}:3808"
config.generators do |g|
g.template_engine :erb
g.test_framework :rspec, :fixture_replacement => :factory_bot, :views => false, :helper => false
g.view_specs false
g.helper_specs false
g.fixture_replacement :factory_bot, :dir => 'spec/factories'
g.fixture_replacement :factory_bot, :dir => "spec/factories"
end
config.autoload_paths << Rails.root.join('lib')
config.autoload_paths << Rails.root.join('lib/sequence_migrations')
config.autoload_paths << Rails.root.join("lib")
config.autoload_paths << Rails.root.join("lib/sequence_migrations")
config.middleware.insert_before ActionDispatch::Static, Rack::Cors do
allow do
origins '*'
resource '/api/*',
origins "*"
resource "/api/*",
headers: :any,
methods: [:get, :post, :delete, :put, :patch, :options, :head],
expose: "X-Farmbot-Rpc-Id",
@ -42,20 +43,18 @@ module FarmBot
Rails.application.routes.default_url_options[:host] = LOCAL_API_HOST
Rails.application.routes.default_url_options[:port] = ENV["API_PORT"] || 3000
# ¯\_(ツ)_/¯
$API_URL = "//#{ Rails.application.routes.default_url_options[:host] }:#{ Rails.application.routes.default_url_options[:port] }"
ALL_LOCAL_URIS = (
[ENV["API_HOST"]] + (ENV["EXTRA_DOMAINS"] || "").split(",")
)
$API_URL = "//#{Rails.application.routes.default_url_options[:host]}:#{Rails.application.routes.default_url_options[:port]}"
ALL_LOCAL_URIS = ([ENV["API_HOST"]] + (ENV["EXTRA_DOMAINS"] || "").split(","))
.map { |x| x.present? ? "#{x}:#{ENV["API_PORT"]}" : nil }.compact
SecureHeaders::Configuration.default do |config|
config.hsts = "max-age=#{1.week.to_i}"
config.hsts = "max-age=#{1.week.to_i}"
# We need this off in dev mode otherwise email previews won't show up.
config.x_frame_options = "DENY" if Rails.env.production?
config.x_content_type_options = "nosniff"
config.x_xss_protection = "1; mode=block"
config.x_download_options = "noopen"
config.x_frame_options = "DENY" if Rails.env.production?
config.x_content_type_options = "nosniff"
config.x_xss_protection = "1; mode=block"
config.x_download_options = "noopen"
config.x_permitted_cross_domain_policies = "none"
config.referrer_policy = %w(
config.referrer_policy = %w(
origin-when-cross-origin
strict-origin-when-cross-origin
)
@ -84,12 +83,12 @@ module FarmBot
fonts.googleapis.com
fonts.gstatic.com
),
form_action: %w('self'),
frame_src: %w(*), # We need "*" to support webcam users.
img_src: %w(* data:), # We need "*" to support webcam users.
form_action: %w('self'),
frame_src: %w(*), # We need "*" to support webcam users.
img_src: %w(* data:), # We need "*" to support webcam users.
manifest_src: %w('self'),
media_src: %w(),
object_src: %w(),
media_src: %w(),
object_src: %w(),
sandbox: %w(
allow-scripts
allow-forms
@ -120,7 +119,7 @@ module FarmBot
# wouldn't, but I think it's too much
# of an inconvenience to block that
# feature. Comments welcome -RC.
report_uri: %w(/csp_reports)
report_uri: %w(/csp_reports),
}
end
end

View File

@ -1,18 +1,19 @@
class Rack::Attack
### Throttle Spammy Clients ###
throttle('req/ip', limit: 1000, period: 1.minutes) do |req|
req.ip
end
# RC 17 APR 19
# class Rack::Attack
# ### Throttle Spammy Clients ###
# throttle('req/ip', limit: 1000, period: 1.minutes) do |req|
# req.ip
# end
### Stop people from overusing the sync object. ###
throttle('sync_req/ip', limit: 5, period: 1.minutes) do |req|
req.ip if req.url.include?("/sync")
end
end
# ### Stop people from overusing the sync object. ###
# throttle('sync_req/ip', limit: 5, period: 1.minutes) do |req|
# req.ip if req.url.include?("/sync")
# end
# end
# Always allow requests from localhost
# (blacklist & throttles are skipped)
Rack::Attack.safelist('allow from localhost') do |req|
# Requests are allowed if the return value is truthy
'127.0.0.1' == req.ip || '::1' == req.ip
end
# # Always allow requests from localhost
# # (blacklist & throttles are skipped)
# Rack::Attack.safelist('allow from localhost') do |req|
# # Requests are allowed if the return value is truthy
# '127.0.0.1' == req.ip || '::1' == req.ip
# end

View File

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

View File

@ -310,6 +310,7 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
show_historic_points: false,
time_format_24_hour: false,
show_pins: false,
disable_emergency_unlock_confirmation: false,
});
}

View File

@ -1,8 +1,3 @@
let mockDev = false;
jest.mock("../account/dev/dev_support", () => ({
DevSettings: { futureFeaturesEnabled: () => mockDev }
}));
jest.mock("react-redux", () => ({ connect: jest.fn() }));
let mockPath = "";
@ -16,7 +11,7 @@ import { App, AppProps, mapStateToProps } from "../app";
import { mount } from "enzyme";
import { bot } from "../__test_support__/fake_state/bot";
import {
fakeUser, fakeWebAppConfig, fakeEnigma
fakeUser, fakeWebAppConfig
} from "../__test_support__/fake_state/resources";
import { fakeState } from "../__test_support__/fake_state";
import {
@ -147,21 +142,4 @@ describe("mapStateToProps()", () => {
const result = mapStateToProps(state);
expect(result.axisInversion.x).toEqual(true);
});
it("doesn't show API alerts", () => {
const state = fakeState();
state.resources = buildResourceIndex([fakeEnigma()]);
mockDev = false;
const props = mapStateToProps(state);
expect(props.alertCount).toEqual(0);
});
it("shows API alerts", () => {
const state = fakeState();
const enigma = fakeEnigma();
state.resources = buildResourceIndex([enigma]);
mockDev = true;
const props = mapStateToProps(state);
expect(props.alertCount).toEqual(1);
});
});

View File

@ -4,7 +4,7 @@ describe("fetchLabFeatures", () => {
Object.defineProperty(window.location, "reload", { value: jest.fn() });
it("basically just initializes stuff", () => {
const val = fetchLabFeatures(jest.fn());
expect(val.length).toBe(9);
expect(val.length).toBe(10);
expect(val[0].value).toBeFalsy();
const { callback } = val[0];
if (callback) {

View File

@ -82,7 +82,15 @@ export const fetchLabFeatures =
description: t(Content.TIME_FORMAT_24_HOUR),
storageKey: BooleanSetting.time_format_24_hour,
value: false,
}
},
{
name: t("Confirm emergency unlock"),
description: t(Content.EMERGENCY_UNLOCK_CONFIRM_CONFIG),
confirmationMessage: t(Content.CONFIRM_EMERGENCY_UNLOCK_CONFIRM_DISABLE),
storageKey: BooleanSetting.disable_emergency_unlock_confirmation,
value: false,
displayInvert: true,
},
].map(fetchSettingValue(getConfigValue)));
/** Always allow toggling from true => false (deactivate).

View File

@ -10,12 +10,11 @@ import {
maybeFetchUser,
maybeGetTimeSettings,
getDeviceAccountSettings,
selectAllEnigmas,
} from "./resources/selectors";
import { HotKeys } from "./hotkeys";
import { ControlsPopup } from "./controls_popup";
import { Content } from "./constants";
import { validBotLocationData, validFwConfig, validFbosConfig, betterCompact } from "./util";
import { validBotLocationData, validFwConfig, validFbosConfig } from "./util";
import { BooleanSetting } from "./session_keys";
import { getPathArray } from "./history";
import {
@ -29,7 +28,7 @@ import { t } from "./i18next_wrapper";
import { ResourceIndex } from "./resources/interfaces";
import { isBotOnline } from "./devices/must_be_online";
import { getStatus } from "./connectivity/reducer_support";
import { DevSettings } from "./account/dev/dev_support";
import { getAlerts } from "./messages/state_to_props";
/** For the logger module */
init();
@ -56,10 +55,6 @@ export interface AppProps {
export function mapStateToProps(props: Everything): AppProps {
const webAppConfigValue = getWebAppConfigValue(() => props);
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const botAlerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(props.resources.index).map(x => x.body);
const alerts =
botAlerts.concat(DevSettings.futureFeaturesEnabled() ? apiAlerts : []);
return {
timeSettings: maybeGetTimeSettings(props.resources.index),
dispatch: props.dispatch,
@ -80,7 +75,7 @@ export function mapStateToProps(props: Everything): AppProps {
tour: props.resources.consumers.help.currentTour,
resources: props.resources.index,
autoSync: !!(fbosConfig && fbosConfig.auto_sync),
alertCount: alerts.length,
alertCount: getAlerts(props.resources.index, props.bot).length,
};
}
/** Time at which the app gives up and asks the user to refresh */

View File

@ -422,8 +422,7 @@ export namespace Content {
// App Settings
export const CONFIRM_STEP_DELETION =
trim(`Show a confirmation dialog when the sequence delete step
icon is pressed.`);
trim(`Show a confirmation dialog when deleting a sequence step.`);
export const HIDE_WEBCAM_WIDGET =
trim(`If not using a webcam, use this setting to remove the
@ -465,6 +464,15 @@ export namespace Content {
export const SHOW_PINS =
trim(`Show raw pin lists in Read Sensor and Control Peripheral steps.`);
export const EMERGENCY_UNLOCK_CONFIRM_CONFIG =
trim(`Confirm when unlocking FarmBot after an emergency stop.`);
export const CONFIRM_EMERGENCY_UNLOCK_CONFIRM_DISABLE =
trim(`Warning! When disabled, clicking the UNLOCK button will immediately
unlock FarmBot instead of confirming that it is safe to do so.
As a result, double-clicking the E-STOP button may not stop FarmBot.
Are you sure you want to disable this feature?`);
// Device
export const NOT_HTTPS =
trim(`WARNING: Sending passwords via HTTP:// is not secure.`);

View File

@ -13,7 +13,7 @@ describe("calcMicrostepsPerMm()", () => {
});
it("calculates value with microstepping", () => {
expect(calcMicrostepsPerMm(5, 4)).toEqual(20);
expect(calcMicrostepsPerMm(5, 4)).toEqual(5);
});
});
@ -30,7 +30,7 @@ describe("calculateAxialLengths()", () => {
firmwareSettings.movement_step_per_mm_z = 25;
firmwareSettings.movement_microsteps_z = 4;
expect(calculateAxialLengths({ firmwareSettings })).toEqual({
x: 0, y: 20, z: 1
x: 0, y: 20, z: 4
});
});
});

View File

@ -4,7 +4,8 @@ import { McuParams } from "farmbot";
export const calcMicrostepsPerMm = (
steps_per_mm: number | undefined,
microsteps_per_step: number | undefined) =>
(steps_per_mm || 1) * (microsteps_per_step || 1);
// The firmware currently interprets steps_per_mm as microsteps_per_mm.
(steps_per_mm || 1) * (1 || microsteps_per_step || 1);
const calcAxisLength = (
nr_steps: number | undefined,

View File

@ -37,7 +37,10 @@ export class Move extends React.Component<MoveProps, {}> {
toggle={this.toggle}
getValue={this.getValue} />
</Popover>
<EStopButton bot={this.props.bot} />
<EStopButton
bot={this.props.bot}
forceUnlock={this.getValue(
BooleanSetting.disable_emergency_unlock_confirmation)} />
</WidgetHeader>
<WidgetBody>
<MustBeOnline

View File

@ -26,9 +26,6 @@
0% {
transform: translateX(-11rem)
}
90% {
transform: translateX(1rem)
}
100% {
transform: translateX(0)
}

View File

@ -100,6 +100,7 @@ nav {
}
div {
display: inline;
vertical-align: middle;
}
}
}

View File

@ -116,9 +116,9 @@ export function emergencyLock() {
.then(commandOK(noun), commandErr(noun));
}
export function emergencyUnlock() {
export function emergencyUnlock(force = false) {
const noun = "Emergency unlock";
if (confirm(t(`Are you sure you want to unlock the device?`))) {
if (force || confirm(t(`Are you sure you want to unlock the device?`))) {
getDevice()
.emergencyUnlock()
.then(commandOK(noun), commandErr(noun));

View File

@ -1,3 +1,6 @@
const mockDevice = { emergencyUnlock: jest.fn(() => Promise.resolve()) };
jest.mock("../../../device", () => ({ getDevice: () => mockDevice }));
import * as React from "react";
import { mount } from "enzyme";
import { EStopButton } from "../e_stop_btn";
@ -5,7 +8,7 @@ import { bot } from "../../../__test_support__/fake_state/bot";
import { EStopButtonProps } from "../../interfaces";
describe("<EStopButton />", () => {
const fakeProps = (): EStopButtonProps => ({ bot });
const fakeProps = (): EStopButtonProps => ({ bot, forceUnlock: false });
it("renders", () => {
bot.hardware.informational_settings.sync_status = "synced";
const wrapper = mount(<EStopButton {...fakeProps()} />);
@ -13,18 +16,45 @@ describe("<EStopButton />", () => {
expect(wrapper.find("button").hasClass("red")).toBeTruthy();
});
it("grayed out", () => {
it("is grayed out when offline", () => {
bot.hardware.informational_settings.sync_status = undefined;
const wrapper = mount(<EStopButton {...fakeProps()} />);
expect(wrapper.text()).toEqual("E-STOP");
expect(wrapper.find("button").hasClass("pseudo-disabled")).toBeTruthy();
});
it("locked", () => {
it("shows locked state", () => {
bot.hardware.informational_settings.sync_status = "synced";
bot.hardware.informational_settings.locked = true;
const wrapper = mount(<EStopButton {...fakeProps()} />);
expect(wrapper.text()).toEqual("UNLOCK");
expect(wrapper.find("button").hasClass("yellow")).toBeTruthy();
});
it("confirms unlock", () => {
bot.hardware.informational_settings.sync_status = "synced";
bot.hardware.informational_settings.locked = true;
const p = fakeProps();
p.forceUnlock = false;
window.confirm = jest.fn(() => false);
const wrapper = mount(<EStopButton {...p} />);
expect(wrapper.text()).toEqual("UNLOCK");
wrapper.find("button").simulate("click");
expect(window.confirm).toHaveBeenCalledWith(
"Are you sure you want to unlock the device?");
expect(mockDevice.emergencyUnlock).not.toHaveBeenCalled();
});
it("doesn't confirm unlock", () => {
bot.hardware.informational_settings.sync_status = "synced";
bot.hardware.informational_settings.locked = true;
const p = fakeProps();
p.forceUnlock = true;
window.confirm = jest.fn(() => false);
const wrapper = mount(<EStopButton {...p} />);
expect(wrapper.text()).toEqual("UNLOCK");
wrapper.find("button").simulate("click");
expect(window.confirm).not.toHaveBeenCalled();
expect(mockDevice.emergencyUnlock).toHaveBeenCalled();
});
});

View File

@ -10,7 +10,9 @@ export class EStopButton extends React.Component<EStopButtonProps, {}> {
render() {
const i = this.props.bot.hardware.informational_settings;
const isLocked = !!i.locked;
const toggleEmergencyLock = isLocked ? emergencyUnlock : emergencyLock;
const toggleEmergencyLock = isLocked
? () => emergencyUnlock(this.props.forceUnlock)
: emergencyLock;
const color = isLocked ? "yellow" : "red";
const emergencyLockStatusColor = isBotUp(i.sync_status) ? color : GRAY;
const emergencyLockStatusText = isLocked ? t("UNLOCK") : "E-STOP";

View File

@ -76,6 +76,9 @@ export function EncodersAndEndStops(props: EncodersProps) {
x={"encoder_scaling_x"}
y={"encoder_scaling_y"}
z={"encoder_scaling_z"}
xScale={sourceFwConfig("movement_microsteps_x").value}
yScale={sourceFwConfig("movement_microsteps_y").value}
zScale={sourceFwConfig("movement_microsteps_z").value}
intSize={shouldDisplay(Feature.long_scaling_factor) ? "long" : "short"}
gray={encodersDisabled}
sourceFwConfig={sourceFwConfig}

View File

@ -198,6 +198,7 @@ export interface McuInputBoxProps {
export interface EStopButtonProps {
bot: BotState;
forceUnlock: boolean;
}
export interface PeripheralsProps {

View File

@ -37,7 +37,7 @@ describe("<Tour />", () => {
const wrapper = shallow<Tour>(<Tour steps={steps} />);
wrapper.instance().callback(fakeCallbackData({ type: "tour:end" }));
expect(wrapper.state()).toEqual({ run: false, index: 0 });
expect(history.push).toHaveBeenCalledWith("/app/help");
expect(history.push).toHaveBeenCalledWith("/app/messages");
});
it("navigates through tour: next", () => {

View File

@ -1,5 +1,4 @@
import * as React from "react";
import Joyride, { Step as TourStep, CallBackProps } from "react-joyride";
import { Color } from "../ui";
import { history } from "../history";
@ -44,7 +43,7 @@ export class Tour extends React.Component<TourProps, TourState> {
}
if (type === "tour:end") {
this.setState({ run: false });
history.push("/app/help");
history.push("/app/messages");
}
};

View File

@ -39,6 +39,7 @@ describe("<Alerts />", () => {
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
findApiAlertById: jest.fn(),
});
it("renders no alerts", () => {

View File

@ -1,9 +1,12 @@
jest.mock("../../api/crud", () => ({ destroy: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";
import { AlertCard } from "../cards";
import { AlertCardProps } from "../interfaces";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { FBSelect } from "../../ui";
import { destroy } from "../../api/crud";
describe("<AlertCard />", () => {
const fakeProps = (): AlertCardProps => ({
@ -19,8 +22,13 @@ describe("<AlertCard />", () => {
});
it("renders unknown card", () => {
const wrapper = mount(<AlertCard {...fakeProps()} />);
const p = fakeProps();
p.alert.id = 1;
p.findApiAlertById = () => "uuid";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("noun: verb (author)");
wrapper.find(".fa-times").simulate("click");
expect(destroy).toHaveBeenCalledWith("uuid");
});
it("renders firmware card", () => {
@ -28,6 +36,8 @@ describe("<AlertCard />", () => {
p.alert.problem_tag = "farmbot_os.firmware.missing";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Firmware missing");
wrapper.find(".fa-times").simulate("click");
expect(destroy).not.toHaveBeenCalled();
});
it("renders seed data card", () => {

View File

@ -12,6 +12,7 @@ describe("<Messages />", () => {
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
dispatch: Function,
findApiAlertById: jest.fn(),
});
it("renders page", () => {

View File

@ -6,7 +6,9 @@ jest.mock("../../account/dev/dev_support", () => ({
import { fakeState } from "../../__test_support__/fake_state";
import { mapStateToProps } from "../state_to_props";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeEnigma, fakeFbosConfig } from "../../__test_support__/fake_state/resources";
import {
fakeEnigma, fakeFbosConfig
} from "../../__test_support__/fake_state/resources";
describe("mapStateToProps()", () => {
it("handles undefined", () => {
@ -18,7 +20,9 @@ describe("mapStateToProps()", () => {
it("doesn't show API alerts", () => {
const state = fakeState();
state.resources = buildResourceIndex([fakeEnigma()]);
const enigma = fakeEnigma();
enigma.body.problem_tag = "api.seed_data.missing";
state.resources = buildResourceIndex([enigma]);
mockDev = false;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
@ -27,6 +31,7 @@ describe("mapStateToProps()", () => {
it("shows API alerts", () => {
const state = fakeState();
const enigma = fakeEnigma();
enigma.body.problem_tag = "api.seed_data.missing";
state.resources = buildResourceIndex([enigma]);
mockDev = true;
const props = mapStateToProps(state);
@ -42,4 +47,13 @@ describe("mapStateToProps()", () => {
const props = mapStateToProps(state);
expect(props.apiFirmwareValue).toEqual("arduino");
});
it("finds alert", () => {
const state = fakeState();
const alert = fakeEnigma();
alert.body.id = 1;
state.resources = buildResourceIndex([alert]);
const props = mapStateToProps(state);
expect(props.findApiAlertById(1)).toEqual(alert.uuid);
});
});

View File

@ -37,6 +37,7 @@ export const Alerts = (props: AlertsProps) =>
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />)}
timeSettings={props.timeSettings}
findApiAlertById={props.findApiAlertById} />)}
</div>
</div>;

View File

@ -3,7 +3,8 @@ import { t } from "../i18next_wrapper";
import {
AlertCardProps, AlertCardTemplateProps, FirmwareMissingProps,
SeedDataMissingProps, SeedDataMissingState, TourNotTakenProps,
CommonAlertCardProps
CommonAlertCardProps,
DismissAlertProps
} from "./interfaces";
import { formatLogTime } from "../logs";
import {
@ -13,20 +14,21 @@ import { DropDownItem, Row, Col, FBSelect, docLink } from "../ui";
import { Content } from "../constants";
import { TourList } from "../help/tour_list";
import { splitProblemTag } from "./alerts";
import { destroy } from "../api/crud";
export const AlertCard = (props: AlertCardProps) => {
const { alert, timeSettings } = props;
const commonProps = { alert, timeSettings };
const { alert, timeSettings, findApiAlertById, dispatch } = props;
const commonProps = { alert, timeSettings, findApiAlertById, dispatch };
switch (alert.problem_tag) {
case "farmbot_os.firmware.missing":
return <FirmwareMissing {...commonProps}
apiFirmwareValue={props.apiFirmwareValue} />;
case "api.seed_data.missing":
return <SeedDataMissing {...commonProps}
dispatch={props.dispatch} />;
dispatch={dispatch} />;
case "api.tour.not_taken":
return <TourNotTaken {...commonProps}
dispatch={props.dispatch} />;
dispatch={dispatch} />;
case "api.user.not_welcomed":
return <UserNotWelcomed {...commonProps} />;
case "api.documentation.unread":
@ -35,20 +37,27 @@ export const AlertCard = (props: AlertCardProps) => {
return UnknownAlert(commonProps);
}
};
const dismissAlert = (props: DismissAlertProps) => () =>
(props.id && props.findApiAlertById && props.dispatch)
? props.dispatch(destroy(props.findApiAlertById(props.id)))
: () => { };
const AlertCardTemplate = (props: AlertCardTemplateProps) =>
<div className={`problem-alert ${props.className}`}>
const AlertCardTemplate = (props: AlertCardTemplateProps) => {
const { alert, findApiAlertById, dispatch } = props;
return <div className={`problem-alert ${props.className}`}>
<div className="problem-alert-title">
<i className="fa fa-exclamation-triangle" />
<h3>{t(props.title)}</h3>
<p>{formatLogTime(props.alert.created_at, props.timeSettings)}</p>
<i className="fa fa-times" />
<p>{formatLogTime(alert.created_at, props.timeSettings)}</p>
<i className="fa fa-times"
onClick={dismissAlert({ id: alert.id, findApiAlertById, dispatch })} />
</div>
<div className="problem-alert-content">
<p>{t(props.message)}</p>
{props.children}
</div>
</div>;
};
const UnknownAlert = (props: CommonAlertCardProps) => {
const { problem_tag, created_at, priority } = props.alert;
@ -60,7 +69,9 @@ const UnknownAlert = (props: CommonAlertCardProps) => {
title={`${t(noun)}: ${t(verb)} (${t(author)})`}
message={t("Unknown problem of priority {{priority}} created at {{createdAt}}",
{ priority, createdAt })}
timeSettings={props.timeSettings} />;
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById} />;
};
const FirmwareMissing = (props: FirmwareMissingProps) =>
@ -69,7 +80,9 @@ const FirmwareMissing = (props: FirmwareMissingProps) =>
className={"firmware-missing-alert"}
title={t("Firmware missing")}
message={t("Your device has no firmware installed.")}
timeSettings={props.timeSettings}>
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
<FirmwareActions
apiFirmwareValue={props.apiFirmwareValue}
botOnline={true} />
@ -92,7 +105,9 @@ class SeedDataMissing
className={"seed-data-missing-alert"}
title={t("Choose your FarmBot")}
message={t(Content.SEED_DATA_SELECTION)}
timeSettings={this.props.timeSettings}>
timeSettings={this.props.timeSettings}
dispatch={this.props.dispatch}
findApiAlertById={this.props.findApiAlertById}>
<Row>
<Col xs={4}>
<label>{t("Choose your FarmBot")}</label>
@ -115,7 +130,9 @@ const TourNotTaken = (props: TourNotTakenProps) =>
className={"tour-not-taken-alert"}
title={t("Take a guided tour")}
message={t(Content.TAKE_A_TOUR)}
timeSettings={props.timeSettings}>
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
<p>{t("Choose a tour to begin")}:</p>
<TourList dispatch={props.dispatch} />
</AlertCardTemplate>;
@ -126,7 +143,9 @@ const UserNotWelcomed = (props: CommonAlertCardProps) =>
className={"user-not-welcomed-alert"}
title={t("Welcome to the FarmBot Web App")}
message={t(Content.WELCOME)}
timeSettings={props.timeSettings}>
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
<p>
{t("You're currently viewing the")} <b>{t("Message Center")}</b>.
&nbsp;{t(Content.MESSAGE_CENTER_WELCOME)}
@ -142,7 +161,9 @@ const DocumentationUnread = (props: CommonAlertCardProps) =>
className={"documentation-unread-alert"}
title={t("Learn more about the app")}
message={t(Content.READ_THE_DOCS)}
timeSettings={props.timeSettings}>
timeSettings={props.timeSettings}
dispatch={props.dispatch}
findApiAlertById={props.findApiAlertById}>
<p>
{t("Head over to")}
&nbsp;<a href={docLink()} target="_blank"

View File

@ -24,7 +24,8 @@ export class Messages extends React.Component<MessagesProps, {}> {
<Alerts alerts={this.props.alerts}
dispatch={this.props.dispatch}
apiFirmwareValue={this.props.apiFirmwareValue}
timeSettings={this.props.timeSettings} />
timeSettings={this.props.timeSettings}
findApiAlertById={this.props.findApiAlertById} />
</Row>
<Row>
<div className="link-to-logs">

View File

@ -1,12 +1,14 @@
import { FirmwareHardware, Enigma } from "farmbot";
import { TimeSettings } from "../interfaces";
import { BotState } from "../devices/interfaces";
import { UUID } from "../resources/interfaces";
export interface MessagesProps {
alerts: Alert[];
apiFirmwareValue: FirmwareHardware | undefined;
timeSettings: TimeSettings;
dispatch: Function;
findApiAlertById(id: number): UUID;
}
export interface AlertsProps {
@ -14,6 +16,7 @@ export interface AlertsProps {
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
dispatch: Function;
findApiAlertById(id: number): UUID;
}
export interface ProblemTag {
@ -36,11 +39,14 @@ export interface AlertCardProps {
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
dispatch: Function;
findApiAlertById?(id: number): UUID;
}
export interface CommonAlertCardProps {
alert: Alert;
timeSettings: TimeSettings;
findApiAlertById?(id: number): UUID;
dispatch?: Function;
}
export interface AlertCardTemplateProps {
@ -50,6 +56,14 @@ export interface AlertCardTemplateProps {
message: string;
timeSettings: TimeSettings;
children?: React.ReactNode;
findApiAlertById?(id: number): UUID;
dispatch?: Function;
}
export interface DismissAlertProps {
id?: number;
findApiAlertById?(id: number): UUID;
dispatch?: Function;
}
export interface FirmwareMissingProps extends CommonAlertCardProps {

View File

@ -1,11 +1,15 @@
import { Everything } from "../interfaces";
import { MessagesProps } from "./interfaces";
import { MessagesProps, Alert } from "./interfaces";
import { validFbosConfig, betterCompact } from "../util";
import { getFbosConfig } from "../resources/getters";
import { sourceFbosConfigValue } from "../devices/components/source_config_value";
import { DevSettings } from "../account/dev/dev_support";
import { selectAllEnigmas, maybeGetTimeSettings } from "../resources/selectors";
import {
selectAllEnigmas, maybeGetTimeSettings, findResourceById
} from "../resources/selectors";
import { isFwHardwareValue } from "../devices/components/fbos_settings/board_type";
import { ResourceIndex, UUID } from "../resources/interfaces";
import { BotState } from "../devices/interfaces";
export const mapStateToProps = (props: Everything): MessagesProps => {
const { hardware } = props.bot;
@ -13,15 +17,23 @@ export const mapStateToProps = (props: Everything): MessagesProps => {
const sourceFbosConfig =
sourceFbosConfigValue(fbosConfig, hardware.configuration);
const apiFirmwareValue = sourceFbosConfig("firmware_hardware").value;
const botAlerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(props.resources.index).map(x => x.body);
const alerts =
botAlerts.concat(DevSettings.futureFeaturesEnabled() ? apiAlerts : []);
const findApiAlertById = (id: number): UUID =>
findResourceById(props.resources.index, "Enigma", id);
return {
alerts,
alerts: getAlerts(props.resources.index, props.bot),
apiFirmwareValue: isFwHardwareValue(apiFirmwareValue)
? apiFirmwareValue : undefined,
timeSettings: maybeGetTimeSettings(props.resources.index),
dispatch: props.dispatch,
findApiAlertById,
};
};
export const getAlerts =
(resourceIndex: ResourceIndex, bot: BotState): Alert[] => {
const botAlerts = betterCompact(Object.values(bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(resourceIndex).map(x => x.body)
.filter(x => DevSettings.futureFeaturesEnabled() ||
x.problem_tag !== "api.seed_data.missing");
return botAlerts.concat(apiAlerts);
};

View File

@ -18,6 +18,7 @@ import { Connectivity } from "../devices/connectivity/connectivity";
import { connectivityData } from "../devices/connectivity/generate_data";
import { DiagnosisSaucer } from "../devices/connectivity/diagnosis";
import { maybeSetTimezone } from "../devices/timezones/guess_timezone";
import { BooleanSetting } from "../session_keys";
export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
@ -111,7 +112,10 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
{AdditionalMenu({ logout: this.logout, close })}
</Popover>
</div>
<EStopButton bot={this.props.bot} />
<EStopButton
bot={this.props.bot}
forceUnlock={!!this.props.getConfigValue(
BooleanSetting.disable_emergency_unlock_confirmation)} />
{this.syncButton()}
<div className="connection-status-popover">
<Popover position={Position.BOTTOM_RIGHT}

View File

@ -22,6 +22,7 @@ export const BooleanSetting: Record<BooleanConfigKey, BooleanConfigKey> = {
show_historic_points: "show_historic_points",
time_format_24_hour: "time_format_24_hour",
show_pins: "show_pins",
disable_emergency_unlock_confirmation: "disable_emergency_unlock_confirmation",
/** "Labs" feature names. (App preferences) */
stub_config: "stub_config",

View File

@ -1,7 +0,0 @@
import { stopIE } from "../stop_ie";
describe("stopIE()", () => {
it("not IE", () => {
expect(stopIE).not.toThrow();
});
});

View File

@ -0,0 +1,25 @@
import { updatePageInfo, attachToRoot } from "../page";
import React from "react";
describe("updatePageInfo()", () => {
it("sets page title", () => {
updatePageInfo("page name");
expect(document.title).toEqual("Page name - FarmBot");
});
it("sets page title: Farm Designer", () => {
updatePageInfo("designer");
expect(document.title).toEqual("Farm designer - FarmBot");
});
});
describe("attachToRoot()", () => {
class Foo extends React.Component<{ text: string }> {
render() { return <p>{this.props.text}</p>; }
}
it("attaches page", () => {
attachToRoot(Foo, { text: "Bar" });
expect(document.body.innerHTML).toEqual(`<div id="root"><p>Bar</p></div>`);
expect(document.body.textContent).toEqual("Bar");
});
});

View File

@ -4,14 +4,13 @@ import {
Attributes
} from "react";
import { render } from "react-dom";
import { capitalize } from "lodash";
import { t } from "../i18next_wrapper";
/** Dynamically change the meta title of the page. */
export function updatePageInfo(pageName: string) {
if (pageName === "designer") { pageName = "Farm Designer"; }
document.title = t(capitalize(pageName));
document.title = `${t(capitalize(pageName))} - FarmBot`;
// Possibly add meta "content" here dynamically as well
}

View File

@ -43,7 +43,7 @@
"coveralls": "3.0.3",
"enzyme": "3.9.0",
"enzyme-adapter-react-16": "1.12.1",
"farmbot": "7.0.4-rc3",
"farmbot": "7.0.4",
"farmbot-toastr": "1.0.3",
"i18next": "15.0.9",
"jest": "24.7.1",