Warn user in WebUI of unsaved changes #911

This commit is contained in:
proddy
2023-02-05 18:46:59 +01:00
parent 834eceab16
commit 71de48fd32
32 changed files with 12956 additions and 157 deletions

View File

@@ -1,5 +1,12 @@
{ {
<<<<<<< HEAD
"adapter": "react", "adapter": "react",
"baseLocale": "pl", "baseLocale": "pl",
"$schema": "https://unpkg.com/typesafe-i18n@5.22.0/schema/typesafe-i18n.json" "$schema": "https://unpkg.com/typesafe-i18n@5.22.0/schema/typesafe-i18n.json"
} }
=======
"adapter": "react",
"baseLocale": "pl",
"$schema": "https://unpkg.com/typesafe-i18n@5.24.0/schema/typesafe-i18n.json"
}
>>>>>>> 941146db (Warn user in WebUI of unsaved changes #911)

12725
interface/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,11 @@
"react-icons": "^4.7.1", "react-icons": "^4.7.1",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"sockette": "^2.0.6", "sockette": "^2.0.6",
<<<<<<< HEAD
"typesafe-i18n": "5.22.0", "typesafe-i18n": "5.22.0",
=======
"typesafe-i18n": "^5.24.0",
>>>>>>> 941146db (Warn user in WebUI of unsaved changes #911)
"typescript": "^4.9.5", "typescript": "^4.9.5",
"axios": "^1.3.2", "axios": "^1.3.2",
"react-router-dom": "^6.8.0" "react-router-dom": "^6.8.0"

View File

@@ -4,8 +4,6 @@ import { SnackbarProvider } from 'notistack';
import { IconButton } from '@mui/material'; import { IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { FeaturesLoader } from './contexts/features';
import CustomTheme from './CustomTheme'; import CustomTheme from './CustomTheme';
import AppRouting from './AppRouting'; import AppRouting from './AppRouting';
@@ -44,9 +42,9 @@ const App: FC = () => {
</IconButton> </IconButton>
)} )}
> >
<FeaturesLoader> {/* <FeaturesLoader> */}
<AppRouting /> <AppRouting />
</FeaturesLoader> {/* </FeaturesLoader> */}
</SnackbarProvider> </SnackbarProvider>
</CustomTheme> </CustomTheme>
</TypesafeI18n> </TypesafeI18n>

View File

@@ -1,11 +1,12 @@
import { FC, useContext, useEffect } from 'react'; import { FC, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
import { Route, Routes, Navigate, useLocation } from 'react-router-dom';
import { useSnackbar, VariantType } from 'notistack'; import { useSnackbar, VariantType } from 'notistack';
import { useI18nContext } from './i18n/i18n-react'; import { useI18nContext } from './i18n/i18n-react';
import { Authentication, AuthenticationContext } from './contexts/authentication'; import { Authentication, AuthenticationContext } from './contexts/authentication';
import { FeaturesContext } from './contexts/features';
import { RequireAuthenticated, RequireUnauthenticated } from './components'; import { RequireAuthenticated, RequireUnauthenticated } from './components';
import SignIn from './SignIn'; import SignIn from './SignIn';
@@ -27,6 +28,7 @@ const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut })
return <Navigate to="/" />; return <Navigate to="/" />;
}; };
// TODO still need this?
export const RemoveTrailingSlashes = () => { export const RemoveTrailingSlashes = () => {
const location = useLocation(); const location = useLocation();
return ( return (
@@ -42,7 +44,6 @@ export const RemoveTrailingSlashes = () => {
}; };
const AppRouting: FC = () => { const AppRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
return ( return (
@@ -51,16 +52,14 @@ const AppRouting: FC = () => {
<Routes> <Routes>
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} /> <Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} />
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} variant="success" />} /> <Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} variant="success" />} />
{features.security && ( <Route
<Route path="/"
path="/" element={
element={ <RequireUnauthenticated>
<RequireUnauthenticated> <SignIn />
<SignIn /> </RequireUnauthenticated>
</RequireUnauthenticated> }
} />
/>
)}
<Route <Route
path="/*" path="/*"
element={ element={

View File

@@ -1,8 +1,7 @@
import { FC, useCallback, useContext, useEffect } from 'react'; import { FC, useCallback, useEffect } from 'react';
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { FeaturesContext } from './contexts/features';
import * as AuthenticationApi from './api/authentication'; import * as AuthenticationApi from './api/authentication';
import { PROJECT_PATH } from './api/env'; import { PROJECT_PATH } from './api/env';
import { AXIOS } from './api/endpoints'; import { AXIOS } from './api/endpoints';
@@ -18,7 +17,6 @@ import System from './framework/system/System';
import Security from './framework/security/Security'; import Security from './framework/security/Security';
const AuthenticatedRouting: FC = () => { const AuthenticatedRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -41,23 +39,21 @@ const AuthenticatedRouting: FC = () => {
return ( return (
<Layout> <Layout>
<Routes> <Routes>
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />} <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />
<Route path="/network/*" element={<NetworkConnection />} /> <Route path="/network/*" element={<NetworkConnection />} />
<Route path="/ap/*" element={<AccessPoint />} /> <Route path="/ap/*" element={<AccessPoint />} />
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />} <Route path="/ntp/*" element={<NetworkTime />} />
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />} <Route path="/mqtt/*" element={<Mqtt />} />
{features.security && ( <Route
<Route path="/security/*"
path="/security/*" element={
element={ <RequireAdmin>
<RequireAdmin> <Security />
<Security /> </RequireAdmin>
</RequireAdmin> }
} />
/>
)}
<Route path="/system/*" element={<System />} /> <Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} /> <Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute} />} />
</Routes> </Routes>
</Layout> </Layout>
); );

