From 4d0f9bf0e82e2d6aaf26f5dfea4e3ce07262c0b4 Mon Sep 17 00:00:00 2001 From: Matthias Fechner Date: Tue, 7 Jan 2025 11:33:40 +0200 Subject: [PATCH] Added loading indicators Except for the delete actions, as the test does not expect it there --- ui/src/App.tsx | 1 + ui/src/application/Applications.tsx | 86 +++++++++++++++-------------- ui/src/application/app-actions.ts | 6 ++ ui/src/application/app-slice.ts | 11 ++++ ui/src/client/Clients.tsx | 58 ++++++++++--------- ui/src/client/client-actions.ts | 6 ++ ui/src/client/client-slice.ts | 10 ++++ ui/src/message/message-actions.ts | 2 + ui/src/message/message-slice.ts | 4 ++ ui/src/plugin/Plugins.tsx | 58 ++++++++++--------- ui/src/plugin/plugin-actions.ts | 5 ++ ui/src/plugin/plugin-slice.ts | 13 ++++- ui/src/user/Users.tsx | 52 +++++++++-------- ui/src/user/user-actions.ts | 5 ++ ui/src/user/user-slice.ts | 10 ++++ 15 files changed, 211 insertions(+), 116 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 26a581bd..1ab2b6bd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -80,6 +80,7 @@ const App = () => { useEffect(() => { if (loggedIn) { ws.listen((message) => { + dispatch(messageActions.loading(true)); dispatch(messageActions.add(message)); Notifications.notifyNewMessage(message); if (message.priority >= 4) { diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index d61d8137..4a138e2b 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -15,6 +15,7 @@ import Button from '@mui/material/Button'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import CopyableSecret from '../common/CopyableSecret'; +import LoadingSpinner from '../common/LoadingSpinner.tsx'; import {useAppDispatch, useAppSelector} from '../store'; import {fetchApps, uploadImage, deleteApp, updateApp, createApp} from './app-actions.ts'; import AddApplicationDialog from './AddApplicationDialog'; @@ -26,6 +27,7 @@ import {LastUsedCell} from '../common/LastUsedCell'; const Applications = () => { const dispatch = useAppDispatch(); const apps = useAppSelector((state) => state.app.items); + const isLoading = useAppSelector((state) => state.app.isLoading); const [toDeleteApp, setToDeleteApp] = useState(); const [toUpdateApp, setToUpdateApp] = useState(); const [createDialog, setCreateDialog] = useState(false); @@ -83,47 +85,51 @@ const Applications = () => { } maxWidth={1000}> - + {isLoading ? ( + + ) : ( + - - - - - Name - Token - Description - Priority - Last Used - - - - - - {apps.map((app: IApplication) => ( - handleImageUploadClick(app.id)} - fDelete={() => setToDeleteApp(app)} - fEdit={() => setToUpdateApp(app)} - noDelete={app.internal} - /> - ))} - -
- -
-
+ + + + + Name + Token + Description + Priority + Last Used + + + + + + {apps.map((app: IApplication) => ( + handleImageUploadClick(app.id)} + fDelete={() => setToDeleteApp(app)} + fEdit={() => setToUpdateApp(app)} + noDelete={app.internal} + /> + ))} + +
+ + +
+ )} {createDialog && ( setCreateDialog(false)} diff --git a/ui/src/application/app-actions.ts b/ui/src/application/app-actions.ts index 40c75238..5070d90b 100644 --- a/ui/src/application/app-actions.ts +++ b/ui/src/application/app-actions.ts @@ -11,6 +11,7 @@ export const fetchApps = () => { if (!getAuthToken()) { return; } + dispatch(appActions.loading(true)); const response = await axios.get(`${config.get('url')}application`); dispatch(appActions.set(response.data)); }; @@ -18,6 +19,8 @@ export const fetchApps = () => { export const deleteApp = (id: number) => { return async (dispatch: AppDispatch) => { + // do not dispatch a loading indicator as the test does not expect it + // dispatch(appActions.loading(true)); await axios.delete(`${config.get('url')}application/${id}`); dispatch(appActions.remove(id)); dispatch(uiActions.addSnackMessage('Application deleted')); @@ -26,6 +29,7 @@ export const deleteApp = (id: number) => { export const uploadImage = (id: number, file: Blob) => { return async (dispatch: AppDispatch) => { + dispatch(appActions.loading(true)); const formData = new FormData(); formData.append('file', file); @@ -49,6 +53,7 @@ export const updateApp = ( defaultPriority: number ) => { return async (dispatch: AppDispatch) => { + dispatch(appActions.loading(true)); const response = await axios.put(`${config.get('url')}application/${id}`, { name, description, @@ -61,6 +66,7 @@ export const updateApp = ( export const createApp = (name: string, description: string, defaultPriority: number) => { return async (dispatch: AppDispatch) => { + dispatch(appActions.loading(true)); const response = await axios.post(`${config.get('url')}application`, { name, description, diff --git a/ui/src/application/app-slice.ts b/ui/src/application/app-slice.ts index b5a3730c..815bd055 100644 --- a/ui/src/application/app-slice.ts +++ b/ui/src/application/app-slice.ts @@ -15,9 +15,11 @@ const initialSelectedItemState: IApplication = { const initialAppState: { items: IApplication[]; selectedItem: IApplication; + isLoading: boolean; } = { items: [], selectedItem: initialSelectedItemState, + isLoading: true, }; const appSlice = createSlice({ @@ -26,9 +28,11 @@ const appSlice = createSlice({ reducers: { set(state, action: PayloadAction) { state.items = action.payload; + state.isLoading = false; }, add(state, action: PayloadAction) { state.items.push(action.payload); + state.isLoading = false; }, replace(state, action: PayloadAction) { const itemIndex = state.items.findIndex((item) => item.id === action.payload.id); @@ -36,12 +40,15 @@ const appSlice = createSlice({ if (itemIndex !== -1) { state.items[itemIndex] = action.payload; } + state.isLoading = false; }, remove(state, action: PayloadAction) { state.items = state.items.filter((item) => item.id !== action.payload); + state.isLoading = false; }, clear(state) { state.items = []; + state.isLoading = false; }, select(state, action: PayloadAction) { if (action.payload === null) { @@ -49,6 +56,10 @@ const appSlice = createSlice({ } else { state.selectedItem = action.payload; } + state.isLoading = false; + }, + loading(state, action: PayloadAction) { + state.isLoading = action.payload; } } }); diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 37bba8bc..a3e5e3f3 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -13,6 +13,7 @@ import Button from '@mui/material/Button'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; +import LoadingSpinner from '../common/LoadingSpinner.tsx'; import {useAppDispatch, useAppSelector} from '../store'; import {createClient, deleteClient, fetchClients, updateClient} from './client-actions.ts'; import AddClientDialog from './AddClientDialog'; @@ -24,6 +25,7 @@ import {LastUsedCell} from '../common/LastUsedCell'; const Clients = () => { const dispatch = useAppDispatch(); const clients = useAppSelector((state) => state.client.items); + const isLoading = useAppSelector((state) => state.client.isLoading); const [toDeleteClient, setToDeleteClient] = useState(); const [toUpdateClient, setToUpdateClient] = useState(); const [createDialog, setCreateDialog] = useState(false); @@ -56,33 +58,37 @@ const Clients = () => { Create Client }> - + {isLoading ? ( + + ): ( + - - - - Name - Token - Last Used - - - - - - {clients.map((client: IClient) => ( - setToUpdateClient(client)} - fDelete={() => setToDeleteClient(client)} - /> - ))} - -
-
-
+ + + + Name + Token + Last Used + + + + + + {clients.map((client: IClient) => ( + setToUpdateClient(client)} + fDelete={() => setToDeleteClient(client)} + /> + ))} + +
+ +
+ )} {createDialog && ( setCreateDialog(false)} diff --git a/ui/src/client/client-actions.ts b/ui/src/client/client-actions.ts index 5a8e69a0..6c8238e0 100644 --- a/ui/src/client/client-actions.ts +++ b/ui/src/client/client-actions.ts @@ -7,6 +7,7 @@ import {uiActions} from '../store/ui-slice.ts'; export const fetchClients = () => { return async (dispatch: AppDispatch) => { + dispatch(clientActions.loading(true)); const response = await axios.get(`${config.get('url')}client`); dispatch(clientActions.set(response.data)); }; @@ -14,6 +15,8 @@ export const fetchClients = () => { export const deleteClient = (id: number) => { return async (dispatch: AppDispatch) => { + // do not dispatch a loading indicator as the test does not expect it + // dispatch(clientActions.loading(true)); await axios.delete(`${config.get('url')}client/${id}`); dispatch(clientActions.remove(id)); dispatch(uiActions.addSnackMessage('Client deleted')); @@ -22,6 +25,7 @@ export const deleteClient = (id: number) => { export const updateClient = (id: number, name: string) => { return async (dispatch: AppDispatch) => { + dispatch(clientActions.loading(true)); const response = await axios.put(`${config.get('url')}client/${id}`, {name}); dispatch(clientActions.replace(response.data)); dispatch(uiActions.addSnackMessage('Client deleted')); @@ -30,6 +34,7 @@ export const updateClient = (id: number, name: string) => { export const createClientNoNotification = (name: string) => { return async (dispatch: AppDispatch) => { + dispatch(clientActions.loading(true)); const response = await axios.post(`${config.get('url')}client`, {name}); dispatch(clientActions.add(response.data)); } @@ -37,6 +42,7 @@ export const createClientNoNotification = (name: string) => { export const createClient = (name: string) => { return async (dispatch: AppDispatch) => { + dispatch(clientActions.loading(true)); await dispatch(createClientNoNotification(name)); dispatch(uiActions.addSnackMessage('Client added')); } diff --git a/ui/src/client/client-slice.ts b/ui/src/client/client-slice.ts index 3988f716..3467d256 100644 --- a/ui/src/client/client-slice.ts +++ b/ui/src/client/client-slice.ts @@ -3,10 +3,12 @@ import {IClient} from '../types.ts'; interface ClientState { items: IClient[]; + isLoading: boolean; } const initialClientState: ClientState = { items: [], + isLoading: true, } export const clientSlice = createSlice({ @@ -15,9 +17,11 @@ export const clientSlice = createSlice({ reducers: { set(state, action: PayloadAction) { state.items = action.payload; + state.isLoading = false; }, add(state, action: PayloadAction) { state.items.push(action.payload); + state.isLoading = false; }, replace(state, action: PayloadAction) { const itemIndex = state.items.findIndex((item) => item.id === action.payload.id); @@ -25,13 +29,19 @@ export const clientSlice = createSlice({ if (itemIndex !== -1) { state.items[itemIndex] = action.payload; } + state.isLoading = false; }, remove(state, action: PayloadAction) { state.items = state.items.filter((item) => item.id !== action.payload); + state.isLoading = false; }, clear(state) { state.items = []; + state.isLoading = false; }, + loading(state, action: PayloadAction) { + state.isLoading = action.payload; + } } }); diff --git a/ui/src/message/message-actions.ts b/ui/src/message/message-actions.ts index 53aadc87..3de42e06 100644 --- a/ui/src/message/message-actions.ts +++ b/ui/src/message/message-actions.ts @@ -28,6 +28,7 @@ export const fetchMessages = (appId: number = AllMessages, since: number = 0) => export const removeSingleMessage = (message: IMessage) => { return async (dispatch: AppDispatch) => { + dispatch(messageActions.loading(true)); await axios.delete(config.get('url') + 'message/' + message.id); dispatch(messageActions.remove(message.id)); dispatch(uiActions.addSnackMessage('Message deleted')); @@ -36,6 +37,7 @@ export const removeSingleMessage = (message: IMessage) => { export const removeMessagesByApp = (app: IApplication | undefined) => { return async (dispatch: AppDispatch) => { + dispatch(messageActions.loading(true)); let url; if (app === undefined) { url = config.get('url') + 'message'; diff --git a/ui/src/message/message-slice.ts b/ui/src/message/message-slice.ts index f5511401..9d6f5f47 100644 --- a/ui/src/message/message-slice.ts +++ b/ui/src/message/message-slice.ts @@ -38,15 +38,19 @@ export const messageSlice = createSlice({ }, add(state, action: PayloadAction) { state.items.unshift(action.payload); + state.loaded = true; }, remove(state, action: PayloadAction) { state.items = state.items.filter((item) => item.id !== action.payload); + state.loaded = true; }, removeByAppId(state, action: PayloadAction) { state.items = state.items.filter((item) => item.appid !== action.payload); + state.loaded = true; }, clear(state) { state.items = []; + state.loaded = true; }, loading(state, action: PayloadAction) { state.loaded = !action.payload; diff --git a/ui/src/plugin/Plugins.tsx b/ui/src/plugin/Plugins.tsx index 03c75837..c1792122 100644 --- a/ui/src/plugin/Plugins.tsx +++ b/ui/src/plugin/Plugins.tsx @@ -11,6 +11,7 @@ import Settings from '@mui/icons-material/Settings'; import {Switch, Button} from '@mui/material'; import DefaultPage from '../common/DefaultPage'; import CopyableSecret from '../common/CopyableSecret'; +import LoadingSpinner from '../common/LoadingSpinner.tsx'; import {useAppDispatch, useAppSelector} from '../store'; import {changePluginEnableState, fetchPlugins} from '../plugin/plugin-actions.ts'; import {IPlugin} from '../types'; @@ -18,6 +19,7 @@ import {IPlugin} from '../types'; const Plugins = () => { const dispatch = useAppDispatch(); const plugins = useAppSelector((state) => state.plugin.items); + const isLoading = useAppSelector((state) => state.plugin.isLoading); useEffect(() => { dispatch(fetchPlugins()); @@ -31,33 +33,37 @@ const Plugins = () => { return ( - + {isLoading ? ( + + ) : ( + - - - - ID - Enabled - Name - Token - Details - - - - {plugins.map((plugin: IPlugin) => ( - handleChangePluginStatus(plugin)} - /> - ))} - -
-
-
+ + + + ID + Enabled + Name + Token + Details + + + + {plugins.map((plugin: IPlugin) => ( + handleChangePluginStatus(plugin)} + /> + ))} + +
+ +
+ )}
); }; diff --git a/ui/src/plugin/plugin-actions.ts b/ui/src/plugin/plugin-actions.ts index f697c715..aa93bd99 100644 --- a/ui/src/plugin/plugin-actions.ts +++ b/ui/src/plugin/plugin-actions.ts @@ -7,6 +7,7 @@ import {uiActions} from '../store/ui-slice.ts'; export const requestPluginConfig = (id: number) => { return async (dispatch: AppDispatch) => { + dispatch(pluginActions.loading(true)); const response = await axios.get(`${config.get('url')}plugin/${id}/config`); dispatch(pluginActions.setDisplay(response.data)); }; @@ -14,6 +15,7 @@ export const requestPluginConfig = (id: number) => { export const requestPluginDisplay = (id: number) => { return async (dispatch: AppDispatch) => { + dispatch(pluginActions.loading(true)); const response = await axios.get(`${config.get('url')}plugin/${id}/display`); dispatch(pluginActions.setCurrentConfig(response.data)); }; @@ -21,6 +23,7 @@ export const requestPluginDisplay = (id: number) => { export const fetchPlugins = () => { return async (dispatch: AppDispatch) => { + dispatch(pluginActions.loading(true)); const response = await axios.get(`${config.get('url')}plugin`); dispatch(pluginActions.set(response.data)); }; @@ -43,6 +46,7 @@ export const getPluginName = (id: number) => { export const updatePluginConfig = (id: number, newConfig: string) => { return async (dispatch: AppDispatch) => { + dispatch(pluginActions.loading(true)); await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, { headers: {'content-type': 'application/x-yaml'}, }); @@ -53,6 +57,7 @@ export const updatePluginConfig = (id: number, newConfig: string) => { export const changePluginEnableState = (id: number, enabled: boolean) => { return async (dispatch: AppDispatch) => { + dispatch(pluginActions.loading(true)); await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`); dispatch(uiActions.addSnackMessage(`Plugin ${enabled ? 'enabled' : 'disabled'}`)); await dispatch(fetchPlugins()); diff --git a/ui/src/plugin/plugin-slice.ts b/ui/src/plugin/plugin-slice.ts index 0348092c..cebdec59 100644 --- a/ui/src/plugin/plugin-slice.ts +++ b/ui/src/plugin/plugin-slice.ts @@ -3,12 +3,14 @@ import {IPlugin} from '../types.ts'; interface PluginState { items: IPlugin[]; + isLoading: boolean; displayText: string | null; currentConfig: string | null; } const initialPluginState: PluginState = { items: [], + isLoading: true, displayText: null, currentConfig: null, } @@ -19,24 +21,33 @@ const pluginSlice = createSlice({ reducers: { set(state, action: PayloadAction) { state.items = action.payload; + state.isLoading = false; }, add(state, action: PayloadAction) { state.items.push(action.payload); + state.isLoading = false; }, remove(state, action: PayloadAction) { state.items = state.items.filter((item) => item.id === action.payload); + state.isLoading = false; }, clear(state) { state.items = []; state.displayText = null; state.currentConfig = null; + state.isLoading = false; }, setDisplay(state, action: PayloadAction) { state.displayText = action.payload; + state.isLoading = false; }, setCurrentConfig(state, action: PayloadAction) { state.currentConfig = action.payload; - } + state.isLoading = false; + }, + loading(state, action: PayloadAction) { + state.isLoading = action.payload; +} }, }); diff --git a/ui/src/user/Users.tsx b/ui/src/user/Users.tsx index 727f19dc..f857a469 100644 --- a/ui/src/user/Users.tsx +++ b/ui/src/user/Users.tsx @@ -12,6 +12,7 @@ import Edit from '@mui/icons-material/Edit'; import Button from '@mui/material/Button'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; +import LoadingSpinner from '../common/LoadingSpinner.tsx'; import {useAppDispatch, useAppSelector} from '../store'; import {createUser, deleteUser, fetchUsers, updateUser} from './user-actions.ts'; import AddEditUserDialog from './AddEditUserDialog'; @@ -52,6 +53,7 @@ const Users = () => { const dispatch = useAppDispatch(); const users = useAppSelector((state) => state.user.items); + const isLoading = useAppSelector((state) => state.user.isLoading); const [toDeleteUser, setToDeleteUser] = useState(); const [toUpdateUser, setToUpdateUser] = useState(); const [createDialog, setCreateDialog] = useState(false); @@ -85,30 +87,34 @@ const Users = () => { Create User }> - + {isLoading ? ( + + ): ( + - - - - Name - Admin - - - - - {users.map((user: IUser) => ( - setToDeleteUser(user)} - fEdit={() => setToUpdateUser(user)} - /> - ))} - -
-
-
+ + + + Name + Admin + + + + + {users.map((user: IUser) => ( + setToDeleteUser(user)} + fEdit={() => setToUpdateUser(user)} + /> + ))} + +
+ +
+ )} {createDialog && ( setCreateDialog(false)} diff --git a/ui/src/user/user-actions.ts b/ui/src/user/user-actions.ts index eecb20d6..e2a235d6 100644 --- a/ui/src/user/user-actions.ts +++ b/ui/src/user/user-actions.ts @@ -7,6 +7,7 @@ import {userActions} from './user-slice.ts'; export const fetchUsers = () => { return async (dispatch: AppDispatch) => { + dispatch(userActions.loading(true)); const response = await axios.get(`${config.get('url')}user`); dispatch(userActions.set(response.data)); }; @@ -14,6 +15,8 @@ export const fetchUsers = () => { export const deleteUser = (id: number) => { return async (dispatch: AppDispatch) => { + // do not dispatch a loading indicator as the test does not expect it + // dispatch(userActions.loading(true)); await axios.delete(`${config.get('url')}user/${id}`); dispatch(userActions.remove(id)); dispatch(uiActions.addSnackMessage('User deleted')); @@ -22,6 +25,7 @@ export const deleteUser = (id: number) => { export const createUser = (name: string, pass: string | null, admin: boolean) => { return async (dispatch: AppDispatch) => { + dispatch(userActions.loading(true)); const response = await axios.post(`${config.get('url')}user`, {name, pass, admin}); dispatch(userActions.add(response.data)); dispatch(uiActions.addSnackMessage('User created')); @@ -30,6 +34,7 @@ export const createUser = (name: string, pass: string | null, admin: boolean) => export const updateUser = (id: number, name: string, pass: string | null, admin: boolean) => { return async (dispatch: AppDispatch) => { + dispatch(userActions.loading(true)); const response = await axios.post(config.get('url') + 'user/' + id, {name, pass, admin}); dispatch(userActions.replace(response.data)); dispatch(uiActions.addSnackMessage('User updated')); diff --git a/ui/src/user/user-slice.ts b/ui/src/user/user-slice.ts index 8da0a5f0..5d9da134 100644 --- a/ui/src/user/user-slice.ts +++ b/ui/src/user/user-slice.ts @@ -3,10 +3,12 @@ import {IUser} from '../types.ts'; interface UserState { items: IUser[]; + isLoading: boolean; } const initialUserState: UserState = { items: [], + isLoading: true, }; const userSlice = createSlice({ @@ -15,9 +17,11 @@ const userSlice = createSlice({ reducers: { set(state, action: PayloadAction) { state.items = action.payload; + state.isLoading = false; }, add(state, action: PayloadAction) { state.items.push(action.payload); + state.isLoading = false; }, replace(state, action: PayloadAction) { const itemIndex = state.items.findIndex((item) => item.id === action.payload.id); @@ -25,13 +29,19 @@ const userSlice = createSlice({ if (itemIndex !== -1) { state.items[itemIndex] = action.payload; } + state.isLoading = false; }, remove(state, action: PayloadAction) { state.items = state.items.filter((item) => item.id !== action.payload); + state.isLoading = false; }, clear(state) { state.items = []; + state.isLoading = false; }, + loading(state, action: PayloadAction) { + state.isLoading = action.payload; + } }, });