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",
"baseLocale": "pl",
"$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-scripts": "^5.0.1",
"sockette": "^2.0.6",
<<<<<<< HEAD
"typesafe-i18n": "5.22.0",
=======
"typesafe-i18n": "^5.24.0",
>>>>>>> 941146db (Warn user in WebUI of unsaved changes #911)
"typescript": "^4.9.5",
"axios": "^1.3.2",
"react-router-dom": "^6.8.0"

View File

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

View File

@@ -1,11 +1,12 @@
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 { useI18nContext } from './i18n/i18n-react';
import { Authentication, AuthenticationContext } from './contexts/authentication';
import { FeaturesContext } from './contexts/features';
import { RequireAuthenticated, RequireUnauthenticated } from './components';
import SignIn from './SignIn';
@@ -27,6 +28,7 @@ const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut })
return <Navigate to="/" />;
};
// TODO still need this?
export const RemoveTrailingSlashes = () => {
const location = useLocation();
return (
@@ -42,7 +44,6 @@ export const RemoveTrailingSlashes = () => {
};
const AppRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext();
return (
@@ -51,16 +52,14 @@ const AppRouting: FC = () => {
<Routes>
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} />
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} variant="success" />} />
{features.security && (
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
)}
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
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 { AxiosError } from 'axios';
import { FeaturesContext } from './contexts/features';
import * as AuthenticationApi from './api/authentication';
import { PROJECT_PATH } from './api/env';
import { AXIOS } from './api/endpoints';
@@ -18,7 +17,6 @@ import System from './framework/system/System';
import Security from './framework/security/Security';
const AuthenticatedRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const location = useLocation();
const navigate = useNavigate();
@@ -41,23 +39,21 @@ const AuthenticatedRouting: FC = () => {
return (
<Layout>
<Routes>
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
<Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />
<Route path="/network/*" element={<NetworkConnection />} />
<Route path="/ap/*" element={<AccessPoint />} />
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
{features.security && (
<Route
path="/security/*"
element={
<RequireAdmin>
<Security />
</RequireAdmin>
}
/>
)}
<Route path="/ntp/*" element={<NetworkTime />} />
<Route path="/mqtt/*" element={<Mqtt />} />
<Route
path="/security/*"
element={
<RequireAdmin>
<Security />
</RequireAdmin>
}
/>
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute} />} />
</Routes>
</Layout>
);

View File