View File

@@ -3,7 +3,7 @@ import * as H from 'history';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
import { Path } from 'react-router-dom'; import { Path } from 'react-router-dom';
import { Features, Me, SignInRequest, SignInResponse } from '../types'; import { Me, SignInRequest, SignInResponse } from '../types';
import { ACCESS_TOKEN, AXIOS } from './endpoints'; import { ACCESS_TOKEN, AXIOS } from './endpoints';
import { PROJECT_PATH } from './env'; import { PROJECT_PATH } from './env';
@@ -11,7 +11,7 @@ import { PROJECT_PATH } from './env';
export const SIGN_IN_PATHNAME = 'loginPathname'; export const SIGN_IN_PATHNAME = 'loginPathname';
export const SIGN_IN_SEARCH = 'loginSearch'; export const SIGN_IN_SEARCH = 'loginSearch';
export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi'); export const getDefaultRoute = `/${PROJECT_PATH}`;
export function verifyAuthorization(): AxiosPromise<void> { export function verifyAuthorization(): AxiosPromise<void> {
return AXIOS.get('/verifyAuthorization'); return AXIOS.get('/verifyAuthorization');
@@ -40,12 +40,12 @@ export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_SEARCH); getStorage().removeItem(SIGN_IN_SEARCH);
} }
export function fetchLoginRedirect(features: Features): Partial<Path> { export function fetchLoginRedirect(): Partial<Path> {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME); const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH); const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect(); clearLoginRedirect();
return { return {
pathname: signInPathname || getDefaultRoute(features), pathname: signInPathname || `/${PROJECT_PATH}`,
search: (signInPathname && signInSearch) || undefined search: (signInPathname && signInSearch) || undefined
}; };
} }

View File

@@ -6,3 +6,4 @@ export * from './upload';
export { default as SectionContent } from './SectionContent'; export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow'; export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox'; export { default as MessageBox } from './MessageBox';
export { default as BlockNavigation } from './routing/BlockNavigation';

View File

