Refactor ui: drop redux in favor of state local to components

master
blallo 2023-03-14 23:19:32 +01:00
parent b5fba3dc5b
commit 3f4f1d5358
Signed by: blallo
GPG Key ID: C530464EEDCF489A
19 changed files with 164 additions and 419 deletions

45
ui/.yarnclean 100644
View File

@ -0,0 +1,45 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
appveyor.yml
circle.yml
codeship-services.yml
codeship-steps.yml
wercker.yml
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.travis.yml
# misc
*.md

View File

@ -13,7 +13,6 @@
"@types/react-dom": "^18.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.1",
"react-scripts": "5.0.1",
"react-use-websocket": "^4.3.1",
"sass": "^1.58.3",

View File

@ -1,15 +1,11 @@
import React from 'react';
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(
<Provider store={store}>
<App />
</Provider>
);
const { getByText } = render(
<App />
);
expect(getByText(/learn/i)).toBeInTheDocument();
expect(getByText(/recupera stato/i)).toBeInTheDocument();
});

View File

@ -1,6 +1,6 @@
import React from 'react';
import { RadioToggler } from './features/radio/Radio';
import { LogViewer } from './features/radio/LogViewer';
import { RadioToggler } from './Radio';
import { LogViewer } from './LogViewer';
import './App.css';
function App() {

View File

@ -1,21 +1,25 @@
import React, { useState } from 'react';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { selectLogsLoading, selectLogs, requestLogsThunk } from './radioSlice';
import { requestLogs } from './radioAPI';
import styles from './LogViewer.module.scss';
export function LogViewer() {
const loading = useAppSelector(selectLogsLoading);
const logs = useAppSelector(selectLogs);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[] | undefined>(undefined);
const [hidden, setHidden] = useState(true);
const dispatch = useAppDispatch();
const onRequestLogsButtonClick = async () => {
setLoading(true);
const logs = await requestLogs();
setLogs(logs);
setLoading(false);
};
return (
<div className={styles.logsContainer}>
<div className={styles.buttonContainer}>
<button
className={styles.button}
onClick={() => dispatch(requestLogsThunk())}
onClick={onRequestLogsButtonClick}
>Recupera stato</button>
<button
className={styles.button}

99
ui/src/Radio.tsx 100644
View File

@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react';
import useWebSocket from 'react-use-websocket';
import { RadioState, ConnectionState } from './types';
import {
requestToStart,
requestToStop,
} from './radioAPI';
import styles from './Radio.module.scss';
const getWSUrl = async () => {
const proto = (window.location.protocol === "http:") ? "ws" : "wss"
const wsUrl = process.env.WS_ADDR ?
process.env.WS_ADDR :
proto + "://" + window.location.host + window.location.pathname + "liveness";
console.log("WS ADDR: %s", wsUrl);
return wsUrl;
};
export function RadioToggler() {
const [loading, setLoading] = useState(false);
const [radioState, setRadioState] = useState<RadioState|undefined>(undefined);
const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED);
const { lastJsonMessage } = useWebSocket(getWSUrl,
{
onOpen: () => setConnectionState(ConnectionState.CONNECTED),
onClose: () => setConnectionState(ConnectionState.DISCONNECTED),
});
const onButtonClick = async () => {
if (radioState === RadioState.STARTED) {
setLoading(true);
const stopped = await requestToStop();
if (stopped) {
setRadioState(RadioState.STOPPED);
}
setLoading(false);
} else if (radioState === RadioState.STOPPED) {
setLoading(true);
const started = await requestToStart();
if (started) {
setRadioState(RadioState.STARTED);
}
setLoading(false);
}
};
const setRadioStateFromWS = (message: any) => {
switch (message.status) {
case "STARTED":
setRadioState(RadioState.STARTED);
break;
case "STOPPED":
setRadioState(RadioState.STOPPED);
break;
default:
setRadioState(undefined);
break;
}
};
useEffect(() => {
if (lastJsonMessage != null) {
if (!loading && lastJsonMessage.hasOwnProperty('status')) {
setRadioStateFromWS(lastJsonMessage);
}
}
}, [lastJsonMessage]);
let buttonStatus = "";
let circleStyle = styles.circleDisconnected;
if (loading) {
buttonStatus = "Attesa";
circleStyle = styles.circleLoading;
} else if (connectionState && connectionState !== ConnectionState.DISCONNECTED) {
switch (radioState) {
case RadioState.STARTED:
buttonStatus = "ON AIR";
circleStyle = styles.circleStarted;
break;
case RadioState.STOPPED:
buttonStatus = "SPENTA";
circleStyle = styles.circleStopped;
break;
}
}
return (
<div>
<div className={styles.buttonWrap} aria-label="toggle-radio-state">
<div
className={styles.clicker}
onClick={onButtonClick}
>{buttonStatus}</div>
<div className={circleStyle}></div>
</div>
</div >
);
}

View File

@ -1,6 +0,0 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -1,17 +0,0 @@
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import radioReducer from '../features/radio/radioSlice';
export const store = configureStore({
reducer: {
globalState: radioReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;

View File

@ -1,79 +0,0 @@
import React, { useEffect } from 'react';
import useWebSocket from 'react-use-websocket';
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import { RadioState, ConnectionState } from '../../app/types';
import {
toggleRadioState,
selectRadioLoading,
selectRadio,
selectConnection,
} from './radioSlice';
import {
getOnWSOpen,
getOnWSClose,
getOnWSMessage,
} from './radioWS';
import styles from './Radio.module.scss';
const getWSUrl = async () => {
const proto = (window.location.protocol === "http:") ? "ws" : "wss"
const wsUrl = process.env.WS_ADDR ?
process.env.WS_ADDR :
proto + "://" + window.location.host + window.location.pathname + "liveness";
console.log("WS ADDR: %s", wsUrl);
return wsUrl;
};
export function RadioToggler() {
const loading = useAppSelector(selectRadioLoading);
const radioState = useAppSelector(selectRadio);
const connectionState = useAppSelector(selectConnection);
const dispatch = useAppDispatch();
const { lastJsonMessage } = useWebSocket(getWSUrl,
{
onOpen: getOnWSOpen(dispatch),
onClose: getOnWSClose(dispatch),
});
const onWSMessage = getOnWSMessage(dispatch);
useEffect(() => {
if (lastJsonMessage != null) {
if (!loading) {
onWSMessage(lastJsonMessage);
}
}
}, [lastJsonMessage]);
let buttonStatus = "";
let circleStyle = styles.circleDisconnected;
if (loading) {
buttonStatus = "Attesa";
circleStyle = styles.circleLoading;
} else if (connectionState && connectionState !== ConnectionState.DISCONNECTED) {
switch (radioState) {
case RadioState.STARTED:
buttonStatus = "ON AIR";
circleStyle = styles.circleStarted;
break;
case RadioState.STOPPED:
buttonStatus = "SPENTA";
circleStyle = styles.circleStopped;
break;
}
}
return (
<div>
<div className={styles.buttonWrap} aria-label="toggle-radio-state">
<div
className={styles.clicker}
onClick={() => dispatch(toggleRadioState())}
>{buttonStatus}</div>
<div className={circleStyle}></div>
</div>
</div >
);
}

View File

@ -1,75 +0,0 @@
import { RadioState, ConnectionState } from '../../app/types';
import reducer, {
AppState,
toggleRadioLoading,
setRadioStarted,
setRadioStopped,
toggleRadio,
setConnected,
setDisconnected,
toggleConnection,
selectRadioLoading,
selectRadio,
selectConnection,
} from './radioSlice';
describe('radio reducer', () => {
const initialState: AppState = {
loading: { radio: false, logs: false },
connection: ConnectionState.DISCONNECTED,
};
const connectedStarted: AppState = {
radio: RadioState.STARTED,
loading: { radio: false, logs: false },
connection: ConnectionState.CONNECTED,
};
const connectedStopped: AppState = {
radio: RadioState.STOPPED,
loading: { radio: false, logs: false },
connection: ConnectionState.CONNECTED,
};
it('should handle initial state', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual({
loading: { radio: false, logs: false },
connection: ConnectionState.DISCONNECTED,
});
});
it('should handle set loading', () => {
const actual = reducer(initialState, toggleRadioLoading());
expect(actual.loading.radio).toEqual(true);
});
it('should hande toggle radio started', () => {
const actual = reducer(connectedStopped, toggleRadio());
expect(actual.radio).toEqual(RadioState.STARTED);
})
it('should hande toggle radio stopped', () => {
const actual = reducer(connectedStarted, toggleRadio());
expect(actual.radio).toEqual(RadioState.STOPPED);
})
it('should hande set radio started', () => {
const actual = reducer(connectedStopped, setRadioStarted());
expect(actual.radio).toEqual(RadioState.STARTED);
})
it('should hande set radio stopped', () => {
const actual = reducer(connectedStarted, setRadioStopped());
expect(actual.radio).toEqual(RadioState.STOPPED);
})
it('should noop set radio started', () => {
const actual = reducer(connectedStarted, setRadioStarted());
expect(actual.radio).toEqual(RadioState.STARTED);
})
it('should noop set radio stopped', () => {
const actual = reducer(connectedStopped, setRadioStopped());
expect(actual.radio).toEqual(RadioState.STOPPED);
})
});

View File

@ -1,138 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { RootState, AppThunk } from '../../app/store';
import { RadioState, ConnectionState } from '../../app/types';
import { requestToStart, requestToStop, requestLogs } from './radioAPI';
export interface LoadingState {
radio: boolean,
logs: boolean,
}
export interface AppState {
radio?: RadioState,
logs?: Array<string>,
loading: LoadingState,
connection: ConnectionState
}
const initialState: AppState = {
radio: undefined,
loading: { radio: false, logs: false },
connection: ConnectionState.DISCONNECTED,
};
export const requestToStartThunk = createAsyncThunk(
'radio/restStart',
async () => {
const success = await requestToStart();
return success;
}
);
export const requestToStopThunk = createAsyncThunk(
'radio/restStop',
async () => {
const success = await requestToStop();
return success;
}
);
export const requestLogsThunk = createAsyncThunk(
'radio/restStatus',
async () => {
return await requestLogs();
}
);
export const radioSlice = createSlice({
name: 'radio',
initialState,
reducers: {
toggleRadioLoading: (state) => { state.loading.radio = !state.loading.radio },
toggleLogsLoading: (state) => { state.loading.logs = !state.loading.logs },
setRadioStarted: (state) => { console.log("[Redux] STARTED"); state.radio = RadioState.STARTED },
setRadioStopped: (state) => { console.log("[Redux] STOPPED"); state.radio = RadioState.STOPPED },
unsetRadio: (state) => { state.radio = undefined },
toggleRadio: (state) => {
if (state.radio === RadioState.STARTED) {
state.radio = RadioState.STOPPED
} else if (state.radio === RadioState.STOPPED) {
state.radio = RadioState.STARTED
}
},
setConnected: (state) => { state.connection = ConnectionState.CONNECTED },
setDisconnected: (state) => { state.connection = ConnectionState.DISCONNECTED },
toggleConnection: (state) => {
if (state.connection === ConnectionState.CONNECTED) {
state.connection = ConnectionState.DISCONNECTED
} else if (state.connection === ConnectionState.DISCONNECTED) {
state.connection = ConnectionState.CONNECTED
}
},
},
extraReducers: builder => {
builder
.addCase(requestToStartThunk.pending, (state) => {
state.loading.radio = true;
return state;
})
.addCase(requestToStartThunk.fulfilled, (state, action) => {
state.loading.radio = false;
if (action.payload) {
state.radio = RadioState.STARTED;
}
return state;
})
.addCase(requestToStopThunk.pending, (state, _) => {
state.loading.radio = true;
return state;
})
.addCase(requestToStopThunk.fulfilled, (state, action) => {
state.loading.radio = false;
if (action.payload) {
state.radio = RadioState.STOPPED;
}
return state;
})
.addCase(requestLogsThunk.pending, (state, _) => {
state.loading.logs = true;
return state;
})
.addCase(requestLogsThunk.fulfilled, (state, action) => {
state.loading.logs = false;
state.logs = action.payload;
return state;
})
},
});
export const {
toggleRadioLoading,
toggleLogsLoading,
setRadioStarted,
setRadioStopped,
unsetRadio,
toggleRadio,
setConnected,
setDisconnected,
toggleConnection,
} = radioSlice.actions;
export const selectRadioLoading = (state: RootState) => state.globalState.loading.radio;
export const selectLogsLoading = (state: RootState) => state.globalState.loading.logs;
export const selectRadio = (state: RootState) => state.globalState.radio;
export const selectLogs = (state: RootState) => state.globalState.logs;
export const selectConnection = (state: RootState) => state.globalState.connection;
export const toggleRadioState =
(): AppThunk =>
async (dispatch, getState) => {
const currentRadioState = selectRadio(getState());
if (currentRadioState === RadioState.STARTED) {
dispatch(requestToStopThunk());
} else if (currentRadioState === RadioState.STOPPED) {
dispatch(requestToStartThunk());
}
};
export default radioSlice.reducer

View File

@ -1,41 +0,0 @@
import { AppDispatch } from '../../app/store';
import {
setRadioStarted,
setRadioStopped,
unsetRadio,
setConnected,
setDisconnected,
} from './radioSlice';
export const getOnWSOpen = (dispatch: AppDispatch) => {
return () => {
console.log("[WS] Opened");
dispatch(setConnected());
}
};
export const getOnWSClose = (dispatch: AppDispatch) => {
return () => {
console.log("[WS] Closed")
dispatch(setDisconnected());
return true
}
};
export const getOnWSMessage = (dispatch: AppDispatch) => {
return (message: any) => {
console.log("[WS] Message: %s", JSON.stringify(message));
switch (message.status) {
case "STARTED":
dispatch(setRadioStarted());
break;
case "STOPPED":
dispatch(setRadioStopped());
break;
default:
dispatch(unsetRadio());
}
}
};

View File

@ -1,7 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import './index.css';
@ -10,8 +8,6 @@ const root = createRoot(container);
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
<App />
</React.StrictMode>
);

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,4 +1,4 @@
import { RadioState } from '../../app/types';
import { RadioState } from './types';
export const requestToStart = async () => {
console.log("[REST] request to start");

View File

@ -1034,7 +1034,7 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.20.13"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b"
integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==
@ -1940,14 +1940,6 @@
dependencies:
"@types/node" "*"
"@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/html-minifier-terser@^6.0.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
@ -2127,11 +2119,6 @@
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
"@types/use-sync-external-store@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
"@types/ws@^8.5.1":
version "8.5.4"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5"
@ -4810,13 +4797,6 @@ he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
hoopy@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
@ -7515,7 +7495,7 @@ react-error-overlay@^6.0.11:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-is@^16.13.1, react-is@^16.7.0:
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@ -7530,18 +7510,6 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-redux@^8.0.1:
version "8.0.5"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.5.tgz#e5fb8331993a019b8aaf2e167a93d10af469c7bd"
integrity sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==
dependencies:
"@babel/runtime" "^7.12.1"
"@types/hoist-non-react-statics" "^3.3.1"
"@types/use-sync-external-store" "^0.0.3"
hoist-non-react-statics "^3.3.2"
react-is "^18.0.0"
use-sync-external-store "^1.0.0"
react-refresh@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
@ -8781,11 +8749,6 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
requires-port "^1.0.0"
use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"