From 6e34b971ecdfcb22a759cbd3727b8fb4148e067d Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Mon, 8 Jan 2024 11:05:28 +0000 Subject: [PATCH 1/6] move response preprocessing to api and add tasks --- ui/src/api/CWaterPumpAPI.js | 39 ++++++++++++++++++++++++++--- ui/src/store/slices/SystemStatus.js | 17 ------------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 978eb55..2942d15 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -10,6 +10,39 @@ function preprocessApiHost(apiHost) { return url; } +function preprocessResponse(response) { + if(null == response) return null; + if('error' in response) { + // TODO: handle errors in slice/SystemStatus.js + throw new Error(response.error); + } + // normal response + // convert "water threshold" to "waterThreshold" + response.waterThreshold = response["water threshold"]; + delete response["water threshold"]; + + // convert "time left" to "timeLeft" + response.pump.timeLeft = response.pump["time left"]; + delete response.pump["time left"]; + + // add field "updated" + response.updated = Date.now(); + // difference between current time on client and time on device + response.timeDelta = response.updated - response.time; + return response; +} + +// TODO: probably we need to know "ping" time to sync time more accurately +// Example: +// 00:00.000 - client sends request +// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1000ms +// 00:00.200 - server sends response +// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1000ms +// total time: 300ms +// on average, time to one-way trip is 150ms +// so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 850ms +// in this case, error is 50ms (150ms - actual 00:00.100), instead of 200ms (300ms - actual 00:00.100) +////////////////////////////////////////////////////////////////////// class CWaterPumpAPI { constructor({ client=null, URL }) { this._client = client || axios.create({ baseURL: preprocessApiHost(URL) }); @@ -19,17 +52,17 @@ class CWaterPumpAPI { const response = await this._client.get('/pour_tea', { milliseconds: runTimeMs, }); - return response.data; + return preprocessResponse(response.data); } async stop() { const response = await this._client.get('/stop'); - return response.data; + return preprocessResponse(response.data); } async status() { const response = await this._client.get('/status'); - return response.data; + return preprocessResponse(response.data); } } diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 8abba9f..4910334 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,25 +1,9 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; -function preprocessSystemStatus(systemStatus) { - if(null == systemStatus) return null; - // convert "water threshold" to "waterThreshold" - systemStatus.waterThreshold = systemStatus["water threshold"]; - delete systemStatus["water threshold"]; - - // convert "time left" to "timeLeft" - systemStatus.pump.timeLeft = systemStatus.pump["time left"]; - delete systemStatus.pump["time left"]; - - // add field "updated" - systemStatus.updated = Date.now(); - return systemStatus; -} - // Async thunks export const startPump = createAsyncThunk( 'systemStatus/startPump', async ({ api, pouringTime }, { dispatch }) => { - console.log('startPump: pouringTime = ' + pouringTime); const response = await api.start(pouringTime); return response; } @@ -28,7 +12,6 @@ export const startPump = createAsyncThunk( export const stopPump = createAsyncThunk( 'systemStatus/stopPump', async ({ api }, { dispatch }) => { - console.log('stopPump'); const response = await api.stop(); return response; } From fe49ff73ee02338faaf18fa02f796b0c5de3448e Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Mon, 8 Jan 2024 13:31:02 +0000 Subject: [PATCH 2/6] countdown for operation and some refactoring --- ui/src/App.css | 7 ++++++ ui/src/App.js | 11 +++++----- ui/src/Utils/time.js | 20 +++++++++++++++++ ui/src/api/CWaterPumpAPI.js | 1 + ui/src/components/CurrentOperationInfoArea.js | 22 +++++++++++++++++++ ui/src/components/SystemStatusArea.js | 22 ++----------------- ui/src/components/TimerArea.js | 22 +++++++++++++++++++ ui/src/store/slices/SystemStatus.js | 2 +- 8 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 ui/src/Utils/time.js create mode 100644 ui/src/components/CurrentOperationInfoArea.js create mode 100644 ui/src/components/TimerArea.js diff --git a/ui/src/App.css b/ui/src/App.css index 8b908d5..1c69a9f 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,2 +1,9 @@ .App { +} + +.countdown-area { + width: 100%; + text-align: center; + font-weight: bold; + font-size: 2rem; } \ No newline at end of file diff --git a/ui/src/App.js b/ui/src/App.js index f27ad68..bb625c7 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -4,13 +4,13 @@ import { Container, Form } from 'react-bootstrap'; import { connect } from 'react-redux'; import NotificationsArea from './components/NotificationsArea.js'; -import APIAddressField from './components/APIAddressField'; -import PourTimeField from './components/PourTimeField'; -import SystemControls from './components/SystemControls'; -import SystemStatusArea from './components/SystemStatusArea'; +import APIAddressField from './components/APIAddressField.js'; +import PourTimeField from './components/PourTimeField.js'; +import SystemControls from './components/SystemControls.js'; +import SystemStatusArea from './components/SystemStatusArea.js'; +import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js'; function App({ isConnected }) { - // TODO: Add a fake countdown timer of timeLeft return (

Tea System UI

@@ -21,6 +21,7 @@ function App({ isConnected }) { {isConnected ? ( <> + ) : null} diff --git a/ui/src/Utils/time.js b/ui/src/Utils/time.js new file mode 100644 index 0000000..248738e --- /dev/null +++ b/ui/src/Utils/time.js @@ -0,0 +1,20 @@ +function toTimeStr(diff) { + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + const secondsStr = (seconds % 60).toString().padStart(2, '0'); + const minutesStr = (minutes % 60).toString().padStart(2, '0'); + const hoursStr = hours.toString().padStart(2, '0'); + + return `${hoursStr}:${minutesStr}:${secondsStr}`; +} + +export function timeBetweenAsString({endTime=null, startTime=null}) { + if (null === startTime) startTime = new Date(); + if (null === endTime) endTime = new Date(); + + const diff = endTime - startTime; // in ms + if (diff < 0) return '-' + toTimeStr(-diff); + return toTimeStr(diff); +} \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 2942d15..d77098f 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -29,6 +29,7 @@ function preprocessResponse(response) { response.updated = Date.now(); // difference between current time on client and time on device response.timeDelta = response.updated - response.time; + // TODO: add field response.pump.estimatedEndTime return response; } diff --git a/ui/src/components/CurrentOperationInfoArea.js b/ui/src/components/CurrentOperationInfoArea.js new file mode 100644 index 0000000..b4ed46c --- /dev/null +++ b/ui/src/components/CurrentOperationInfoArea.js @@ -0,0 +1,22 @@ +import React from "react"; +import { connect } from "react-redux"; +import TimerArea from "./TimerArea"; + +export function CurrentOperationInfoAreaComponent({ + isRunning, estimatedEndTime +}) { + if (!isRunning) return null; + return ( +
+ +
+ ); +} + +export default connect( + state => ({ + isRunning: state.systemStatus.pump.running, + estimatedEndTime: state.systemStatus.pump.estimatedEndTime, + }), + [] +)(CurrentOperationInfoAreaComponent); \ No newline at end of file diff --git a/ui/src/components/SystemStatusArea.js b/ui/src/components/SystemStatusArea.js index 1279b0b..b95373f 100644 --- a/ui/src/components/SystemStatusArea.js +++ b/ui/src/components/SystemStatusArea.js @@ -1,25 +1,7 @@ import React from 'react'; import { Card } from 'react-bootstrap'; import { connect } from 'react-redux'; - -// time elapsed since last update -function TimeElapsedComponent({ updated }) { - const [diffString, setDiffString] = React.useState(''); - React.useEffect(() => { - const interval = setInterval(() => { - const now = new Date(); - const diff = now - updated; - const newDiffString = new Date(diff).toISOString().substr(11, 8); - setDiffString(newDiffString); - }, 1000); - - return () => clearInterval(interval); - }, [updated]); - - return ( - {diffString} - ); -} +import TimerArea from '../components/TimerArea'; function _systemStatus(status) { if (null === status) { @@ -30,7 +12,7 @@ function _systemStatus(status) { return ( <> Time since last update:{' '} - +
Pump Running: {pump.running ? "Yes" : "No"}
Time Left: {pump.timeLeft} ms diff --git a/ui/src/components/TimerArea.js b/ui/src/components/TimerArea.js new file mode 100644 index 0000000..2eb0a69 --- /dev/null +++ b/ui/src/components/TimerArea.js @@ -0,0 +1,22 @@ +import React from "react"; +import { timeBetweenAsString } from "../Utils/time"; + +export function TimerArea({ startTime=null, endTime=null, interval=450 }) { + const [countdown, setCountdown] = React.useState(''); + + React.useEffect(() => { + const tid = setInterval(() => { + setCountdown(timeBetweenAsString({ startTime, endTime })); + }, interval); + + return () => clearInterval(tid); + }, [startTime, endTime, interval]); + + return ( + + {countdown} + + ); +} + +export default TimerArea; \ No newline at end of file diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 4910334..745cf4f 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -19,7 +19,7 @@ export const stopPump = createAsyncThunk( // slice for system status const bindStatus = (state, action) => { - return preprocessSystemStatus(action.payload); + return action.payload; }; export const SystemStatusSlice = createSlice({ From 20bdb88ccd34756f219cf419ec19991c698e1633 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 12:28:31 +0000 Subject: [PATCH 3/6] update API --- ui/src/App.test.js | 6 -- ui/src/api/CWaterPumpAPI.js | 58 ++---------- ui/src/api/CWaterPumpAPIImpl.js | 66 +++++++++++++ ui/src/api/CWaterPumpAPIImpl.test.js | 135 +++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 57 deletions(-) delete mode 100644 ui/src/App.test.js create mode 100644 ui/src/api/CWaterPumpAPIImpl.js create mode 100644 ui/src/api/CWaterPumpAPIImpl.test.js diff --git a/ui/src/App.test.js b/ui/src/App.test.js deleted file mode 100644 index 0d76197..0000000 --- a/ui/src/App.test.js +++ /dev/null @@ -1,6 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('1 == 1', () => { - expect(1).toEqual(1); -}); \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index d77098f..33b6a8a 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; // helper function to preprocess the API host function preprocessApiHost(apiHost) { @@ -10,61 +11,16 @@ function preprocessApiHost(apiHost) { return url; } -function preprocessResponse(response) { - if(null == response) return null; - if('error' in response) { - // TODO: handle errors in slice/SystemStatus.js - throw new Error(response.error); - } - // normal response - // convert "water threshold" to "waterThreshold" - response.waterThreshold = response["water threshold"]; - delete response["water threshold"]; - - // convert "time left" to "timeLeft" - response.pump.timeLeft = response.pump["time left"]; - delete response.pump["time left"]; - - // add field "updated" - response.updated = Date.now(); - // difference between current time on client and time on device - response.timeDelta = response.updated - response.time; - // TODO: add field response.pump.estimatedEndTime - return response; -} - -// TODO: probably we need to know "ping" time to sync time more accurately -// Example: -// 00:00.000 - client sends request -// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1000ms -// 00:00.200 - server sends response -// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1000ms -// total time: 300ms -// on average, time to one-way trip is 150ms -// so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 850ms -// in this case, error is 50ms (150ms - actual 00:00.100), instead of 200ms (300ms - actual 00:00.100) -////////////////////////////////////////////////////////////////////// class CWaterPumpAPI { - constructor({ client=null, URL }) { - this._client = client || axios.create({ baseURL: preprocessApiHost(URL) }); - } - - async start(runTimeMs) { - const response = await this._client.get('/pour_tea', { - milliseconds: runTimeMs, + constructor({ URL }) { + this._impl = new CWaterPumpAPIImpl({ + client: axios.create({ baseURL: preprocessApiHost(URL) }), }); - return preprocessResponse(response.data); } - async stop() { - const response = await this._client.get('/stop'); - return preprocessResponse(response.data); - } - - async status() { - const response = await this._client.get('/status'); - return preprocessResponse(response.data); - } + async start(runTimeMs) { return await this._impl.start(runTimeMs); } + async stop() { return await this._impl.stop(); } + async status() { return await this._impl.status(); } } export default CWaterPumpAPI; diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js new file mode 100644 index 0000000..e83837f --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -0,0 +1,66 @@ +class CWaterPumpAPIImpl { + constructor({ client, currentTime=null }) { + this._client = client; + this._currentTime = currentTime || Date.now; + } + + async _execute(callback) { + const start = this._currentTime(); + const response = await callback(); + const end = this._currentTime(); + return { response, requestTime: end - start }; + } + + async start(runTimeMs) { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/pour_tea', { milliseconds: runTimeMs }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async stop() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/stop', {}) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async status() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/status', {}) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + /////////////////////// + // helper functions + preprocessResponse({ response, requestTime }) { + if(null == response) return null; + if('error' in response) { + // TODO: handle errors in slice/SystemStatus.js + throw new Error(response.error); + } + // make a deep copy of response + response = JSON.parse(JSON.stringify(response)); + // normal response + // convert "water threshold" to "waterThreshold" + response.waterThreshold = response["water threshold"]; + delete response["water threshold"]; + + // convert "time left" to "timeLeft" and adjust time + response.pump.timeLeft = response.pump["time left"]; + delete response.pump["time left"]; + + // adjust time by network delay + const oneWayTripTime = Math.round(requestTime / 2); + response.time += oneWayTripTime; + response.pump.timeLeft -= oneWayTripTime; + + const now = this._currentTime(); + response.updated = now; + response.pump.estimatedEndTime = response.pump.timeLeft + now; + return response; + } +} + +export default CWaterPumpAPIImpl; +export { CWaterPumpAPIImpl }; \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js new file mode 100644 index 0000000..8d75b99 --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -0,0 +1,135 @@ +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; + +describe('CWaterPumpAPIImpl', () => { + const DUMMY_STATUS = { + pump: { + "running": true, + "time left": 1000, + "water threshold": 100, + }, + time: 1000, + }; + // common test cases + async function shouldThrowErrorFromResponse(apiCall) { + const mockClient = { get: jest.fn() }; + const errorMessage = 'Error ' + Math.random(); + mockClient.get.mockResolvedValue({ data: { error: errorMessage } }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await expect(apiCall(api)).rejects.toThrow(errorMessage); + } + + async function shouldBeCalledWith(apiCall, url, params=null) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await apiCall(api); + + expect(mockClient.get).toHaveBeenCalledWith(url, params); + } + + async function shouldRethrowError(apiCall) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockRejectedValue(new Error('Network Error')); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await expect(apiCall(api)).rejects.toThrow('Network Error'); + } + + async function shouldPreprocessResponse(apiCall) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + const response = await apiCall(api); + + expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]); + expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]); + expect(response).toHaveProperty('updated'); + } + // end of common test cases + // tests per method + describe('start', () => { + it('common test cases', async () => { + const T = Math.random() * 1000; + const callback = async (api) => await api.start(T); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T }); + }); + }); + + describe('stop', () => { + it('common test cases', async () => { + const callback = async (api) => await api.stop(); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/stop', {}); + }); + }); + + describe('status', () => { + it('common test cases', async () => { + const callback = async (api) => await api.status(); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/status', {}); + }); + }); + // tests for helper function preprocessResponse + describe('preprocessResponse', () => { + it('should return null if response is null', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + expect(api.preprocessResponse({ response: null, requestTime: 0 })).toBeNull(); + }); + + it('should throw error if response has error', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + const errorMessage = 'Error ' + Math.random(); + expect(() => api.preprocessResponse({ + response: { error: errorMessage }, + requestTime: 0, + })).toThrow(errorMessage); + }); + + it('should preprocess response', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 }); + expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]); + expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]); + }); + + it('should add field "updated" with current time', () => { + const T = Math.random() * 1000; + const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => T }); + const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 }); + expect(response.updated).toBe(T); + }); + + /////////// + // Scenario: + // 00:00.000 - client sends request + // 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1234ms + // 00:00.200 - server sends response + // 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1234ms + // total time: 300ms + // on average, time to one-way trip is 150ms + // so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 1084ms + // estimatedEndTime = 00:00.300 + 1084ms = 00:01.384 + it('should adjust time', () => { + const responseObj = JSON.parse(JSON.stringify(DUMMY_STATUS)); + responseObj.time = 100; + responseObj.pump["time left"] = 1234; + + const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => 300 }); + const response = api.preprocessResponse({ response: responseObj, requestTime: 300 }); + expect(response.time).toBe(250); + expect(response.pump.timeLeft).toBe(1084); + expect(response.pump.estimatedEndTime).toBe(1384); + }); + }); +}); \ No newline at end of file From 754d33d1eb39611cb10d62e98e86f0b03714b626 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 21:04:02 +0000 Subject: [PATCH 4/6] misc --- ui/src/{contexts => components}/WaterPumpStatusProvider.js | 4 ++-- ui/src/contexts/WaterPumpAPIContext.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename ui/src/{contexts => components}/WaterPumpStatusProvider.js (92%) diff --git a/ui/src/contexts/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js similarity index 92% rename from ui/src/contexts/WaterPumpStatusProvider.js rename to ui/src/components/WaterPumpStatusProvider.js index 915caa5..3cadb5b 100644 --- a/ui/src/contexts/WaterPumpStatusProvider.js +++ b/ui/src/components/WaterPumpStatusProvider.js @@ -1,8 +1,8 @@ import React from 'react'; import { connect } from 'react-redux'; import { updateSystemStatus } from '../store/slices/SystemStatus'; -import { useWaterPumpAPI } from './WaterPumpAPIContext'; -import { useNotificationsSystem } from './NotificationsContext'; +import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; +import { useNotificationsSystem } from '../contexts/NotificationsContext'; const FETCH_INTERVAL = 5000; const CHECK_INTERVAL = Math.round(FETCH_INTERVAL / 10); diff --git a/ui/src/contexts/WaterPumpAPIContext.js b/ui/src/contexts/WaterPumpAPIContext.js index aff1fac..9f5ae25 100644 --- a/ui/src/contexts/WaterPumpAPIContext.js +++ b/ui/src/contexts/WaterPumpAPIContext.js @@ -1,7 +1,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { CWaterPumpAPI } from '../api/CWaterPumpAPI.js'; -import WaterPumpStatusProvider from './WaterPumpStatusProvider.js'; +import WaterPumpStatusProvider from '../components/WaterPumpStatusProvider.js'; const WaterPumpAPIContext = React.createContext(); From c59fca4834ff2ee42b681fd0cdb1bb95c297d273 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 22:09:29 +0000 Subject: [PATCH 5/6] rework notifications --- ui/src/components/NotificationsArea.js | 24 +++++---- ui/src/components/SystemControls.js | 15 +----- ui/src/components/WaterPumpStatusProvider.js | 13 +---- ui/src/contexts/NotificationsContext.js | 23 --------- ui/src/index.js | 9 ++-- ui/src/store/slices/Notifications.js | 18 +++++++ ui/src/store/slices/SystemStatus.js | 54 +++++++++++++++----- ui/src/store/slices/index.js | 3 +- 8 files changed, 82 insertions(+), 77 deletions(-) delete mode 100644 ui/src/contexts/NotificationsContext.js create mode 100644 ui/src/store/slices/Notifications.js diff --git a/ui/src/components/NotificationsArea.js b/ui/src/components/NotificationsArea.js index afb38c0..c6e1832 100644 --- a/ui/src/components/NotificationsArea.js +++ b/ui/src/components/NotificationsArea.js @@ -1,19 +1,23 @@ import React from 'react'; +import { connect } from 'react-redux'; import { Alert } from 'react-bootstrap'; -import { useNotificationsSystem } from '../contexts/NotificationsContext'; +import { NotificationsSystemActions } from '../store/slices/Notifications'; -function NotificationsArea() { - const NotificationsSystem = useNotificationsSystem(); - const { currentNotifications } = NotificationsSystem; - if(!currentNotifications) return null; - - const hideNotifications = () => { NotificationsSystem.clear(); }; +function NotificationsArea({ hasNotifications, message, clearNotifications }) { + if(!hasNotifications) return null; return ( - - {currentNotifications.message} + + {message} ); } -export default NotificationsArea; +export default connect( + (state) => ({ + hasNotifications: state.notifications.currentNotifications != null, + message: state.notifications.currentNotifications?.message + }), { + clearNotifications: NotificationsSystemActions.clear + } +)(NotificationsArea); \ No newline at end of file diff --git a/ui/src/components/SystemControls.js b/ui/src/components/SystemControls.js index 6076831..16d1a8a 100644 --- a/ui/src/components/SystemControls.js +++ b/ui/src/components/SystemControls.js @@ -3,29 +3,18 @@ import { connect } from 'react-redux'; import { Button } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { useNotificationsSystem } from '../contexts/NotificationsContext.js'; import { startPump, stopPump } from '../store/slices/SystemStatus.js'; export function SystemControlsComponent({ pouringTime, systemStatus, startPump, stopPump }) { const api = useWaterPumpAPI().API; - const NotificationsSystem = useNotificationsSystem(); - const handleStart = async () => { - try { - await startPump({ api , pouringTime }); - } catch (error) { - NotificationsSystem.alert('Error starting water pump: ' + error.message); - } + await startPump({ api , pouringTime }); }; const handleStop = async () => { - try { - await stopPump({ api }); - } catch (error) { - NotificationsSystem.alert('Error stopping water pump: ' + error.message); - } + await stopPump({ api }); }; const isRunning = systemStatus.pump.running; diff --git a/ui/src/components/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js index 3cadb5b..035cfed 100644 --- a/ui/src/components/WaterPumpStatusProvider.js +++ b/ui/src/components/WaterPumpStatusProvider.js @@ -2,14 +2,12 @@ import React from 'react'; import { connect } from 'react-redux'; import { updateSystemStatus } from '../store/slices/SystemStatus'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { useNotificationsSystem } from '../contexts/NotificationsContext'; const FETCH_INTERVAL = 5000; const CHECK_INTERVAL = Math.round(FETCH_INTERVAL / 10); function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatus }) { const { API } = useWaterPumpAPI(); - const NotificationsSystem = useNotificationsSystem(); const nextFetchTime = React.useRef(0); // Function to fetch water pump status @@ -19,16 +17,10 @@ function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatu if(null == API) return; nextFetchTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent fetches - try { - const status = await API.status(); - updateStatus(status); - } catch (error) { - NotificationsSystem.alert('Error fetching system status: ' + error.message); - updateStatus(null); - } + await updateStatus(API); nextFetchTime.current = Date.now() + FETCH_INTERVAL; }, - [API, NotificationsSystem, updateStatus, nextFetchTime] + [API, updateStatus, nextFetchTime] ); // Effect to start fetching periodically and when API changes @@ -58,6 +50,5 @@ export default connect( systemStatus: state.systemStatus }), { updateStatus: updateSystemStatus - } )(WaterPumpStatusProviderComoponent); \ No newline at end of file diff --git a/ui/src/contexts/NotificationsContext.js b/ui/src/contexts/NotificationsContext.js deleted file mode 100644 index 6685a68..0000000 --- a/ui/src/contexts/NotificationsContext.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -const NotificationsContext = React.createContext(); - -export function useNotificationsSystem() { - return React.useContext(NotificationsContext); -} - -export function NotificationsProvider({ children }) { - const [notifications, setNotifications] = React.useState(null); - - const value = { - alert: (message) => { setNotifications({ message }); }, - clear: () => { setNotifications(null); }, - currentNotifications: notifications, - }; - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/ui/src/index.js b/ui/src/index.js index 6777eb1..aa24bae 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -2,7 +2,6 @@ import React from 'react'; import App from './App.js'; import 'bootstrap/dist/css/bootstrap.min.css'; // Importing Bootstrap CSS -import { NotificationsProvider } from './contexts/NotificationsContext.js'; import { WaterPumpAPIProvider } from './contexts/WaterPumpAPIContext.js'; // Redux store import { AppStore } from './store'; @@ -12,11 +11,9 @@ const root = createRoot(document.getElementById('root')); root.render( - - - - - + + + ); diff --git a/ui/src/store/slices/Notifications.js b/ui/src/store/slices/Notifications.js new file mode 100644 index 0000000..9fbc377 --- /dev/null +++ b/ui/src/store/slices/Notifications.js @@ -0,0 +1,18 @@ +import { createSlice } from '@reduxjs/toolkit'; + +export const NotificationsSlice = createSlice({ + name: 'notifications', + initialState: { + currentNotifications: null + }, + reducers: { + alert: (state, action) => { + state.currentNotifications = action.payload; + }, + clear: state => { + state.currentNotifications = null; + } + } +}); + +export const NotificationsSystemActions = NotificationsSlice.actions; \ No newline at end of file diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 745cf4f..8e80d2f 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,20 +1,49 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { NotificationsSystemActions } from './Notifications'; + +function withNotification(action, message) { + return async (params, { dispatch }) => { + try { + return await action(params); + } catch(error) { + dispatch(NotificationsSystemActions.alert({ + type: 'error', + message: `${message} (${error.message})` + })); + throw error; + } + }; +} // Async thunks export const startPump = createAsyncThunk( 'systemStatus/startPump', - async ({ api, pouringTime }, { dispatch }) => { - const response = await api.start(pouringTime); - return response; - } + withNotification( + async ({ api, pouringTime }) => { + return await api.start(pouringTime); + }, + 'Failed to start pump' + ) ); export const stopPump = createAsyncThunk( 'systemStatus/stopPump', - async ({ api }, { dispatch }) => { - const response = await api.stop(); - return response; - } + withNotification( + async ({ api }) => { + return await api.stop(); + }, + 'Failed to stop pump' + ) +); + +export const updateSystemStatus = createAsyncThunk( + 'systemStatus/update', + withNotification( + async ( api ) => { + return await api.status(); + }, + 'Failed to update system status' + ) ); // slice for system status @@ -25,18 +54,17 @@ const bindStatus = (state, action) => { export const SystemStatusSlice = createSlice({ name: 'systemStatus', initialState: null, - reducers: { - updateSystemStatus: bindStatus, - }, + reducers: {}, extraReducers: (builder) => { // update system status on start/stop pump builder.addCase(startPump.fulfilled, bindStatus); builder.addCase(stopPump.fulfilled, bindStatus); + builder.addCase(updateSystemStatus.fulfilled, bindStatus); // on error, do not update system status builder.addCase(startPump.rejected, (state, action) => state); builder.addCase(stopPump.rejected, (state, action) => state); + builder.addCase(updateSystemStatus.rejected, (state, action) => state); } }); -export const actions = SystemStatusSlice.actions; -export const { updateSystemStatus } = actions; \ No newline at end of file +export const actions = SystemStatusSlice.actions; \ No newline at end of file diff --git a/ui/src/store/slices/index.js b/ui/src/store/slices/index.js index 6f7cd36..9031b1d 100644 --- a/ui/src/store/slices/index.js +++ b/ui/src/store/slices/index.js @@ -1,7 +1,8 @@ import { SystemStatusSlice } from "./SystemStatus"; import { UISlice } from "./UI"; +import { NotificationsSlice } from "./Notifications"; -const slices = [ SystemStatusSlice, UISlice ]; +const slices = [ SystemStatusSlice, UISlice, NotificationsSlice ]; // export all slices as an object { [sliceName]: slice } export const ALL_APP_SLICES = slices.reduce((acc, slice) => { acc[slice.name] = slice; From f4d462332166b2a49491f483aeab82ca193759d4 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 22:15:29 +0000 Subject: [PATCH 6/6] fix requests params --- ui/src/api/CWaterPumpAPIImpl.js | 7 +++---- ui/src/api/CWaterPumpAPIImpl.test.js | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js index e83837f..995f260 100644 --- a/ui/src/api/CWaterPumpAPIImpl.js +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -13,21 +13,21 @@ class CWaterPumpAPIImpl { async start(runTimeMs) { const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/pour_tea', { milliseconds: runTimeMs }) + async () => await this._client.get('/pour_tea', { params: { milliseconds: runTimeMs } }) ); return this.preprocessResponse({ response: data, requestTime }); } async stop() { const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/stop', {}) + async () => await this._client.get('/stop', { params: {} }) ); return this.preprocessResponse({ response: data, requestTime }); } async status() { const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/status', {}) + async () => await this._client.get('/status', { params: {} }) ); return this.preprocessResponse({ response: data, requestTime }); } @@ -36,7 +36,6 @@ class CWaterPumpAPIImpl { preprocessResponse({ response, requestTime }) { if(null == response) return null; if('error' in response) { - // TODO: handle errors in slice/SystemStatus.js throw new Error(response.error); } // make a deep copy of response diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js index 8d75b99..5a61036 100644 --- a/ui/src/api/CWaterPumpAPIImpl.test.js +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -19,14 +19,14 @@ describe('CWaterPumpAPIImpl', () => { await expect(apiCall(api)).rejects.toThrow(errorMessage); } - async function shouldBeCalledWith(apiCall, url, params=null) { + async function shouldBeCalledWith(apiCall, url, params) { const mockClient = { get: jest.fn() }; mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); const api = new CWaterPumpAPIImpl({ client: mockClient }); await apiCall(api); - expect(mockClient.get).toHaveBeenCalledWith(url, params); + expect(mockClient.get).toHaveBeenCalledWith(url, { params }); } async function shouldRethrowError(apiCall) {