@@ -1,12 +1,10 @@
import { FC, useContext } from 'react'; import { FC } from 'react';
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material'; import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import LayoutAuthMenu from './LayoutAuthMenu'; import LayoutAuthMenu from './LayoutAuthMenu';
import { FeaturesContext } from '../../contexts/features';
export const DRAWER_WIDTH = 240; export const DRAWER_WIDTH = 240;
interface LayoutAppBarProps { interface LayoutAppBarProps {
@@ -15,8 +13,6 @@ interface LayoutAppBarProps {
} }
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => { const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return ( return (
<AppBar <AppBar
position="fixed" position="fixed"
@@ -41,7 +37,7 @@ const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
{title} {title}
</Typography> </Typography>
<Box flexGrow={1} /> <Box flexGrow={1} />
{features.security && <LayoutAuthMenu />} <LayoutAuthMenu />
</Toolbar> </Toolbar>
</AppBar> </AppBar>
); );

View File

@@ -9,7 +9,6 @@ import SettingsIcon from '@mui/icons-material/Settings';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import { FeaturesContext } from '../../contexts/features';
import ProjectMenu from '../../project/ProjectMenu'; import ProjectMenu from '../../project/ProjectMenu';
import LayoutMenuItem from './LayoutMenuItem'; import LayoutMenuItem from './LayoutMenuItem';
@@ -18,23 +17,20 @@ import { AuthenticatedContext } from '../../contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react'; import { useI18nContext } from '../../i18n/i18n-react';
const LayoutMenu: FC = () => { const LayoutMenu: FC = () => {
const { features } = useContext(FeaturesContext);
const authenticatedContext = useContext(AuthenticatedContext); const authenticatedContext = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
return ( return (
<> <>
{features.project && ( <List disablePadding component="nav">
<List disablePadding component="nav"> <ProjectMenu />
<ProjectMenu /> <Divider />
<Divider /> </List>
</List>
)}
<List disablePadding component="nav"> <List disablePadding component="nav">
<LayoutMenuItem icon={SettingsEthernetIcon} label={LL.NETWORK(0)} to="/network" /> <LayoutMenuItem icon={SettingsEthernetIcon} label={LL.NETWORK(0)} to="/network" />
<LayoutMenuItem icon={SettingsInputAntennaIcon} label={LL.ACCESS_POINT(0)} to="/ap" /> <LayoutMenuItem icon={SettingsInputAntennaIcon} label={LL.ACCESS_POINT(0)} to="/ap" />
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="NTP" to="/ntp" />} <LayoutMenuItem icon={AccessTimeIcon} label="NTP" to="/ntp" />
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />} <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />
<LayoutMenuItem <LayoutMenuItem
icon={LockIcon} icon={LockIcon}
label={LL.SECURITY(0)} label={LL.SECURITY(0)}

View File

@@ -0,0 +1,30 @@
import { FC } from 'react';
import type { Blocker } from '@remix-run/router';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import { useI18nContext } from '../../i18n/i18n-react';
interface BlockNavigationProps {
blocker: Blocker;
}
const BlockNavigation: FC<BlockNavigationProps> = ({ blocker }) => {
const { LL } = useI18nContext();
return (
<Dialog open={blocker.state === 'blocked'}>
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => blocker.reset?.()} color="secondary">
{LL.STAY()}
</Button>
<Button variant="contained" onClick={() => blocker.proceed?.()} color="primary" autoFocus>
{LL.LEAVE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default BlockNavigation;

View File

@@ -4,13 +4,11 @@ import { Navigate } from 'react-router-dom';
import * as AuthenticationApi from '../../api/authentication'; import * as AuthenticationApi from '../../api/authentication';
import { AuthenticationContext } from '../../contexts/authentication'; import { AuthenticationContext } from '../../contexts/authentication';
import { RequiredChildrenProps } from '../../utils'; import { RequiredChildrenProps } from '../../utils';
import { FeaturesContext } from '../../contexts/features';
const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => { const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>; return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect()} /> : <>{children}</>;
}; };
export default RequireUnauthenticated; export default RequireUnauthenticated;

View File

@@ -1,4 +1,4 @@
import { FC, useCallback, useContext, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useState } from 'react';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@@ -9,11 +9,9 @@ import { ACCESS_TOKEN } from '../../api/endpoints';
import { RequiredChildrenProps } from '../../utils'; import { RequiredChildrenProps } from '../../utils';
import { LoadingSpinner } from '../../components'; import { LoadingSpinner } from '../../components';
import { Me } from '../../types'; import { Me } from '../../types';
import { FeaturesContext } from '../features';
import { AuthenticationContext } from './context'; import { AuthenticationContext } from './context';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => { const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -43,11 +41,6 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
}; };
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!features.security) {
setMe({ admin: true, username: 'admin' });
setInitialized(true);
return;
}
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN); const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
try { try {
@@ -62,7 +55,7 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
setMe(undefined); setMe(undefined);
setInitialized(true); setInitialized(true);
} }
}, [features]); }, []);
useEffect(() => { useEffect(() => {
refresh(); refresh();

View File

@@ -13,7 +13,8 @@ import {
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField,
BlockNavigation
} from '../../components'; } from '../../components';
import { APProvisionMode, APSettings } from '../../types'; import { APProvisionMode, APSettings } from '../../types';
@@ -27,7 +28,7 @@ export const isAPEnabled = ({ provision_mode }: APSettings) => {
}; };
const APSettingsForm: FC = () => { const APSettingsForm: FC = () => {
const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, saveData, errorMessage } = const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, blocker, saveData, errorMessage } =
useRest<APSettings>({ useRest<APSettings>({
read: APApi.readAPSettings, read: APApi.readAPSettings,
update: APApi.updateAPSettings update: APApi.updateAPSettings
@@ -195,6 +196,7 @@ const APSettingsForm: FC = () => {
return ( return (
<SectionContent title={LL.SETTINGS_OF(LL.ACCESS_POINT(1))} titleGutter> <SectionContent title={LL.SETTINGS_OF(LL.ACCESS_POINT(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );

View File

@@ -27,6 +27,7 @@ const AccessPoint: FC = () => {
</RouterTabs> </RouterTabs>
<Routes> <Routes>
<Route path="status" element={<APStatusForm />} /> <Route path="status" element={<APStatusForm />} />
<Route index element={<Navigate to="status" />} />
<Route <Route
path="settings" path="settings"
element={ element={
@@ -35,6 +36,7 @@ const AccessPoint: FC = () => {
</RequireAdmin> </RequireAdmin>
} }
/> />
{/* <Route path="/*" element={<Navigate to="status" />} /> */}
<Route path="/*" element={<Navigate replace to="status" />} /> <Route path="/*" element={<Navigate replace to="status" />} />
</Routes> </Routes>
</> </>

View File

@@ -2,6 +2,7 @@ import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator'; import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment } from '@mui/material'; import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -12,7 +13,8 @@ import {
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField,
BlockNavigation
} from '../../components'; } from '../../components';
import { MqttSettings } from '../../types'; import { MqttSettings } from '../../types';
import { numberValue, updateValueDirty, useRest } from '../../utils'; import { numberValue, updateValueDirty, useRest } from '../../utils';
@@ -21,7 +23,7 @@ import * as MqttApi from '../../api/mqtt';
import { useI18nContext } from '../../i18n/i18n-react'; import { useI18nContext } from '../../i18n/i18n-react';
const MqttSettingsForm: FC = () => { const MqttSettingsForm: FC = () => {
const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, saveData, errorMessage } = const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, blocker, saveData, errorMessage } =
useRest<MqttSettings>({ useRest<MqttSettings>({
read: MqttApi.readMqttSettings, read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings update: MqttApi.updateMqttSettings
@@ -405,6 +407,7 @@ const MqttSettingsForm: FC = () => {
return ( return (
<SectionContent title={LL.SETTINGS_OF('MQTT')} titleGutter> <SectionContent title={LL.SETTINGS_OF('MQTT')} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );

View File

@@ -29,7 +29,8 @@ import {
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField, ValidatedTextField,
MessageBox MessageBox,
BlockNavigation
} from '../../components'; } from '../../components';
import { NetworkSettings } from '../../types'; import { NetworkSettings } from '../../types';
import * as NetworkApi from '../../api/network'; import * as NetworkApi from '../../api/network';
@@ -61,6 +62,7 @@ const WiFiSettingsForm: FC = () => {
origData, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
blocker,
saveData, saveData,
errorMessage, errorMessage,
restartNeeded restartNeeded
@@ -330,6 +332,7 @@ const WiFiSettingsForm: FC = () => {
return ( return (
<SectionContent title={LL.SETTINGS_OF(LL.NETWORK(1))} titleGutter> <SectionContent title={LL.SETTINGS_OF(LL.NETWORK(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : content()} {restarting ? <RestartMonitor /> : content()}
</SectionContent> </SectionContent>
); );

View File

@@ -6,7 +6,14 @@ import WarningIcon from '@mui/icons-material/Warning';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { validate } from '../../validators'; import { validate } from '../../validators';
import { BlockFormControlLabel, ButtonRow, FormLoader, SectionContent, ValidatedTextField } from '../../components'; import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedTextField,
BlockNavigation
} from '../../components';
import { NTPSettings } from '../../types'; import { NTPSettings } from '../../types';
import { updateValueDirty, useRest } from '../../utils'; import { updateValueDirty, useRest } from '../../utils';
import * as NTPApi from '../../api/ntp'; import * as NTPApi from '../../api/ntp';
@@ -16,7 +23,7 @@ import { NTP_SETTINGS_VALIDATOR } from '../../validators/ntp';
import { useI18nContext } from '../../i18n/i18n-react'; import { useI18nContext } from '../../i18n/i18n-react';
const NTPSettingsForm: FC = () => { const NTPSettingsForm: FC = () => {
const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, saveData, errorMessage } = const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, blocker, saveData, errorMessage } =
useRest<NTPSettings>({ useRest<NTPSettings>({
read: NTPApi.readNTPSettings, read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings update: NTPApi.updateNTPSettings
@@ -111,6 +118,7 @@ const NTPSettingsForm: FC = () => {
return ( return (
<SectionContent title={LL.SETTINGS_OF('NTP')} titleGutter> <SectionContent title={LL.SETTINGS_OF('NTP')} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );

View File

@@ -7,7 +7,14 @@ import CancelIcon from '@mui/icons-material/Cancel';
import * as SecurityApi from '../../api/security'; import * as SecurityApi from '../../api/security';
import { SecuritySettings } from '../../types'; import { SecuritySettings } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField } from '../../components'; import {
ButtonRow,
FormLoader,
MessageBox,
SectionContent,
ValidatedPasswordField,
BlockNavigation
} from '../../components';
import { SECURITY_SETTINGS_VALIDATOR, validate } from '../../validators'; import { SECURITY_SETTINGS_VALIDATOR, validate } from '../../validators';
import { updateValueDirty, useRest } from '../../utils'; import { updateValueDirty, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication'; import { AuthenticatedContext } from '../../contexts/authentication';
@@ -18,7 +25,7 @@ const SecuritySettingsForm: FC = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, saveData, errorMessage } = const { loadData, saving, data, setData, origData, dirtyFlags, blocker, setDirtyFlags, saveData, errorMessage } =
useRest<SecuritySettings>({ useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings, read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings update: SecurityApi.updateSecuritySettings
@@ -87,6 +94,7 @@ const SecuritySettingsForm: FC = () => {
return ( return (
<SectionContent title={LL.SETTINGS_OF(LL.SECURITY(1))} titleGutter> <SectionContent title={LL.SETTINGS_OF(LL.SECURITY(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );

View File

@@ -11,7 +11,8 @@ import {
FormLoader, FormLoader,
SectionContent, SectionContent,
ValidatedPasswordField, ValidatedPasswordField,
ValidatedTextField ValidatedTextField,
BlockNavigation
} from '../../components'; } from '../../components';
import { OTASettings } from '../../types'; import { OTASettings } from '../../types';
@@ -24,7 +25,7 @@ import { OTA_SETTINGS_VALIDATOR } from '../../validators/system';
import { useI18nContext } from '../../i18n/i18n-react'; import { useI18nContext } from '../../i18n/i18n-react';
const OTASettingsForm: FC = () => { const OTASettingsForm: FC = () => {
const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, saveData, errorMessage } = const { loadData, saving, data, setData, origData, dirtyFlags, setDirtyFlags, blocker, saveData, errorMessage } =
useRest<OTASettings>({ useRest<OTASettings>({
read: SystemApi.readOTASettings, read: SystemApi.readOTASettings,
update: SystemApi.updateOTASettings update: SystemApi.updateOTASettings
@@ -108,6 +109,7 @@ const OTASettingsForm: FC = () => {
return ( return (
<SectionContent title={LL.SETTINGS_OF('OTA')} titleGutter> <SectionContent title={LL.SETTINGS_OF('OTA')} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {content()}
</SectionContent> </SectionContent>
); );

View File

@@ -5,7 +5,6 @@ import { Tab } from '@mui/material';
import { useRouterTab, RouterTabs, useLayoutTitle, RequireAdmin } from '../../components'; import { useRouterTab, RouterTabs, useLayoutTitle, RequireAdmin } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication'; import { AuthenticatedContext } from '../../contexts/authentication';
import { FeaturesContext } from '../../contexts/features';
import UploadFileForm from './UploadFileForm'; import UploadFileForm from './UploadFileForm';
import SystemStatusForm from './SystemStatusForm'; import SystemStatusForm from './SystemStatusForm';
import OTASettingsForm from './OTASettingsForm'; import OTASettingsForm from './OTASettingsForm';
@@ -20,7 +19,6 @@ const System: FC = () => {
useLayoutTitle(LL.SYSTEM(0)); useLayoutTitle(LL.SYSTEM(0));
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const { features } = useContext(FeaturesContext);
const { routerTab } = useRouterTab(); const { routerTab } = useRouterTab();
return ( return (
@@ -28,33 +26,28 @@ const System: FC = () => {
<RouterTabs value={routerTab}> <RouterTabs value={routerTab}>
<Tab value="status" label={LL.STATUS_OF(LL.SYSTEM(1))} /> <Tab value="status" label={LL.STATUS_OF(LL.SYSTEM(1))} />
<Tab value="log" label={LL.LOG_OF(LL.SYSTEM(2))} /> <Tab value="log" label={LL.LOG_OF(LL.SYSTEM(2))} />
<Tab value="ota" label={LL.SETTINGS_OF('OTA')} disabled={!me.admin} />
{features.ota && <Tab value="ota" label={LL.SETTINGS_OF('OTA')} disabled={!me.admin} />} <Tab value="upload" label={LL.UPLOAD_DOWNLOAD()} disabled={!me.admin} />
{features.upload_firmware && <Tab value="upload" label={LL.UPLOAD_DOWNLOAD()} disabled={!me.admin} />}
</RouterTabs> </RouterTabs>
<Routes> <Routes>
<Route path="status" element={<SystemStatusForm />} /> <Route path="status" element={<SystemStatusForm />} />
<Route path="log" element={<SystemLog />} /> <Route path="log" element={<SystemLog />} />
{features.ota && ( <Route
<Route path="ota"
path="ota" element={
element={ <RequireAdmin>
<RequireAdmin> <OTASettingsForm />
<OTASettingsForm /> </RequireAdmin>
</RequireAdmin> }
} />
/> <Route
)} path="upload"
{features.upload_firmware && ( element={
<Route <RequireAdmin>
path="upload" <UploadFileForm />
element={ </RequireAdmin>
<RequireAdmin> }
<UploadFileForm /> />
</RequireAdmin>
}
/>
)}
<Route path="/*" element={<Navigate replace to="status" />} /> <Route path="/*" element={<Navigate replace to="status" />} />
</Routes> </Routes>
</> </>

View File

@@ -302,7 +302,11 @@ const de: Translation = {
NEW_NAME_OF: 'Ändere {0}', NEW_NAME_OF: 'Ändere {0}',
ENTITY: 'Entität', ENTITY: 'Entität',
MIN: 'min', MIN: 'min',
MAX: 'max' MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave' // TODO translate
}; };
export default de; export default de;

View File

@@ -302,7 +302,11 @@ const en: Translation = {
NEW_NAME_OF: 'New {0} name', NEW_NAME_OF: 'New {0} name',
ENTITY: 'entity', ENTITY: 'entity',
MIN: 'min', MIN: 'min',
MAX: 'max' MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes',
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?',
STAY: 'Stay',
LEAVE: 'Leave'
}; };
export default en; export default en;

View File

@@ -302,7 +302,11 @@ const fr: Translation = {
NEW_NAME_OF: 'Nouveau nom de {0}', NEW_NAME_OF: 'Nouveau nom de {0}',
ENTITY: 'entité', ENTITY: 'entité',
MIN: 'min', MIN: 'min',
MAX: 'max' MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave' // TODO translate
}; };
export default fr; export default fr;

View File

@@ -302,7 +302,11 @@ const nl: Translation = {
NEW_NAME_OF: 'Hernoem {0}', NEW_NAME_OF: 'Hernoem {0}',
ENTITY: 'Entiteit', ENTITY: 'Entiteit',
MIN: 'min', MIN: 'min',
MAX: 'max' MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave' // TODO translate
}; };
export default nl; export default nl;

View File

@@ -302,7 +302,11 @@ const no: Translation = {
NEW_NAME_OF: 'Bytt navn {0}', NEW_NAME_OF: 'Bytt navn {0}',
ENTITY: 'Entitet', ENTITY: 'Entitet',
MIN: 'min', MIN: 'min',
MAX: 'max' MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave' // TODO translate
}; };
export default no; export default no;

View File

@@ -302,7 +302,11 @@ const pl: BaseTranslation = {
NEW_NAME_OF: 'Nowa nazwa {0}', NEW_NAME_OF: 'Nowa nazwa {0}',
ENTITY: 'encji', ENTITY: 'encji',
MIN: 'Min.', MIN: 'Min.',
MAX: 'Maks.' MAX: 'Maks.',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave' // TODO translate
}; };
export default pl; export default pl;

View File

@@ -302,7 +302,11 @@ const sv: Translation = {
NEW_NAME_OF: 'Byt namn {0}', NEW_NAME_OF: 'Byt namn {0}',
ENTITY: 'Entitet', ENTITY: 'Entitet',
MIN: 'min', MIN: 'min',
MAX: 'max' MAX: 'max',
BLOCK_NAVIGATE_1: 'You have unsaved changes', // TODO translate
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', // TODO translate
STAY: 'Stay', // TODO translate
LEAVE: 'Leave' // TODO translate
}; };
export default sv; export default sv;

View File

@@ -1,15 +1,13 @@
import { StrictMode } from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router-dom';
import App from './App'; import App from './App';
const root = createRoot(document.getElementById('root') as HTMLElement); const router = createBrowserRouter(createRoutesFromElements(<Route path="/*" element={<App />}></Route>));
root.render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode> <React.StrictMode>
<BrowserRouter> <RouterProvider router={router} />
<App /> </React.StrictMode>
</BrowserRouter>
</StrictMode>
); );

View File

@@ -18,7 +18,8 @@ import {
BlockFormControlLabel, BlockFormControlLabel,
ValidatedTextField, ValidatedTextField,
ButtonRow, ButtonRow,
MessageBox MessageBox,
BlockNavigation
} from '../components'; } from '../components';
import { numberValue, extractErrorMessage, updateValueDirty, useRest } from '../utils'; import { numberValue, extractErrorMessage, updateValueDirty, useRest } from '../utils';
@@ -46,6 +47,7 @@ const SettingsApplication: FC = () => {
origData, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
blocker,
errorMessage, errorMessage,
restartNeeded restartNeeded
} = useRest<Settings>({ } = useRest<Settings>({
@@ -662,6 +664,7 @@ const SettingsApplication: FC = () => {
return ( return (
<SectionContent title={LL.APPLICATION_SETTINGS()} titleGutter> <SectionContent title={LL.APPLICATION_SETTINGS()} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : content()} {restarting ? <RestartMonitor /> : content()}
</SectionContent> </SectionContent>
); );

View File

@@ -1,5 +1,7 @@
import { FC, useState, useEffect, useCallback } from 'react'; import { FC, useState, useEffect, useCallback } from 'react';
import { unstable_useBlocker as useBlocker } from 'react-router-dom';
import { import {
Button, Button,
Typography, Typography,
@@ -34,7 +36,7 @@ import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
import { ButtonRow, FormLoader, ValidatedTextField, SectionContent, MessageBox } from '../components'; import { ButtonRow, FormLoader, ValidatedTextField, SectionContent, MessageBox, BlockNavigation } from '../components';
import * as EMSESP from './api'; import * as EMSESP from './api';
@@ -54,6 +56,9 @@ const SettingsCustomization: FC = () => {
const emptyDeviceEntity = { id: '', v: 0, n: '', cn: '', m: 0, w: false }; const emptyDeviceEntity = { id: '', v: 0, n: '', cn: '', m: 0, w: false };
const [numChanges, setNumChanges] = useState<number>(0);
let blocker = useBlocker(numChanges !== 0);
const [restarting, setRestarting] = useState<boolean>(false); const [restarting, setRestarting] = useState<boolean>(false);
const [restartNeeded, setRestartNeeded] = useState<boolean>(false); const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([emptyDeviceEntity]); const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([emptyDeviceEntity]);
@@ -67,6 +72,10 @@ const SettingsCustomization: FC = () => {
// eslint-disable-next-line // eslint-disable-next-line
const [masks, setMasks] = useState(() => ['']); const [masks, setMasks] = useState(() => ['']);
useEffect(() => {
countChanges();
});
const entities_theme = useTheme({ const entities_theme = useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 150px repeat(1, minmax(80px, 1fr)) 45px 45px 120px; --data-table-library_grid-template-columns: 150px repeat(1, minmax(80px, 1fr)) 45px 45px 120px;
@@ -278,6 +287,10 @@ const SettingsCustomization: FC = () => {
); );
}; };
const countChanges = () => {
setNumChanges(getChanges().length);
};
const restart = async () => { const restart = async () => {
try { try {
await EMSESP.restart(); await EMSESP.restart();
@@ -451,34 +464,30 @@ const SettingsCustomization: FC = () => {
</Grid> </Grid>
<Grid item> <Grid item>
<Tooltip arrow placement="top" title="set selected entities to be both visible and output"> <Button
<Button size="small"
size="small" sx={{ fontSize: 10 }}
sx={{ fontSize: 10 }} variant="outlined"
variant="outlined" color="inherit"
color="inherit" onClick={() => maskDisabled(false)}
onClick={() => maskDisabled(false)} >
> {LL.SET_ALL()}&nbsp;
{LL.SET_ALL()}&nbsp; <OptionIcon type="api_mqtt_exclude" isSet={false} />
<OptionIcon type="api_mqtt_exclude" isSet={false} /> <OptionIcon type="web_exclude" isSet={false} />
<OptionIcon type="web_exclude" isSet={false} /> </Button>
</Button>
</Tooltip>
</Grid> </Grid>
<Grid item> <Grid item>
<Tooltip arrow placement="top" title="set selected entities to be not visible and not output"> <Button
<Button size="small"
size="small" sx={{ fontSize: 10 }}
sx={{ fontSize: 10 }} variant="outlined"
variant="outlined" color="inherit"
color="inherit" onClick={() => maskDisabled(true)}
onClick={() => maskDisabled(true)} >
> {LL.SET_ALL()}&nbsp;
{LL.SET_ALL()}&nbsp; <OptionIcon type="api_mqtt_exclude" isSet={true} />
<OptionIcon type="api_mqtt_exclude" isSet={true} /> <OptionIcon type="web_exclude" isSet={true} />
<OptionIcon type="web_exclude" isSet={true} /> </Button>
</Button>
</Tooltip>
</Grid> </Grid>
</Grid> </Grid>
<Table data={{ nodes: shown_data }} theme={entities_theme} layout={{ custom: true }}> <Table data={{ nodes: shown_data }} theme={entities_theme} layout={{ custom: true }}>
@@ -511,7 +520,6 @@ const SettingsCustomization: FC = () => {
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) { if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE; de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
} }
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) { if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE; de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
} }
@@ -590,8 +598,6 @@ const SettingsCustomization: FC = () => {
); );
const renderContent = () => { const renderContent = () => {
const num_changes = getChanges().length;
return ( return (
<> <>
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
@@ -609,7 +615,7 @@ const SettingsCustomization: FC = () => {
{!restartNeeded && ( {!restartNeeded && (
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1}> <Box flexGrow={1}>
{num_changes !== 0 && ( {numChanges !== 0 && (
<ButtonRow> <ButtonRow>
<Button <Button
startIcon={<WarningIcon color="warning" />} startIcon={<WarningIcon color="warning" />}
@@ -617,7 +623,7 @@ const SettingsCustomization: FC = () => {
color="info" color="info"
onClick={() => saveCustomization()} onClick={() => saveCustomization()}
> >
{LL.APPLY_CHANGES(num_changes)} {LL.APPLY_CHANGES(numChanges)}
</Button> </Button>
</ButtonRow> </ButtonRow>
)} )}
@@ -712,6 +718,7 @@ const SettingsCustomization: FC = () => {
return ( return (
<SectionContent title={LL.CUSTOMIZATIONS()} titleGutter> <SectionContent title={LL.CUSTOMIZATIONS()} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : renderContent()} {restarting ? <RestartMonitor /> : renderContent()}
{renderEditDialog()} {renderEditDialog()}
</SectionContent> </SectionContent>

View File

@@ -6,6 +6,8 @@ import { extractErrorMessage } from '.';
import { useI18nContext } from '../i18n/i18n-react'; import { useI18nContext } from '../i18n/i18n-react';
import { unstable_useBlocker as useBlocker } from 'react-router-dom';
export interface RestRequestOptions<D> { export interface RestRequestOptions<D> {
read: () => AxiosPromise<D>; read: () => AxiosPromise<D>;
update?: (value: D) => AxiosPromise<D>; update?: (value: D) => AxiosPromise<D>;
@@ -24,6 +26,8 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
const [origData, setOrigData] = useState<D>(); const [origData, setOrigData] = useState<D>();
const [dirtyFlags, setDirtyFlags] = useState<string[]>(); const [dirtyFlags, setDirtyFlags] = useState<string[]>();
let blocker = useBlocker(dirtyFlags?.length !== 0);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setData(undefined); setData(undefined);
setDirtyFlags([]); setDirtyFlags([]);
@@ -82,6 +86,7 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
origData, origData,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
blocker,
errorMessage, errorMessage,
restartNeeded restartNeeded
} as const; } as const;