add messages

pull/1157/head
gabrielburnworth 2019-04-16 10:03:44 -07:00
parent beee68850a
commit 45908ff660
34 changed files with 715 additions and 325 deletions

View File

@ -1,6 +1,9 @@
class Enigma < ApplicationRecord
belongs_to :device
PROBLEM_TAGS = [
SEED_DATA = "api.seed_data.missing"
SEED_DATA = "api.seed_data.missing",
TOUR = "api.tour.not_taken",
USER = "api.user.not_welcomed",
DOCUMENTATION = "api.documentation.unread"
]
end

View File

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

View File

@ -10,11 +10,12 @@ import {
maybeFetchUser,
maybeGetTimeSettings,
getDeviceAccountSettings,
selectAllEnigmas,
} from "./resources/selectors";
import { HotKeys } from "./hotkeys";
import { ControlsPopup } from "./controls_popup";
import { Content } from "./constants";
import { validBotLocationData, validFwConfig, validFbosConfig } from "./util";
import { validBotLocationData, validFwConfig, validFbosConfig, betterCompact } from "./util";
import { BooleanSetting } from "./session_keys";
import { getPathArray } from "./history";
import {
@ -28,6 +29,7 @@ import { t } from "./i18next_wrapper";
import { ResourceIndex } from "./resources/interfaces";
import { isBotOnline } from "./devices/must_be_online";
import { getStatus } from "./connectivity/reducer_support";
import { DevSettings } from "./account/dev/dev_support";
/** For the logger module */
init();
@ -48,11 +50,16 @@ export interface AppProps {
tour: string | undefined;
resources: ResourceIndex;
autoSync: boolean;
alertCount: number;
}
export function mapStateToProps(props: Everything): AppProps {
const webAppConfigValue = getWebAppConfigValue(() => props);
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const botAlerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(props.resources.index).map(x => x.body);
const alerts =
botAlerts.concat(DevSettings.futureFeaturesEnabled() ? apiAlerts : []);
return {
timeSettings: maybeGetTimeSettings(props.resources.index),
dispatch: props.dispatch,
@ -73,6 +80,7 @@ export function mapStateToProps(props: Everything): AppProps {
tour: props.resources.consumers.help.currentTour,
resources: props.resources.index,
autoSync: !!(fbosConfig && fbosConfig.auto_sync),
alertCount: alerts.length,
};
}
/** Time at which the app gives up and asks the user to refresh */
@ -129,6 +137,7 @@ export class App extends React.Component<AppProps, {}> {
getConfigValue={this.props.getConfigValue}
tour={this.props.tour}
autoSync={this.props.autoSync}
alertCount={this.props.alertCount}
device={getDeviceAccountSettings(this.props.resources)} />}
{syncLoaded && this.props.children}
{!(["controls", "account", "regimens"].includes(currentPage)) &&

View File

@ -346,6 +346,9 @@ export namespace ToolTips {
export const FIRMWARE_DEBUG_MESSAGES =
trim(`Log all debug received from firmware (clears after refresh).`);
export const MESSAGES =
trim(`View messages.`);
// App
export const LABS =
trim(`Customize your web app experience.`);
@ -384,6 +387,7 @@ export namespace Content {
trim(`Export request received. Please allow up to 10 minutes for
delivery.`);
// Messages
export const SEED_DATA_SELECTION =
trim(`To finish setting up your account and FarmBot, please select which
FarmBot you have. Once you make a selection, we'll automatically add some
@ -391,6 +395,31 @@ export namespace Content {
faster. If you want to start completely from scratch, feel free to select
"Custom bot" and we won't change a thing.`);
export const TAKE_A_TOUR =
trim(`Since you're new around here, we recommend taking our guided tours
of the app. This is the fastest way to learn about the most important pages
and features at your fingertips.`);
export const READ_THE_DOCS =
trim(`The FarmBot web app is a powerful tool that allows you to control
and configure your FarmBot in any way you want. To give you so much power,
we've packed the app with a ton of settings, features, and pages, which
can be a lot to understand. That's why we've created comprehensive written
documentation and videos to teach you how to use everything.`);
export const WELCOME =
trim(`Let's get you familiar with the app and finish setting everything
up.`);
export const MESSAGE_CENTER_WELCOME =
trim(`Here you'll find important information about your account, your
FarmBot, and news such as new feature announcements. Look for the blue
badge in the main menu to see when new messages are available.`);
export const MESSAGE_DISMISS =
trim(`When you're finished with a message, press the x button in the
top right of the card to dismiss it.`);
// App Settings
export const CONFIRM_STEP_DELETION =
trim(`Show a confirmation dialog when the sequence delete step

View File

@ -938,6 +938,18 @@ ul {
}
}
.messages-page {
max-width: 600px;
padding-left: 2rem !important;
padding-right: 2rem !important;
.link-to-logs {
margin-top: 2rem;
margin-bottom: 4rem;
text-align: center;
font-style: italic;
}
}
.account-page {
label {
margin-top: 5px;
@ -1151,42 +1163,12 @@ ul {
}
}
.problem-alerts {
position: fixed;
top: 85px;
left: 50%;
z-index: 3;
padding: 1rem;
background: $off_white;
border-radius: 5px;
box-shadow: 0px 1px 4px #555;
transform: translate(-50%);
.problem-alerts-header {
text-align: center;
i {
font-size: 2rem;
color: $dark_gray;
margin-left: 1rem;
}
.saucer {
display: inline-flex;
top: -0.25rem;
margin-left: 0.5rem;
}
h3 {
margin: 0;
}
}
button {
float: none !important;
}
}
.problem-alert {
margin: 1rem;
padding: 1rem;
border-radius: 3px;
box-shadow: 0px 2px 5px $gray;
margin-bottom: 3rem;
padding: 2rem;
border-radius: 4px;
box-shadow: 0px 2px 5px $medium_gray;
background: $off_white;
.problem-alert-title {
.fa-exclamation-triangle {
@ -1204,10 +1186,34 @@ ul {
color: $medium_gray;
font-size: 1.2rem;
}
.fa-times {
color: $medium_light_gray;
float: right;
&:hover {
color: $dark_gray;
}
}
}
.problem-alert-content {
p {
margin-bottom: 0.75rem !important;
font-size: 1.4rem;
line-height: 2rem;
}
label {
margin-top: 0.5rem;
}
.row {
margin-top: 2rem;
}
.tour-list {
margin: 0;
}
.link-button {
color: $off_white;
font-weight: bold;
float: none;
margin-bottom: 2rem;
}
}
}

View File

@ -48,47 +48,58 @@ nav {
.external-links {
display: none;
}
.nav-links a {
display: inline-block;
position: relative;
font-size: 1.2rem;
white-space: nowrap;
padding: 2rem 1rem;
letter-spacing: 1.2px;
transition: font-weight 0.2s ease;
&:focus {
font-weight: bold;
}
&:hover {
font-weight: bold;
color: $white;
}
&.active {
pointer-events: none;
font-weight: bold;
color: $white;
.nav-links {
display: inline-flex;
a {
display: inline-block;
position: relative;
height: 5.5rem;
font-size: 1.2rem;
white-space: nowrap;
padding: 2rem 1rem;
letter-spacing: 1.2px;
transition: font-weight 0.2s ease;
&:focus {
font-weight: bold;
}
&:hover {
font-weight: bold;
color: $white;
}
&.active {
pointer-events: none;
font-weight: bold;
color: $white;
&:after {
transition: all 0.4s ease;
transform: translateY(-3px);
}
}
&:before {
content: "";
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: $white;
}
&:after {
transition: all 0.4s ease;
transform: translateY(-3px);
content: "";
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: $dark_gray;
}
div .saucer {
display: inline-flex;
margin-left: 1rem;
}
}
&:before {
content: "";
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: $white;
}
&:after {
content: "";
position: absolute;
left: 0;
bottom: 0;
right: 0;
height: 3px;
background: $dark_gray;
div {
display: inline;
}
}
}
@ -156,8 +167,13 @@ nav {
.mobile-menu {
.nav-links {
display: block;
a {
display: block;
div .saucer {
display: inline-flex;
margin-left: 1rem;
}
}
i {
display: inline-block;
@ -168,7 +184,7 @@ nav {
@media screen and (max-width: 1075px) {
.top-menu-container .nav-links {
display: none;
display: none !important;
}
}

View File

@ -24,6 +24,7 @@ describe("<FirmwareHardwareStatusDetails />", () => {
mcuFirmwareValue: undefined,
shouldDisplay: () => true,
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
});
it("renders details: unknown", () => {
@ -79,6 +80,7 @@ describe("<FirmwareHardwareStatus />", () => {
apiFirmwareValue: undefined,
shouldDisplay: () => true,
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
});
it("renders: inconsistent", () => {

View File

@ -126,6 +126,7 @@ export class BoardType extends React.Component<BoardTypeProps, BoardTypeState> {
botOnline={this.props.botOnline}
apiFirmwareValue={this.apiValue}
bot={this.props.bot}
dispatch={this.props.dispatch}
timeSettings={this.props.timeSettings}
shouldDisplay={this.props.shouldDisplay} />
</Col>

View File

@ -4,7 +4,7 @@ import { FIRMWARE_CHOICES_DDI, isFwHardwareValue, boardType } from "./board_type
import { flashFirmware } from "../../actions";
import { t } from "../../../i18next_wrapper";
import { BotState, Feature, ShouldDisplay } from "../../interfaces";
import { FirmwareAlerts } from "../../../logs/alerts";
import { FirmwareAlerts } from "../../../messages/alerts";
import { TimeSettings } from "../../../interfaces";
import { trim } from "../../../util";
@ -36,6 +36,7 @@ export interface FirmwareHardwareStatusDetailsProps {
mcuFirmwareValue: string | undefined;
shouldDisplay: ShouldDisplay;
timeSettings: TimeSettings;
dispatch: Function;
}
export interface FirmwareActionsProps {
@ -77,6 +78,7 @@ export const FirmwareHardwareStatusDetails =
</div>}
<FirmwareAlerts
bot={props.bot}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />
</div>;
@ -88,6 +90,7 @@ export interface FirmwareHardwareStatusProps {
botOnline: boolean;
timeSettings: TimeSettings;
shouldDisplay: ShouldDisplay;
dispatch: Function;
}
export const FirmwareHardwareStatus = (props: FirmwareHardwareStatusProps) => {
@ -106,6 +109,7 @@ export const FirmwareHardwareStatus = (props: FirmwareHardwareStatusProps) => {
botFirmwareValue={firmware_hardware}
mcuFirmwareValue={boardType(firmware_version)}
timeSettings={props.timeSettings}
dispatch={props.dispatch}
shouldDisplay={props.shouldDisplay} />
</Popover>;
};

View File

@ -5,7 +5,7 @@ import { ToolTips, Actions } from "../constants";
import { tourNames } from "./tours";
import { t } from "../i18next_wrapper";
const TourList = ({ dispatch }: { dispatch: Function }) =>
export const TourList = ({ dispatch }: { dispatch: Function }) =>
<div className="tour-list">
{tourNames().map(tour => <div key={tour.name}>
<label>{tour.description}</label>

View File

@ -1,6 +1,4 @@
jest.mock("react-redux", () => ({
connect: jest.fn()
}));
jest.mock("react-redux", () => ({ connect: jest.fn() }));
const mockStorj: Dictionary<number | boolean> = {};
@ -25,17 +23,13 @@ describe("<Logs />", () => {
return [log1, log2];
}
const fakeProps = (): LogsProps => {
return {
logs: fakeLogs(),
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
sourceFbosConfig: jest.fn(),
getConfigValue: x => mockStorj[x],
alerts: [],
apiFirmwareValue: undefined,
};
};
const fakeProps = (): LogsProps => ({
logs: fakeLogs(),
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
sourceFbosConfig: jest.fn(),
getConfigValue: x => mockStorj[x],
});
it("renders", () => {
const wrapper = mount(<Logs {...fakeProps()} />);

View File

@ -1,17 +1,10 @@
let mockDev = false;
jest.mock("../../account/dev/dev_support", () => ({
DevSettings: {
futureFeaturesEnabled: () => mockDev,
}
}));
import { mapStateToProps } from "../state_to_props";
import { fakeState } from "../../__test_support__/fake_state";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { TaggedLog } from "farmbot";
import { times } from "lodash";
import {
fakeFbosConfig, fakeLog, fakeEnigma
fakeFbosConfig, fakeLog
} from "../../__test_support__/fake_state/resources";
describe("mapStateToProps()", () => {
@ -51,28 +44,4 @@ describe("mapStateToProps()", () => {
value: false, consistent: true
});
});
it("handles undefined", () => {
const state = fakeState();
state.bot.hardware.enigmas = undefined;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
});
it("doesn't show API alerts", () => {
const state = fakeState();
state.resources = buildResourceIndex([fakeEnigma()]);
mockDev = false;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
});
it("shows API alerts", () => {
const state = fakeState();
const enigma = fakeEnigma();
state.resources = buildResourceIndex([enigma]);
mockDev = true;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([enigma.body]);
});
});

View File

@ -1,161 +0,0 @@
import * as React from "react";
import { t } from "../i18next_wrapper";
import { betterCompact } from "../util";
import { BotState } from "../devices/interfaces";
import {
FirmwareActions
} from "../devices/components/fbos_settings/firmware_hardware_status";
import { formatLogTime } from "./index";
import { TimeSettings } from "../interfaces";
import { Enigma } from "farmbot";
import { sortBy } from "lodash";
import { Content } from "../constants";
export interface AlertsProps {
alerts: Alert[];
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
}
interface AlertsState {
open: boolean;
}
interface ProblemTag {
author: string;
noun: string;
verb: string;
}
const splitTag = (problemTag: string): ProblemTag => {
const parts = problemTag.split(".");
return { author: parts[0], noun: parts[1], verb: parts[2] };
};
export const sortAlerts = (alerts: Alert[]): Alert[] =>
sortBy(alerts, "priority", "created_at");
export class Alerts extends React.Component<AlertsProps, AlertsState> {
state: AlertsState = { open: true };
render() {
const alertCount = Object.entries(this.props.alerts).length;
return alertCount > 0
? <div className="problem-alerts">
<div className="problem-alerts-header"
onClick={() => this.setState({ open: !this.state.open })}>
<h3>{t("Alerts")}</h3>
<div className={"saucer warn"}>
<p>{alertCount}</p>
</div>
<i className={`fa fa-caret-${this.state.open ? "up" : "down"}`} />
</div>
{this.state.open &&
<div className="problem-alerts-content">
{sortAlerts(this.props.alerts).map((x, i) =>
<AlertCard key={i}
alert={x}
apiFirmwareValue={this.props.apiFirmwareValue}
timeSettings={this.props.timeSettings} />)}
</div>}
</div> : <div />;
}
}
export interface FirmwareAlertsProps {
bot: BotState;
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
}
export const FirmwareAlerts = (props: FirmwareAlertsProps) => {
const alerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const firmwareAlerts = sortAlerts(alerts)
.filter(x => splitTag(x.problem_tag).noun === "firmware");
return <div className="firmware-alerts">
{firmwareAlerts.filter(x => x.problem_tag && x.priority && x.created_at)
.map((x, i) =>
<AlertCard key={i}
alert={x}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />)}
</div>;
};
export interface Alert extends Enigma { }
interface AlertCardProps {
alert: Alert;
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
}
const AlertCard = (props: AlertCardProps) => {
switch (props.alert.problem_tag) {
case "farmbot_os.firmware.missing":
return <FirmwareMissing
createdAt={props.alert.created_at}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />;
case "api.seed_data.missing":
return <SeedDataMissing
createdAt={props.alert.created_at}
timeSettings={props.timeSettings} />;
default:
return UnknownAlert(props.alert, props.timeSettings);
}
};
const UnknownAlert = (alert: Alert, timeSettings: TimeSettings) => {
const { problem_tag, priority, created_at } = alert;
const { author, noun, verb } = splitTag(problem_tag);
const createdAt = formatLogTime(created_at, timeSettings);
return <div className="problem-alert unknown-alert">
<div className="problem-alert-title">
<i className="fa fa-exclamation-triangle" />
<h3>{`${t(noun)}: ${t(verb)} (${t(author)})`}</h3>
<p>{createdAt}</p>
</div>
<div className="problem-alert-content">
<p>
{t("Unknown problem of priority {{priority}} created at {{createdAt}}",
{ priority, createdAt })}
</p>
</div>
</div>;
};
interface CommonAlertCardProps {
createdAt: number;
timeSettings: TimeSettings;
}
interface FirmwareMissingProps extends CommonAlertCardProps {
apiFirmwareValue: string | undefined;
}
const FirmwareMissing = (props: FirmwareMissingProps) =>
<div className="problem-alert firmware-missing-alert">
<div className="problem-alert-title">
<i className="fa fa-exclamation-triangle" />
<h3>{t("Firmware missing")}</h3>
<p>{formatLogTime(props.createdAt, props.timeSettings)}</p>
</div>
<div className="problem-alert-content">
<p>{t("Your device has no firmware installed.")}</p>
<FirmwareActions
apiFirmwareValue={props.apiFirmwareValue}
botOnline={true} />
</div>
</div>;
const SeedDataMissing = (props: CommonAlertCardProps) =>
<div className="problem-alert seed-data-missing-alert">
<div className="problem-alert-title">
<i className="fa fa-exclamation-triangle" />
<h3>{t("Choose your FarmBot")}</h3>
<p>{formatLogTime(props.createdAt, props.timeSettings)}</p>
</div>
<div className="problem-alert-content">
<p>{Content.SEED_DATA_SELECTION}</p>
</div>
</div>;

View File

@ -15,7 +15,6 @@ import { NumericSetting } from "../session_keys";
import { setWebAppConfigValue } from "../config_storage/actions";
import { NumberConfigKey } from "farmbot/dist/resources/configs/web_app";
import { t } from "../i18next_wrapper";
import { Alerts } from "./alerts";
import { TimeSettings } from "../interfaces";
import { timeFormatString } from "../util";
@ -85,9 +84,6 @@ export class Logs extends React.Component<LogsProps, Partial<LogsState>> {
render() {
const filterBtnColor = this.filterActive ? "green" : "gray";
return <Page className="logs-page">
<Alerts alerts={this.props.alerts}
apiFirmwareValue={this.props.apiFirmwareValue}
timeSettings={this.props.timeSettings} />
<Row>
<Col xs={7}>
<h3>

View File

@ -1,7 +1,6 @@
import { TaggedLog, ConfigurationName, ALLOWED_MESSAGE_TYPES } from "farmbot";
import { SourceFbosConfig } from "../devices/interfaces";
import { GetWebAppConfigValue } from "../config_storage/actions";
import { Alert } from "./alerts";
import { TimeSettings } from "../interfaces";
export interface LogsProps {
@ -10,8 +9,6 @@ export interface LogsProps {
dispatch: Function;
sourceFbosConfig: SourceFbosConfig;
getConfigValue: GetWebAppConfigValue;
alerts: Alert[];
apiFirmwareValue: string | undefined;
}
export type Filters = Record<ALLOWED_MESSAGE_TYPES, number>;

View File

@ -1,19 +1,17 @@
import { Everything } from "../interfaces";
import {
selectAllLogs, maybeGetTimeSettings, selectAllEnigmas
selectAllLogs, maybeGetTimeSettings
} from "../resources/selectors";
import { LogsProps } from "./interfaces";
import {
sourceFbosConfigValue
} from "../devices/components/source_config_value";
import { validFbosConfig, betterCompact } from "../util";
import { validFbosConfig } from "../util";
import { ResourceIndex } from "../resources/interfaces";
import { TaggedLog } from "farmbot";
import { getWebAppConfigValue } from "../config_storage/actions";
import { getFbosConfig } from "../resources/getters";
import { chain } from "lodash";
import { isFwHardwareValue } from "../devices/components/fbos_settings/board_type";
import { DevSettings } from "../account/dev/dev_support";
/** Take the specified number of logs after sorting by time created. */
export function takeSortedLogs(
@ -30,19 +28,11 @@ export function mapStateToProps(props: Everything): LogsProps {
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const sourceFbosConfig =
sourceFbosConfigValue(fbosConfig, hardware.configuration);
const apiFirmwareValue = sourceFbosConfig("firmware_hardware").value;
const botAlerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(props.resources.index).map(x => x.body);
const alerts =
botAlerts.concat(DevSettings.futureFeaturesEnabled() ? apiAlerts : []);
return {
dispatch: props.dispatch,
sourceFbosConfig,
logs: takeSortedLogs(250, props.resources.index),
timeSettings: maybeGetTimeSettings(props.resources.index),
getConfigValue: getWebAppConfigValue(() => props),
alerts,
apiFirmwareValue: isFwHardwareValue(apiFirmwareValue)
? apiFirmwareValue : undefined,
};
}

View File

@ -1,10 +1,9 @@
import * as React from "react";
import { mount } from "enzyme";
import {
Alerts, AlertsProps, Alert, FirmwareAlerts, FirmwareAlertsProps, sortAlerts
} from "../alerts";
import { FirmwareAlerts, sortAlerts, Alerts } from "../alerts";
import { bot } from "../../__test_support__/fake_state/bot";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { Alert, AlertsProps, FirmwareAlertsProps } from "../interfaces";
const FIRMWARE_MISSING_ALERT: Alert = {
created_at: 123,
@ -39,11 +38,13 @@ describe("<Alerts />", () => {
alerts: [],
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
});
it("renders no alerts", () => {
const wrapper = mount(<Alerts {...fakeProps()} />);
expect(wrapper.html()).toEqual("<div></div>");
expect(wrapper.html())
.toContain(`<div class="problem-alerts-content"></div>`);
});
it("renders alerts", () => {
@ -62,15 +63,6 @@ describe("<Alerts />", () => {
expect(wrapper.text()).toContain("1");
expect(wrapper.text()).toContain("firmware: alert");
});
it("collapses alerts", () => {
const p = fakeProps();
p.alerts = [FIRMWARE_MISSING_ALERT];
const wrapper = mount<Alerts>(<Alerts {...p} />);
expect(wrapper.state().open).toEqual(true);
wrapper.find(".problem-alerts-header").simulate("click");
expect(wrapper.state().open).toEqual(false);
});
});
describe("<FirmwareAlerts />", () => {
@ -78,6 +70,7 @@ describe("<FirmwareAlerts />", () => {
bot,
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
});
it("renders no alerts", () => {

View File

@ -0,0 +1,61 @@
import * as React from "react";
import { mount } from "enzyme";
import { AlertCard } from "../cards";
import { AlertCardProps } from "../interfaces";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { FBSelect } from "../../ui";
describe("<AlertCard />", () => {
const fakeProps = (): AlertCardProps => ({
alert: {
created_at: 123,
problem_tag: "author.noun.verb",
priority: 100,
uuid: "uuid",
},
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
});
it("renders unknown card", () => {
const wrapper = mount(<AlertCard {...fakeProps()} />);
expect(wrapper.text()).toContain("noun: verb (author)");
});
it("renders firmware card", () => {
const p = fakeProps();
p.alert.problem_tag = "farmbot_os.firmware.missing";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Firmware missing");
});
it("renders seed data card", () => {
const p = fakeProps();
p.alert.problem_tag = "api.seed_data.missing";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("FarmBot");
wrapper.find(FBSelect).simulate("change");
});
it("renders tour card", () => {
const p = fakeProps();
p.alert.problem_tag = "api.tour.not_taken";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("tour");
});
it("renders welcome card", () => {
const p = fakeProps();
p.alert.problem_tag = "api.user.not_welcomed";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Welcome");
});
it("renders documentation card", () => {
const p = fakeProps();
p.alert.problem_tag = "api.documentation.unread";
const wrapper = mount(<AlertCard {...p} />);
expect(wrapper.text()).toContain("Learn");
});
});

View File

@ -0,0 +1,35 @@
jest.mock("react-redux", () => ({ connect: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";
import { Messages } from "../index";
import { MessagesProps } from "../interfaces";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
describe("<Messages />", () => {
const fakeProps = (): MessagesProps => ({
alerts: [],
apiFirmwareValue: undefined,
timeSettings: fakeTimeSettings(),
dispatch: Function,
});
it("renders page", () => {
const wrapper = mount(<Messages {...fakeProps()} />);
expect(wrapper.text()).toContain("Message Center");
expect(wrapper.text()).toContain("No messages");
});
it("renders content", () => {
const p = fakeProps();
p.alerts = [{
created_at: 123,
problem_tag: "author.noun.verb",
priority: 100,
uuid: "uuid",
}];
const wrapper = mount(<Messages {...p} />);
expect(wrapper.text()).toContain("Message Center");
expect(wrapper.text()).toContain("No more messages");
});
});

View File

@ -0,0 +1,45 @@
let mockDev = false;
jest.mock("../../account/dev/dev_support", () => ({
DevSettings: { futureFeaturesEnabled: () => mockDev }
}));
import { fakeState } from "../../__test_support__/fake_state";
import { mapStateToProps } from "../state_to_props";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeEnigma, fakeFbosConfig } from "../../__test_support__/fake_state/resources";
describe("mapStateToProps()", () => {
it("handles undefined", () => {
const state = fakeState();
state.bot.hardware.enigmas = undefined;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
});
it("doesn't show API alerts", () => {
const state = fakeState();
state.resources = buildResourceIndex([fakeEnigma()]);
mockDev = false;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([]);
});
it("shows API alerts", () => {
const state = fakeState();
const enigma = fakeEnigma();
state.resources = buildResourceIndex([enigma]);
mockDev = true;
const props = mapStateToProps(state);
expect(props.alerts).toEqual([enigma.body]);
});
it("returns firmware value", () => {
const state = fakeState();
const fbosConfig = fakeFbosConfig();
fbosConfig.body.api_migrated = true;
fbosConfig.body.firmware_hardware = "arduino";
state.resources = buildResourceIndex([fbosConfig]);
const props = mapStateToProps(state);
expect(props.apiFirmwareValue).toEqual("arduino");
});
});

View File

@ -0,0 +1,42 @@
import * as React from "react";
import { betterCompact } from "../util";
import { sortBy } from "lodash";
import {
ProblemTag, Alert, FirmwareAlertsProps, AlertsProps,
} from "./interfaces";
import { AlertCard } from "./cards";
export const splitProblemTag = (problemTag: string): ProblemTag => {
const parts = problemTag.split(".");
return { author: parts[0], noun: parts[1], verb: parts[2] };
};
export const sortAlerts = (alerts: Alert[]): Alert[] =>
sortBy(alerts, "priority", "created_at");
export const FirmwareAlerts = (props: FirmwareAlertsProps) => {
const alerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const firmwareAlerts = sortAlerts(alerts)
.filter(x => splitProblemTag(x.problem_tag).noun === "firmware");
return <div className="firmware-alerts">
{firmwareAlerts.filter(x => x.problem_tag && x.priority && x.created_at)
.map((x, i) =>
<AlertCard key={i}
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />)}
</div>;
};
export const Alerts = (props: AlertsProps) =>
<div className="problem-alerts">
<div className="problem-alerts-content">
{sortAlerts(props.alerts).map((x, i) =>
<AlertCard key={i}
alert={x}
dispatch={props.dispatch}
apiFirmwareValue={props.apiFirmwareValue}
timeSettings={props.timeSettings} />)}
</div>
</div>;

View File

@ -0,0 +1,159 @@
import React from "react";
import { t } from "../i18next_wrapper";
import {
AlertCardProps, AlertCardTemplateProps, FirmwareMissingProps,
SeedDataMissingProps, SeedDataMissingState, TourNotTakenProps,
CommonAlertCardProps
} from "./interfaces";
import { formatLogTime } from "../logs";
import {
FirmwareActions
} from "../devices/components/fbos_settings/firmware_hardware_status";
import { DropDownItem, Row, Col, FBSelect, docLink } from "../ui";
import { Content } from "../constants";
import { TourList } from "../help/tour_list";
import { splitProblemTag } from "./alerts";
export const AlertCard = (props: AlertCardProps) => {
const { alert, timeSettings } = props;
const commonProps = { alert, timeSettings };
switch (alert.problem_tag) {
case "farmbot_os.firmware.missing":
return <FirmwareMissing {...commonProps}
apiFirmwareValue={props.apiFirmwareValue} />;
case "api.seed_data.missing":
return <SeedDataMissing {...commonProps}
dispatch={props.dispatch} />;
case "api.tour.not_taken":
return <TourNotTaken {...commonProps}
dispatch={props.dispatch} />;
case "api.user.not_welcomed":
return <UserNotWelcomed {...commonProps} />;
case "api.documentation.unread":
return <DocumentationUnread {...commonProps} />;
default:
return UnknownAlert(commonProps);
}
};
const AlertCardTemplate = (props: AlertCardTemplateProps) =>
<div className={`problem-alert ${props.className}`}>
<div className="problem-alert-title">
<i className="fa fa-exclamation-triangle" />
<h3>{t(props.title)}</h3>
<p>{formatLogTime(props.alert.created_at, props.timeSettings)}</p>
<i className="fa fa-times" />
</div>
<div className="problem-alert-content">
<p>{t(props.message)}</p>
{props.children}
</div>
</div>;
const UnknownAlert = (props: CommonAlertCardProps) => {
const { problem_tag, created_at, priority } = props.alert;
const { author, noun, verb } = splitProblemTag(problem_tag);
const createdAt = formatLogTime(created_at, props.timeSettings);
return <AlertCardTemplate
alert={props.alert}
className={"unknown-alert"}
title={`${t(noun)}: ${t(verb)} (${t(author)})`}
message={t("Unknown problem of priority {{priority}} created at {{createdAt}}",
{ priority, createdAt })}
timeSettings={props.timeSettings} />;
};
const FirmwareMissing = (props: FirmwareMissingProps) =>
<AlertCardTemplate
alert={props.alert}
className={"firmware-missing-alert"}
title={t("Firmware missing")}
message={t("Your device has no firmware installed.")}
timeSettings={props.timeSettings}>
<FirmwareActions
apiFirmwareValue={props.apiFirmwareValue}
botOnline={true} />
</AlertCardTemplate>;
const SEED_DATA_OPTIONS: DropDownItem[] = [
{ label: "Genesis v1.2", value: "12" },
{ label: "Genesis v1.3", value: "13" },
{ label: "Genesis v1.4", value: "14" },
{ label: "Genesis v1.4 XL", value: "14XL" },
{ label: "Custom Bot", value: "custom" },
];
class SeedDataMissing
extends React.Component<SeedDataMissingProps, SeedDataMissingState> {
state: SeedDataMissingState = { selection: "" };
render() {
return <AlertCardTemplate
alert={this.props.alert}
className={"seed-data-missing-alert"}
title={t("Choose your FarmBot")}
message={t(Content.SEED_DATA_SELECTION)}
timeSettings={this.props.timeSettings}>
<Row>
<Col xs={4}>
<label>{t("Choose your FarmBot")}</label>
</Col>
<Col xs={5}>
<FBSelect
key={this.state.selection}
list={SEED_DATA_OPTIONS}
selectedItem={SEED_DATA_OPTIONS[0]}
onChange={() => { }} />
</Col>
</Row>
</AlertCardTemplate>;
}
}
const TourNotTaken = (props: TourNotTakenProps) =>
<AlertCardTemplate
alert={props.alert}
className={"tour-not-taken-alert"}
title={t("Take a guided tour")}
message={t(Content.TAKE_A_TOUR)}
timeSettings={props.timeSettings}>
<p>{t("Choose a tour to begin")}:</p>
<TourList dispatch={props.dispatch} />
</AlertCardTemplate>;
const UserNotWelcomed = (props: CommonAlertCardProps) =>
<AlertCardTemplate
alert={props.alert}
className={"user-not-welcomed-alert"}
title={t("Welcome to the FarmBot Web App")}
message={t(Content.WELCOME)}
timeSettings={props.timeSettings}>
<p>
{t("You're currently viewing the")} <b>{t("Message Center")}</b>.
&nbsp;{t(Content.MESSAGE_CENTER_WELCOME)}
</p>
<p>
{t(Content.MESSAGE_DISMISS)}
</p>
</AlertCardTemplate>;
const DocumentationUnread = (props: CommonAlertCardProps) =>
<AlertCardTemplate
alert={props.alert}
className={"documentation-unread-alert"}
title={t("Learn more about the app")}
message={t(Content.READ_THE_DOCS)}
timeSettings={props.timeSettings}>
<p>
{t("Head over to")}
&nbsp;<a href={docLink()} target="_blank"
title={t("Open documentation in a new tab")}>
{t("software.farm.bot")}
</a>
&nbsp;{t("to get started")}.
</p>
<a className="link-button fb-button green"
href={docLink()} target="_blank"
title={t("Open documentation in a new tab")}>
{t("Read the docs")}
</a>
</AlertCardTemplate>;

View File

@ -0,0 +1,38 @@
import * as React from "react";
import { connect } from "react-redux";
import { Col, Row, Page, ToolTip } from "../ui/index";
import { ToolTips } from "../constants";
import { t } from "../i18next_wrapper";
import { Alerts } from "./alerts";
import { mapStateToProps } from "./state_to_props";
import { MessagesProps } from "./interfaces";
import { Link } from "../link";
@connect(mapStateToProps)
export class Messages extends React.Component<MessagesProps, {}> {
render() {
return <Page className="messages-page">
<Row>
<Col xs={12}>
<h3>
<i>{t("Message Center")}</i>
</h3>
<ToolTip helpText={ToolTips.MESSAGES} />
</Col>
</Row>
<Row>
<Alerts alerts={this.props.alerts}
dispatch={this.props.dispatch}
apiFirmwareValue={this.props.apiFirmwareValue}
timeSettings={this.props.timeSettings} />
</Row>
<Row>
<div className="link-to-logs">
{this.props.alerts.length > 0
? t("No more messages.") : t("No messages.")}
&nbsp;<Link to="/app/logs">{t("View Logs")}</Link>
</div>
</Row>
</Page>;
}
}

View File

@ -0,0 +1,69 @@
import { FirmwareHardware, Enigma } from "farmbot";
import { TimeSettings } from "../interfaces";
import { BotState } from "../devices/interfaces";
export interface MessagesProps {
alerts: Alert[];
apiFirmwareValue: FirmwareHardware | undefined;
timeSettings: TimeSettings;
dispatch: Function;
}
export interface AlertsProps {
alerts: Alert[];
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
dispatch: Function;
}
export interface ProblemTag {
author: string;
noun: string;
verb: string;
}
export interface FirmwareAlertsProps {
bot: BotState;
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
dispatch: Function;
}
export interface Alert extends Enigma { }
export interface AlertCardProps {
alert: Alert;
apiFirmwareValue: string | undefined;
timeSettings: TimeSettings;
dispatch: Function;
}
export interface CommonAlertCardProps {
alert: Alert;
timeSettings: TimeSettings;
}
export interface AlertCardTemplateProps {
className: string;
title: string;
alert: Alert;
message: string;
timeSettings: TimeSettings;
children?: React.ReactNode;
}
export interface FirmwareMissingProps extends CommonAlertCardProps {
apiFirmwareValue: string | undefined;
}
export interface SeedDataMissingProps extends CommonAlertCardProps {
dispatch: Function;
}
export interface SeedDataMissingState {
selection: string;
}
export interface TourNotTakenProps extends CommonAlertCardProps {
dispatch: Function;
}

View File

@ -0,0 +1,27 @@
import { Everything } from "../interfaces";
import { MessagesProps } from "./interfaces";
import { validFbosConfig, betterCompact } from "../util";
import { getFbosConfig } from "../resources/getters";
import { sourceFbosConfigValue } from "../devices/components/source_config_value";
import { DevSettings } from "../account/dev/dev_support";
import { selectAllEnigmas, maybeGetTimeSettings } from "../resources/selectors";
import { isFwHardwareValue } from "../devices/components/fbos_settings/board_type";
export const mapStateToProps = (props: Everything): MessagesProps => {
const { hardware } = props.bot;
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const sourceFbosConfig =
sourceFbosConfigValue(fbosConfig, hardware.configuration);
const apiFirmwareValue = sourceFbosConfig("firmware_hardware").value;
const botAlerts = betterCompact(Object.values(props.bot.hardware.enigmas || {}));
const apiAlerts = selectAllEnigmas(props.resources.index).map(x => x.body);
const alerts =
botAlerts.concat(DevSettings.futureFeaturesEnabled() ? apiAlerts : []);
return {
alerts,
apiFirmwareValue: isFwHardwareValue(apiFirmwareValue)
? apiFirmwareValue : undefined,
timeSettings: maybeGetTimeSettings(props.resources.index),
dispatch: props.dispatch,
};
};

View File

@ -1,3 +1,8 @@
let mockDev = false;
jest.mock("../../account/dev/dev_support", () => ({
DevSettings: { futureFeaturesEnabled: () => mockDev }
}));
import * as React from "react";
import { mount, shallow } from "enzyme";
import { AdditionalMenu } from "../additional_menu";
@ -31,4 +36,12 @@ describe("AdditionalMenu", () => {
wrapper.find("a").at(1).simulate("click");
expect(logout).toHaveBeenCalled();
});
it("navigates to help page", () => {
mockDev = true;
const wrapper = shallow(<AdditionalMenu
logout={jest.fn()}
close={jest.fn()} />);
wrapper.find("Link").at(2).simulate("click");
});
});

View File

@ -24,6 +24,7 @@ describe("NavBar", () => {
tour: undefined,
device: fakeDevice(),
autoSync: false,
alertCount: 0,
});
it("has correct parent classname", () => {

View File

@ -1,12 +1,19 @@
import * as React from "react";
import { shallow } from "enzyme";
import { shallow, mount } from "enzyme";
import { NavLinks } from "../nav_links";
describe("NavLinks", () => {
it("toggles the mobile nav menu", () => {
const close = jest.fn();
const wrapper = shallow(<NavLinks close={(x) => () => close(x)} />);
const wrapper = shallow(<NavLinks close={(x) => () => close(x)}
alertCount={0} />);
wrapper.find("Link").first().simulate("click");
expect(close).toHaveBeenCalledWith("mobileMenuOpen");
expect(wrapper.text()).not.toContain("0");
});
it("shows indicator", () => {
const wrapper = mount(<NavLinks close={jest.fn()} alertCount={1} />);
expect(wrapper.text()).toContain("1");
});
});

View File

@ -15,6 +15,12 @@ export const AdditionalMenu = (props: AccountMenuProps) => {
{t("Account Settings")}
</Link>
</div>
<div>
<Link to="/app/logs" onClick={props.close("accountMenuOpen")}>
<i className="fa fa-list"></i>
{t("Logs")}
</Link>
</div>
{DevSettings.futureFeaturesEnabled() &&
<Link to="/app/help" onClick={props.close("accountMenuOpen")}>
<i className="fa fa-question-circle"></i>

View File

@ -73,7 +73,7 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
const { toggle, close } = this;
const { mobileMenuOpen, tickerListOpen, accountMenuOpen } = this.state;
const { logs, timeSettings, getConfigValue } = this.props;
const { logs, timeSettings, getConfigValue, alertCount } = this.props;
const tickerListProps = {
logs, tickerListOpen, toggle, timeSettings, getConfigValue
};
@ -90,10 +90,10 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
className={menuIconClassNames.join(" ")}
onClick={this.toggle("mobileMenuOpen")} />
<span className="mobile-menu-container">
{MobileMenu({ close, mobileMenuOpen })}
{MobileMenu({ close, mobileMenuOpen, alertCount })}
</span>
<span className="top-menu-container">
{NavLinks({ close })}
{NavLinks({ close, alertCount })}
</span>
</div>
<div className="nav-right">

View File

@ -22,6 +22,7 @@ export interface NavBarProps {
tour: string | undefined;
device: TaggedDevice;
autoSync: boolean;
alertCount: number;
}
export interface NavBarState {
@ -35,6 +36,7 @@ type ToggleEventHandler = (e: React.MouseEvent<HTMLElement>) => void;
export interface MobileMenuProps {
close: (property: keyof NavBarState) => ToggleEventHandler;
mobileMenuOpen: boolean;
alertCount: number;
}
export interface TickerListProps {
@ -47,6 +49,7 @@ export interface TickerListProps {
export interface NavLinksProps {
close: (property: keyof NavBarState) => ToggleEventHandler;
alertCount: number;
}
export interface AccountMenuProps {

View File

@ -8,12 +8,13 @@ const classes = [Classes.CARD, Classes.ELEVATION_4, "mobile-menu"];
export let MobileMenu = (props: MobileMenuProps) => {
const isActive = props.mobileMenuOpen ? "active" : "inactive";
const { alertCount } = props;
return <div>
<Overlay
isOpen={props.mobileMenuOpen}
onClose={props.close("mobileMenuOpen")}>
<div className={`${classes.join(" ")} ${isActive}`}>
{NavLinks({ close: props.close })}
{NavLinks({ close: props.close, alertCount })}
</div>
</Overlay>
</div>;

View File

@ -41,7 +41,7 @@ export const links: NavLinkParams[] = [
name: "Farmware", icon: "crosshairs", slug: "farmware",
computeHref: computeFarmwareUrlFromState
},
{ name: "Logs", icon: "list", slug: "logs" },
{ name: "Messages", icon: "list", slug: "messages" },
];
export const NavLinks = (props: NavLinksProps) => {
@ -59,7 +59,13 @@ export const NavLinks = (props: NavLinksProps) => {
draggable={false}
onClick={props.close("mobileMenuOpen")}>
<i className={`fa fa-${link.icon}`} />
{t(link.name)}
<div>
{t(link.name)}
{link.slug === "messages" && props.alertCount > 0 &&
<div className={"saucer fun"}>
<p>{props.alertCount}</p>
</div>}
</div>
</Link>;
})}
</div>

View File

@ -130,6 +130,12 @@ export const UNBOUND_ROUTES = [
getModule: () => import("./logs"),
key: "Logs",
}),
route({
children: false,
$: "/messages",
getModule: () => import("./messages"),
key: "Messages",
}),
route({
children: false,
$: "/regimens(/:regimen)",