Merge conflicts

pull/1015/head
Rick Carlino 2018-10-18 11:13:14 -05:00
commit 08b341f820
54 changed files with 834 additions and 53 deletions

View File

@ -11,3 +11,4 @@ CI=true
CODECLIMATE_REPO_TOKEN=2216bf71b977a85ed8da497942c34a503dbedd77da1b6b97c33551ba02ea02f8
COVERALLS_REPO_TOKEN=lEX6nkql7y2YFCcIXVq5ORvdvMtYzfZdG
CODECOV_TOKEN=c0fe1e65-d284-4d58-a742-4088e88be35d
RAILS_ENV=test

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ const fakeProps = (): AppProps => {
xySwap: false,
animate: false,
getConfigValue: jest.fn(),
tour: undefined,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -951,3 +951,9 @@ ul {
}
}
}
.tour-list {
margin: auto;
width: 75%;
margin-top: 1rem;
}

View File

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

View File

@ -60,6 +60,8 @@ describe("<FarmDesigner/>", () => {
},
tzOffset: 0,
getConfigValue: jest.fn(),
sensorReadings: [],
sensors: [],
};
}

View File

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

View File

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

View File

@ -31,6 +31,7 @@ describe("<GardenMapLegend />", () => {
showSpread: false,
showFarmbot: false,
showImages: false,
showSensorReadings: false,
dispatch: jest.fn(),
tzOffset: 0,
getConfigValue: jest.fn(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -40,6 +40,7 @@ export interface GardenMapLegendProps {
showSpread: boolean;
showFarmbot: boolean;
showImages: boolean;
showSensorReadings: boolean;
dispatch: Function;
tzOffset: number;
getConfigValue: GetWebAppConfigValue;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ describe("NavBar", () => {
user: taggedUser,
dispatch: jest.fn(),
getConfigValue: jest.fn(),
tour: undefined,
});
it("has correct parent classname", () => {

View File

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

View File

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

View File

@ -18,6 +18,7 @@ export interface NavBarProps {
dispatch: Function;
timeOffset: number;
getConfigValue: GetWebAppConfigValue;
tour: string | undefined;
}
export interface NavBarState {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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