@@ -3,7 +3,7 @@ import * as H from 'history';
import jwtDecode from 'jwt-decode';
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 { PROJECT_PATH } from './env';
@@ -11,7 +11,7 @@ import { PROJECT_PATH } from './env';
export const SIGN_IN_PATHNAME = 'loginPathname';
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> {
return AXIOS.get('/verifyAuthorization');
@@ -40,12 +40,12 @@ export function clearLoginRedirect() {
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 signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect();
return {
pathname: signInPathname || getDefaultRoute(features),
pathname: signInPathname || `/${PROJECT_PATH}`,
search: (signInPathname && signInSearch) || undefined
};
}

View File

@@ -6,3 +6,4 @@ export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
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 MenuIcon from '@mui/icons-material/Menu';
import LayoutAuthMenu from './LayoutAuthMenu';
import { FeaturesContext } from '../../contexts/features';
export const DRAWER_WIDTH = 240;
interface LayoutAppBarProps {
@@ -15,8 +13,6 @@ interface LayoutAppBarProps {
}
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return (
<AppBar
position="fixed"
@@ -41,7 +37,7 @@ const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
{title}
</Typography>
<Box flexGrow={1} />
{features.security && <LayoutAuthMenu />}
<LayoutAuthMenu />
</Toolbar>
</AppBar>
);

View File

@@ -9,7 +9,6 @@ import SettingsIcon from '@mui/icons-material/Settings';
import LockIcon from '@mui/icons-material/Lock';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import { FeaturesContext } from '../../contexts/features';
import ProjectMenu from '../../project/ProjectMenu';
import LayoutMenuItem from './LayoutMenuItem';
@@ -18,23 +17,20 @@ import { AuthenticatedContext } from '../../contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react';
const LayoutMenu: FC = () => {
const { features } = useContext(FeaturesContext);
const authenticatedContext = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
return (
<>
{features.project && (
<List disablePadding component="nav">
<ProjectMenu />
<Divider />
</List>
)}
<List disablePadding component="nav">
<ProjectMenu />
<Divider />
</List>
<List disablePadding component="nav">
<LayoutMenuItem icon={SettingsEthernetIcon} label={LL.NETWORK(0)} to="/network" />
<LayoutMenuItem icon={SettingsInputAntennaIcon} label={LL.ACCESS_POINT(0)} to="/ap" />
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="NTP" to="/ntp" />}
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
<LayoutMenuItem icon={AccessTimeIcon} label="NTP" to="/ntp" />
<LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />
<LayoutMenuItem
icon={LockIcon}
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 { AuthenticationContext } from '../../contexts/authentication';
import { RequiredChildrenProps } from '../../utils';
import { FeaturesContext } from '../../contexts/features';
const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext);
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect()} /> : <>{children}</>;
};
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 { useNavigate } from 'react-router-dom';
@@ -9,11 +9,9 @@ import { ACCESS_TOKEN } from '../../api/endpoints';
import { RequiredChildrenProps } from '../../utils';
import { LoadingSpinner } from '../../components';
import { Me } from '../../types';
import { FeaturesContext } from '../features';
import { AuthenticationContext } from './context';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext();
const navigate = useNavigate();
@@ -43,11 +41,6 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
};
const refresh = useCallback(async () => {
if (!features.security) {
setMe({ admin: true, username: 'admin' });
setInitialized(true);
return;
}
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
try {
@@ -62,7 +55,7 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
setMe(undefined);
setInitialized(true);
}
}, [features]);
}, []);
useEffect(() => {
refresh();

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,14 @@ import WarningIcon from '@mui/icons-material/Warning';
import CancelIcon from '@mui/icons-material/Cancel';
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 { updateValueDirty, useRest } from '../../utils';
import * as NTPApi from '../../api/ntp';
@@ -16,7 +23,7 @@ import { NTP_SETTINGS_VALIDATOR } from '../../validators/ntp';
import { useI18nContext } from '../../i18n/i18n-react';
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>({
read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings
@@ -111,6 +118,7 @@ const NTPSettingsForm: FC = () => {
return (
<SectionContent title={LL.SETTINGS_OF('NTP')} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

@@ -7,7 +7,14 @@ import CancelIcon from '@mui/icons-material/Cancel';
import * as SecurityApi from '../../api/security';
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 { updateValueDirty, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
@@ -18,7 +25,7 @@ const SecuritySettingsForm: FC = () => {
const { LL } = useI18nContext();
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>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
@@ -87,6 +94,7 @@ const SecuritySettingsForm: FC = () => {
return (
<SectionContent title={LL.SETTINGS_OF(LL.SECURITY(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

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

View File

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

View File

@@ -302,7 +302,11 @@ const de: Translation = {
NEW_NAME_OF: 'Ändere {0}',
ENTITY: 'Entität',
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;

View File

@@ -302,7 +302,11 @@ const en: Translation = {
NEW_NAME_OF: 'New {0} name',
ENTITY: 'entity',
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;

View File

@@ -302,7 +302,11 @@ const fr: Translation = {
NEW_NAME_OF: 'Nouveau nom de {0}',
ENTITY: 'entité',
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;

View File

@@ -302,7 +302,11 @@ const nl: Translation = {
NEW_NAME_OF: 'Hernoem {0}',
ENTITY: 'Entiteit',
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;

View File

@@ -302,7 +302,11 @@ const no: Translation = {
NEW_NAME_OF: 'Bytt navn {0}',
ENTITY: 'Entitet',
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;

View File

@@ -302,7 +302,11 @@ const pl: BaseTranslation = {
NEW_NAME_OF: 'Nowa nazwa {0}',
ENTITY: 'encji',
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;

View File

@@ -302,7 +302,11 @@ const sv: Translation = {
NEW_NAME_OF: 'Byt namn {0}',
ENTITY: 'Entitet',
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;

View File

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

View File

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

View File

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

View File

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