Merge conflicts
commit
08b341f820
|
@ -11,3 +11,4 @@ CI=true
|
|||
CODECLIMATE_REPO_TOKEN=2216bf71b977a85ed8da497942c34a503dbedd77da1b6b97c33551ba02ea02f8
|
||||
COVERALLS_REPO_TOKEN=lEX6nkql7y2YFCcIXVq5ORvdvMtYzfZdG
|
||||
CODECOV_TOKEN=c0fe1e65-d284-4d58-a742-4088e88be35d
|
||||
RAILS_ENV=test
|
||||
|
|
|
@ -6,22 +6,35 @@ jobs:
|
|||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Setup the database and (fake) secrets
|
||||
name: Shuffle configs around, install Docker-Compose
|
||||
command: |
|
||||
curl -L https://github.com/docker/compose/releases/download/1.22.0/docker-compose-`uname -s`-`uname -m` > docker-compose
|
||||
chmod +x docker-compose
|
||||
sudo mv docker-compose /usr/local/bin
|
||||
mv .circleci/circle_envs .env
|
||||
- run:
|
||||
name: Install Ruby/JS deps
|
||||
command: |
|
||||
sudo docker-compose run web bundle install
|
||||
sudo docker-compose run web npm install
|
||||
sudo docker-compose run web bundle exec rails db:setup
|
||||
- run:
|
||||
name: Create databases and secrets
|
||||
command: |
|
||||
sudo docker-compose run web bundle exec rails db:create
|
||||
sudo docker-compose run web bundle exec rails db:migrate
|
||||
sudo docker-compose run web rake keys:generate
|
||||
- run:
|
||||
name: Run Rails and JS tests
|
||||
name: Run Ruby tests
|
||||
command: |
|
||||
sudo docker-compose run web rspec spec
|
||||
- run:
|
||||
name: Run linters
|
||||
command: |
|
||||
sudo docker-compose run webpack npm run tslint
|
||||
sudo docker-compose run webpack npm run sass-lint
|
||||
sudo docker-compose run webpack npm run typecheck
|
||||
- run:
|
||||
name: Run JS tests
|
||||
command: |
|
||||
sudo docker-compose run webpack npm run test-slow
|
||||
sudo docker-compose run webpack npm run coverage
|
||||
sudo docker-compose run -e COVERALLS_REPO_TOKEN=lEX6nkql7y2YFCcIXVq5ORvdvMtYzfZdG webpack npm run coverage
|
||||
|
|
10
Gemfile.lock
10
Gemfile.lock
|
@ -157,9 +157,9 @@ GEM
|
|||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.7)
|
||||
hashdiff (0.3.7)
|
||||
hashie (3.5.7)
|
||||
hashie (3.6.0)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.1.0)
|
||||
i18n (1.1.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.1.0)
|
||||
jsonapi-renderer (0.2.0)
|
||||
|
@ -175,9 +175,9 @@ GEM
|
|||
loofah (2.2.2)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.0)
|
||||
mail (2.7.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (0.3.2)
|
||||
marcel (0.3.3)
|
||||
mimemagic (~> 0.3.2)
|
||||
memoist (0.16.0)
|
||||
method_source (0.9.0)
|
||||
|
@ -281,7 +281,7 @@ GEM
|
|||
rspec-mocks (~> 3.8.0)
|
||||
rspec-core (3.8.0)
|
||||
rspec-support (~> 3.8.0)
|
||||
rspec-expectations (3.8.1)
|
||||
rspec-expectations (3.8.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.8.0)
|
||||
rspec-mocks (3.8.0)
|
||||
|
|
|
@ -17,7 +17,7 @@ module CeleryScriptSettingsBag
|
|||
|
||||
# List of all celery script nodes that can be used as a varaible...
|
||||
ANY_VARIABLE = [:tool, :coordinate, :point, :identifier]
|
||||
PLANT_STAGES = %w(planned planted harvested)
|
||||
PLANT_STAGES = %w(planned planted harvested sprouted)
|
||||
ALLOWED_PIN_MODES = [DIGITAL = 0, ANALOG = 1]
|
||||
ALLOWED_RPC_NODES = %w(home emergency_lock emergency_unlock read_status
|
||||
sync check_updates power_off reboot toggle_pin
|
||||
|
|
|
@ -7,20 +7,25 @@ class GlobalConfig < ApplicationRecord
|
|||
validates_uniqueness_of :key
|
||||
validates_presence_of :key
|
||||
|
||||
# Bootstrap these values, but NEVER clobber pre-existing ones:
|
||||
{
|
||||
"FBOS_END_OF_LIFE_VERSION" => "6.3.0",
|
||||
"MINIMUM_FBOS_VERSION" => "6.0.0",
|
||||
"TOS_URL" => ENV.fetch("TOS_URL", ""),
|
||||
"PRIV_URL" => ENV.fetch("PRIV_URL", "")
|
||||
}.map do |(key, value)|
|
||||
x = self.find_by(key: key)
|
||||
self.create!(key: key, value: value) unless x
|
||||
end
|
||||
|
||||
LONG_REVISION = ENV["BUILT_AT"] || ENV["HEROKU_SLUG_COMMIT"] || "NONE"
|
||||
# Bootstrap initial defaults:
|
||||
# Bootstrap these values, and ALWAYS clobber pre-existing ones:
|
||||
{
|
||||
"NODE_ENV" => Rails.env || "development",
|
||||
"TOS_URL" => ENV.fetch("TOS_URL", ""),
|
||||
"PRIV_URL" => ENV.fetch("PRIV_URL", ""),
|
||||
"LONG_REVISION" => LONG_REVISION,
|
||||
"SHORT_REVISION" => LONG_REVISION.first(8),
|
||||
"FBOS_END_OF_LIFE_VERSION" => "0.0.0",
|
||||
"MINIMUM_FBOS_VERSION" => "6.0.0"
|
||||
}.map do |(key, value)|
|
||||
self
|
||||
.find_or_create_by(key: key)
|
||||
.update_attributes(key: key, value: value)
|
||||
self.find_or_create_by(key: key).update_attributes(key: key, value: value)
|
||||
end
|
||||
|
||||
# Memoized version of every GlobalConfig, with key/values layed out in a hash.
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
class ChangeDefaultApiMigrated < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
change_column_default(:fbos_configs, :api_migrated, from: false, to: true)
|
||||
change_column_default(:firmware_configs, :api_migrated, from: false, to: true)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
class AddShowSensorReadingsToWebAppConfigs < ActiveRecord::Migration[5.2]
|
||||
safety_assured
|
||||
def change
|
||||
add_column :web_app_configs,
|
||||
:show_sensor_readings,
|
||||
:boolean,
|
||||
default: false
|
||||
end
|
||||
end
|
|
@ -376,7 +376,7 @@ CREATE TABLE public.fbos_configs (
|
|||
sequence_init_log boolean DEFAULT false,
|
||||
network_not_found_timer integer,
|
||||
firmware_hardware character varying DEFAULT 'arduino'::character varying,
|
||||
api_migrated boolean DEFAULT false,
|
||||
api_migrated boolean DEFAULT true,
|
||||
os_auto_update boolean DEFAULT true,
|
||||
arduino_debug_messages boolean DEFAULT false
|
||||
);
|
||||
|
@ -499,7 +499,7 @@ CREATE TABLE public.firmware_configs (
|
|||
pin_guard_5_active_state integer DEFAULT 1,
|
||||
pin_guard_5_pin_nr integer DEFAULT 0,
|
||||
pin_guard_5_time_out integer DEFAULT 60,
|
||||
api_migrated boolean DEFAULT false,
|
||||
api_migrated boolean DEFAULT true,
|
||||
movement_invert_2_endpoints_x integer DEFAULT 0,
|
||||
movement_invert_2_endpoints_y integer DEFAULT 0,
|
||||
movement_invert_2_endpoints_z integer DEFAULT 0
|
||||
|
@ -2389,6 +2389,8 @@ INSERT INTO "schema_migrations" (version) VALUES
|
|||
('20180829211322'),
|
||||
('20180910143055'),
|
||||
('20180920194120'),
|
||||
('20180926161918');
|
||||
('20180925203846'),
|
||||
('20180926161918'),
|
||||
('20181014231010');
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
FROM ruby:2.5
|
||||
# WHY: We need Postgres 10, not the default (9)
|
||||
RUN wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O - | apt-key add -
|
||||
RUN sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ stretch-pgdg main" >> /etc/apt/sources.list.d/pgdg.list'
|
||||
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev \
|
||||
postgresql postgresql-contrib
|
||||
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash -
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"url": "https://github.com/farmbot/farmbot-web-frontend"
|
||||
},
|
||||
"scripts": {
|
||||
"coverage": "cat **/*lcov.info | ./node_modules/coveralls/bin/coveralls.js",
|
||||
"coverage": "cat **/*lcov.info | ./node_modules/coveralls/bin/coveralls.js --verbose",
|
||||
"clean": "rm -rf public/dist && rm -rf public/webpack",
|
||||
"build": "TARGET=production bundle exec rake webpack:compile",
|
||||
"start": "echo 'use `sudo docker-compose up` instead.'",
|
||||
|
@ -43,6 +43,7 @@
|
|||
"@types/react": "^16.4.18",
|
||||
"@types/react-color": "2.13.6",
|
||||
"@types/react-dom": "^16.0.9",
|
||||
"@types/react-joyride": "^2.0.0",
|
||||
"@types/react-redux": "6.0.9",
|
||||
"axios": "^0.18.0",
|
||||
"boxed_value": "^1.0.0",
|
||||
|
@ -51,7 +52,7 @@
|
|||
"css-loader": "1.0.0",
|
||||
"enzyme": "^3.7.0",
|
||||
"enzyme-adapter-react-16": "^1.6.0",
|
||||
"farmbot": "^6.5.2",
|
||||
"farmbot": "6.5.2",
|
||||
"farmbot-toastr": "^1.0.3",
|
||||
"fastclick": "^1.0.6",
|
||||
"file-loader": "2.0.0",
|
||||
|
@ -71,6 +72,7 @@
|
|||
"react-addons-test-utils": "^15.6.2",
|
||||
"react-color": "2.14.1",
|
||||
"react-dom": "16.5.2",
|
||||
"react-joyride": "^2.0.0-15",
|
||||
"react-redux": "^5.0.6",
|
||||
"react-test-renderer": "16.5.2",
|
||||
"react-transition-group": "2.5.0",
|
||||
|
|
|
@ -27,7 +27,7 @@ describe Api::FbosConfigsController do
|
|||
network_not_found_timer: nil,
|
||||
os_auto_update: true,
|
||||
firmware_hardware: "arduino",
|
||||
api_migrated: false
|
||||
api_migrated: true
|
||||
}.to_a.map do |key, value|
|
||||
actual = json[key]
|
||||
expected = value
|
||||
|
|
|
@ -26,16 +26,20 @@ describe Api::PointsController do
|
|||
time = (DateTime.now - 1.day).to_json
|
||||
p = { x: 23,
|
||||
y: 45,
|
||||
name: "My Lettuce",
|
||||
name: "Put me in a salad",
|
||||
pointer_type: "Plant",
|
||||
openfarm_slug: "limestone-lettuce",
|
||||
planted_at: time }
|
||||
openfarm_slug: "mung-bean",
|
||||
planted_at: time,
|
||||
plant_stage: "sprouted"
|
||||
}
|
||||
post :create, body: p.to_json, params: { format: :json }
|
||||
expect(response.status).to eq(200)
|
||||
plant = Plant.last
|
||||
expect(plant.x).to eq(p[:x])
|
||||
expect(plant.y).to eq(p[:y])
|
||||
expect(plant.name).to eq(p[:name])
|
||||
expect(plant.x).to eq(p[:x])
|
||||
expect(plant.y).to eq(p[:y])
|
||||
expect(plant.name).to eq(p[:name])
|
||||
expect(plant.plant_stage).to eq("sprouted")
|
||||
expect(p[:plant_stage]).to eq("sprouted")
|
||||
expect(plant.openfarm_slug).to eq(p[:openfarm_slug])
|
||||
expect(plant.created_at).to be_truthy
|
||||
p.keys.each do |key|
|
||||
|
|
|
@ -271,6 +271,7 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
|
|||
show_spread: false,
|
||||
show_farmbot: true,
|
||||
show_images: false,
|
||||
show_sensor_readings: false,
|
||||
show_plants: true,
|
||||
show_points: true,
|
||||
x_axis_inverted: false,
|
||||
|
|
|
@ -31,6 +31,7 @@ const fakeProps = (): AppProps => {
|
|||
xySwap: false,
|
||||
animate: false,
|
||||
getConfigValue: jest.fn(),
|
||||
tour: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ export class LabsFeatures extends React.Component<LabsFeaturesProps, {}> {
|
|||
|
||||
render() {
|
||||
const { getConfigValue, dispatch } = this.props;
|
||||
return <Widget className="peripherals-widget">
|
||||
return <Widget className="app-settings-widget">
|
||||
<WidgetHeader title={t("App Settings")}
|
||||
helpText={ToolTips.LABS}>
|
||||
</WidgetHeader>
|
||||
|
|
|
@ -45,6 +45,7 @@ export interface AppProps {
|
|||
firmwareConfig: FirmwareConfig | undefined;
|
||||
animate: boolean;
|
||||
getConfigValue: GetWebAppConfigValue;
|
||||
tour: string | undefined;
|
||||
}
|
||||
|
||||
function mapStateToProps(props: Everything): AppProps {
|
||||
|
@ -66,6 +67,7 @@ function mapStateToProps(props: Everything): AppProps {
|
|||
firmwareConfig: validFwConfig(getFirmwareConfig(props.resources.index)),
|
||||
animate: !webAppConfigValue(BooleanSetting.disable_animations),
|
||||
getConfigValue: webAppConfigValue,
|
||||
tour: props.resources.consumers.help.currentTour,
|
||||
};
|
||||
}
|
||||
/** Time at which the app gives up and asks the user to refresh */
|
||||
|
@ -114,7 +116,8 @@ export class App extends React.Component<AppProps, {}> {
|
|||
bot={this.props.bot}
|
||||
dispatch={this.props.dispatch}
|
||||
logs={this.props.logs}
|
||||
getConfigValue={this.props.getConfigValue} />
|
||||
getConfigValue={this.props.getConfigValue}
|
||||
tour={this.props.tour} />
|
||||
{!syncLoaded && <LoadingPlant animate={this.props.animate} />}
|
||||
{syncLoaded && this.props.children}
|
||||
{!(["controls", "account", "regimens"].includes(currentPage)) &&
|
||||
|
|
|
@ -48,6 +48,7 @@ export interface WebAppConfig {
|
|||
home_button_homing: boolean;
|
||||
show_motor_plot: boolean;
|
||||
show_historic_points: boolean;
|
||||
show_sensor_readings: boolean;
|
||||
}
|
||||
|
||||
export type NumberConfigKey = "id"
|
||||
|
@ -93,4 +94,5 @@ export type BooleanConfigKey = "confirm_step_deletion"
|
|||
|"xy_swap"
|
||||
|"home_button_homing"
|
||||
|"show_motor_plot"
|
||||
|"show_historic_points";
|
||||
|"show_historic_points"
|
||||
|"show_sensor_readings";
|
||||
|
|
|
@ -317,6 +317,9 @@ export namespace ToolTips {
|
|||
// App
|
||||
export const LABS =
|
||||
trim(`Customize your web app experience.`);
|
||||
|
||||
export const TOURS =
|
||||
trim(`Take a guided tour of the Web App.`);
|
||||
}
|
||||
|
||||
export namespace Content {
|
||||
|
@ -591,6 +594,55 @@ export namespace Content {
|
|||
trim(`Not available when device is offline.`);
|
||||
}
|
||||
|
||||
export namespace TourContent {
|
||||
// Getting started
|
||||
export const ADD_PLANTS =
|
||||
trim(`Add plants by pressing the + button and searching for a plant,
|
||||
selecting one, and dragging it into the garden.`);
|
||||
|
||||
export const ADD_TOOLS =
|
||||
trim(`Press edit and then the + button to add tools.`);
|
||||
|
||||
export const ADD_TOOLS_SLOTS =
|
||||
trim(`Add the newly created tools to the corresponding toolbay slots on
|
||||
FarmBot: press edit and then + to create a toolbay slot.`);
|
||||
|
||||
export const ADD_PERIPHERALS =
|
||||
trim(`Press edit and then the + button to add peripherals.`);
|
||||
|
||||
export const ADD_SEQUENCES =
|
||||
trim(`Press the + button to add a new sequence. You will need
|
||||
to create sequences to mount tools, move to the plant locations you
|
||||
created in the Farm Designer, and seed/water them.`);
|
||||
|
||||
export const ADD_REGIMENS =
|
||||
trim(`Press the + button and add your newly created sequences to a
|
||||
regimen via the scheduler. The regimen should include all actions
|
||||
needed to take care of a plant over its life.`);
|
||||
|
||||
export const ADD_FARM_EVENTS =
|
||||
trim(`Add a farm event via the + button to schedule a sequence or
|
||||
regimen in the calendar.`);
|
||||
|
||||
// Monitoring
|
||||
export const LOCATION_GRID =
|
||||
trim(`View FarmBot's current location using the axis position display.`);
|
||||
|
||||
export const VIRTUAL_FARMBOT =
|
||||
trim(`Or view FarmBot's current location in the virtual garden.`);
|
||||
|
||||
export const LOGS_TABLE =
|
||||
trim(`View recent log messages here. More detailed log messages can be
|
||||
shown by adjusting filter settings.`);
|
||||
|
||||
export const PHOTOS =
|
||||
trim(`View photos your FarmBot has taken here.`);
|
||||
|
||||
// Fun stuff
|
||||
export const APP_SETTINGS =
|
||||
trim(`Toggle various settings to customize your web app experience.`);
|
||||
}
|
||||
|
||||
export enum Actions {
|
||||
|
||||
// Resources
|
||||
|
@ -668,6 +720,9 @@ export enum Actions {
|
|||
SELECT_IMAGE = "SELECT_IMAGE",
|
||||
FETCH_FIRST_PARTY_FARMWARE_NAMES_OK = "FETCH_FIRST_PARTY_FARMWARE_NAMES_OK",
|
||||
|
||||
// App
|
||||
START_TOUR = "START_TOUR",
|
||||
|
||||
// Network
|
||||
NETWORK_EDGE_CHANGE = "NETWORK_EDGE_CHANGE",
|
||||
RESET_NETWORK = "RESET_NETWORK",
|
||||
|
|
|
@ -28,7 +28,7 @@ export class Move extends React.Component<MoveProps, {}> {
|
|||
render() {
|
||||
const { location_data, informational_settings } = this.props.bot.hardware;
|
||||
const locationData = validBotLocationData(location_data);
|
||||
return <Widget>
|
||||
return <Widget className="move-widget">
|
||||
<WidgetHeader
|
||||
title={t("Move")}
|
||||
helpText={ToolTips.MOVE}>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { Widget, WidgetHeader } from "../../ui/index";
|
||||
import { Widget, WidgetHeader, WidgetBody } from "../../ui/index";
|
||||
import { t } from "i18next";
|
||||
import { ToolTips } from "../../constants";
|
||||
import { WebcamPanelProps } from "./interfaces";
|
||||
|
@ -27,7 +27,7 @@ export function Edit(props: WebcamPanelProps) {
|
|||
const unsaved = props
|
||||
.feeds
|
||||
.filter(x => x.specialStatus === SpecialStatus.DIRTY);
|
||||
return <Widget>
|
||||
return <Widget className="webcam-widget">
|
||||
<WidgetHeader title="Edit" helpText={ToolTips.WEBCAM}>
|
||||
<button
|
||||
className="fb-button gray"
|
||||
|
@ -46,8 +46,8 @@ export function Edit(props: WebcamPanelProps) {
|
|||
<i className="fa fa-plus" />
|
||||
</button>
|
||||
</WidgetHeader>
|
||||
<div className="widget-body">
|
||||
<WidgetBody>
|
||||
{rows}
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { Widget, WidgetHeader, FallbackImg } from "../../ui/index";
|
||||
import { Widget, WidgetHeader, FallbackImg, WidgetBody } from "../../ui/index";
|
||||
import { t } from "i18next";
|
||||
import { ToolTips } from "../../constants";
|
||||
import { WebcamPanelProps } from "./interfaces";
|
||||
|
@ -53,7 +53,7 @@ export class Show extends React.Component<WebcamPanelProps, State> {
|
|||
const title = flipper.current.name || t("Webcam Feeds");
|
||||
const msg = this.getMessage(flipper.current.url);
|
||||
const imageClass = msg.length > 0 ? "no-flipper-image-container" : "";
|
||||
return <Widget>
|
||||
return <Widget className="webcam-widget">
|
||||
<WidgetHeader title={title} helpText={ToolTips.WEBCAM}>
|
||||
<button
|
||||
className="fb-button gray"
|
||||
|
@ -62,7 +62,7 @@ export class Show extends React.Component<WebcamPanelProps, State> {
|
|||
</button>
|
||||
<IndexIndicator i={this.state.current} total={feeds.length} />
|
||||
</WidgetHeader>
|
||||
<div className="widget-body">
|
||||
<WidgetBody>
|
||||
<div className="image-flipper">
|
||||
<div className={imageClass}>
|
||||
<p>{msg}</p>
|
||||
|
@ -85,7 +85,7 @@ export class Show extends React.Component<WebcamPanelProps, State> {
|
|||
{t("Next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -951,3 +951,9 @@ ul {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tour-list {
|
||||
margin: auto;
|
||||
width: 75%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import { Widget, WidgetHeader } from "../ui/index";
|
||||
import { Widget, WidgetHeader, WidgetBody } from "../ui";
|
||||
|
||||
/*
|
||||
* Widget to display if the desired widget fails to load.
|
||||
|
@ -29,9 +29,9 @@ export class FallbackWidget extends
|
|||
<WidgetHeader
|
||||
title={t(this.props.title)}
|
||||
helpText={this.props.helpText} />
|
||||
<div className="widget-body">
|
||||
<WidgetBody>
|
||||
{t("Widget load failed.")}
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,6 +60,8 @@ describe("<FarmDesigner/>", () => {
|
|||
},
|
||||
tzOffset: 0,
|
||||
getConfigValue: jest.fn(),
|
||||
sensorReadings: [],
|
||||
sensors: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
|
|||
show_spread: this.initializeSetting(BooleanSetting.show_spread, false),
|
||||
show_farmbot: this.initializeSetting(BooleanSetting.show_farmbot, true),
|
||||
show_images: this.initializeSetting(BooleanSetting.show_images, false),
|
||||
show_sensor_readings: this.initializeSetting(BooleanSetting.show_sensor_readings, false),
|
||||
bot_origin_quadrant: this.getBotOriginQuadrant(),
|
||||
zoom_level: calcZoomLevel(getZoomLevelIndex(this.props.getConfigValue))
|
||||
};
|
||||
|
@ -106,6 +107,7 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
|
|||
show_spread,
|
||||
show_farmbot,
|
||||
show_images,
|
||||
show_sensor_readings,
|
||||
bot_origin_quadrant,
|
||||
zoom_level
|
||||
} = this.state;
|
||||
|
@ -144,6 +146,7 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
|
|||
showSpread={show_spread}
|
||||
showFarmbot={show_farmbot}
|
||||
showImages={show_images}
|
||||
showSensorReadings={show_sensor_readings}
|
||||
dispatch={this.props.dispatch}
|
||||
tzOffset={this.props.tzOffset}
|
||||
getConfigValue={this.props.getConfigValue}
|
||||
|
@ -163,6 +166,7 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
|
|||
showSpread={show_spread}
|
||||
showFarmbot={show_farmbot}
|
||||
showImages={show_images}
|
||||
showSensorReadings={show_sensor_readings}
|
||||
selectedPlant={this.props.selectedPlant}
|
||||
crops={this.props.crops}
|
||||
dispatch={this.props.dispatch}
|
||||
|
@ -182,7 +186,10 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
|
|||
eStopStatus={this.props.eStopStatus}
|
||||
latestImages={this.props.latestImages}
|
||||
cameraCalibrationData={this.props.cameraCalibrationData}
|
||||
getConfigValue={this.props.getConfigValue} />
|
||||
getConfigValue={this.props.getConfigValue}
|
||||
sensorReadings={this.props.sensorReadings}
|
||||
timeOffset={this.props.tzOffset}
|
||||
sensors={this.props.sensors} />
|
||||
</div>
|
||||
|
||||
{this.props.designer.openedSavedGarden &&
|
||||
|
|
|
@ -7,6 +7,8 @@ import {
|
|||
TaggedRegimen,
|
||||
TaggedGenericPointer,
|
||||
TaggedImage,
|
||||
TaggedSensorReading,
|
||||
TaggedSensor,
|
||||
} from "farmbot";
|
||||
import { SlotWithTool } from "../resources/interfaces";
|
||||
import { BotPosition, StepsPerMmXY, BotLocationData } from "../devices/interfaces";
|
||||
|
@ -16,7 +18,9 @@ import { AxisNumberProperty, BotSize, TaggedPlant } from "./map/interfaces";
|
|||
import { SelectionBoxData } from "./map/selection_box";
|
||||
import { BooleanConfigKey } from "../config_storage/web_app_configs";
|
||||
import { GetWebAppConfigValue } from "../config_storage/actions";
|
||||
import { ExecutableType, PlantPointer } from "farmbot/dist/resources/api_resources";
|
||||
import {
|
||||
ExecutableType, PlantPointer
|
||||
} from "farmbot/dist/resources/api_resources";
|
||||
|
||||
/* BotOriginQuadrant diagram
|
||||
|
||||
|
@ -42,6 +46,7 @@ export interface State extends TypeCheckerHint {
|
|||
show_spread: boolean;
|
||||
show_farmbot: boolean;
|
||||
show_images: boolean;
|
||||
show_sensor_readings: boolean;
|
||||
bot_origin_quadrant: BotOriginQuadrant;
|
||||
zoom_level: number;
|
||||
}
|
||||
|
@ -64,6 +69,8 @@ export interface Props {
|
|||
cameraCalibrationData: CameraCalibrationData;
|
||||
tzOffset: number;
|
||||
getConfigValue: GetWebAppConfigValue;
|
||||
sensorReadings: TaggedSensorReading[];
|
||||
sensors: TaggedSensor[];
|
||||
}
|
||||
|
||||
export interface MovePlantProps {
|
||||
|
@ -156,6 +163,7 @@ export interface GardenMapProps {
|
|||
showSpread: boolean | undefined;
|
||||
showFarmbot: boolean | undefined;
|
||||
showImages: boolean | undefined;
|
||||
showSensorReadings: boolean | undefined;
|
||||
dispatch: Function;
|
||||
designer: DesignerState;
|
||||
points: TaggedGenericPointer[];
|
||||
|
@ -176,6 +184,9 @@ export interface GardenMapProps {
|
|||
latestImages: TaggedImage[];
|
||||
cameraCalibrationData: CameraCalibrationData;
|
||||
getConfigValue: GetWebAppConfigValue;
|
||||
sensorReadings: TaggedSensorReading[];
|
||||
sensors: TaggedSensor[];
|
||||
timeOffset: number;
|
||||
}
|
||||
|
||||
export interface GardenMapState {
|
||||
|
|
|
@ -31,6 +31,7 @@ describe("<GardenMapLegend />", () => {
|
|||
showSpread: false,
|
||||
showFarmbot: false,
|
||||
showImages: false,
|
||||
showSensorReadings: false,
|
||||
dispatch: jest.fn(),
|
||||
tzOffset: 0,
|
||||
getConfigValue: jest.fn(),
|
||||
|
|
|
@ -37,6 +37,7 @@ function fakeProps(): GardenMapProps {
|
|||
showSpread: false,
|
||||
showFarmbot: false,
|
||||
showImages: false,
|
||||
showSensorReadings: false,
|
||||
selectedPlant: fakePlant(),
|
||||
crops: [],
|
||||
dispatch: jest.fn(),
|
||||
|
@ -92,6 +93,9 @@ function fakeProps(): GardenMapProps {
|
|||
calibrationZ: undefined
|
||||
},
|
||||
getConfigValue: jest.fn(),
|
||||
sensorReadings: [],
|
||||
sensors: [],
|
||||
timeOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import {
|
||||
GardenSensorReading, GardenSensorReadingProps
|
||||
} from "../garden_sensor_reading";
|
||||
import {
|
||||
fakeSensorReading
|
||||
} from "../../../__test_support__/fake_state/resources";
|
||||
import {
|
||||
fakeMapTransformProps
|
||||
} from "../../../__test_support__/map_transform_props";
|
||||
|
||||
describe("<GardenSensorReading />", () => {
|
||||
const fakeProps = (): GardenSensorReadingProps => ({
|
||||
sensorReading: fakeSensorReading(),
|
||||
mapTransformProps: fakeMapTransformProps(),
|
||||
endTime: undefined,
|
||||
timeOffset: 0,
|
||||
sensorLookup: {},
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<GardenSensorReading {...fakeProps()} />);
|
||||
expect(wrapper.html()).toContain("sensor-reading-");
|
||||
expect(wrapper.find("circle").length).toEqual(2);
|
||||
});
|
||||
|
||||
it("doesn't render", () => {
|
||||
const p = fakeProps();
|
||||
p.sensorReading.body.x = undefined;
|
||||
const wrapper = mount(<GardenSensorReading {...p} />);
|
||||
expect(wrapper.find("circle").length).toEqual(0);
|
||||
});
|
||||
|
||||
it("renders sensor name", () => {
|
||||
const p = fakeProps();
|
||||
p.sensorLookup = { 1: "Sensor Name" };
|
||||
const wrapper = mount(<GardenSensorReading {...p} />);
|
||||
expect(wrapper.text()).toContain("Sensor Name (pin 1)");
|
||||
});
|
||||
|
||||
it("renders analog reading", () => {
|
||||
const p = fakeProps();
|
||||
p.sensorReading.body.mode = 1;
|
||||
const wrapper = mount(<GardenSensorReading {...p} />);
|
||||
expect(wrapper.text()).toContain("value 0 (analog)");
|
||||
});
|
||||
|
||||
it("calls hover", () => {
|
||||
const wrapper = shallow<GardenSensorReading>(
|
||||
<GardenSensorReading {...fakeProps()} />);
|
||||
wrapper.find("circle").first().simulate("mouseEnter");
|
||||
expect(wrapper.find("text").props().visibility).toEqual("visible");
|
||||
expect(wrapper.state().hovered).toEqual(true);
|
||||
wrapper.find("circle").first().simulate("mouseLeave");
|
||||
expect(wrapper.find("text").props().visibility).toEqual("hidden");
|
||||
expect(wrapper.state().hovered).toEqual(false);
|
||||
});
|
||||
});
|
|
@ -39,6 +39,7 @@ import { Bugs, showBugs } from "./easter_eggs/bugs";
|
|||
import { BooleanSetting } from "../../session_keys";
|
||||
import { savedGardenOpen } from "../saved_gardens/saved_gardens";
|
||||
import { unpackUUID } from "../../util";
|
||||
import { SensorReadingsLayer } from "./layers/sensor_readings_layer";
|
||||
|
||||
/** Garden map interaction modes. */
|
||||
export enum Mode {
|
||||
|
@ -442,6 +443,12 @@ export class GardenMap extends
|
|||
<Grid
|
||||
onClick={closePlantInfo(this.props.dispatch)}
|
||||
mapTransformProps={mapTransformProps} />
|
||||
<SensorReadingsLayer
|
||||
visible={!!this.props.showSensorReadings}
|
||||
sensorReadings={this.props.sensorReadings}
|
||||
mapTransformProps={mapTransformProps}
|
||||
timeOffset={this.props.timeOffset}
|
||||
sensors={this.props.sensors} />
|
||||
<SpreadLayer
|
||||
mapTransformProps={mapTransformProps}
|
||||
plants={this.props.plants}
|
||||
|
|
|
@ -97,6 +97,11 @@ const LayerToggles = (props: GardenMapLegendProps) => {
|
|||
dispatch={props.dispatch}
|
||||
getConfigValue={getConfigValue}
|
||||
imageAgeInfo={props.imageAgeInfo} />} />
|
||||
{localStorage.getItem("FUTURE_FEATURES") &&
|
||||
<LayerToggle
|
||||
value={props.showSensorReadings}
|
||||
label={t("Readings?")}
|
||||
onClick={toggle("show_sensor_readings")} />}
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import * as React from "react";
|
||||
import * as moment from "moment";
|
||||
import { transformXY } from "./util";
|
||||
import { TaggedSensorReading } from "farmbot";
|
||||
import { MapTransformProps } from "./interfaces";
|
||||
import { isNumber } from "lodash";
|
||||
import { formatLogTime } from "../../logs";
|
||||
import { t } from "i18next";
|
||||
import { Color } from "../../ui";
|
||||
|
||||
const VISIBLE_PERIOD_SECONDS = 24 * 60 * 60.;
|
||||
|
||||
export interface GardenSensorReadingProps {
|
||||
sensorReading: TaggedSensorReading;
|
||||
mapTransformProps: MapTransformProps;
|
||||
endTime: string | undefined;
|
||||
timeOffset: number;
|
||||
sensorLookup: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GardenSensorReadingState {
|
||||
hovered: boolean;
|
||||
}
|
||||
|
||||
export class GardenSensorReading
|
||||
extends React.Component<GardenSensorReadingProps, GardenSensorReadingState> {
|
||||
state: GardenSensorReadingState = { hovered: false };
|
||||
|
||||
hover = () => this.setState({ hovered: true });
|
||||
unhover = () => this.setState({ hovered: false });
|
||||
|
||||
render() {
|
||||
const {
|
||||
sensorReading, mapTransformProps, endTime, timeOffset, sensorLookup
|
||||
} = this.props;
|
||||
const { id, x, y, value, mode, created_at, pin } = sensorReading.body;
|
||||
if (isNumber(x) && isNumber(y)) {
|
||||
const { qx, qy } = transformXY(x, y, mapTransformProps);
|
||||
const val = 255 - value / (mode === 1 ? 1024. : 1) * 255;
|
||||
const age = moment(endTime).unix() - moment(created_at).unix();
|
||||
const opacity = Math.max(0, 1 - age / VISIBLE_PERIOD_SECONDS);
|
||||
const sensorName = sensorLookup[pin]
|
||||
? `${sensorLookup[pin]} (${t("pin")} ${pin})`
|
||||
: `${t("pin")} ${pin}`;
|
||||
const modeText = mode === 0 ? t("digital") : t("analog");
|
||||
const hovered = this.state.hovered ? "visible" : "hidden";
|
||||
const textX = qx + 13;
|
||||
return <g id={"sensor-reading-" + id}>
|
||||
<circle
|
||||
fill={`rgb(${val}, ${val}, ${val})`}
|
||||
cx={qx} cy={qy} r={10} opacity={opacity}
|
||||
onMouseEnter={this.hover}
|
||||
onMouseLeave={this.unhover} />
|
||||
<circle
|
||||
visibility={hovered}
|
||||
fill={"none"} stroke={Color.darkGray} strokeWidth={2}
|
||||
cx={qx} cy={qy} r={10} />
|
||||
<text
|
||||
x={textX} y={qy}
|
||||
visibility={hovered}>
|
||||
<tspan x={textX} dy={"0.6em"}>{sensorName}</tspan>
|
||||
<tspan x={textX} dy={"1.2em"}>
|
||||
{`${t("value")} ${value} (${modeText})`}
|
||||
</tspan>
|
||||
<tspan x={textX} dy={"1.2em"}>
|
||||
{formatLogTime(moment(created_at).unix(), timeOffset)}
|
||||
</tspan>
|
||||
</text>
|
||||
</g>;
|
||||
}
|
||||
return <g id={"sensor-reading-" + id} />;
|
||||
}
|
||||
}
|
|
@ -40,6 +40,7 @@ export interface GardenMapLegendProps {
|
|||
showSpread: boolean;
|
||||
showFarmbot: boolean;
|
||||
showImages: boolean;
|
||||
showSensorReadings: boolean;
|
||||
dispatch: Function;
|
||||
tzOffset: number;
|
||||
getConfigValue: GetWebAppConfigValue;
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import {
|
||||
SensorReadingsLayer, SensorReadingsLayerProps
|
||||
} from "../sensor_readings_layer";
|
||||
import {
|
||||
fakeMapTransformProps
|
||||
} from "../../../../__test_support__/map_transform_props";
|
||||
import {
|
||||
fakeSensorReading, fakeSensor
|
||||
} from "../../../../__test_support__/fake_state/resources";
|
||||
|
||||
describe("<SensorReadingsLayer />", () => {
|
||||
const fakeProps = (): SensorReadingsLayerProps => ({
|
||||
visible: true,
|
||||
sensorReadings: [fakeSensorReading()],
|
||||
mapTransformProps: fakeMapTransformProps(),
|
||||
timeOffset: 0,
|
||||
sensors: [fakeSensor()],
|
||||
});
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<SensorReadingsLayer {...fakeProps()} />);
|
||||
expect(wrapper.html()).toContain("sensor-readings-layer");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,33 @@
|
|||
import * as React from "react";
|
||||
import { TaggedSensorReading, TaggedSensor } from "farmbot";
|
||||
import { MapTransformProps } from "../interfaces";
|
||||
import { GardenSensorReading } from "../garden_sensor_reading";
|
||||
import { last } from "lodash";
|
||||
|
||||
export interface SensorReadingsLayerProps {
|
||||
visible: boolean;
|
||||
sensorReadings: TaggedSensorReading[];
|
||||
mapTransformProps: MapTransformProps;
|
||||
timeOffset: number;
|
||||
sensors: TaggedSensor[];
|
||||
}
|
||||
|
||||
export function SensorReadingsLayer(props: SensorReadingsLayerProps) {
|
||||
const {
|
||||
visible, sensorReadings, mapTransformProps, timeOffset, sensors
|
||||
} = props;
|
||||
const mostRecentSensorReading = last(sensorReadings);
|
||||
const sensorNameByPinLookup: { [x: number]: string } = {};
|
||||
sensors.map(x => { sensorNameByPinLookup[x.body.pin || 0] = x.body.label; });
|
||||
return <g id="sensor-readings-layer">
|
||||
{visible && mostRecentSensorReading &&
|
||||
sensorReadings.map(sr =>
|
||||
<GardenSensorReading
|
||||
key={sr.uuid}
|
||||
sensorReading={sr}
|
||||
mapTransformProps={mapTransformProps}
|
||||
endTime={mostRecentSensorReading.body.created_at}
|
||||
timeOffset={timeOffset}
|
||||
sensorLookup={sensorNameByPinLookup} />)}
|
||||
</g>;
|
||||
}
|
|
@ -8,7 +8,9 @@ import {
|
|||
maybeGetTimeOffset,
|
||||
selectAllPeripherals,
|
||||
getFirmwareConfig,
|
||||
selectAllPlantTemplates
|
||||
selectAllPlantTemplates,
|
||||
selectAllSensorReadings,
|
||||
selectAllSensors
|
||||
} from "../resources/selectors";
|
||||
import * as _ from "lodash";
|
||||
import { validBotLocationData, validFwConfig, unpackUUID } from "../util";
|
||||
|
@ -84,6 +86,13 @@ export function mapStateToProps(props: Everything): Props {
|
|||
calibrationZ: user_env["CAMERA_CALIBRATION_camera_z"],
|
||||
};
|
||||
|
||||
const sensorReadings = _(selectAllSensorReadings(props.resources.index))
|
||||
.sortBy(x => x.body.created_at)
|
||||
.reverse()
|
||||
.take(500)
|
||||
.reverse()
|
||||
.value();
|
||||
|
||||
return {
|
||||
crops: selectAllCrops(props.resources.index),
|
||||
dispatch: props.dispatch,
|
||||
|
@ -102,5 +111,7 @@ export function mapStateToProps(props: Everything): Props {
|
|||
cameraCalibrationData,
|
||||
tzOffset: maybeGetTimeOffset(props.resources.index),
|
||||
getConfigValue,
|
||||
sensorReadings,
|
||||
sensors: selectAllSensors(props.resources.index),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
jest.mock("react-redux", () => ({ connect: jest.fn() }));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import { Help, mapStateToProps } from "../help";
|
||||
import { fakeState } from "../../__test_support__/fake_state";
|
||||
import { clickButton } from "../../__test_support__/helpers";
|
||||
import { tourNames } from "../tours";
|
||||
import { Actions } from "../../constants";
|
||||
|
||||
describe("<Help />", () => {
|
||||
const fakeProps = () => ({ dispatch: jest.fn() });
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<Help {...fakeProps()} />);
|
||||
["help with", "start tour", "getting started"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
|
||||
});
|
||||
|
||||
it("starts tour", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = mount(<Help {...p} />);
|
||||
clickButton(wrapper, 0, "start tour");
|
||||
expect(p.dispatch).toHaveBeenCalledWith({
|
||||
type: Actions.START_TOUR,
|
||||
payload: tourNames()[0].name
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapStateToProps()", () => {
|
||||
it("returns props", () => {
|
||||
const props = mapStateToProps(fakeState());
|
||||
expect(props).toEqual({ dispatch: expect.any(Function) });
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
import { helpReducer, HelpState } from "../reducer";
|
||||
import { Actions } from "../../constants";
|
||||
|
||||
describe("helpReducer", () => {
|
||||
const fakeState = (): HelpState => ({
|
||||
currentTour: undefined,
|
||||
});
|
||||
|
||||
it("changes current tour", () => {
|
||||
const oldState = fakeState();
|
||||
oldState.currentTour = "aTour";
|
||||
const newState = helpReducer(oldState, {
|
||||
type: Actions.START_TOUR,
|
||||
payload: undefined
|
||||
});
|
||||
expect(oldState.currentTour).not.toEqual(newState.currentTour);
|
||||
expect(newState.currentTour).toBeUndefined();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
jest.mock("../../history", () => ({ history: { push: jest.fn() } }));
|
||||
|
||||
import * as React from "react";
|
||||
import { tourNames, TOUR_STEPS } from "../tours";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import { RunTour, Tour } from "../tour";
|
||||
import { history } from "../../history";
|
||||
|
||||
describe("<RunTour />", () => {
|
||||
const EMPTY_DIV = "<div></div>";
|
||||
|
||||
it("tour is running", () => {
|
||||
const wrapper = mount(<RunTour currentTour={tourNames()[0].name} />);
|
||||
expect(wrapper.html()).not.toBe(EMPTY_DIV);
|
||||
});
|
||||
|
||||
it("tour is not running", () => {
|
||||
const wrapper = mount(<RunTour currentTour={undefined} />);
|
||||
expect(wrapper.html()).toBe(EMPTY_DIV);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<Tour />", () => {
|
||||
it("ends tour", () => {
|
||||
const steps = [TOUR_STEPS()[tourNames()[0].name][0]];
|
||||
const wrapper = shallow(<Tour steps={steps} />);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.callback({ action: "", index: 0, step: {}, type: "tour:end" });
|
||||
expect(wrapper.state()).toEqual({ run: false, index: 0 });
|
||||
expect(history.push).toHaveBeenCalledWith("/app/help");
|
||||
});
|
||||
|
||||
it("navigates through tour: next", () => {
|
||||
const steps = TOUR_STEPS()[tourNames()[0].name];
|
||||
const wrapper = shallow(<Tour steps={steps} />);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.callback({
|
||||
action: "next", index: 0, step: {}, type: "step:after"
|
||||
});
|
||||
expect(wrapper.state()).toEqual({ run: true, index: 1 });
|
||||
expect(history.push).toHaveBeenCalledWith("/app/tools");
|
||||
});
|
||||
|
||||
it("navigates through tour: other", () => {
|
||||
const steps = [TOUR_STEPS()[tourNames()[0].name][0]];
|
||||
const wrapper = shallow(<Tour steps={steps} />);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.callback({
|
||||
action: "prev", index: 9, step: {}, type: "step:after"
|
||||
});
|
||||
expect(wrapper.state()).toEqual({ run: true, index: 8 });
|
||||
expect(history.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
jest.mock("../../history", () => ({ history: { push: jest.fn() } }));
|
||||
|
||||
import { tourPageNavigation } from "../tours";
|
||||
import { history } from "../../history";
|
||||
|
||||
describe("tourPageNavigation()", () => {
|
||||
const testCase = (el: string) => {
|
||||
tourPageNavigation(el);
|
||||
expect(history.push).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
it("covers all cases", () => {
|
||||
testCase(".farm-designer");
|
||||
testCase(".plant-inventory-panel");
|
||||
testCase(".farm-event-panel");
|
||||
testCase(".move-widget");
|
||||
testCase(".peripherals-widget");
|
||||
testCase(".device-widget");
|
||||
testCase(".sequence-list-panel");
|
||||
testCase(".regimen-list-panel");
|
||||
testCase(".tool-list");
|
||||
testCase(".toolbay-list");
|
||||
testCase(".photos");
|
||||
testCase(".logs-table");
|
||||
testCase(".app-settings-widget");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { t } from "i18next";
|
||||
import { Widget, WidgetBody, WidgetHeader, Page, Col } from "../ui";
|
||||
import { ToolTips, Actions } from "../constants";
|
||||
import { Everything } from "../interfaces";
|
||||
import { tourNames } from "./tours";
|
||||
|
||||
const TourList = ({ dispatch }: { dispatch: Function }) =>
|
||||
<div className="tour-list">
|
||||
{tourNames().map(tour => <div key={tour.name}>
|
||||
<label>{tour.description}</label>
|
||||
<button className="fb-button green"
|
||||
onClick={() =>
|
||||
dispatch({ type: Actions.START_TOUR, payload: tour.name })}>
|
||||
{t("Start tour")}
|
||||
</button>
|
||||
</div>)}
|
||||
</div>;
|
||||
|
||||
export function mapStateToProps(props: Everything): { dispatch: Function } {
|
||||
const { dispatch } = props;
|
||||
return { dispatch };
|
||||
}
|
||||
|
||||
@connect(mapStateToProps)
|
||||
export class Help extends React.Component<{ dispatch: Function }, {}> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatch({ type: Actions.START_TOUR, payload: undefined });
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Page className="help-page">
|
||||
<Col xs={12} sm={6} smOffset={3}>
|
||||
<Widget className="help-widget">
|
||||
<WidgetHeader helpText={ToolTips.TOURS} title={t("Tours")} />
|
||||
<WidgetBody>
|
||||
{t("What do you need help with?")}
|
||||
<TourList dispatch={this.props.dispatch} />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</Col>
|
||||
</Page>;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { generateReducer } from "../redux/generate_reducer";
|
||||
import { Actions } from "../constants";
|
||||
|
||||
export interface HelpState {
|
||||
currentTour: string | undefined;
|
||||
}
|
||||
|
||||
export let initialState: HelpState = {
|
||||
currentTour: undefined,
|
||||
};
|
||||
|
||||
export let helpReducer = generateReducer<HelpState>(initialState)
|
||||
.add<string>(Actions.START_TOUR, (s, { payload }) => {
|
||||
s.currentTour = payload;
|
||||
return s;
|
||||
});
|
|
@ -0,0 +1,77 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import Joyride, { Step as TourStep, State as CBData } from "react-joyride";
|
||||
import { Color } from "../ui";
|
||||
import { history } from "../history";
|
||||
import { TOUR_STEPS, tourPageNavigation } from "./tours";
|
||||
|
||||
const strings = () => ({
|
||||
back: t("Back"),
|
||||
close: t("Close"),
|
||||
last: t("End Tour"),
|
||||
next: t("Next"),
|
||||
skip: t("End Tour")
|
||||
});
|
||||
|
||||
// tslint:disable-next-line:no-any // broken typing
|
||||
const STYLES: any = {
|
||||
buttonNext: { backgroundColor: Color.green },
|
||||
buttonBack: { color: Color.darkGray }
|
||||
};
|
||||
|
||||
interface TourProps {
|
||||
steps: TourStep[];
|
||||
}
|
||||
|
||||
interface TourState {
|
||||
run: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export class Tour extends React.Component<TourProps, TourState> {
|
||||
state: TourState = { run: false, index: 0, };
|
||||
|
||||
// tslint:disable-next-line:no-any // broken typing
|
||||
callback: any = ({ action, index, step, type }: CBData) => {
|
||||
console.log("Tour debug:", step.target, type, action);
|
||||
tourPageNavigation(step.target);
|
||||
if (type === "step:after") {
|
||||
const increment = action === "prev" ? -1 : 1;
|
||||
const nextStepIndex = index + increment;
|
||||
this.setState({ index: nextStepIndex });
|
||||
const nextStepTarget = nextStepIndex < this.props.steps.length
|
||||
? this.props.steps[nextStepIndex].target : "";
|
||||
tourPageNavigation(nextStepTarget);
|
||||
}
|
||||
if (type === "tour:end") {
|
||||
this.setState({ run: false });
|
||||
history.push("/app/help");
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ run: true, index: 0 });
|
||||
}
|
||||
|
||||
render() {
|
||||
const steps = this.props.steps.map(step => {
|
||||
step.disableBeacon = true;
|
||||
return step;
|
||||
});
|
||||
return <div className="tour">
|
||||
<Joyride
|
||||
steps={steps}
|
||||
run={this.state.run}
|
||||
callback={this.callback}
|
||||
stepIndex={this.state.index}
|
||||
showSkipButton={true}
|
||||
continuous={true}
|
||||
styles={STYLES}
|
||||
locale={strings()} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export const RunTour = ({ currentTour }: { currentTour: string | undefined }) => {
|
||||
return currentTour ? <Tour steps={TOUR_STEPS()[currentTour]} /> : <div />;
|
||||
};
|
|
@ -0,0 +1,125 @@
|
|||
import { history } from "../history";
|
||||
import { Step as TourStep } from "react-joyride";
|
||||
import { t } from "i18next";
|
||||
import { TourContent } from "../constants";
|
||||
|
||||
export enum Tours {
|
||||
gettingStarted = "gettingStarted",
|
||||
monitoring = "monitoring",
|
||||
funStuff = "funStuff",
|
||||
}
|
||||
|
||||
export const tourNames = () => [
|
||||
{ name: Tours.gettingStarted, description: t("getting started") },
|
||||
{ name: Tours.monitoring, description: t("see what FarmBot is doing") },
|
||||
{ name: Tours.funStuff, description: t("find new features") },
|
||||
];
|
||||
|
||||
export const TOUR_STEPS = (): { [x: string]: TourStep[] } => ({
|
||||
[Tours.gettingStarted]: [
|
||||
{
|
||||
target: ".plant-inventory-panel",
|
||||
content: TourContent.ADD_PLANTS,
|
||||
title: t("Add plants"),
|
||||
},
|
||||
{
|
||||
target: ".tool-list",
|
||||
content: TourContent.ADD_TOOLS,
|
||||
title: t("Add tools"),
|
||||
},
|
||||
{
|
||||
target: ".toolbay-list",
|
||||
content: TourContent.ADD_TOOLS_SLOTS,
|
||||
title: t("Add tools to tool bay"),
|
||||
},
|
||||
{
|
||||
target: ".peripherals-widget",
|
||||
content: TourContent.ADD_PERIPHERALS,
|
||||
title: t("Add peripherals"),
|
||||
},
|
||||
{
|
||||
target: ".sequence-list-panel",
|
||||
content: TourContent.ADD_SEQUENCES,
|
||||
title: t("Create sequences"),
|
||||
},
|
||||
{
|
||||
target: ".regimen-list-panel",
|
||||
content: TourContent.ADD_REGIMENS,
|
||||
title: t("Create regimens"),
|
||||
},
|
||||
{
|
||||
target: ".farm-event-panel",
|
||||
content: TourContent.ADD_FARM_EVENTS,
|
||||
title: t("Create farm events"),
|
||||
},
|
||||
],
|
||||
[Tours.monitoring]: [
|
||||
{
|
||||
target: ".move-widget",
|
||||
content: TourContent.LOCATION_GRID,
|
||||
title: t("View current location"),
|
||||
},
|
||||
{
|
||||
target: ".farm-designer",
|
||||
content: TourContent.VIRTUAL_FARMBOT,
|
||||
title: t("View current location"),
|
||||
},
|
||||
{
|
||||
target: ".logs-table",
|
||||
content: TourContent.LOGS_TABLE,
|
||||
title: t("View log messages"),
|
||||
},
|
||||
{
|
||||
target: ".photos",
|
||||
content: TourContent.PHOTOS,
|
||||
title: t("Take and view photos"),
|
||||
},
|
||||
],
|
||||
[Tours.funStuff]: [
|
||||
{
|
||||
target: ".app-settings-widget",
|
||||
content: TourContent.APP_SETTINGS,
|
||||
title: t("Customize your web app experience"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const tourPageNavigation = (nextStepTarget: string | HTMLElement) => {
|
||||
switch (nextStepTarget) {
|
||||
case ".farm-designer":
|
||||
history.push("/app/designer");
|
||||
break;
|
||||
case ".plant-inventory-panel":
|
||||
history.push("/app/designer/plants");
|
||||
break;
|
||||
case ".farm-event-panel":
|
||||
history.push("/app/designer/farm_events");
|
||||
break;
|
||||
case ".move-widget":
|
||||
case ".peripherals-widget":
|
||||
history.push("/app/controls");
|
||||
break;
|
||||
case ".device-widget":
|
||||
history.push("/app/device");
|
||||
break;
|
||||
case ".sequence-list-panel":
|
||||
history.push("/app/sequences");
|
||||
break;
|
||||
case ".regimen-list-panel":
|
||||
history.push("/app/regimens");
|
||||
break;
|
||||
case ".tool-list":
|
||||
case ".toolbay-list":
|
||||
history.push("/app/tools");
|
||||
break;
|
||||
case ".photos":
|
||||
history.push("/app/farmware");
|
||||
break;
|
||||
case ".logs-table":
|
||||
history.push("/app/logs");
|
||||
break;
|
||||
case ".app-settings-widget":
|
||||
history.push("/app/account");
|
||||
break;
|
||||
}
|
||||
};
|
|
@ -15,6 +15,7 @@ describe("NavBar", () => {
|
|||
user: taggedUser,
|
||||
dispatch: jest.fn(),
|
||||
getConfigValue: jest.fn(),
|
||||
tour: undefined,
|
||||
});
|
||||
|
||||
it("has correct parent classname", () => {
|
||||
|
|
|
@ -10,6 +10,11 @@ export const AdditionalMenu = (props: AccountMenuProps) => {
|
|||
<i className="fa fa-cog"></i>
|
||||
{t("Account Settings")}
|
||||
</Link>
|
||||
{localStorage.getItem("FUTURE_FEATURES") &&
|
||||
<Link to="/app/help" onClick={props.close("accountMenuOpen")}>
|
||||
<i className="fa fa-question-circle"></i>
|
||||
{t("Help")}
|
||||
</Link>}
|
||||
<div>
|
||||
<a href={docLink("the-farmbot-web-app")}
|
||||
target="_blank">
|
||||
|
|
|
@ -13,6 +13,7 @@ import { AdditionalMenu } from "./additional_menu";
|
|||
import { MobileMenu } from "./mobile_menu";
|
||||
import { Popover, Position } from "@blueprintjs/core";
|
||||
import { ErrorBoundary } from "../error_boundary";
|
||||
import { RunTour } from "../help/tour";
|
||||
|
||||
export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
|
||||
|
||||
|
@ -94,6 +95,7 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
|
|||
bot={this.props.bot}
|
||||
user={this.props.user} />
|
||||
{this.syncButton()}
|
||||
<RunTour currentTour={this.props.tour} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface NavBarProps {
|
|||
dispatch: Function;
|
||||
timeOffset: number;
|
||||
getConfigValue: GetWebAppConfigValue;
|
||||
tour: string | undefined;
|
||||
}
|
||||
|
||||
export interface NavBarState {
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "farmbot";
|
||||
import { RegimenState } from "../regimens/reducer";
|
||||
import { FarmwareState } from "../farmware/interfaces";
|
||||
import { HelpState } from "../help/reducer";
|
||||
|
||||
type UUID = string;
|
||||
|
||||
|
@ -30,6 +31,7 @@ export interface RestResources {
|
|||
regimens: RegimenState;
|
||||
farm_designer: DesignerState;
|
||||
farmware: FarmwareState;
|
||||
help: HelpState;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,10 @@ import {
|
|||
farmwareReducer as farmware,
|
||||
farmwareState
|
||||
} from "../farmware/reducer";
|
||||
import {
|
||||
helpReducer as help,
|
||||
initialState as helpState
|
||||
} from "../help/reducer";
|
||||
import { Actions } from "../constants";
|
||||
import { maybeTagSteps as dontTouchThis } from "./sequence_tagging";
|
||||
import { GeneralizedError } from "./actions";
|
||||
|
@ -44,7 +48,8 @@ const consumerReducer = combineReducers<RestResources["consumers"]>({
|
|||
regimens,
|
||||
sequences,
|
||||
farm_designer,
|
||||
farmware
|
||||
farmware,
|
||||
help
|
||||
} as any); // tslint:disable-line
|
||||
|
||||
export function emptyState(): RestResources {
|
||||
|
@ -53,7 +58,8 @@ export function emptyState(): RestResources {
|
|||
sequences: sequenceState,
|
||||
regimens: regimenState,
|
||||
farm_designer: designerState,
|
||||
farmware: farmwareState
|
||||
farmware: farmwareState,
|
||||
help: helpState,
|
||||
},
|
||||
loaded: [],
|
||||
index: {
|
||||
|
@ -97,7 +103,8 @@ const afterEach = (state: RestResources, a: ReduxAction<object>) => {
|
|||
sequences: state.consumers.sequences,
|
||||
regimens: state.consumers.regimens,
|
||||
farm_designer: state.consumers.farm_designer,
|
||||
farmware: state.consumers.farmware
|
||||
farmware: state.consumers.farmware,
|
||||
help: state.consumers.help,
|
||||
}, a);
|
||||
return state;
|
||||
};
|
||||
|
|
|
@ -99,6 +99,12 @@ export const UNBOUND_ROUTES = [
|
|||
getModule: () => import("./account"),
|
||||
key: "Account",
|
||||
}),
|
||||
route({
|
||||
children: false,
|
||||
$: "/help",
|
||||
getModule: () => import("./help/help"),
|
||||
key: "Help",
|
||||
}),
|
||||
route({
|
||||
children: false,
|
||||
$: "/controls",
|
||||
|
|
|
@ -12,6 +12,7 @@ export const BooleanSetting: Record<BooleanConfigKey, BooleanConfigKey> = {
|
|||
show_spread: "show_spread",
|
||||
show_farmbot: "show_farmbot",
|
||||
show_images: "show_images",
|
||||
show_sensor_readings: "show_sensor_readings",
|
||||
xy_swap: "xy_swap",
|
||||
home_button_homing: "home_button_homing",
|
||||
show_motor_plot: "show_motor_plot",
|
||||
|
|
|
@ -26,7 +26,7 @@ export class ToolList extends React.Component<ToolListAndFormProps, {}> {
|
|||
render() {
|
||||
const { tools, toggle } = this.props;
|
||||
|
||||
return <Widget>
|
||||
return <Widget className="tool-list">
|
||||
<WidgetHeader helpText={ToolTips.TOOL_LIST} title={t("Tools")}>
|
||||
<button
|
||||
className="fb-button gray"
|
||||
|
|
|
@ -22,7 +22,7 @@ export class ToolBayList extends React.Component<ToolBayListProps, {}> {
|
|||
}
|
||||
|
||||
render() {
|
||||
return <Widget>
|
||||
return <Widget className="toolbay-list">
|
||||
<WidgetHeader
|
||||
helpText={ToolTips.TOOLBAY_LIST}
|
||||
title={t("ToolBay ") + "1"}>
|
||||
|
|
Loading…
Reference in New Issue