diff --git a/react-ui/src/components/subscriptions/action.subscription.ts b/react-ui/src/components/subscriptions/action.subscription.ts new file mode 100644 index 0000000000000000000000000000000000000000..746e3df3633f8ef2c11c75463257578b3b1f3b13 --- /dev/null +++ b/react-ui/src/components/subscriptions/action.subscription.ts @@ -0,0 +1,2 @@ +export const FETCH_MNE_ACTION = 'subscription/device/fetchSelectedMNE'; +export const FETCH_DEVICE_ACTION = 'subscription/device/fetchDevices' \ No newline at end of file diff --git a/react-ui/src/components/subscriptions/device.subscription.ts b/react-ui/src/components/subscriptions/device.subscription.ts new file mode 100644 index 0000000000000000000000000000000000000000..5afcc7c68d5c7be6aeb2549a5ef2e5dae0d113cd --- /dev/null +++ b/react-ui/src/components/subscriptions/device.subscription.ts @@ -0,0 +1,43 @@ +import { NetworkElementServiceGetAllFlattenedApiArg, api } from "@api/api"; +import { setDevices } from "@reducer/device.reducer"; +import { setUser } from "@reducer/user.reducer"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { RootState } from "src/stores"; +import { startListening } from "../../../src/stores/middleware/listener.middleware"; +import { FETCH_DEVICE_ACTION } from "./action.subscription"; + +// continously fetch devices +const FETCH_DEVICES_INTERVAL = 15000; // in ms +startListening({ + actionCreator: setUser, + effect: async (_, listenerApi) => { + listenerApi.dispatch(fetchDevicesThunk()); + }, +}) + +export const fetchDevicesThunk = createAsyncThunk(FETCH_DEVICE_ACTION, (_, thunkApi) => { + const { user } = thunkApi.getState() as RootState; + + const payload: NetworkElementServiceGetAllFlattenedApiArg = { + pid: Object.keys(user?.user.roles)[0], + timestamp: new Date().getTime().toString(), + } + + const subscription = thunkApi.dispatch(api.endpoints.networkElementServiceGetAllFlattened.initiate(payload, { + subscriptionOptions: { + pollingInterval: FETCH_DEVICES_INTERVAL, + skipPollingIfUnfocused: true, + } + })); + + return subscription; +}); + + +// save fetched devices +startListening({ + predicate: (action) => api.endpoints.networkElementServiceGetAllFlattened.matchFulfilled(action), + effect: async (action, listenerApi) => { + listenerApi.dispatch(setDevices(action.payload.mne)); + }, +}) \ No newline at end of file diff --git a/react-ui/src/components/subscriptions/index.ts b/react-ui/src/components/subscriptions/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a3e02f20022a66fa6d4c68dd814bd03e0c973aa --- /dev/null +++ b/react-ui/src/components/subscriptions/index.ts @@ -0,0 +1,26 @@ +import { AsyncThunk } from '@reduxjs/toolkit'; +import { fetchDevicesThunk } from './device.subscription'; +import { fetchSelectedMneThunk } from './mne.subscription'; + + +export enum THUNK_TYPE { + MNE = 'device/fetch', + DEVICE = 'mne/fetch', +} + +export interface SubscriptionThunkModule { + thunkFn?: AsyncThunk<any, any, {}> + type: THUNK_TYPE, +} + +export const SubscriptionThunks: SubscriptionThunkModule[] = [ + { + thunkFn: fetchDevicesThunk, + type: THUNK_TYPE.DEVICE + }, { + + thunkFn: fetchSelectedMneThunk, + type: THUNK_TYPE.MNE + } +] + diff --git a/react-ui/src/components/subscriptions/mne.subscription.ts b/react-ui/src/components/subscriptions/mne.subscription.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2b036007846f030f58c27763516aaee7e8c9ef4 --- /dev/null +++ b/react-ui/src/components/subscriptions/mne.subscription.ts @@ -0,0 +1,45 @@ +import { api, NetworkElementServiceGetApiArg } from "@api/api"; +import { Device, setSelectedDevice, setSelectedMne } from "@reducer/device.reducer"; +import { CATEGORIES, triggerSubscription } from "@reducer/subscription.reducer"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { RootState } from "src/stores"; +import { THUNK_TYPE } from "."; +import { startListening } from "../../../src/stores/middleware/listener.middleware"; +import { FETCH_MNE_ACTION } from "./action.subscription"; + +// fetch mne if selected device is set +startListening({ + predicate: (action) => setSelectedDevice.match(action) && !!action.payload, + effect: async (action, listenerApi) => { + listenerApi.dispatch(triggerSubscription({category: CATEGORIES.TAB, thunkType: THUNK_TYPE.MNE, payload: action.payload})); + }, +}) + + +const FETCH_MNE_INTERVAL = 5000; // in ms +export const fetchSelectedMneThunk = createAsyncThunk(FETCH_MNE_ACTION, async (device: Device, thunkApi) => { + const { user } = thunkApi.getState() as RootState; + + const payload: NetworkElementServiceGetApiArg = { + pid: Object.keys(user?.user.roles)[0], + timestamp: new Date().getTime().toString(), + mneid: device.id, + } + + const subscription = thunkApi.dispatch(api.endpoints.networkElementServiceGet.initiate(payload, { + subscriptionOptions: { + pollingInterval: FETCH_MNE_INTERVAL, + skipPollingIfUnfocused: true, + } + })); + + return {...subscription}; +}); + +// save fetched mne +startListening({ + predicate: (action) => api.endpoints.networkElementServiceGet.matchFulfilled(action), + effect: async (action, listenerApi) => { + listenerApi.dispatch(setSelectedMne(action.payload.mne)); + }, +}) \ No newline at end of file diff --git a/react-ui/src/components/view/device/deivce.view.tabs.tsx b/react-ui/src/components/view/device/deivce.view.tabs.tsx deleted file mode 100644 index a49c7616baf74fb12991ede6ee410ff533a54fa6..0000000000000000000000000000000000000000 --- a/react-ui/src/components/view/device/deivce.view.tabs.tsx +++ /dev/null @@ -1,28 +0,0 @@ - -export enum DeviceViewTabValues { - METADATA = 'metadata', - YANGMODEL = 'yang_model' -} - -export const DeviceViewTabs = (activeTab: DeviceViewTabValues) => { - - const metadataTab = () => { - return ( - <div>test</div> - ) - } - - const yangModelTab = () => { - return ( - <div>asdf</div> - ) - } - - - return ( - <> - {(activeTab === DeviceViewTabValues.METADATA) && metadataTab()} - {(activeTab === DeviceViewTabValues.YANGMODEL) && yangModelTab()} - </> - ); -} diff --git a/react-ui/src/components/view/device/device.view.table.tsx b/react-ui/src/components/view/device/device.view.table.tsx index e2e6c4db5707086618c23bee8f985cc9245d3411..17d4bfd25e598320bc4ab0a2791b3ee558a69d84 100644 --- a/react-ui/src/components/view/device/device.view.table.tsx +++ b/react-ui/src/components/view/device/device.view.table.tsx @@ -7,7 +7,8 @@ import { useTranslation } from "react-i18next"; export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) => { const { devices, pnds } = useAppSelector(state => state.device); const { t } = useTranslation('common'); - const { searchTerm } = useDeviceTableViewModel(searchRef); + const { searchTerm, trClickHandler } = useDeviceTableViewModel(searchRef); + const cropUUID = (uuid: string): string => { return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); @@ -27,7 +28,7 @@ export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) = const user = pnds.find(pnd => pnd.id === device.pid); return ( - <tr key={index}> + <tr key={index} onClick={() => trClickHandler(device)}> <td>{device.name}</td> <OverlayTrigger overlay={<Tooltip id={device.id}>{device.id}</Tooltip>}> <td>{cropUUID(device.id)}</td> diff --git a/react-ui/src/components/view/device/device.view.tabs.tsx b/react-ui/src/components/view/device/device.view.tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc096b14705cd89e2c57acfdbebd37614f39edc4 --- /dev/null +++ b/react-ui/src/components/view/device/device.view.tabs.tsx @@ -0,0 +1,57 @@ +import { useAppSelector } from "@hooks"; + +export enum DeviceViewTabValues { + METADATA = 'metadata', + YANGMODEL = 'yang_model' +} + +export const DeviceViewTabs = (activeTab: DeviceViewTabValues) => { + const { selectedDevice } = useAppSelector(state => state.device); + + + const metadataTab = () => { + return ( + <div> + {selectedDevice.mne.name} + </div> + ) + } + + const yangModelTab = () => { + return ( + <div>asdf</div> + ) + } + + const renderLoading = () => { + return ( + <div> + Loading... + </div> + ) + } + + const renderNoDeviceSelected = () => { + + return ( + <div> + No device selected + </div> + ) + } + + + return ( + <> + {selectedDevice?.mne ? ( + <> + {activeTab === DeviceViewTabValues.METADATA && metadataTab()} + {activeTab === DeviceViewTabValues.YANGMODEL && yangModelTab()} + </> + ) : + selectedDevice ? renderLoading() : renderNoDeviceSelected() + } + + </> + ); +} diff --git a/react-ui/src/components/view/device/device.view.tsx b/react-ui/src/components/view/device/device.view.tsx index d4955d4b334ac65a6f9c8bd3ae50a46870884fbd..0ef5c05b96ded16966d81393ab681e215b1fd5c5 100644 --- a/react-ui/src/components/view/device/device.view.tsx +++ b/react-ui/src/components/view/device/device.view.tsx @@ -2,7 +2,7 @@ import { useDeviceViewModel } from '@viewmodel/device.viewmodel'; import { useRef } from 'react'; import { Button, Col, Container, FloatingLabel, Form, Nav, NavLink, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { DeviceViewTabs, DeviceViewTabValues } from './deivce.view.tabs'; +import { DeviceViewTabs, DeviceViewTabValues } from './device.view.tabs'; import './device.scss'; import { DeviceViewTable } from './device.view.table'; diff --git a/react-ui/src/components/view_model/device.table.viewmodel.ts b/react-ui/src/components/view_model/device.table.viewmodel.ts index 224904699c3a26ccc3e6f1498035c27efd41ca72..22d8ae9e0857891d20a6b5504c6c7df7165c44b2 100644 --- a/react-ui/src/components/view_model/device.table.viewmodel.ts +++ b/react-ui/src/components/view_model/device.table.viewmodel.ts @@ -1,7 +1,11 @@ +import { useAppDispatch } from "@hooks"; +import { Device, setSelectedDevice } from "@reducer/device.reducer"; import { useEffect, useState } from "react"; export const useDeviceTableViewModel = (searchRef) => { const [searchTerm, setSearchTerm] = useState(''); + const dispatch = useAppDispatch(); + useEffect(() => { const handleSearchChange = () => { @@ -21,9 +25,13 @@ export const useDeviceTableViewModel = (searchRef) => { }; }, []); + const trClickHandler = (device: Device) => { + dispatch(setSelectedDevice(device)); + } return { - searchTerm + searchTerm, + trClickHandler } } \ No newline at end of file diff --git a/react-ui/src/components/view_model/device.viewmodel.ts b/react-ui/src/components/view_model/device.viewmodel.ts index b7b46cd6aa7b2e044b68c497cc24111d881b7514..ec75927f033dd5d19b4779fd6d6fe6d000e6cd39 100644 --- a/react-ui/src/components/view_model/device.viewmodel.ts +++ b/react-ui/src/components/view_model/device.viewmodel.ts @@ -1,6 +1,6 @@ import { useAppDispatch, useAppSelector } from "@hooks"; import { setActiveTab as setActiveTabState } from "@reducer/device.reducer"; -import { DeviceViewTabValues } from "@view/device/deivce.view.tabs"; +import { DeviceViewTabValues } from "@view/device/device.view.tabs"; export const useDeviceViewModel = () => { const {activeTab} = useAppSelector(state => state.device); diff --git a/react-ui/src/index.tsx b/react-ui/src/index.tsx index 3eb8a604f746fc0f6f677e7b16f0debe39942559..a2ddc81a9091427a9d3eaf25a711836dc6819de3 100644 --- a/react-ui/src/index.tsx +++ b/react-ui/src/index.tsx @@ -32,4 +32,6 @@ ReactDOM.createRoot(document.getElementById("root")).render( </PersistGate> </Provider> </React.StrictMode> -); \ No newline at end of file +); + +import './components/subscriptions' \ No newline at end of file diff --git a/react-ui/src/stores/index.ts b/react-ui/src/stores/index.ts index e00e33f485e2d834ef3dc254d788977bb2662750..8d2be362f62efa197e771cb2caaecd9798c4cc60 100644 --- a/react-ui/src/stores/index.ts +++ b/react-ui/src/stores/index.ts @@ -1,11 +1,12 @@ import { configureStore } from '@reduxjs/toolkit' import { setupListeners } from '@reduxjs/toolkit/query' +import { FETCH_DEVICE_ACTION, FETCH_MNE_ACTION } from '@subscription/action.subscription' import { FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE } from 'redux-persist' import persistStore from 'redux-persist/es/persistStore' import { emptySplitApi } from './api.store' import { rtkQueryErrorLogger } from './middleware/devLogging.middleware' -import persistedReducer from './persist.store' import { listenerMiddleware } from './middleware/listener.middleware' +import persistedReducer from './persist.store' export const store = configureStore({ @@ -13,7 +14,7 @@ export const store = configureStore({ middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER, FETCH_DEVICE_ACTION + '/fulfilled', FETCH_MNE_ACTION + '/fulfilled'], }, }).prepend(listenerMiddleware.middleware).concat(emptySplitApi.middleware, rtkQueryErrorLogger), }) diff --git a/react-ui/src/stores/persist.store.ts b/react-ui/src/stores/persist.store.ts index feb5241a879f80dfeafa7de5b5e7276c5813b07e..f14de1bd7f383e3f667a880293a34a52605c8c3e 100644 --- a/react-ui/src/stores/persist.store.ts +++ b/react-ui/src/stores/persist.store.ts @@ -1,24 +1,24 @@ +import deviceReducer from "@reducer/device.reducer"; +import subscriptionReducer from "@reducer/subscription.reducer"; import userReducer from "@reducer/user.reducer"; import { combineReducers } from "redux"; import { persistReducer } from "redux-persist"; import storage from "redux-persist/es/storage"; import { emptySplitApi } from "./api.store"; -import scheduleReducer from "@reducer/schedule.reducer"; -import deviceReducer from "@reducer/device.reducer"; /** local storage config */ const rootPersistConfig = { key: 'root', storage, - blacklist: [emptySplitApi.reducerPath], + blacklist: [emptySplitApi.reducerPath, ], } const rootReducer = combineReducers({ user: userReducer, device: deviceReducer, - scheduler: scheduleReducer, + subscription: subscriptionReducer, [emptySplitApi.reducerPath]: emptySplitApi.reducer, }) diff --git a/react-ui/src/stores/reducer/device.reducer.ts b/react-ui/src/stores/reducer/device.reducer.ts index 3252fd8c792d6a2929878ebd5e348976edbdcd82..5919062d27f4464dca20b03e78d2cf4ca4d3148c 100644 --- a/react-ui/src/stores/reducer/device.reducer.ts +++ b/react-ui/src/stores/reducer/device.reducer.ts @@ -1,25 +1,30 @@ -import { api, NetworkelementFlattenedManagedNetworkElement, NetworkElementServiceGetAllFlattenedApiArg, PndPrincipalNetworkDomain, PndServiceGetPndListApiArg } from '@api/api'; +import { api, NetworkelementFlattenedManagedNetworkElement, NetworkelementManagedNetworkElement, PndPrincipalNetworkDomain, PndServiceGetPndListApiArg } from '@api/api'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { QueryActionCreatorResult } from '@reduxjs/toolkit/query'; -import { RootState } from '..'; +import { DeviceViewTabValues } from '@view/device/device.view.tabs'; import { startListening } from '../middleware/listener.middleware'; -import { setUser } from './user.reducer'; -import { DeviceViewTabValues } from '@view/device/deivce.view.tabs'; -type Device = NetworkelementFlattenedManagedNetworkElement; +export type Device = NetworkelementFlattenedManagedNetworkElement; +interface SelectedDeviceInterface { + device: Device, + mne: NetworkelementManagedNetworkElement | null +} + +type SelectedDeviceType = SelectedDeviceInterface | undefined; export interface DeviceSliceState { devices: Device[], pnds: PndPrincipalNetworkDomain[], - + activeTab: DeviceViewTabValues + selectedDevice: SelectedDeviceType } const initialState: DeviceSliceState = { devices: [], pnds: [], - activeTab: DeviceViewTabValues.METADATA + activeTab: DeviceViewTabValues.METADATA, + selectedDevice: null } const deviceSlice = createSlice({ @@ -29,10 +34,19 @@ const deviceSlice = createSlice({ setDevices: (state, action: PayloadAction<Device[]>) => { state.devices = action.payload }, setPnds: (state, action: PayloadAction<PndPrincipalNetworkDomain[]>) => { state.pnds = action.payload }, setActiveTab: (state, action: PayloadAction<DeviceViewTabValues>) => { state.activeTab = action.payload }, + setSelectedDevice: (state, action: PayloadAction<Device | null>) => { + let selectedDevice: SelectedDeviceType; + if (action.payload) { + selectedDevice = {device: action.payload, mne: null}; + } + + state.selectedDevice = selectedDevice; + }, + setSelectedMne: (state, action: PayloadAction<NetworkelementManagedNetworkElement>) => { state.selectedDevice.mne = action.payload }, }, }) -export const { setDevices, setActiveTab } = deviceSlice.actions +export const { setDevices, setActiveTab, setSelectedDevice, setSelectedMne } = deviceSlice.actions const { setPnds } = deviceSlice.actions export default deviceSlice.reducer @@ -40,45 +54,6 @@ export const deviceReducerPath = deviceSlice.reducerPath; -let fetchSubscription: QueryActionCreatorResult<any>[] = []; -export const abortFetching = () => { - fetchSubscription.forEach((subscription) => { - subscription.unsubscribe(); - }); - fetchSubscription = []; -} - -// continously fetch devices -const FETCH_DEVICES_INTERVAL = 15000; // in ms -startListening({ - actionCreator: setUser, - effect: async (_, listenerApi) => { - const { user } = listenerApi.getState() as RootState; - - const payload: NetworkElementServiceGetAllFlattenedApiArg = { - pid: Object.keys(user?.user.roles)[0], - timestamp: new Date().getTime().toString(), - } - - const subscription = listenerApi.dispatch(api.endpoints.networkElementServiceGetAllFlattened.initiate(payload, { - subscriptionOptions: { - pollingInterval: FETCH_DEVICES_INTERVAL, - skipPollingIfUnfocused: true, - } - })); - - fetchSubscription = [...fetchSubscription, subscription]; - }, -}) - -// save fetched devices -startListening({ - predicate: (action) => api.endpoints.networkElementServiceGetAllFlattened.matchFulfilled(action), - effect: async (action, listenerApi) => { - listenerApi.dispatch(setDevices(action.payload.mne)); - }, -}) - export const fetchPnds = createAsyncThunk('device/fetchPnds', (_, thunkApi) => { const payload: PndServiceGetPndListApiArg = { timestamp: new Date().getTime().toString(), @@ -89,3 +64,4 @@ export const fetchPnds = createAsyncThunk('device/fetchPnds', (_, thunkApi) => { thunkApi.dispatch(setPnds(response.pnd)); }); }); + diff --git a/react-ui/src/stores/reducer/schedule.reducer.ts b/react-ui/src/stores/reducer/schedule.reducer.ts deleted file mode 100644 index 2e2bbe36ba76278a7c16533a1839081ca370a6a1..0000000000000000000000000000000000000000 --- a/react-ui/src/stores/reducer/schedule.reducer.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import { startListening } from '../middleware/listener.middleware'; - -export enum ScheduleState { INIT, RUNNING, STOPPED } - -export type Task = { - job: (options: object) => void, - interval: number, - type: string -}; - -interface Schedule { - task: Task, - id: number, - state: ScheduleState, - intervalId: NodeJS.Timeout | undefined -} - -export interface ScheduleReducerState { - schedules: Schedule[] -} - -const initialState: ScheduleReducerState = { - schedules: [] -} - -const ScheduleSlice = createSlice({ - name: 'schedule', - initialState, - reducers: { - registerTask: (state, action: PayloadAction<Task>) => { - const newSchedule = { - task: action.payload.task, - id: state.schedules.length, - state: ScheduleState.INIT, - intervalId: undefined - } - - state.schedules = [...state.schedules, newSchedule] - }, - startSchedule: (state, action: PayloadAction<Schedule>) => { - const schedule = action.payload; - schedule.intervalId = setInterval(schedule.task.job, schedule.task.interval); - schedule.state = ScheduleState.RUNNING; - - state.schedules[schedule.id] = schedule; - }, - }, -}) - -export const { registerTask } = ScheduleSlice.actions -export const { startSchedule } = ScheduleSlice.actions - -export default ScheduleSlice.reducer - - -// startListening({ -// actionCreator: addSchedule, - -// effect: (action, listenerApi) => { -// const newState = listenerApi.getState() as ScheduleReducerState; -// const originalState = listenerApi.getOriginalState() as ScheduleReducerState; - -// // get the added schedule -// const schedule = newState.schedules.filter(s => !originalState.schedules.includes(s)).at(0); -// if (!schedule) { -// throw new Error("Added schedule not found in store"); -// } - -// listenerApi.dispatch(startSchedule(schedule)) -// }, -// }) \ No newline at end of file diff --git a/react-ui/src/stores/reducer/subscription.reducer.ts b/react-ui/src/stores/reducer/subscription.reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..a21024eab0d1f867d33edc479a15f72dfd1fdf85 --- /dev/null +++ b/react-ui/src/stores/reducer/subscription.reducer.ts @@ -0,0 +1,105 @@ +import { PayloadAction, createSlice, current } from '@reduxjs/toolkit'; +import { SubscriptionThunks, THUNK_TYPE } from '@subscription/index'; +import { RootState } from '..'; +import { addSubscription, unsubscribe, unsubscribeAll } from '../../utils/api/subscription.handler'; +import { startListening } from '../middleware/listener.middleware'; + + + +interface ThunkEntityDTO { + thunkType: THUNK_TYPE, + payload: any + + /** + * Only one subscription per category is allowed. New subscription will unsubscribe and overwrite the old one + */ + category: CATEGORIES, +} + +interface ThunkEntity extends ThunkEntityDTO { + id?: number, + locked: boolean +} + + +export interface SubscriptionReducerState { + thunks: {[key in keyof typeof CATEGORIES]: ThunkEntity | null} +} + +export enum CATEGORIES { + TABLE, + TAB +} + +const initialState: SubscriptionReducerState = { + thunks: { + TABLE: null, + TAB: null + } +} + +const SubscriptionSlice = createSlice({ + name: 'subscription', + initialState, + reducers: { + triggerSubscription: (state, {payload}: PayloadAction<ThunkEntityDTO>) => { + // overwrite old subscription if it exists + const currentState = current(state) + const currentThunk = currentState.thunks[CATEGORIES[payload.category]]; + + const newThunk: ThunkEntity = {...payload, locked: true}; + + state.thunks[CATEGORIES[payload.category]] = newThunk; + }, + + setThunkId: (state, {payload}: PayloadAction<{id: number, category: CATEGORIES}>) => { + let thunk = state.thunks[CATEGORIES[payload.category]]; + + if (!thunk) { + // TODO + throw new Error('Thunk not found'); + } + + state.thunks[CATEGORIES[payload.category]] = {...thunk, id: payload.id, locked: false}; + }, + + stopAllSubscriptions: (state) => { + unsubscribeAll() + state.thunks = initialState.thunks; + }, + }, +}) + +export const { triggerSubscription, stopAllSubscriptions } = SubscriptionSlice.actions + +// unsubscribe old subscription +startListening({ + predicate: (action) => triggerSubscription.match(action), + effect: async (action, listenerApi) => { + const {subscription} = listenerApi.getOriginalState() as RootState; + const lastThunk = subscription.thunks[CATEGORIES[action.payload.category]]; + unsubscribe(lastThunk.id); + }, +}) + +// add new subscription +startListening({ + predicate: (action) => triggerSubscription.match(action), + effect: async (action, listenerApi) => { + const {thunkType} = action.payload as ThunkEntity; + + const {thunkFn} = SubscriptionThunks.find(({type}) => type === thunkType); + if (!thunkFn) { + // TODO + throw new Error('Thunk not found'); + } + + const subscription = await listenerApi.dispatch(thunkFn(action.payload.payload)); + const thunkId = await addSubscription(subscription.payload); + listenerApi.dispatch(SubscriptionSlice.actions.setThunkId({id: thunkId, category: action.payload.category})); + + }, +}) + + +export default SubscriptionSlice.reducer diff --git a/react-ui/src/utils/api/subscription.handler.ts b/react-ui/src/utils/api/subscription.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..e288a4a6f468fc1f919edcd2e65c46966f14f4f3 --- /dev/null +++ b/react-ui/src/utils/api/subscription.handler.ts @@ -0,0 +1,65 @@ +import { QueryActionCreatorResult } from '@reduxjs/toolkit/query'; + +type SubscriptionType = QueryActionCreatorResult<any>; + +interface SubscriptionEntity { + subscription: SubscriptionType, + id: number +} + +interface SubscriptionReducerState { + subscriptions: SubscriptionEntity[] +} + + +const initialState: SubscriptionReducerState = { + subscriptions: [] +} + +let state = initialState; + + +export const addSubscription = (subscription: SubscriptionType): number => { + const id = state.subscriptions.length; + + const subscriptionEntity: SubscriptionEntity = { + subscription, + id + } + + state.subscriptions = [...state.subscriptions, subscriptionEntity]; + + return id; +} + + +export const unsubscribeAll = () => { + state.subscriptions.forEach(({ subscription }) => { + unsubscribeAction(subscription) + }); + + state.subscriptions = initialState.subscriptions; +} + +/** + * @param id + * @returns returns true if the subscription was stopped, false if it was not found + */ +export const unsubscribe = (id: number): boolean => { + const subscription = state.subscriptions.find(({ id: subscriptionId }) => subscriptionId === id); + + if (subscription) { + unsubscribeAction(subscription.subscription); + } + + return !!subscription; +} + +/** + * Actual unsubscribe action + * + * @param subscription + */ +const unsubscribeAction = (subscription: SubscriptionType) => { + subscription.unsubscribe(); +} \ No newline at end of file diff --git a/react-ui/src/utils/provider/auth.provider.tsx b/react-ui/src/utils/provider/auth.provider.tsx index e3a90d7118edeeb558523116e2771570e5049190..44901c66f80d47a43c339120e897fc613595ac78 100644 --- a/react-ui/src/utils/provider/auth.provider.tsx +++ b/react-ui/src/utils/provider/auth.provider.tsx @@ -1,7 +1,7 @@ import { AuthServiceLoginApiArg, AuthServiceLoginApiResponse, useAuthServiceLoginMutation } from "@api/api"; +import { unsubscribeAll } from "@api/subscription.handler"; import { getCookieValue } from "@helper/coookie"; import { useAppDispatch, useAppSelector } from "@hooks"; -import { abortFetching } from "@reducer/device.reducer"; import { setToken } from "@reducer/user.reducer"; import { DEVICE_URL, LOGIN_URL } from "@routes"; import { jwtDecode } from "jwt-decode"; @@ -37,7 +37,7 @@ export const AuthProvider = ({ children }) => { useEffect(() => { const token = getCookieValue('token'); - + if (token) { navigate(DEVICE_URL) } else { @@ -100,7 +100,7 @@ export const AuthProvider = ({ children }) => { } const logout = () => { - abortFetching(); + unsubscribeAll(); dispatch(setToken(null)); // TODO: purge other information } @@ -125,3 +125,4 @@ export const AuthProvider = ({ children }) => { export const useAuth = () => { return useContext(AuthContext); } + diff --git a/react-ui/tsconfig.json b/react-ui/tsconfig.json index 32b67b0d1754aa4b4f5e592231a532dc52fdf60f..41c9c8f6684c18bff5f0ac8aa7c808040e92cfbf 100644 --- a/react-ui/tsconfig.json +++ b/react-ui/tsconfig.json @@ -1,44 +1,47 @@ { "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, - "baseUrl": ".", - "paths": { - "@assets/*": ["assets/*"], - "@api/*": ["src/utils/api/*"], - "@viewmodel/*": ["src/components/view_model/*"], - "@view/*": ["src/components/view/*"], - "@reducer/*": ["src/stores/reducer/*"], - "@provider/*": ["src/utils/provider/*"], - "@layout/*": ["src/utils/layouts/*"], - "@hooks": ["src/hooks"], - "@routes": ["src/routes.tsx"], - "@task/*": ["src/utils/tasks/*"], - "@helper/*": ["src/utils/helper/*"], - } + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "@assets/*": ["assets/*"], + "@api/*": ["src/utils/api/*"], + "@viewmodel/*": ["src/components/view_model/*"], + "@view/*": ["src/components/view/*"], + "@reducer/*": ["src/stores/reducer/*"], + "@provider/*": ["src/utils/provider/*"], + "@layout/*": ["src/utils/layouts/*"], + "@hooks": ["src/hooks"], + "@routes": ["src/routes.tsx"], + "@task/*": ["src/utils/tasks/*"], + "@helper/*": ["src/utils/helper/*"], + "@subscription/*": ["src/components/subscriptions/*"] + } }, "include": [ - "src/**/*.d.ts", - "src/**/*.ts", - "src/**/*.tsx", "src/stores/api.store.ts", "scripts/test.ts", - ], + "src/**/*.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + "src/stores/api.store.ts", + "scripts/test.ts" + ] //"references": [{ "path": "./tsconfig.node.json" }] -} \ No newline at end of file +} diff --git a/react-ui/vite.config.mjs b/react-ui/vite.config.mjs index c5073c14e64ba0065869955c523069418953c1cc..96be1c8444581aa8a9268841dcc275755fb31b45 100644 --- a/react-ui/vite.config.mjs +++ b/react-ui/vite.config.mjs @@ -1,48 +1,56 @@ -import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' - +import { defineConfig } from 'vite' export default defineConfig({ - plugins: [react()], - server: { - port: 3000, - proxy: { - '/api': { - target: 'http://127.0.0.1:8080', - changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/api/, ''), - configure: (proxy, _options) => { - proxy.on('error', (err, _req, _res) => { - console.log('proxy error', err); - }); - proxy.on('proxyReq', (proxyReq, req, _res) => { - console.log('Sending Request to the Target:', req.method, req.url); - }); - proxy.on('proxyRes', (proxyRes, req, _res) => { - console.log('Received Response from the Target:', proxyRes.statusCode, req.url); - }); + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(/^\/api/, ''), + configure: (proxy, _options) => { + proxy.on('error', (err, _req, _res) => { + console.log('proxy error', err) + }) + proxy.on('proxyReq', (proxyReq, req, _res) => { + console.log( + 'Sending Request to the Target:', + req.method, + req.url + ) + }) + proxy.on('proxyRes', (proxyRes, req, _res) => { + console.log( + 'Received Response from the Target:', + proxyRes.statusCode, + req.url + ) + }) + }, + }, + }, + }, + resolve: { + alias: { + '@assets': '/assets', + '@api': '/src/utils/api', + '@viewmodel': '/src/components/view_model', + '@view': '/src/components/view', + '@reducer': '/src/stores/reducer', + '@provider': '/src/utils/provider', + '@layout': '/src/utils/layouts', + '@hooks': '/src/hooks.ts', + '@task': '/src/utils/tasks', + '@helper': '/src/utils/helper', + '@routes': '/src/routes.tsx', + '@subscription': '/src/components/subscriptions', }, - } - } - }, - resolve: { - alias: { - '@assets': '/assets', - '@api': '/src/utils/api', - "@viewmodel": "/src/components/view_model", - "@view": "/src/components/view", - "@reducer": "/src/stores/reducer", - "@provider": "/src/utils/provider", - "@layout": "/src/utils/layouts", - "@hooks": "/src/hooks.ts", - "@task": "/src/utils/tasks", - "@helper": "/src/utils/helper", - "@routes": "/src/routes.tsx", }, - }, - build: { - sourcemap: true, // Source Maps für den Build aktivieren - }, -}); \ No newline at end of file + build: { + sourcemap: true, // Source Maps für den Build aktivieren + }, +})