diff --git a/src/components/UiItemList.js b/src/components/UiItemList.js new file mode 100644 index 0000000..dd256fd --- /dev/null +++ b/src/components/UiItemList.js @@ -0,0 +1,32 @@ +// @flow +import * as React from "react"; +import ListItem from "@material-ui/core/ListItem"; + +import type { ControlUI } from "config/flowtypes"; + +import UiItem from "components/UiItems"; + +export type UiItemListProps = { + controls: Array +}; + +export default function UiItemList(props: UiItemListProps): React.Node { + return props.controls.map((control, key) => { + if (control.type == null) { + throw new Error( + "A control is missing the \"type\" parameter" + ); + } + if (control.type === "section") { + return ( + + ); + } + return ( + + + + ); + }); +} + diff --git a/src/components/UiItemList/UiItem.js b/src/components/UiItemList/UiItem.js deleted file mode 100644 index 70cc53e..0000000 --- a/src/components/UiItemList/UiItem.js +++ /dev/null @@ -1,271 +0,0 @@ -// @flow -import React from "react"; -import keys from "lodash/keys"; -import map from "lodash/map"; -import throttle from "lodash/throttle"; -import { renderRawIcon } from "config/icon"; -import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; -import ListItemText from "@material-ui/core/ListItemText"; -import ListSubheader from "@material-ui/core/ListSubheader"; -import Switch from "@material-ui/core/Switch"; -import Input from "@material-ui/core/Input"; -import InputLabel from "@material-ui/core/InputLabel"; -import FormControl from "@material-ui/core/FormControl"; -import Select from "@material-ui/core/Select"; -import MenuItem from "@material-ui/core/MenuItem"; -import Button from "@material-ui/core/Button"; -import LinearProgress from "@material-ui/core/LinearProgress"; -import SliderComponent from "@material-ui/lab/Slider"; - -import type { - UIControl, UIToggle, UIDropDown, UILink, - UISection, UIText, UIProgress, UISlider -} from "config/flowtypes"; - -import keyOf from "utils/keyOf"; - -type UiItemProps = { - item: I, - state: State, - onChangeState: (topic: string, nextState: string) => void -}; - -// eslint-disable-next-line flowtype/no-weak-types -export default class UiItem - extends React.Component> { - constructor(props: UiItemProps) { - super(props); - } - - runPrimaryAction() { - - } - - render() { - return null; - } - - /* - * TODO: The type system can't really check if the enableCondition is of - * any function type or if it is a TopicDependentOption or a - * StateDependentOption. This should be fixed. - */ - rawIsEnabled(props: UiItemProps) { - if (Object.keys(props.item).includes("enableCondition") && - typeof props.item.enableCondition == "function") { - const enableCondition = props.item.enableCondition; - const state = props.state; - return enableCondition(state); - } else { - return true; - } - } - - isEnabled() { - return this.rawIsEnabled(this.props); - } -} - -export class UiControl extends UiItem { - constructor(props: UiItemProps) { - super(props); - } - - changeState(next: string) { - if (this.props.item.topic == null) { - throw new Error( - `Missing topic in ${this.props.item.type} "${this.props.item.text}"` - ); - } - this.debouncedChange(next); - } - - debouncedChange = throttle((next: string) => - this.props.onChangeState(this.props.item.topic, next), 50, { - leading: true, - trailing: true - }); - - // $FlowFixMe - shouldComponentUpdate(nextProps: UiItemProps) { // TODO: Fix Flow - return nextProps.item.topic !== this.props.item.topic - || nextProps.state[nextProps.item.topic] !== - this.props.state[this.props.item.topic] - || this.isEnabled() !== this.rawIsEnabled(nextProps); - } - - getValue() { - const control = this.props.item; - const topic: string = control.topic || ""; - const value = this.props.state[topic]; - if (value == null) { - if (topic === "") { - throw new Error( - `Missing topic in ${control.type} "${control.text}"` - ); - } - throw new Error( - `Unknown topic "${topic}" in ${control.type} "${control.text}"` - ); - } - return value; - } -} - -export class Toggle extends UiControl { - isToggled = () => { - const value = this.getValue(); - const control = this.props.item; - const isChecked = control.toggled || - ((i, _s) => i === (control.on || "on")); - const checked = isChecked(value, this.props.state); - return checked; - } - - runPrimaryAction = () => { - if (this.isEnabled()) { - const control = this.props.item; - const toggled = this.isToggled(); - const on = control.on == null ? "on" : control.on; - const off = control.off == null ? "off" : control.off; - const next = toggled ? off : on; - this.changeState(next); - } - } - - render() { - return [ - , - - - - ]; - } -} - -export class DropDown extends UiControl { - runPrimaryAction = (next?: string) => { - if (this.isEnabled()) { - const control = this.props.item; - const optionKeys = keys(control.options); - const value = this.getValue(); - const valueIndex = keyOf(optionKeys, value); - if (next == null) { - this.changeState(optionKeys[(valueIndex + 1) % optionKeys.length]); - } else { - this.changeState(next); - } - } - } - - render() { - const control = this.props.item; - const value = this.getValue(); - const id = `${control.topic}-${control.text}`; - const options = control.options; - if (options == null) { - throw new Error( - `Parameter "options" missing for ${control.type} "${control.text}"` - ); - } - return ( - - {control.text} - } - > - {map(options, (v, k) => {v})} - - - ); - } -} - -export class Slider extends UiControl { - runPrimaryAction = (e: ?Event, v: ?number) => { - if (v != null) { - this.changeState(v.toString()); - } - } - - render() { - return [ - , - - ]; - } -} - -export class Link extends UiItem { - runPrimaryAction = () => { - const control = this.props.item; - if (control.link == null) { - throw new Error( - `Parameter "link" missing for ${control.type} "${control.text}"` - ); - } - if (this.isEnabled()) { - window.open(control.link, "_blank"); - } - } - - render() { - return ( - - ); - } -} - -export class Section extends UiItem { - render() { - return ( - {this.props.item.text} - ); - } -} - -export class Text extends UiControl { - render() { - return [ - , - - ]; - } -} - -export class Progress extends UiControl { - render() { - const min = this.props.item.min || 0; - const max = this.props.item.max || 100; - const val = parseFloat(this.getValue()); - const value = val * 100 / max - min; - return [ - , -
- -
- ]; - } -} - diff --git a/src/components/UiItemList/index.js b/src/components/UiItemList/index.js deleted file mode 100644 index e2d433d..0000000 --- a/src/components/UiItemList/index.js +++ /dev/null @@ -1,98 +0,0 @@ -// @flow -import React from "react"; -import ListItem from "@material-ui/core/ListItem"; -import ListItemIcon from "@material-ui/core/ListItemIcon"; -import { renderRawIcon } from "config/icon"; - -import type { ControlUI } from "config/flowtypes"; - -import { Toggle, DropDown, Link, - Section, Text, Progress, Slider } from "./UiItem"; -import MqttContext from "mqtt/context"; -import type { MqttContextValue } from "mqtt/context"; - -export type UiItemListProps = { - controls: Array -}; - -export default class UiItemList extends React.PureComponent { - constructor(props: UiItemListProps) { - super(props); - } - - render() { - return this.props.controls.map((control, key) => { - if (control.type == null) { - throw new Error( - "A control is missing the \"type\" parameter" - ); - } - if (control.type === "section") { - return ( - - {this.renderListItem(control, key)} - - ); - } - return ( - - - {this.renderListItem(control, key)} - - - ); - }); - } - - renderListItem(control: ControlUI, key: number) { - return (mqtt: MqttContextValue) => { - const node = this.renderControl(control, key.toString(), mqtt); - if (control.icon == null || control.type === "link") { - return node; - } else { - const listIconNode = ( - - {renderRawIcon(control.icon(mqtt.state), "mdi-24px")} - - ); - return [listIconNode, node]; - } - }; - } - - renderControl(control: ControlUI, key: string, mqtt: MqttContextValue) { - const props = { - state: Object.assign({}, mqtt.state), - onChangeState: mqtt.changeState, - key: `${key}-licontrol` - }; - switch (control.type) { - case "toggle": { - return ; - } - case "dropDown": { - return ; - } - case "section": { - return
; - } - case "link": { - return ; - } - case "slider": { - return ; - } - case "text": { - return ; - } - case "progress": { - return ; - } - default: { - throw new Error( - `Unknown UI type "${control.type}" for "${control.text}" component` - ); - } - } - } -} diff --git a/src/components/UiItems/DropDown.js b/src/components/UiItems/DropDown.js new file mode 100644 index 0000000..2ce1841 --- /dev/null +++ b/src/components/UiItems/DropDown.js @@ -0,0 +1,50 @@ +// @flow +import React from "react"; +import map from "lodash/map"; +import createComponent from "./base"; +import { isDisabled, getValue } from "./utils"; + +import type { UIDropDown } from "config/flowtypes"; + +import Select from "@material-ui/core/Select"; +import FormControl from "@material-ui/core/FormControl"; +import InputLabel from "@material-ui/core/InputLabel"; +import MenuItem from "@material-ui/core/MenuItem"; +import Input from "@material-ui/core/Input"; + +const componentId = (item: UIDropDown) => `dropdown-${item.topic}`; + +const DropDownOptions = ({options}) => + map(options, (v, k) => {v}); + +const onChangeEvent = (item: UIDropDown, changeState) => + (event) => changeState(item, event.target.value); + +const BaseComponent = ({Icon}, item, state, changeState) => ( + + + + {item.text} + + + +); + +export default createComponent({ + id: "dropDown", + name: "Drop Down", + desc: ` + The Drop Down can be used to select from a small range of different options. + `, + parameters: { + text: "A descriptive label for the drop down", + topic: "The topic id" + }, + baseComponent: BaseComponent +}); diff --git a/src/components/UiItems/Link.js b/src/components/UiItems/Link.js new file mode 100644 index 0000000..506f86d --- /dev/null +++ b/src/components/UiItems/Link.js @@ -0,0 +1,47 @@ +// @flow +import React from "react"; +import createComponent from "./base"; +import { isEnabled, isDisabled } from "./utils"; +import { renderRawIcon } from "config/icon"; + +import type { UILink } from "config/flowtypes"; + +import Button from "@material-ui/core/Button"; + +const followLink = (item, state) => () => { + if (isEnabled(item, state)) { + return window.open(item.link, "_blank"); + } + return false; +}; + +const Icon = ({item, state}) => { + if (item.icon == null) { + return false; + } + return renderRawIcon(item.icon(state), "mdi-24px"); +}; + +const BaseComponent = (_h, item: UILink, state, _changeState) => ( + +); + +export default createComponent({ + id: "link", + name: "Link", + desc: ` + The link is a button that opens a web page in a new tab. + `, + parameters: { + text: "A descriptive label for the link" + }, + baseComponent: BaseComponent +}); diff --git a/src/components/UiItems/Progress.js b/src/components/UiItems/Progress.js new file mode 100644 index 0000000..6cebd81 --- /dev/null +++ b/src/components/UiItems/Progress.js @@ -0,0 +1,38 @@ +// @flow +import React from "react"; +import createComponent from "./base"; +import { getValue } from "./utils"; + +import type { UIProgress } from "config/flowtypes"; + +import LinearProgress from "@material-ui/core/LinearProgress"; + +const progressVal = (item, state) => { + const min = item.min || 0; + const max = item.max || 100; + const val = parseFloat(getValue(item, state)); + return val * 100 / max - min; +}; + +const BaseComponent = ({Icon, Label}, item: UIProgress, state, _c) => ( + + + +); + +export default createComponent({ + id: "progress", + name: "Progress Bar", + desc: ` + The progress bar is used to display a progress value from MQTT + `, + parameters: { + text: "A descriptive label for the progress bar", + topic: "The topic id" + }, + baseComponent: BaseComponent +}); diff --git a/src/components/UiItems/Section.js b/src/components/UiItems/Section.js new file mode 100644 index 0000000..c03724f --- /dev/null +++ b/src/components/UiItems/Section.js @@ -0,0 +1,23 @@ +// @flow +import React from "react"; +import createComponent from "./base"; + +import type { UISection } from "config/flowtypes"; + +import ListSubheader from "@material-ui/core/ListSubheader"; + +const BaseComponent = (_b, item: UISection, _state, _changeState) => ( + {item.text} +); + +export default createComponent({ + id: "section", + name: "Section", + desc: ` + The section is a divider that can visually group components. + `, + parameters: { + text: "The text that is being displayed" + }, + baseComponent: BaseComponent +}); diff --git a/src/components/UiItems/Slider.js b/src/components/UiItems/Slider.js new file mode 100644 index 0000000..e3fceac --- /dev/null +++ b/src/components/UiItems/Slider.js @@ -0,0 +1,40 @@ +// @flow +import React from "react"; +import createComponent from "./base"; +import { isDisabled, getValue } from "./utils"; + +import type { UISlider } from "config/flowtypes"; + +import SliderComponent from "@material-ui/lab/Slider"; + +const changeSliderValue = (item: UISlider, changeState) => (_e, v) => + changeState(item, v.toString()); + +const BaseComponent = ({Icon, Label}, item, state, changeState) => ( + + + +); + +export default createComponent({ + id: "slider", + name: "Slider", + desc: ` + The Slider can be used to choose a number between two a min and a max value. + `, + parameters: { + text: "A descriptive label for the slider", + topic: "The topic id" + }, + baseComponent: BaseComponent +}); + + diff --git a/src/components/UiItems/Text.js b/src/components/UiItems/Text.js new file mode 100644 index 0000000..137d74b --- /dev/null +++ b/src/components/UiItems/Text.js @@ -0,0 +1,29 @@ +// @flow +import React from "react"; +import createComponent from "./base"; +import { getValue } from "./utils"; + +import type { UIText } from "config/flowtypes"; + +import ListItemText from "@material-ui/core/ListItemText"; + +const BaseComponent = ({Icon}, item: UIText, state, _changeState) => ( + + + + + +); + +export default createComponent({ + id: "text", + name: "Text", + desc: ` + The Text is used to display an MQTT value. + `, + parameters: { + text: "A descriptive label", + topic: "The topic id" + }, + baseComponent: BaseComponent +}); diff --git a/src/components/UiItems/Toggle.js b/src/components/UiItems/Toggle.js new file mode 100644 index 0000000..e7ef1b4 --- /dev/null +++ b/src/components/UiItems/Toggle.js @@ -0,0 +1,53 @@ +// @flow +import React from "react"; +import createComponent from "./base"; +import { isDisabled, isEnabled, getValue } from "./utils"; + +import type { UIToggle } from "config/flowtypes"; + +import Switch from "@material-ui/core/Switch"; + +const isToggled = (item: UIToggle, state: State) => { + const isChecked = item.toggled || + ((i, _s) => i === (item.on || "on")); + const checked = isChecked(getValue(item, state), state); + return checked; +}; + +const doToggle = (item: UIToggle, state: State, changeState) => { + if (isEnabled(item, state)) { + const toggled = isToggled(item, state); + const on = item.on == null ? "on" : item.on; + const off = item.off == null ? "off" : item.off; + const next = toggled ? off : on; + return changeState(item, next); + } + return false; +}; + +const BaseComponent = ({Icon, Label, Action}, item, state, changeState) => ( + + + +); + +export default createComponent({ + id: "toggle", + name: "Toggle Button", + desc: ` + The toggle button can be used to toggle between two values. + `, + parameters: { + text: "A descriptive label for the toggle button", + topic: "The topic id" + }, + baseComponent: BaseComponent +}); diff --git a/src/components/UiItems/base.js b/src/components/UiItems/base.js new file mode 100644 index 0000000..dd359a6 --- /dev/null +++ b/src/components/UiItems/base.js @@ -0,0 +1,77 @@ +// @flow +import * as React from "react"; +import MqttContext from "mqtt/context"; + +import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction"; +import ListItemText from "@material-ui/core/ListItemText"; +import ListItemIcon from "@material-ui/core/ListItemIcon"; + +import throttle from "lodash/throttle"; +import { renderRawIcon } from "config/icon"; +import type { Icon } from "config/icon"; + +export type Helpers = { + Icon: (props: Object) => React.Node, + Label: (props: Object) => React.Node, + Action: (props: Object) => React.Node +}; + +export type BaseComponent = ( + helpers: Helpers, + item: T, + state: State, + nextValue: (item: T, next: string) => void +) => React.Node; + +export type Component = { + id: string, + name: string, + desc: string, + + /* + * TODO: Map<$Keys, string> doesn't really work :( + * See https://github.com/facebook/flow/issues/5276 + * If there is progress on the issue try to make it $Exact as well + */ + parameters: Map<$Keys, string>, + baseComponent: BaseComponent +}; + +type SuperT = $ReadOnly<{ text: string }>; + +const IconHelper = ({item, state}: { item: { +icon?: Icon }, state: State }) => + ( + {item.icon == null || renderRawIcon(item.icon(state), "mdi-24px")} + + ); + +const createHelpers = (item: T) => + ({ + Icon: IconHelper, + Label: (props) => ( + + ), + Action: (props) => ( + + ) + }); + +const debouncedChangeState = (chState: (tpc: string, nxt: string) => void) => ( + throttle( (item: T, next: string) => + chState(item.topic, next), 50, { + leading: true, + trailing: true + }) +); + +const createComponent = (component: Component) => ({ + component: (item: T) => ( + + {({state, changeState}) => component.baseComponent( + createHelpers(item), item, state, debouncedChangeState(changeState) + )} + + ) +}); + +export default createComponent; diff --git a/src/components/UiItems/index.js b/src/components/UiItems/index.js new file mode 100644 index 0000000..8c8e44f --- /dev/null +++ b/src/components/UiItems/index.js @@ -0,0 +1,43 @@ +// @flow +import Toggle from "./Toggle"; +import DropDown from "./DropDown"; +import Section from "./Section"; +import Link from "./Link"; +import Slider from "./Slider"; +import Text from "./Text"; +import Progress from "./Progress"; + +import type { ControlUI } from "config/flowtypes"; + +const Control = ({item}: {item: ControlUI}) => { + switch (item.type) { + case "toggle": { + return Toggle.component(item); + } + case "dropDown": { + return DropDown.component(item); + } + case "section": { + return Section.component(item); + } + case "link": { + return Link.component(item); + } + case "slider": { + return Slider.component(item); + } + case "text": { + return Text.component(item); + } + case "progress": { + return Progress.component(item); + } + default: { + throw new Error( + `Unknown UI type "${item.type}" for "${item.text}" component` + ); + } + } +}; + +export default Control; diff --git a/src/components/UiItems/utils.js b/src/components/UiItems/utils.js new file mode 100644 index 0000000..b51a591 --- /dev/null +++ b/src/components/UiItems/utils.js @@ -0,0 +1,27 @@ +// @flow +import type { Enableable, UIControl } from "config/flowtypes"; + +export const getValue = (item: T, state: State) => { + const value = state[item.topic]; + if (value == null) { + if (item.topic === "") { + throw new Error( + `Missing topic in ${item.type} "${item.text}"` + ); + } + throw new Error( + `Unknown topic "${item.topic}" in ${item.type} "${item.text}"` + ); + } + return value; +}; + +export const isEnabled = (item: T, state: State) => { + if (item.enableCondition != null) { + return item.enableCondition(state); + } + return true; +}; + +export const isDisabled = (item: T, state: State) => + !isEnabled(item, state); diff --git a/src/config/flowtypes.js b/src/config/flowtypes.js index 643af1a..f3fe9d3 100644 --- a/src/config/flowtypes.js +++ b/src/config/flowtypes.js @@ -22,9 +22,9 @@ export interface UIControl { +topic: string } -export interface Enableable { +export type Enableable = $ReadOnly<{ enableCondition?: (s: State) => boolean -} +}>; export type UIToggle = $ReadOnly<{| type: "toggle",