Browse Source

Many ui improvements; saving program updates works

update-deps
Alex Mikhalev 7 years ago
parent
commit
120c719623
  1. 7
      app/components/App.tsx
  2. 33
      app/components/DeviceView.scss
  3. 8
      app/components/DeviceView.tsx
  4. 5
      app/components/DevicesView.tsx
  5. 57
      app/components/DurationInput.tsx
  6. 74
      app/components/DurationView.tsx
  7. 97
      app/components/ProgramSequenceView.tsx
  8. 8
      app/components/ProgramTable.tsx
  9. 38
      app/components/RunSectionForm.tsx
  10. 47
      app/components/SectionChooser.tsx
  11. 5
      app/components/SectionTable.tsx
  12. 3
      app/components/index.ts
  13. BIN
      app/images/favicon-16x16.png
  14. BIN
      app/images/favicon-32x32.png
  15. BIN
      app/images/favicon-96x96.png
  16. BIN
      app/images/favicon.ico
  17. 214
      app/pages/ProgramPage.tsx
  18. 18
      app/styles/app.scss
  19. 2
      app/webpack.config.js
  20. 17
      common/sprinklersRpc/Program.ts
  21. 2
      common/sprinklersRpc/Section.ts
  22. 1
      package.json
  23. 3
      server/entities/SprinklersDevice.ts
  24. 612
      yarn.lock

7
app/components/App.tsx

@ -17,12 +17,11 @@ function NavContainer() {
<Container className="app"> <Container className="app">
<NavBar/> <NavBar/>
<Switch>
<Route path={rp.program(":deviceId", ":programId")} component={p.ProgramPage}/>
<Route path={rp.device(":deviceId")} component={p.DevicePage}/> <Route path={rp.device(":deviceId")} component={p.DevicePage}/>
<Route path={rp.messagesTest} component={p.MessagesTestPage}/> <Route path={rp.messagesTest} component={p.MessagesTestPage}/>
<Redirect to="/"/> {/*<Switch>*/}
</Switch> {/*<Redirect to="/"/>*/}
{/*</Switch>*/}
<MessagesView/> <MessagesView/>
</Container> </Container>

33
app/components/DeviceView.scss

@ -37,3 +37,36 @@
height: 14em; height: 14em;
overflow-y: scroll; overflow-y: scroll;
} }
.ui.modal.programEditor > .header > .header.item .inline.fields {
margin-bottom: 0;
}
.programSequence.editing .item .content {
width: 20em;
}
.durationInputs {
display: flex;
}
$durationInput-labelWidth: 2.5em;
.ui.form .field .ui.input.durationInput {
&.minutes {
margin-right: 1em;
}
&.seconds {
}
> input {
flex: 1 1 6em;
width: 100%;
}
> .label {
flex: 0 0 $durationInput-labelWidth;
text-align: center;
}
}

8
app/components/DeviceView.tsx

@ -3,8 +3,11 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Grid, Header, Icon, Item, SemanticICONS } from "semantic-ui-react"; import { Grid, Header, Icon, Item, SemanticICONS } from "semantic-ui-react";
import * as p from "@app/pages";
import * as rp from "@app/routePaths";
import { AppState, injectState } from "@app/state"; import { AppState, injectState } from "@app/state";
import { ConnectionState as ConState } from "@common/sprinklersRpc"; import { ConnectionState as ConState } from "@common/sprinklersRpc";
import { Route, RouteComponentProps, withRouter } from "react-router";
import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from "."; import { ProgramTable, RunSectionForm, SectionRunnerView, SectionTable } from ".";
import "./DeviceView.scss"; import "./DeviceView.scss";
@ -44,7 +47,7 @@ interface DeviceViewProps {
appState: AppState; appState: AppState;
} }
class DeviceView extends React.Component<DeviceViewProps> { class DeviceView extends React.Component<DeviceViewProps & RouteComponentProps<any>> {
render() { render() {
const { uiStore, sprinklersRpc, routerStore } = this.props.appState; const { uiStore, sprinklersRpc, routerStore } = this.props.appState;
const device = sprinklersRpc.getDevice(this.props.deviceId); const device = sprinklersRpc.getDevice(this.props.deviceId);
@ -61,6 +64,7 @@ class DeviceView extends React.Component<DeviceViewProps> {
</Grid.Column> </Grid.Column>
</Grid> </Grid>
<ProgramTable device={device} routerStore={routerStore}/> <ProgramTable device={device} routerStore={routerStore}/>
<Route path={rp.program(":deviceId", ":programId")} component={p.ProgramPage}/>
</React.Fragment> </React.Fragment>
); );
return ( return (
@ -81,4 +85,4 @@ class DeviceView extends React.Component<DeviceViewProps> {
} }
} }
export default injectState(observer(DeviceView)); export default injectState(withRouter(observer(DeviceView)));

5
app/components/DevicesView.tsx

@ -3,8 +3,9 @@ import * as React from "react";
import { Item } from "semantic-ui-react"; import { Item } from "semantic-ui-react";
import DeviceView from "@app/components/DeviceView"; import DeviceView from "@app/components/DeviceView";
import { RouteComponentProps, withRouter } from "react-router";
class DevicesView extends React.Component<{deviceId: string}> { class DevicesView extends React.Component<{deviceId: string} & RouteComponentProps<any>> {
render() { render() {
return ( return (
<Item.Group divided> <Item.Group divided>
@ -14,4 +15,4 @@ class DevicesView extends React.Component<{deviceId: string}> {
} }
} }
export default observer(DevicesView); export default withRouter(observer(DevicesView));

57
app/components/DurationInput.tsx

@ -1,57 +0,0 @@
import * as classNames from "classnames";
import * as React from "react";
import { Input, InputProps } from "semantic-ui-react";
import { Duration } from "@common/Duration";
export default class DurationInput extends React.Component<{
duration: Duration,
onDurationChange: (newDuration: Duration) => void,
className?: string,
}> {
render() {
const duration = this.props.duration;
const className = classNames("field", "durationInput", this.props.className);
// const editing = this.props.onDurationChange != null;
return (
<div className={className}>
<label>Duration</label>
<div className="ui two fields">
<Input
type="number"
className="field durationInput--minutes"
value={duration.minutes}
onChange={this.onMinutesChange}
label="M"
labelPosition="right"
/>
<Input
type="number"
className="field durationInput--seconds"
value={duration.seconds}
onChange={this.onSecondsChange}
max="60"
label="S"
labelPosition="right"
/>
</div>
</div>
);
}
private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
if (isNaN(Number(value))) {
return;
}
const newMinutes = parseInt(value, 10);
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
}
private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
if (isNaN(Number(value))) {
return;
}
const newSeconds = parseInt(value, 10);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
}
}

74
app/components/DurationView.tsx

@ -0,0 +1,74 @@
import * as classNames from "classnames";
import * as React from "react";
import { Form, Input, InputProps } from "semantic-ui-react";
import { Duration } from "@common/Duration";
export default class DurationView extends React.Component<{
label?: string,
inline?: boolean,
duration: Duration,
onDurationChange?: (newDuration: Duration) => void,
className?: string,
}> {
render() {
const { duration, label, inline, onDurationChange } = this.props;
const className = classNames("durationInput", this.props.className);
if (onDurationChange) {
return (
<React.Fragment>
<Form.Field inline={inline}>
{label && <label>{label}</label>}
<div className="durationInputs">
<Input
type="number"
className="durationInput minutes"
value={duration.minutes}
onChange={this.onMinutesChange}
label="M"
labelPosition="right"
onWheel={this.onWheel}
/>
<Input
type="number"
className="durationInput seconds"
value={duration.seconds}
onChange={this.onSecondsChange}
max="60"
label="S"
labelPosition="right"
onWheel={this.onWheel}
/>
</div>
</Form.Field>
</React.Fragment>
);
} else {
return (
<span className={className}>
{label && <label>{label}</label>} {duration.minutes}M {duration.seconds}S
</span>
);
}
}
private onMinutesChange: InputProps["onChange"] = (e, { value }) => {
if (!this.props.onDurationChange || isNaN(Number(value))) {
return;
}
const newMinutes = Number(value);
this.props.onDurationChange(this.props.duration.withMinutes(newMinutes));
}
private onSecondsChange: InputProps["onChange"] = (e, { value }) => {
if (!this.props.onDurationChange || isNaN(Number(value))) {
return;
}
const newSeconds = Number(value);
this.props.onDurationChange(this.props.duration.withSeconds(newSeconds));
}
private onWheel = () => {
// do nothing
}
}

97
app/components/ProgramSequenceView.tsx

@ -1,19 +1,94 @@
import classNames = require("classnames");
import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Form, List } from "semantic-ui-react";
import { DurationView, SectionChooser } from "@app/components/index";
import { Duration } from "@common/Duration"; import { Duration } from "@common/Duration";
import { ProgramItem, Section} from "@common/sprinklersRpc"; import { ProgramItem, Section } from "@common/sprinklersRpc";
export default function ProgramSequenceView({ sequence, sections }: { @observer
sequence: ProgramItem[], sections: Section[], class ProgramSequenceItem extends React.Component<{
}) { sequenceItem: ProgramItem, sections: Section[], onChange?: (newItem: ProgramItem) => void,
const sequenceItems = sequence.map((item, index) => { }> {
const section = sections[item.section]; renderContent() {
const duration = Duration.fromSeconds(item.duration); const { sequenceItem, sections } = this.props;
const editing = this.props.onChange != null;
const section = sections[sequenceItem.section];
const duration = Duration.fromSeconds(sequenceItem.duration);
if (editing) {
return (
<Form.Group inline>
<SectionChooser
label="Section"
inline
sections={sections}
value={section}
onChange={this.onSectionChange}
/>
<DurationView
label="Duration"
inline
duration={duration}
onDurationChange={this.onDurationChange}
/>
</Form.Group>
);
} else {
return (
<React.Fragment>
<List.Header>{section.toString()}</List.Header>
<List.Description>for {duration.toString()}</List.Description>
</React.Fragment>
);
}
}
render() {
return ( return (
<li key={index}> <List.Item>
<em>"{section.name}"</em> for {duration.toString()} <List.Icon name="caret right"/>
</li> <List.Content>{this.renderContent()}</List.Content>
</List.Item>
); );
}
private onSectionChange = (newSection: Section) => {
if (!this.props.onChange) {
return;
}
this.props.onChange(new ProgramItem({
...this.props.sequenceItem, section: newSection.id,
}));
}
private onDurationChange = (newDuration: Duration) => {
if (!this.props.onChange) {
return;
}
this.props.onChange(new ProgramItem({
...this.props.sequenceItem, duration: newDuration.toSeconds(),
}));
}
}
@observer
export default class ProgramSequenceView extends React.Component<{
sequence: ProgramItem[], sections: Section[], editing?: boolean,
}> {
render() {
const { sequence, sections } = this.props;
const editing = this.props.editing || false;
const className = classNames("programSequence", { editing });
const sequenceItems = sequence.map((item, index) => {
const onChange = editing ? (newItem: ProgramItem) => this.changeItem(newItem, index) : undefined;
return <ProgramSequenceItem sequenceItem={item} sections={sections} key={index} onChange={onChange}/>;
}); });
return <ul>{sequenceItems}</ul>; return <List className={className}>{sequenceItems}</List>;
}
private changeItem = (newItem: ProgramItem, index: number) => {
this.props.sequence[index] = newItem;
}
} }

8
app/components/ProgramTable.tsx

@ -6,7 +6,7 @@ import { Button, ButtonProps, Table } from "semantic-ui-react";
import { ProgramSequenceView, ScheduleView } from "@app/components"; import { ProgramSequenceView, ScheduleView } from "@app/components";
import * as rp from "@app/routePaths"; import * as rp from "@app/routePaths";
import { Program, Section, SprinklersDevice } from "@common/sprinklersRpc"; import { Program, SprinklersDevice } from "@common/sprinklersRpc";
@observer @observer
class ProgramRows extends React.Component<{ class ProgramRows extends React.Component<{
@ -15,7 +15,7 @@ class ProgramRows extends React.Component<{
expanded: boolean, toggleExpanded: (program: Program) => void, expanded: boolean, toggleExpanded: (program: Program) => void,
}> { }> {
render() { render() {
const { program, device, expanded, routerStore } = this.props; const { program, device, expanded } = this.props;
const { sections } = device; const { sections } = device;
const { name, running, enabled, schedule, sequence } = program; const { name, running, enabled, schedule, sequence } = program;
@ -46,7 +46,7 @@ class ProgramRows extends React.Component<{
); );
const detailRow = expanded && ( const detailRow = expanded && (
<Table.Row> <Table.Row>
<Table.Cell className="program--sequence" colSpan="4"> <Table.Cell className="program--sequence" colSpan="5">
<h4>Sequence: </h4> <ProgramSequenceView sequence={sequence} sections={sections}/> <h4>Sequence: </h4> <ProgramSequenceView sequence={sequence} sections={sections}/>
<h4>Schedule: </h4> <ScheduleView schedule={schedule}/> <h4>Schedule: </h4> <ScheduleView schedule={schedule}/>
</Table.Cell> </Table.Cell>
@ -82,7 +82,7 @@ export default class ProgramTable extends React.Component<{
} }
render() { render() {
const { programs, sections } = this.props.device; const { programs } = this.props.device;
const programRows = programs.map(this.renderRows); const programRows = programs.map(this.renderRows);
return ( return (

38
app/components/RunSectionForm.tsx

@ -1,14 +1,13 @@
import { computed } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { DropdownItemProps, DropdownProps, Form, Header, Segment } from "semantic-ui-react"; import { Form, Header, Segment } from "semantic-ui-react";
import { DurationView, SectionChooser } from "@app/components";
import { UiStore } from "@app/state"; import { UiStore } from "@app/state";
import { Duration } from "@common/Duration"; import { Duration } from "@common/Duration";
import log from "@common/logger"; import log from "@common/logger";
import { Section, SprinklersDevice } from "@common/sprinklersRpc"; import { Section, SprinklersDevice } from "@common/sprinklersRpc";
import { RunSectionResponse } from "@common/sprinklersRpc/deviceRequests"; import { RunSectionResponse } from "@common/sprinklersRpc/deviceRequests";
import DurationInput from "./DurationInput";
@observer @observer
export default class RunSectionForm extends React.Component<{ export default class RunSectionForm extends React.Component<{
@ -16,13 +15,13 @@ export default class RunSectionForm extends React.Component<{
uiStore: UiStore, uiStore: UiStore,
}, { }, {
duration: Duration, duration: Duration,
section: number | "", section: Section | undefined,
}> { }> {
constructor(props: any, context?: any) { constructor(props: any, context?: any) {
super(props, context); super(props, context);
this.state = { this.state = {
duration: new Duration(0, 0), duration: new Duration(0, 0),
section: "", section: undefined,
}; };
} }
@ -32,20 +31,18 @@ export default class RunSectionForm extends React.Component<{
<Segment> <Segment>
<Header>Run Section</Header> <Header>Run Section</Header>
<Form> <Form>
<Form.Select <SectionChooser
label="Section" label="Section"
placeholder="Section" sections={this.props.device.sections}
options={this.sectionOptions}
value={section} value={section}
onChange={this.onSectionChange} onChange={this.onSectionChange}
/> />
<DurationInput <DurationView
label="Duration"
duration={duration} duration={duration}
onDurationChange={this.onDurationChange} onDurationChange={this.onDurationChange}
/> />
{/*Label must be &nbsp; to align it properly*/}
<Form.Button <Form.Button
label="&nbsp;"
primary primary
onClick={this.run} onClick={this.run}
disabled={!this.isValid} disabled={!this.isValid}
@ -57,8 +54,8 @@ export default class RunSectionForm extends React.Component<{
); );
} }
private onSectionChange = (e: React.SyntheticEvent<HTMLElement>, v: DropdownProps) => { private onSectionChange = (newSection: Section) => {
this.setState({ section: v.value as number }); this.setState({ section: newSection });
} }
private onDurationChange = (newDuration: Duration) => { private onDurationChange = (newDuration: Duration) => {
@ -67,11 +64,10 @@ export default class RunSectionForm extends React.Component<{
private run = (e: React.SyntheticEvent<HTMLElement>) => { private run = (e: React.SyntheticEvent<HTMLElement>) => {
e.preventDefault(); e.preventDefault();
if (typeof this.state.section !== "number") { const { section, duration } = this.state;
if (!section) {
return; return;
} }
const section: Section = this.props.device.sections[this.state.section];
const { duration } = this.state;
section.run(duration.toSeconds()) section.run(duration.toSeconds())
.then(this.onRunSuccess) .then(this.onRunSuccess)
.catch(this.onRunError); .catch(this.onRunError);
@ -94,14 +90,6 @@ export default class RunSectionForm extends React.Component<{
} }
private get isValid(): boolean { private get isValid(): boolean {
return typeof this.state.section === "number"; return this.state.section != null && this.state.duration.toSeconds() > 0;
}
@computed
private get sectionOptions(): DropdownItemProps[] {
return this.props.device.sections.map((s, i) => ({
text: s ? s.name : null,
value: i,
}));
} }
} }

47
app/components/SectionChooser.tsx

@ -0,0 +1,47 @@
import { computed } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { DropdownItemProps, DropdownProps, Form } from "semantic-ui-react";
import { Section } from "@common/sprinklersRpc";
@observer
export default class SectionChooser extends React.Component<{
label?: string,
inline?: boolean,
sections: Section[],
value?: Section,
onChange?: (section: Section) => void,
}> {
render() {
const { label, inline, sections, value, onChange } = this.props;
let section = (value == null) ? "" : sections.indexOf(value);
section = (section === -1) ? "" : section;
const onSectionChange = (onChange == null) ? undefined : this.onSectionChange;
if (onChange == null) {
return <React.Fragment>{label || ""} '{value ? value.toString() : ""}'</React.Fragment>;
}
return (
<Form.Select
label={label}
inline={inline}
placeholder="Section"
options={this.sectionOptions}
value={section}
onChange={onSectionChange}
/>
);
}
private onSectionChange = (e: React.SyntheticEvent<HTMLElement>, v: DropdownProps) => {
this.props.onChange!(this.props.sections[v.value as number]);
}
@computed
private get sectionOptions(): DropdownItemProps[] {
return this.props.sections.map((s, i) => ({
text: s ? `${s.id}: ${s.name}` : null,
value: i,
}));
}
}

5
app/components/SectionTable.tsx

@ -15,9 +15,8 @@ export default class SectionTable extends React.Component<{ sections: Section[]
} }
const { name, state } = section; const { name, state } = section;
const sectionStateClass = classNames({ const sectionStateClass = classNames({
"section--state": true, "section-state": true,
"section--state-true": state, "running": state,
"section--state-false": !state,
}); });
const sectionState = state ? const sectionState = state ?
(<span><Icon name={"shower" as any} /> Irrigating</span>) (<span><Icon name={"shower" as any} /> Irrigating</span>)

3
app/components/index.ts

@ -1,7 +1,7 @@
export { default as App } from "./App"; export { default as App } from "./App";
export { default as DevicesView } from "./DevicesView"; export { default as DevicesView } from "./DevicesView";
export { default as DeviceView } from "./DeviceView"; export { default as DeviceView } from "./DeviceView";
export { default as DurationInput } from "./DurationInput"; export { default as DurationView } from "./DurationView";
export { default as MessagesView } from "./MessagesView"; export { default as MessagesView } from "./MessagesView";
export { default as ProgramTable } from "./ProgramTable"; export { default as ProgramTable } from "./ProgramTable";
export { default as RunSectionForm } from "./RunSectionForm"; export { default as RunSectionForm } from "./RunSectionForm";
@ -11,3 +11,4 @@ export { default as SectionTable } from "./SectionTable";
export { default as NavBar } from "./NavBar"; export { default as NavBar } from "./NavBar";
export { default as MessageTest } from "./MessageTest"; export { default as MessageTest } from "./MessageTest";
export { default as ProgramSequenceView } from "./ProgramSequenceView"; export { default as ProgramSequenceView } from "./ProgramSequenceView";
export { default as SectionChooser } from "./SectionChooser";

BIN
app/images/favicon-16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

BIN
app/images/favicon-32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
app/images/favicon-96x96.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
app/images/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

214
app/pages/ProgramPage.tsx

@ -1,23 +1,95 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { createViewModel, IViewModel } from "mobx-utils";
import * as React from "react"; import * as React from "react";
import { Button, Menu, Segment} from "semantic-ui-react"; import { RouteComponentProps } from "react-router";
import { Button, CheckboxProps, Form, Input, InputOnChangeData, Menu, Modal, Segment } from "semantic-ui-react";
import { ProgramSequenceView, ScheduleView } from "@app/components"; import { ProgramSequenceView, ScheduleView } from "@app/components";
import * as rp from "@app/routePaths";
import { AppState, injectState } from "@app/state"; import { AppState, injectState } from "@app/state";
import log from "@common/logger";
import { Program, SprinklersDevice } from "@common/sprinklersRpc"; import { Program, SprinklersDevice } from "@common/sprinklersRpc";
import { RouteComponentProps } from "react-router";
import { Link } from "react-router-dom";
@observer
class ProgramDetailView extends React.Component {
}
@observer @observer
class ProgramPage extends React.Component<{ class ProgramPage extends React.Component<{
appState: AppState, appState: AppState,
} & RouteComponentProps<{ deviceId: string, programId: number }>> { } & RouteComponentProps<{ deviceId: string, programId: number }>, {
device!: SprinklersDevice; programView: Program & IViewModel<Program> | undefined,
}> {
private device!: SprinklersDevice;
private program!: Program;
constructor(p: any) {
super(p);
this.state = {
programView: undefined,
};
}
get isEditing(): boolean {
return this.state.programView != null;
}
renderName(program: Program) {
const { name } = program;
if (this.isEditing) {
return (
<Menu.Item header>
<Form>
<Form.Group inline>
<Form.Field inline>
<label><h4>Program</h4></label>
<Input
placeholder="Program Name"
type="text"
value={name}
onChange={this.onNameChange}
/>
</Form.Field>
({program.id})
</Form.Group>
</Form>
</Menu.Item>
);
} else {
return <Menu.Item header as="h4">Program {name} ({program.id})</Menu.Item>;
}
}
renderActions(program: Program) {
const { running } = program;
const editing = this.isEditing;
let editButtons;
if (editing) {
editButtons = (
<React.Fragment>
<Button primary onClick={this.save}>
Save
</Button>
<Button negative onClick={this.cancelEditing}>
Cancel
</Button>
</React.Fragment>
);
} else {
editButtons = (
<Button primary onClick={this.startEditing}>
Edit
</Button>
);
}
return (
<Modal.Actions>
<Button positive={!running} negative={running} onClick={this.cancelOrRun}>
{running ? "Cancel" : "Run"}
</Button>
{editButtons}
<Button onClick={this.close}>
Close
</Button>
</Modal.Actions>
);
}
render() { render() {
const { deviceId, programId } = this.props.match.params; const { deviceId, programId } = this.props.match.params;
@ -26,42 +98,102 @@ class ProgramPage extends React.Component<{
if (device.programs.length <= programId || !device.programs[programId]) { if (device.programs.length <= programId || !device.programs[programId]) {
return null; return null;
} }
const program = device.programs[programId]; this.program = device.programs[programId];
const { programView } = this.state;
const program = programView || this.program;
const editing = programView != null;
const { running, enabled, schedule, sequence } = program;
const programRows = this.renderRows(program, programId);
return ( return (
<div> <Modal open onClose={this.close} className="programEditor">
<Menu attached="top"> <Modal.Header>{this.renderName(program)}</Modal.Header>
<Menu.Item header>Program {program.name} ({program.id})</Menu.Item> <Modal.Content>
<Menu.Menu position="right"> <Form>
<Menu.Item> <Form.Group>
<Button as={Link} to={"/devices/" + deviceId}> <Form.Checkbox
Back toggle
</Button> label="Enabled"
</Menu.Item> checked={enabled}
</Menu.Menu> readOnly={!editing}
</Menu> onChange={this.onEnabledChange}
<Segment attached="bottom"> />
{programRows} <Form.Checkbox toggle label="Running" checked={running} readOnly={!editing}/>
</Segment> </Form.Group>
</div> <Form.Field>
<label><h4>Sequence</h4></label>
<ProgramSequenceView sequence={sequence} sections={this.device.sections} editing={editing}/>
</Form.Field>
<Form.Field>
<label><h4>Schedule</h4></label>
<ScheduleView schedule={schedule}/>
</Form.Field>
</Form>
</Modal.Content>
{this.renderActions(program)}
</Modal>
); );
} }
private renderRows = (program: Program, i: number): JSX.Element | null => { private cancelOrRun = () => {
const { name, running, enabled, schedule, sequence } = program; if (!this.program) {
const cancelOrRun = () => running ? program.cancel() : program.run(); return;
return ( }
<React.Fragment key={i}> this.program.running ? this.program.cancel() : this.program.run();
<b>Enabled: </b>{enabled ? "Enabled" : "Not enabled"}<br/> }
<b>Running: </b>{running ? "Running" : "Not running"}<br/>
<Button size="small" onClick={cancelOrRun}> private startEditing = () => {
{running ? "Cancel" : "Run"} let { programView } = this.state;
</Button> if (programView) { // stop editing, so save
<h4>Sequence: </h4> <ProgramSequenceView sequence={sequence} sections={this.device.sections}/> programView.submit();
<h4>Schedule: </h4> <ScheduleView schedule={schedule}/> programView = undefined;
</React.Fragment> } else { // start editing
); programView = createViewModel(this.program);
}
this.setState({ programView });
}
private save = () => {
let { programView } = this.state;
if (programView) { // stop editing, so save
programView.submit();
programView = undefined;
}
this.setState({ programView });
this.program.update()
.then((data) => {
log.info({ data }, "Program updated");
}, (err) => {
log.error({ err }, "error updating Program");
});
}
private cancelEditing = () => {
let { programView } = this.state;
if (programView) {
programView = undefined; // stop editing
}
this.setState({ programView });
}
private close = () => {
const { deviceId } = this.props.match.params;
this.props.history.push({ pathname: rp.device(deviceId) });
}
private onNameChange = (e: any, p: InputOnChangeData) => {
const { programView } = this.state;
if (programView) {
programView.name = p.value;
}
}
private onEnabledChange = (e: any, p: CheckboxProps) => {
const { programView } = this.state;
if (programView) {
programView.enabled = p.checked!;
}
} }
} }

18
app/styles/app.scss

@ -1,5 +1,5 @@
.app { .app {
margin-top: 1em; padding-top: 1em;
} }
.sectionRunner--pausedState { .sectionRunner--pausedState {
@ -51,26 +51,12 @@
display: flex !important; display: flex !important;
} }
.section--state-true { .section-state.running {
color: green; color: green;
} }
.section--state-false {
}
.durationInput--minutes,
.durationInput--seconds {
min-width: 6em !important;
}
.durationInput .ui.labeled.input > .label {
width: 3em;
}
.messages { .messages {
position: fixed; position: fixed;
/* top: 12px; */
bottom: 1em; bottom: 1em;
left: 1em; left: 1em;
right: 1em; right: 1em;

2
app/webpack.config.js

@ -2,6 +2,7 @@ const path = require("path");
const webpack = require("webpack"); const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin");
const FaviconsWebpackPlugin = require("favicons-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin"); const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin"); const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
const DashboardPlugin = require("webpack-dashboard/plugin"); const DashboardPlugin = require("webpack-dashboard/plugin");
@ -160,6 +161,7 @@ const getConfig = module.exports = (env) => {
minifyURLs: true, minifyURLs: true,
} : undefined, } : undefined,
}), }),
new FaviconsWebpackPlugin(path.resolve(paths.appDir, "images", "favicon-96x96.png")),
// Makes some environment variables available to the JS code, for example: // Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === "production") { ... }. See `./env.js`. // if (process.env.NODE_ENV === "production") { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here. // It is absolutely essential that NODE_ENV was set to production here.

17
common/sprinklersRpc/Program.ts

@ -1,16 +1,20 @@
import { observable } from "mobx"; import { observable } from "mobx";
import { serialize } from "serializr";
import { Schedule } from "./schedule"; import { Schedule } from "./schedule";
import * as schema from "./schema";
import { SprinklersDevice } from "./SprinklersDevice"; import { SprinklersDevice } from "./SprinklersDevice";
export class ProgramItem { export class ProgramItem {
// the section number // the section number
readonly section: number; readonly section!: number;
// duration of the run, in seconds // duration of the run, in seconds
readonly duration: number; readonly duration!: number;
constructor(section: number = 0, duration: number = 0) { constructor(data?: Partial<ProgramItem>) {
this.section = section; if (data) {
this.duration = duration; Object.assign(this, data);
}
} }
} }
@ -37,7 +41,8 @@ export class Program {
return this.device.cancelProgram({ programId: this.id }); return this.device.cancelProgram({ programId: this.id });
} }
update(data: any) { update() {
const data = serialize(schema.program, this);
return this.device.updateProgram({ programId: this.id, data }); return this.device.updateProgram({ programId: this.id, data });
} }

2
common/sprinklersRpc/Section.ts

@ -23,6 +23,6 @@ export class Section {
} }
toString(): string { toString(): string {
return `Section{id=${this.id}, name="${this.name}", state=${this.state}}`; return `Section ${this.id}: '${this.name}'`;
} }
} }

1
package.json

@ -81,6 +81,7 @@
"classnames": "^2.2.6", "classnames": "^2.2.6",
"css-loader": "^0.28.11", "css-loader": "^0.28.11",
"dotenv": "^6.0.0", "dotenv": "^6.0.0",
"favicons-webpack-plugin": "^0.0.9",
"file-loader": "^1.1.11", "file-loader": "^1.1.11",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"happypack": "^5.0.0", "happypack": "^5.0.0",

3
server/entities/SprinklersDevice.ts

@ -7,6 +7,9 @@ export class SprinklersDevice {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@Column({ nullable: true, type: "uuid" })
deviceId: string | null = null;
@Column() @Column()
name: string = ""; name: string = "";

612
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save