diff --git a/eslint.config.js b/eslint.config.js index c6ca7ce..7b82757 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -140,6 +140,18 @@ const projectConfig = [ optionalDependencies: false, }, ], + 'import-x/no-restricted-paths': [ + 'error', + { + zones: [ + // shared must not import from features or app + { target: './src/shared', from: './src/features' }, + { target: './src/shared', from: './src/app' }, + // features must not import from app + { target: './src/features', from: './src/app' }, + ], + }, + ], // Disable strict type-aware rules that weren't in old config '@typescript-eslint/no-unsafe-enum-comparison': 'off', diff --git a/src/app/App.tsx b/src/app/App.tsx index a34fc6c..61b0459 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -17,7 +17,7 @@ import { import { getErrorMessage } from 'shared/lib/error'; import { fetchIdpSettings } from 'shared/config/idp-settings'; import { useAppParametersInvalidationListener } from 'features/app-parameters/hooks/use-app-parameters-invalidation-listener'; -import { useProcessInvalidationsListener } from 'features/process-config/hooks/use-process-invalidation-listener'; +import { useProcessInvalidationListener } from 'features/process-config/hooks/use-process-invalidation-listener'; import AppTopBar, { AppTopBarProps } from 'features/top-bar/components/AppTopBar'; import { useAppDispatch, useAppSelector } from './store/store'; import { AppRouter } from './router/AppRouter'; @@ -73,7 +73,7 @@ function App() { }, [initialMatchSigninCallbackUrl, initialMatchSilentRenewCallbackUrl, dispatch]); useAppParametersInvalidationListener({ isAuthenticated: user !== null }); - useProcessInvalidationsListener({ isAuthenticated: user !== null }); + useProcessInvalidationListener({ isAuthenticated: user !== null }); return ( <> diff --git a/src/app/config/app-config.ts b/src/app/config/app-config.ts index 3c0abf1..8264d0f 100644 --- a/src/app/config/app-config.ts +++ b/src/app/config/app-config.ts @@ -4,5 +4,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { configureParams, TokenSelector } from 'shared/config/config-params'; +import { selectToken } from 'features/authentication/store/authentication.selectors'; -export const APP_NAME = 'monitor'; +const APP_NAME = 'monitor'; +configureParams({ appName: APP_NAME, tokenSelector: selectToken as TokenSelector }); diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx index 5d52c58..8a4eecf 100644 --- a/src/app/providers/AppProviders.tsx +++ b/src/app/providers/AppProviders.tsx @@ -5,7 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { StyledEngineProvider, ThemeProvider, CssBaseline } from '@mui/material'; +import { CssBaseline, StyledEngineProvider, ThemeProvider } from '@mui/material'; import { CardErrorBoundary, getComputedLanguage, @@ -16,19 +16,28 @@ import { import { IntlProvider } from 'react-intl'; import { BrowserRouter } from 'react-router'; import { Provider } from 'react-redux'; -import { store } from 'app/store/store'; +import 'app/config/app-config'; // side-effect import: configure params, ex. appName, must before all other side-effect imports, such as configureStore +import { store, useAppSelector } from 'app/store/store'; import App from 'app/App'; import { appMessages } from 'app/config/app-messages'; import { getAppTheme } from 'app/config/app-theme'; import { useGetConfigParameterWithFallback } from 'features/app-parameters/hooks/use-get-config-parameter-with-fallback'; +import { selectUser } from 'features/authentication/store/authentication.selectors'; import { SnackRefRegisterer } from './SnackRefRegisterer'; const basename = new URL(document.querySelector('base')?.href ?? '').pathname; function AppProvidersWithStore() { - const { data: language } = useGetConfigParameterWithFallback(PARAM_LANGUAGE); + const user = useAppSelector(selectUser); + const { data: language } = useGetConfigParameterWithFallback({ + paramName: PARAM_LANGUAGE, + isAuthenticated: user !== null, + }); const computedLanguage = getComputedLanguage(language); - const { data: theme } = useGetConfigParameterWithFallback(PARAM_THEME); + const { data: theme } = useGetConfigParameterWithFallback({ + paramName: PARAM_THEME, + isAuthenticated: user !== null, + }); return ( diff --git a/src/app/store/store.ts b/src/app/store/store.ts index bdf1182..21267b9 100644 --- a/src/app/store/store.ts +++ b/src/app/store/store.ts @@ -7,11 +7,12 @@ import { configureStore } from '@reduxjs/toolkit'; import { useDispatch, useSelector } from 'react-redux'; +import { errorMiddleware } from 'shared/store/rtk-query-error-middleware'; import { monitorApi } from 'shared/api/monitor-api'; import { studyApi } from 'shared/api/study-api'; import { configApi } from 'shared/api/config-api'; +import { updateConfigParams } from 'shared/config/config-params'; import { reducer } from './reducer'; -import { errorMiddleware } from './rtk-query-error-middleware'; export const setupStore = (preloadedState?: PreloadedState) => configureStore({ @@ -28,6 +29,8 @@ export const setupStore = (preloadedState?: PreloadedState) => }); export const store = setupStore(); +// push store to configParams to be able to access it in the token selector at low-level module +updateConfigParams({ store }); export type PreloadedState = Parameters[0]; export type RootState = ReturnType; diff --git a/src/features/app-parameters/__tests__/hooks/use-app-parameter-state.test.ts b/src/features/app-parameters/__tests__/hooks/use-app-parameter-state.test.ts index a58f88f..3f4c76b 100644 --- a/src/features/app-parameters/__tests__/hooks/use-app-parameter-state.test.ts +++ b/src/features/app-parameters/__tests__/hooks/use-app-parameter-state.test.ts @@ -5,20 +5,20 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { renderHook, act, waitFor } from '@testing-library/react'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { server } from 'test-utils/msw/server'; -import { createTestContext } from 'test-utils/create-test-context'; +import { DARK_THEME, LIGHT_THEME, PARAM_THEME } from '@gridsuite/commons-ui'; +import { server } from 'shared/test-utils/msw/server'; +import { createBaseContext } from 'features/test-utils/create-base-context'; import { useAppParameterState } from 'features/app-parameters/hooks/use-app-parameter-state'; -import { DARK_THEME, LIGHT_THEME } from '@gridsuite/commons-ui'; describe('useAppParameterState', () => { beforeEach(() => { server.use( http.get('*/config/v1/applications/*/parameters/theme', () => HttpResponse.json({ - name: 'theme', + name: PARAM_THEME, value: DARK_THEME, }) ) @@ -34,8 +34,10 @@ describe('useAppParameterState', () => { return HttpResponse.json({}); }) ); - const { wrapper } = createTestContext(); - const { result } = renderHook(() => useAppParameterState('theme'), { wrapper }); + const { wrapper } = createBaseContext(); + const { result } = renderHook(() => useAppParameterState({ paramName: PARAM_THEME, isAuthenticated: true }), { + wrapper, + }); // check state before updating await waitFor(() => { @@ -74,8 +76,10 @@ describe('useAppParameterState', () => { }) ); - const { wrapper } = createTestContext(); - const { result } = renderHook(() => useAppParameterState('theme'), { wrapper }); + const { wrapper } = createBaseContext(); + const { result } = renderHook(() => useAppParameterState({ paramName: PARAM_THEME, isAuthenticated: true }), { + wrapper, + }); // check state before updating await waitFor(() => { diff --git a/src/features/app-parameters/__tests__/hooks/use-app-parameters-invalidation-listener.test.ts b/src/features/app-parameters/__tests__/hooks/use-app-parameters-invalidation-listener.test.ts index c6c4adb..ea3b9c8 100644 --- a/src/features/app-parameters/__tests__/hooks/use-app-parameters-invalidation-listener.test.ts +++ b/src/features/app-parameters/__tests__/hooks/use-app-parameters-invalidation-listener.test.ts @@ -6,12 +6,12 @@ */ import { renderHook } from '@testing-library/react'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as configWs from 'shared/api/ws/config-ws'; -import { createTestContext } from 'test-utils/create-test-context'; -import { useAppParametersInvalidationListener } from 'features/app-parameters/hooks/use-app-parameters-invalidation-listener'; -import * as configApiModule from 'shared/api/config-api'; import { connectConfigNotificationsWs } from 'shared/api/ws/config-ws'; +import { createBaseContext } from 'features/test-utils/create-base-context'; +import * as configApiModule from 'shared/api/config-api'; +import { useAppParametersInvalidationListener } from 'features/app-parameters/hooks/use-app-parameters-invalidation-listener'; vi.spyOn(configWs, 'connectConfigNotificationsWs').mockImplementation(vi.fn()); vi.spyOn(configApiModule, 'invalidateConfigQueries').mockImplementation(vi.fn()); @@ -34,7 +34,7 @@ describe('useAppParametersInvalidationListener', () => { }); it('connects websocket on mount', () => { - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); renderHook(() => useAppParametersInvalidationListener({ isAuthenticated: true }), { wrapper }); @@ -42,7 +42,7 @@ describe('useAppParametersInvalidationListener', () => { }); it('does not connect websocket when user is not authenticated', () => { - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); renderHook(() => useAppParametersInvalidationListener({ isAuthenticated: false }), { wrapper }); @@ -50,7 +50,7 @@ describe('useAppParametersInvalidationListener', () => { }); it('invalidates config when receiving a message', () => { - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); renderHook(() => useAppParametersInvalidationListener({ isAuthenticated: true }), { wrapper }); @@ -65,7 +65,7 @@ describe('useAppParametersInvalidationListener', () => { }); it('closes websocket on unmount', () => { - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); const { unmount } = renderHook(() => useAppParametersInvalidationListener({ isAuthenticated: true }), { wrapper, diff --git a/src/features/app-parameters/__tests__/hooks/use-get-config-parameter-with-fallback.test.ts b/src/features/app-parameters/__tests__/hooks/use-get-config-parameter-with-fallback.test.ts index 73457d8..7a576c9 100644 --- a/src/features/app-parameters/__tests__/hooks/use-get-config-parameter-with-fallback.test.ts +++ b/src/features/app-parameters/__tests__/hooks/use-get-config-parameter-with-fallback.test.ts @@ -5,13 +5,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DARK_THEME, LIGHT_THEME, PARAM_THEME } from '@gridsuite/commons-ui'; import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; import { http, HttpResponse } from 'msw'; +import { DARK_THEME, LIGHT_THEME, PARAM_THEME } from '@gridsuite/commons-ui'; +import { server } from 'shared/test-utils/msw/server'; +import { createBaseContext } from 'features/test-utils/create-base-context'; import { useGetConfigParameterWithFallback } from 'features/app-parameters/hooks/use-get-config-parameter-with-fallback'; -import { server } from 'test-utils/msw/server'; -import { createTestContext } from 'test-utils/create-test-context'; import { saveLocalStorageTheme } from 'features/app-parameters/store/app-parameters.local-storage'; beforeEach(() => localStorage.clear()); @@ -27,9 +27,12 @@ describe('useGetConfigParameterWithFallback', () => { ) ); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); - const { result } = renderHook(() => useGetConfigParameterWithFallback(PARAM_THEME), { wrapper }); + const { result } = renderHook( + () => useGetConfigParameterWithFallback({ paramName: PARAM_THEME, isAuthenticated: true }), + { wrapper } + ); await waitFor(() => { expect(result.current.isSuccess).toBe(true); @@ -38,23 +41,29 @@ describe('useGetConfigParameterWithFallback', () => { expect(result.current.data).toBe(LIGHT_THEME); }); - it('hook returns localstorage if no user in store', async () => { - const { wrapper } = createTestContext({ authentication: { user: null } }); + it('hook returns local storage if not authenticated in store', async () => { + const { wrapper } = createBaseContext(); saveLocalStorageTheme(LIGHT_THEME); - const { result } = renderHook(() => useGetConfigParameterWithFallback(PARAM_THEME), { - wrapper, - }); + const { result } = renderHook( + () => useGetConfigParameterWithFallback({ paramName: PARAM_THEME, isAuthenticated: false }), + { + wrapper, + } + ); expect(result.current.data).toBe(LIGHT_THEME); }); - it('hook returns fallback if no user in store and nothing in local storage', async () => { - const { wrapper } = createTestContext({ authentication: { user: null } }); + it('hook returns fallback if not authenticated and nothing in local storage', async () => { + const { wrapper } = createBaseContext(); - const { result } = renderHook(() => useGetConfigParameterWithFallback(PARAM_THEME), { - wrapper, - }); + const { result } = renderHook( + () => useGetConfigParameterWithFallback({ paramName: PARAM_THEME, isAuthenticated: false }), + { + wrapper, + } + ); expect(result.current.data).toBe(DARK_THEME); }); diff --git a/src/features/app-parameters/hooks/use-app-parameter-state.ts b/src/features/app-parameters/hooks/use-app-parameter-state.ts index d83b1ac..21a9eb3 100644 --- a/src/features/app-parameters/hooks/use-app-parameter-state.ts +++ b/src/features/app-parameters/hooks/use-app-parameter-state.ts @@ -5,19 +5,27 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { getAppName as getAppNameCommons } from '@gridsuite/commons-ui'; import { AppParameters, AppParametersKey } from 'features/app-parameters/store/app-parameters.type'; -import { getAppName } from '@gridsuite/commons-ui'; +import { getAppName } from 'shared/config/config-params'; import { useUpdateParameterMutation } from 'shared/api/config-api'; import { useGetConfigParameterWithFallback } from './use-get-config-parameter-with-fallback'; -import { APP_NAME } from '../../../app/config/app-config'; -export function useAppParameterState(paramName: K) { - const { data: paramValue } = useGetConfigParameterWithFallback(paramName); +type UseAppParameterStateProps = { + paramName: K; + isAuthenticated: boolean; +}; + +export function useAppParameterState({ + paramName, + isAuthenticated, +}: UseAppParameterStateProps) { + const { data: paramValue } = useGetConfigParameterWithFallback({ paramName, isAuthenticated }); const [updateConfigParameter] = useUpdateParameterMutation(); const setValue = async (newValue: AppParameters[K]) => { await updateConfigParameter({ - appName: getAppName(APP_NAME, paramName), + appName: getAppNameCommons(getAppName(), paramName), name: paramName, value: newValue, }).unwrap(); diff --git a/src/features/app-parameters/hooks/use-app-parameters-invalidation-listener.ts b/src/features/app-parameters/hooks/use-app-parameters-invalidation-listener.ts index 2995b11..c5e68c6 100644 --- a/src/features/app-parameters/hooks/use-app-parameters-invalidation-listener.ts +++ b/src/features/app-parameters/hooks/use-app-parameters-invalidation-listener.ts @@ -5,10 +5,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useAppDispatch } from 'app/store/store'; +import { useDispatch } from 'react-redux'; import { useEffect } from 'react'; -import { connectConfigNotificationsWs } from 'shared/api/ws/config-ws'; import { invalidateConfigQueries } from 'shared/api/config-api'; +import { connectConfigNotificationsWs } from 'shared/api/ws/config-ws'; +import type { AnyAppDispatch } from 'shared/store/state.type'; type ConfigNotificationData = { headers?: { @@ -16,8 +17,14 @@ type ConfigNotificationData = { }; }; -export const useAppParametersInvalidationListener = ({ isAuthenticated }: { isAuthenticated: boolean }) => { - const dispatch = useAppDispatch(); +type UseAppParametersInvalidationListenerProps = { + isAuthenticated: boolean; +}; + +export const useAppParametersInvalidationListener = ({ + isAuthenticated, +}: UseAppParametersInvalidationListenerProps) => { + const dispatch = useDispatch(); useEffect(() => { if (!isAuthenticated) { diff --git a/src/features/app-parameters/hooks/use-get-config-parameter-with-fallback.ts b/src/features/app-parameters/hooks/use-get-config-parameter-with-fallback.ts index 70294d0..b6462b5 100644 --- a/src/features/app-parameters/hooks/use-get-config-parameter-with-fallback.ts +++ b/src/features/app-parameters/hooks/use-get-config-parameter-with-fallback.ts @@ -5,25 +5,28 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { getAppName } from '@gridsuite/commons-ui'; -import { APP_NAME } from 'app/config/app-config'; -import { useAppSelector } from 'app/store/store'; -import { selectUser } from 'features/authentication/store/authentication.selectors'; +import { getAppName as getAppNameCommons } from '@gridsuite/commons-ui'; import { useGetParameterQuery } from 'shared/api/config-api'; +import { getAppName } from 'shared/config/config-params'; import { getInitialAppParametersState } from '../store/app-parameters.default'; import { AppParameters, AppParametersKey } from '../store/app-parameters.type'; +type UseGetConfigParameterWithFallbackProps = { + paramName: K; + isAuthenticated: boolean; +}; /** - * This data is fetched from AppTopBar, which is displayed before user is authenticated - * If user is not authenticated, or before the fetch request has responded, we use data from initialAppParametersState + * This data is fetched from AppTopBar, which is displayed before the user is authenticated, + * If the user is not authenticated, or before the fetch request has responded, we use data from initialAppParametersState */ -export const useGetConfigParameterWithFallback = (paramName: K) => { - const user = useAppSelector(selectUser); - +export const useGetConfigParameterWithFallback = ({ + paramName, + isAuthenticated, +}: UseGetConfigParameterWithFallbackProps) => { return useGetParameterQuery( - { name: paramName, appName: getAppName(APP_NAME, paramName) }, + { name: paramName, appName: getAppNameCommons(getAppName(), paramName) }, { - skip: !user, + skip: !isAuthenticated, selectFromResult: (result) => { const data = result.data?.value ?? getInitialAppParametersState()[paramName]; diff --git a/src/features/app-parameters/store/app-parameters.local-storage.ts b/src/features/app-parameters/store/app-parameters.local-storage.ts index 98bc98b..5982643 100644 --- a/src/features/app-parameters/store/app-parameters.local-storage.ts +++ b/src/features/app-parameters/store/app-parameters.local-storage.ts @@ -1,28 +1,12 @@ /** - * Copyright (c) 2020, RTE (http://www.rte-france.com) + * Copyright (c) 2026, RTE (http://www.rte-france.com) * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { DARK_THEME, GsLang, GsTheme, LANG_SYSTEM } from '@gridsuite/commons-ui'; -import { APP_NAME } from 'app/config/app-config'; - -export const LOCAL_STORAGE_THEME_KEY = `${APP_NAME}_THEME`.toUpperCase(); -const LOCAL_STORAGE_LANGUAGE_KEY = `${APP_NAME}_LANGUAGE`.toUpperCase(); - -export function getLocalStorageTheme() { - return (localStorage.getItem(LOCAL_STORAGE_THEME_KEY) as GsTheme) || DARK_THEME; -} - -export function saveLocalStorageTheme(theme: GsTheme): void { - localStorage.setItem(LOCAL_STORAGE_THEME_KEY, theme); -} - -export function getLocalStorageLanguage() { - return (localStorage.getItem(LOCAL_STORAGE_LANGUAGE_KEY) as GsLang) || LANG_SYSTEM; -} - -export function saveLocalStorageLanguage(language: GsLang): void { - localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, language); -} +export { + getLocalStorageLanguage, + getLocalStorageTheme, + saveLocalStorageTheme, +} from 'shared/api/config-api/config-api.local-storage'; diff --git a/src/features/app-parameters/store/app-parameters.type.ts b/src/features/app-parameters/store/app-parameters.type.ts index 3553cdd..2c48955 100644 --- a/src/features/app-parameters/store/app-parameters.type.ts +++ b/src/features/app-parameters/store/app-parameters.type.ts @@ -5,11 +5,4 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { GsLang, GsTheme } from '@gridsuite/commons-ui'; - -export type AppParameters = { - language: GsLang; - theme: GsTheme; -}; - -export type AppParametersKey = keyof AppParameters; +export type { AppParameters, AppParametersKey } from 'shared/api/config-api/config-api.type'; diff --git a/src/features/authentication/store/authentication.selectors.ts b/src/features/authentication/store/authentication.selectors.ts index 179f6a7..6038395 100644 --- a/src/features/authentication/store/authentication.selectors.ts +++ b/src/features/authentication/store/authentication.selectors.ts @@ -5,14 +5,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { RootState } from 'app/store/store'; +import { StateWithAuthentication } from './authentication.type'; -export const selectAuthentication = (state: RootState) => state.authentication; -export const selectUser = (state: RootState) => selectAuthentication(state).user; -export const selectSignInCallbackError = (state: RootState) => selectAuthentication(state).signInCallbackError; +export const selectAuthentication = (state: StateWithAuthentication) => state.authentication; +export const selectUser = (state: StateWithAuthentication) => selectAuthentication(state).user; +export const selectToken = (state: StateWithAuthentication) => selectUser(state)?.id_token; +export const selectSignInCallbackError = (state: StateWithAuthentication) => + selectAuthentication(state).signInCallbackError; -export const selectAuthenticationRouterError = (state: RootState) => +export const selectAuthenticationRouterError = (state: StateWithAuthentication) => selectAuthentication(state).authenticationRouterError; -export const selectShowAuthenticationRouterLogin = (state: RootState) => +export const selectShowAuthenticationRouterLogin = (state: StateWithAuthentication) => selectAuthentication(state).showAuthenticationRouterLogin; diff --git a/src/features/authentication/store/authentication.type.ts b/src/features/authentication/store/authentication.type.ts index 7337c38..9b7b24a 100644 --- a/src/features/authentication/store/authentication.type.ts +++ b/src/features/authentication/store/authentication.type.ts @@ -12,3 +12,8 @@ export type AuthenticationState = CommonStoreState & { authenticationRouterError: AuthenticationRouterErrorState | null; showAuthenticationRouterLogin: boolean; }; + +// Liskov Substitution Principle (LSP) implemented in using the structural subtyping +// The base type for state of authentication feature is StateWithAuthentication +// The root state is a subtype that is usable in every selector of the authentication feature +export type StateWithAuthentication = { authentication: AuthenticationState }; diff --git a/src/features/process-config/__tests__/hooks/use-process-invalidation-listener.test.ts b/src/features/process-config/__tests__/hooks/use-process-invalidation-listener.test.ts index 7b6e701..66f29d3 100644 --- a/src/features/process-config/__tests__/hooks/use-process-invalidation-listener.test.ts +++ b/src/features/process-config/__tests__/hooks/use-process-invalidation-listener.test.ts @@ -8,15 +8,15 @@ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as monitorWs from 'shared/api/ws/monitor-ws'; -import * as monitorApiModule from 'shared/api/monitor-api'; -import { createTestContext } from 'test-utils/create-test-context'; -import { useProcessInvalidationsListener } from 'features/process-config/hooks/use-process-invalidation-listener'; import { connectMonitorNotificationsWs } from 'shared/api/ws/monitor-ws'; +import * as monitorApiModule from 'shared/api/monitor-api'; +import { useProcessInvalidationListener } from 'features/process-config/hooks/use-process-invalidation-listener'; +import { createBaseContext } from '../../../test-utils/create-base-context'; vi.spyOn(monitorWs, 'connectMonitorNotificationsWs').mockImplementation(vi.fn()); vi.spyOn(monitorApiModule, 'invalidateProcessExecutionsLists').mockImplementation(vi.fn()); -describe('useMonitorInvalidationsListener', () => { +describe('useProcessInvalidationListener', () => { let mockWs: ReturnType; beforeEach(() => { @@ -32,25 +32,22 @@ describe('useMonitorInvalidationsListener', () => { }); it('connects websocket on mount', () => { - const { wrapper } = createTestContext(); - - renderHook(() => useProcessInvalidationsListener({ isAuthenticated: true }), { wrapper }); + const { wrapper } = createBaseContext(); + renderHook(() => useProcessInvalidationListener({ isAuthenticated: true }), { wrapper }); expect(connectMonitorNotificationsWs).toHaveBeenCalled(); }); it('does not connect websocket when user is not authenticated', () => { - const { wrapper } = createTestContext(); - - renderHook(() => useProcessInvalidationsListener({ isAuthenticated: false }), { wrapper }); + const { wrapper } = createBaseContext(); + renderHook(() => useProcessInvalidationListener({ isAuthenticated: false }), { wrapper }); expect(connectMonitorNotificationsWs).not.toHaveBeenCalled(); }); it('invalidates process execution lists when receiving an update message', () => { - const { wrapper } = createTestContext(); - - renderHook(() => useProcessInvalidationsListener({ isAuthenticated: true }), { wrapper }); + const { wrapper } = createBaseContext(); + renderHook(() => useProcessInvalidationListener({ isAuthenticated: true }), { wrapper }); mockWs.onmessage?.({ data: JSON.stringify({ @@ -62,9 +59,8 @@ describe('useMonitorInvalidationsListener', () => { }); it('closes websocket on unmount', () => { - const { wrapper } = createTestContext(); - - const { unmount } = renderHook(() => useProcessInvalidationsListener({ isAuthenticated: true }), { wrapper }); + const { wrapper } = createBaseContext(); + const { unmount } = renderHook(() => useProcessInvalidationListener({ isAuthenticated: true }), { wrapper }); unmount(); diff --git a/src/features/process-config/__tests__/pages/ProcessConfigListPage.test.tsx b/src/features/process-config/__tests__/pages/ProcessConfigListPage.test.tsx index c23786d..b28d28f 100644 --- a/src/features/process-config/__tests__/pages/ProcessConfigListPage.test.tsx +++ b/src/features/process-config/__tests__/pages/ProcessConfigListPage.test.tsx @@ -9,8 +9,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { createTestContext } from 'test-utils/create-test-context'; -import { server } from 'test-utils/msw/server'; +import { server } from 'shared/test-utils/msw/server'; +import { createBaseContext } from '../../../test-utils/create-base-context'; import ProcessConfigListPage from '../../pages/ProcessConfigListPage'; describe('ProcessConfigListPage', () => { @@ -41,7 +41,7 @@ describe('ProcessConfigListPage', () => { ); const user = userEvent.setup(); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render(, { wrapper }); @@ -69,7 +69,7 @@ describe('ProcessConfigListPage', () => { }) ); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render(, { wrapper }); @@ -79,7 +79,7 @@ describe('ProcessConfigListPage', () => { it('displays the error state', async () => { server.use(http.get('*/v1/process-configs', () => HttpResponse.error())); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render(, { wrapper }); diff --git a/src/features/process-config/hooks/use-process-invalidation-listener.ts b/src/features/process-config/hooks/use-process-invalidation-listener.ts index 58fe4a3..49f0dd0 100644 --- a/src/features/process-config/hooks/use-process-invalidation-listener.ts +++ b/src/features/process-config/hooks/use-process-invalidation-listener.ts @@ -6,9 +6,10 @@ */ import { useEffect } from 'react'; -import { useAppDispatch } from 'app/store/store'; +import { useDispatch } from 'react-redux'; import { connectMonitorNotificationsWs } from 'shared/api/ws/monitor-ws'; import { invalidateProcessExecutionsLists } from 'shared/api/monitor-api'; +import type { AnyAppDispatch } from 'shared/store/state.type'; type MonitorNotificationData = { headers?: { @@ -18,8 +19,12 @@ type MonitorNotificationData = { }; }; -export const useProcessInvalidationsListener = ({ isAuthenticated }: { isAuthenticated: boolean }) => { - const dispatch = useAppDispatch(); +type UseProcessInvalidationListenerProps = { + isAuthenticated: boolean; +}; + +export const useProcessInvalidationListener = ({ isAuthenticated }: UseProcessInvalidationListenerProps) => { + const dispatch = useDispatch(); useEffect(() => { if (!isAuthenticated) { diff --git a/src/features/process/execute/__tests__/pages/ProcessExecutePage.test.tsx b/src/features/process/execute/__tests__/pages/ProcessExecutePage.test.tsx index f176a8b..282523e 100644 --- a/src/features/process/execute/__tests__/pages/ProcessExecutePage.test.tsx +++ b/src/features/process/execute/__tests__/pages/ProcessExecutePage.test.tsx @@ -9,20 +9,16 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { createTestContext } from 'test-utils/create-test-context'; -import { server } from 'test-utils/msw/server'; +import { server } from 'shared/test-utils/msw/server'; +import { createBaseContext } from '../../../../test-utils/create-base-context'; import ProcessExecutePage from '../../pages/ProcessExecutePage'; describe('ProcessExecutePage', () => { it('submits the form and displays the success message', async () => { - server.use( - http.post('*/v1/execute', () => { - return HttpResponse.json('execution-id'); - }) - ); + server.use(http.post('*/v1/execute', () => HttpResponse.json('execution-id'))); const user = userEvent.setup(); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render(, { wrapper }); @@ -48,7 +44,7 @@ describe('ProcessExecutePage', () => { ); const user = userEvent.setup(); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render(, { wrapper }); @@ -64,7 +60,7 @@ describe('ProcessExecutePage', () => { server.use(http.post('*/v1/execute', () => HttpResponse.error())); const user = userEvent.setup(); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render(, { wrapper }); @@ -85,7 +81,7 @@ describe('ProcessExecutePage', () => { const user = userEvent.setup(); render(, { - wrapper: createTestContext().wrapper, + wrapper: createBaseContext().wrapper, }); await user.click(screen.getByRole('button', { name: 'Execute process' })); diff --git a/src/features/process/execute/components/ExecuteProcessResult.tsx b/src/features/process/execute/components/ExecuteProcessResult.tsx index 21a52c4..0a62f3e 100644 --- a/src/features/process/execute/components/ExecuteProcessResult.tsx +++ b/src/features/process/execute/components/ExecuteProcessResult.tsx @@ -6,7 +6,7 @@ */ import { Alert, Stack } from '@mui/material'; -import type { ExecuteProcessApiResponse } from '../../../../shared/api/monitor-api'; +import type { ExecuteProcessApiResponse } from 'shared/api/monitor-api'; type ExecuteProcessResultProps = { isLoading?: boolean; diff --git a/src/features/process/results/__tests__/pages/ProcessResultsPage.test.tsx b/src/features/process/results/__tests__/pages/ProcessResultsPage.test.tsx index 7c5d4b6..1f82fdc 100644 --- a/src/features/process/results/__tests__/pages/ProcessResultsPage.test.tsx +++ b/src/features/process/results/__tests__/pages/ProcessResultsPage.test.tsx @@ -9,8 +9,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { describe, expect, it } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { createTestContext } from 'test-utils/create-test-context'; -import { server } from 'test-utils/msw/server'; +import { createBaseContext } from 'features/test-utils/create-base-context'; +import { server } from 'shared/test-utils/msw/server'; import ProcessResultsPage from '../../pages/ProcessResultsPage'; import { PROCESS_PATHS } from '../../../router/process-paths'; @@ -28,7 +28,7 @@ describe('ProcessResultsPage', () => { ) ); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render( @@ -61,7 +61,7 @@ describe('ProcessResultsPage', () => { }) ); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render( @@ -76,7 +76,7 @@ describe('ProcessResultsPage', () => { it('displays the error state', async () => { server.use(http.get('*/v1/executions', () => HttpResponse.error())); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render( diff --git a/src/features/process/results/__tests__/pages/ProcessStepInfosPage.test.tsx b/src/features/process/results/__tests__/pages/ProcessStepInfosPage.test.tsx index 0fbf6a7..e24003f 100644 --- a/src/features/process/results/__tests__/pages/ProcessStepInfosPage.test.tsx +++ b/src/features/process/results/__tests__/pages/ProcessStepInfosPage.test.tsx @@ -10,8 +10,8 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router'; import { describe, expect, it } from 'vitest'; import { http, HttpResponse } from 'msw'; -import { createTestContext } from 'test-utils/create-test-context'; -import { server } from 'test-utils/msw/server'; +import { server } from 'shared/test-utils/msw/server'; +import { createBaseContext } from 'features/test-utils/create-base-context'; import ProcessStepInfosPage from '../../pages/ProcessStepInfosPage'; describe('ProcessStepInfosPage', () => { @@ -36,7 +36,7 @@ describe('ProcessStepInfosPage', () => { ); const user = userEvent.setup(); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render( @@ -71,7 +71,7 @@ describe('ProcessStepInfosPage', () => { }) ); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render( @@ -88,7 +88,7 @@ describe('ProcessStepInfosPage', () => { it('displays the error state', async () => { server.use(http.get('*/v1/executions/execution-1/step-infos', () => HttpResponse.error())); - const { wrapper } = createTestContext(); + const { wrapper } = createBaseContext(); render( diff --git a/src/features/process/results/models/process-result.ts b/src/features/process/results/models/process-result.ts index eea0088..3e8b091 100644 --- a/src/features/process/results/models/process-result.ts +++ b/src/features/process/results/models/process-result.ts @@ -4,7 +4,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import type { ProcessExecution, ProcessExecutionStep } from '../../../../shared/api/monitor-api'; +import type { ProcessExecution, ProcessExecutionStep } from 'shared/api/monitor-api'; export type ProcessStepModel = Omit & { startedAt?: Date; diff --git a/src/features/test-utils/create-base-context.tsx b/src/features/test-utils/create-base-context.tsx new file mode 100644 index 0000000..86ffeaf --- /dev/null +++ b/src/features/test-utils/create-base-context.tsx @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { PropsWithChildren } from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import { errorMiddleware } from 'shared/store/rtk-query-error-middleware'; +import { monitorApi } from 'shared/api/monitor-api'; +import { studyApi } from 'shared/api/study-api'; +import { configApi } from 'shared/api/config-api'; + +export const createBaseContext = () => { + const store = configureStore({ + reducer: { + [monitorApi.reducerPath]: monitorApi.reducer, + [configApi.reducerPath]: configApi.reducer, + [studyApi.reducerPath]: studyApi.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware() + .prepend(errorMiddleware) + .concat(monitorApi.middleware, studyApi.middleware, configApi.middleware), + }); + const wrapper = ({ children }: PropsWithChildren) => {children}; + return { store, wrapper }; +}; diff --git a/src/features/top-bar/api/get-servers-infos.ts b/src/features/top-bar/api/get-servers-infos.ts index ecab028..d6593bb 100644 --- a/src/features/top-bar/api/get-servers-infos.ts +++ b/src/features/top-bar/api/get-servers-infos.ts @@ -5,11 +5,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { store } from 'app/store/store'; +import { GridSuiteModule } from '@gridsuite/commons-ui'; import { rtkQueryToPromise } from 'shared/api/rtk-query/rtk-query-to-promise'; import { getErrorMessage } from 'shared/lib/error'; +import { AnyAppDispatch } from 'shared/store/state.type'; import { AboutInfo, studyApi, Type } from 'shared/api/study-api'; -import { GridSuiteModule } from '@gridsuite/commons-ui'; // TODO: remove this function once the backend is fixed with actual types const toGridSuiteModule = (aboutInfos: AboutInfo[]): GridSuiteModule[] => { @@ -21,9 +21,9 @@ const toGridSuiteModule = (aboutInfos: AboutInfo[]): GridSuiteModule[] => { })); }; -export const getServersInfos = async () => { +export const getServersInfos = (dispatch: AnyAppDispatch): Promise => { const serverInfos = rtkQueryToPromise( - store.dispatch( + dispatch( studyApi.endpoints.getSuiteAboutInformation.initiate( {}, { diff --git a/src/features/top-bar/components/AppNavBar.tsx b/src/features/top-bar/components/AppNavBar.tsx index 7a7eeba..f6d4a1f 100644 --- a/src/features/top-bar/components/AppNavBar.tsx +++ b/src/features/top-bar/components/AppNavBar.tsx @@ -5,9 +5,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Tabs, Tab } from '@mui/material'; +import { Tab, Tabs } from '@mui/material'; import { NavLink, useLocation } from 'react-router'; -import { PlayCircleFilled, TableView, SettingsInputComponent } from '@mui/icons-material'; +import { PlayCircleFilled, SettingsInputComponent, TableView } from '@mui/icons-material'; import type { ReactNode } from 'react'; import { PROCESS_PATHS } from '../../process/router/process-paths'; import { PROCESS_CONFIG_PATHS } from '../../process-config/router/process-config-paths'; diff --git a/src/features/top-bar/components/AppTopBar.tsx b/src/features/top-bar/components/AppTopBar.tsx index 0f7f579..39e24d2 100644 --- a/src/features/top-bar/components/AppTopBar.tsx +++ b/src/features/top-bar/components/AppTopBar.tsx @@ -5,7 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { fetchAppsMetadata, logout, @@ -16,30 +16,38 @@ import { UserManagerState, } from '@gridsuite/commons-ui'; import { useNavigate } from 'react-router'; -import { APP_NAME } from 'app/config/app-config'; +import { useDispatch } from 'react-redux'; import PowsyblLogo from 'assets/images/powsybl_logo.svg?react'; import { useAppParameterState } from 'features/app-parameters/hooks/use-app-parameter-state'; -import { useAppDispatch } from 'app/store/store'; import { AuthenticationState } from 'features/authentication/store/authentication.type'; import { fetchVersion } from 'shared/config/version'; +import { AnyAppDispatch } from 'shared/store/state.type'; +import { getAppName } from 'shared/config/config-params'; import { getServersInfos } from '../api/get-servers-infos'; import AppPackage from '../../../../package.json'; import { SettingsTabs } from './AppNavBar'; export type AppTopBarProps = { - user?: AuthenticationState['user']; + user: AuthenticationState['user'] | null; userManager: UserManagerState; }; function AppTopBar({ user, userManager }: Readonly) { const navigate = useNavigate(); - const dispatch = useAppDispatch(); + const dispatch = useDispatch(); + const isAuthenticated = user !== null; const [appsAndUrls, setAppsAndUrls] = useState([]); - const [themeLocal, handleChangeTheme] = useAppParameterState(PARAM_THEME); - const [languageLocal, handleChangeLanguage] = useAppParameterState(PARAM_LANGUAGE); + const [themeLocal, handleChangeTheme] = useAppParameterState({ + paramName: PARAM_THEME, + isAuthenticated, + }); + const [languageLocal, handleChangeLanguage] = useAppParameterState({ + paramName: PARAM_LANGUAGE, + isAuthenticated, + }); useEffect(() => { - if (user !== null) { + if (isAuthenticated) { fetchAppsMetadata() .then((metadata) => { setAppsAndUrls(metadata); @@ -48,11 +56,15 @@ function AppTopBar({ user, userManager }: Readonly) { console.error(error); }); } - }, [user]); + }, [isAuthenticated]); + + const serversInfosModulePromise = useMemo(() => { + return () => getServersInfos(dispatch); + }, [dispatch]); return ( } appVersion={AppPackage.version} @@ -62,7 +74,7 @@ function AppTopBar({ user, userManager }: Readonly) { user={user ?? undefined} appsAndUrls={appsAndUrls} globalVersionPromise={() => fetchVersion().then((res) => res?.deployVersion ?? 'unknown')} - additionalModulesPromise={getServersInfos} + additionalModulesPromise={serversInfosModulePromise} onThemeClick={handleChangeTheme} theme={themeLocal} onLanguageClick={handleChangeLanguage} diff --git a/src/shared/api/config-api/config-api.local-storage.ts b/src/shared/api/config-api/config-api.local-storage.ts new file mode 100644 index 0000000..e463dc5 --- /dev/null +++ b/src/shared/api/config-api/config-api.local-storage.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { DARK_THEME, GsLang, GsTheme, LANG_SYSTEM } from '@gridsuite/commons-ui'; +import { getAppName } from '../../config/config-params'; + +export const LOCAL_STORAGE_THEME_KEY = `${getAppName()}_THEME`.toUpperCase(); +const LOCAL_STORAGE_LANGUAGE_KEY = `${getAppName()}_LANGUAGE`.toUpperCase(); + +export function getLocalStorageTheme() { + return (localStorage.getItem(LOCAL_STORAGE_THEME_KEY) as GsTheme) || DARK_THEME; +} + +export function saveLocalStorageTheme(theme: GsTheme): void { + localStorage.setItem(LOCAL_STORAGE_THEME_KEY, theme); +} + +export function getLocalStorageLanguage() { + return (localStorage.getItem(LOCAL_STORAGE_LANGUAGE_KEY) as GsLang) || LANG_SYSTEM; +} + +export function saveLocalStorageLanguage(language: GsLang): void { + localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, language); +} diff --git a/src/shared/api/config-api/config-api.type.ts b/src/shared/api/config-api/config-api.type.ts new file mode 100644 index 0000000..11822d4 --- /dev/null +++ b/src/shared/api/config-api/config-api.type.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { GsLang, GsTheme } from '@gridsuite/commons-ui'; + +export type AppParameters = { + language: GsLang; + theme: GsTheme; +}; + +export type AppParametersKey = keyof AppParameters; diff --git a/src/shared/api/config-api/config.enhanced.ts b/src/shared/api/config-api/config.enhanced.ts index e940b4e..2762fe8 100644 --- a/src/shared/api/config-api/config.enhanced.ts +++ b/src/shared/api/config-api/config.enhanced.ts @@ -6,13 +6,9 @@ */ import { GsLang, GsTheme, PARAM_LANGUAGE, PARAM_THEME } from '@gridsuite/commons-ui'; -import type { AppDispatch } from 'app/store/store'; -import { AppParameters, AppParametersKey } from 'features/app-parameters/store/app-parameters.type'; -import { - saveLocalStorageLanguage, - saveLocalStorageTheme, -} from 'features/app-parameters/store/app-parameters.local-storage'; +import { saveLocalStorageLanguage, saveLocalStorageTheme } from './config-api.local-storage'; import { ConfigTags } from './config-base-api'; +import { AnyAppDispatch } from '../../store/state.type'; import { configGeneratedApi } from './config.generated'; export const configApi = configGeneratedApi.enhanceEndpoints({ @@ -63,23 +59,8 @@ export const configApi = configGeneratedApi.enhanceEndpoints({ }, }); -export const invalidateConfigQueries = (dispatch: AppDispatch, paramName: string) => { +export const invalidateConfigQueries = (dispatch: AnyAppDispatch, paramName: string) => { dispatch(configApi.util.invalidateTags([{ type: ConfigTags.Parameters, id: paramName }])); }; -// https://github.com/gridsuite/config-server/blob/main/src/main/java/org/gridsuite/config/server/dto/ParameterInfos.java -export type ConfigParameter = - | { - readonly name: typeof PARAM_LANGUAGE; - value: GsLang; - } - | { - readonly name: typeof PARAM_THEME; - value: GsTheme; - }; -export type UpdateConfigParameterRequest = { - name: AppParametersKey; - value: AppParameters[AppParametersKey]; -}; - export const { useGetParameterQuery, useUpdateParameterMutation } = configApi; diff --git a/src/shared/api/monitor-api/monitor.enhanced.ts b/src/shared/api/monitor-api/monitor.enhanced.ts index 84fcf11..094516a 100644 --- a/src/shared/api/monitor-api/monitor.enhanced.ts +++ b/src/shared/api/monitor-api/monitor.enhanced.ts @@ -7,7 +7,7 @@ import { monitorGeneratedApi } from './monitor.generated'; import { MonitorTags } from './monitor-base-api'; -import type { AppDispatch } from '../../../app/store/store'; +import { AnyAppDispatch } from '../../store/state.type'; export const monitorApi = monitorGeneratedApi.enhanceEndpoints({ endpoints: { @@ -17,5 +17,5 @@ export const monitorApi = monitorGeneratedApi.enhanceEndpoints({ }, }); -export const invalidateProcessExecutionsLists = (dispatch: AppDispatch) => +export const invalidateProcessExecutionsLists = (dispatch: AnyAppDispatch) => dispatch(monitorApi.util.invalidateTags([{ type: MonitorTags.ProcessExecutions, id: 'LIST' }])); diff --git a/src/shared/api/rtk-query/base-api.ts b/src/shared/api/rtk-query/base-api.ts index 6d87c84..c640f89 100644 --- a/src/shared/api/rtk-query/base-api.ts +++ b/src/shared/api/rtk-query/base-api.ts @@ -6,15 +6,15 @@ */ import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'; -import type { RootState } from '../../../app/store/store'; +import { getTokenSelector } from '../../config/config-params'; export const createBaseQuery = (baseUrl: string) => fetchBaseQuery({ baseUrl, prepareHeaders: (headers, { getState }) => { - const state = getState() as RootState; + const state = getState(); - const token = state?.authentication?.user?.id_token; + const token = getTokenSelector()(state); if (token) { headers.set('Authorization', `Bearer ${token}`); diff --git a/src/shared/api/ws/config-ws.ts b/src/shared/api/ws/config-ws.ts index 7d9947e..fcadf10 100644 --- a/src/shared/api/ws/config-ws.ts +++ b/src/shared/api/ws/config-ws.ts @@ -5,8 +5,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { APP_NAME } from 'app/config/app-config'; import { createReconnectingWebSocket } from './ws-client'; +import { getAppName } from '../../config/config-params'; const PREFIX_CONFIG_NOTIFICATION_WS = `${import.meta.env.VITE_WS_GATEWAY}/config-notification`; @@ -14,7 +14,7 @@ export function connectConfigNotificationsWs() { return createReconnectingWebSocket({ path: `${PREFIX_CONFIG_NOTIFICATION_WS}/notify`, queryParams: { - appName: APP_NAME, + appName: getAppName(), }, name: 'config-notifications', }); diff --git a/src/shared/api/ws/monitor-ws.ts b/src/shared/api/ws/monitor-ws.ts index 8391fd0..987b4db 100644 --- a/src/shared/api/ws/monitor-ws.ts +++ b/src/shared/api/ws/monitor-ws.ts @@ -5,8 +5,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { APP_NAME } from 'app/config/app-config'; import { createReconnectingWebSocket } from './ws-client'; +import { getAppName } from '../../config/config-params'; const PREFIX_MONITOR_NOTIFICATION_WS = `${import.meta.env.VITE_WS_GATEWAY}/monitor-notification`; @@ -14,7 +14,7 @@ export function connectMonitorNotificationsWs() { return createReconnectingWebSocket({ path: `${PREFIX_MONITOR_NOTIFICATION_WS}/notify`, queryParams: { - appName: APP_NAME, + appName: getAppName(), }, name: 'monitor-notifications', }); diff --git a/src/shared/api/ws/ws-client.ts b/src/shared/api/ws/ws-client.ts index c72c8e8..1a45e45 100644 --- a/src/shared/api/ws/ws-client.ts +++ b/src/shared/api/ws/ws-client.ts @@ -6,7 +6,8 @@ */ import ReconnectingWebSocket from 'reconnecting-websocket'; -import { buildWebSocketBaseUrl, getToken } from './ws.utils'; +import { buildWebSocketBaseUrl } from './ws.utils'; +import { getToken } from '../../config/config-params'; export type CreateWsOptions = { path: string; diff --git a/src/shared/api/ws/ws.utils.ts b/src/shared/api/ws/ws.utils.ts index 26b562c..e8a6344 100644 --- a/src/shared/api/ws/ws.utils.ts +++ b/src/shared/api/ws/ws.utils.ts @@ -5,14 +5,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { store } from 'app/store/store'; -import { selectAuthentication } from 'features/authentication/store/authentication.selectors'; - -export function getToken(): string | null { - const state = store.getState(); - return selectAuthentication(state).user?.id_token ?? null; -} - export function buildWebSocketBaseUrl(): string { return document.baseURI.replace(/^http:\/\//, 'ws://').replace(/^https:\/\//, 'wss://'); } diff --git a/src/shared/config/config-params.ts b/src/shared/config/config-params.ts new file mode 100644 index 0000000..1166ac5 --- /dev/null +++ b/src/shared/config/config-params.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { Store } from '@reduxjs/toolkit'; + +export type TokenSelector = (state: unknown) => string | undefined; + +interface ConfigParams { + appName: string; + tokenSelector: TokenSelector; // only used while configuring store + store?: Store; // init later after create store + // future parameters can be added here as needed +} + +let configParams: ConfigParams | undefined; + +export function configureParams(config: ConfigParams): void { + if (configParams !== undefined) { + console.warn('configureParams called more than once — ignoring'); + return; + } + configParams = config; +} + +export function updateConfigParams(config: Partial): void { + if (configParams === undefined) { + throw new Error('Config params not initialized. Call configureParams() before using modules.'); + } + Object.assign(configParams, config); +} + +function getConfigParams(): ConfigParams { + if (configParams === undefined) { + throw new Error('Config params not initialized. Call configureParams() before using modules.'); + } + return configParams; +} + +export function getTokenSelector(): TokenSelector { + return getConfigParams().tokenSelector; +} + +export function getToken(): string | undefined { + const params = getConfigParams(); + const state = params.store?.getState(); + if (!state) { + return undefined; + } + return params.tokenSelector(state); +} + +export function getAppName() { + return getConfigParams().appName; +} diff --git a/src/app/store/rtk-query-error-middleware.ts b/src/shared/store/rtk-query-error-middleware.ts similarity index 82% rename from src/app/store/rtk-query-error-middleware.ts rename to src/shared/store/rtk-query-error-middleware.ts index 21d1a1f..eb03372 100644 --- a/src/app/store/rtk-query-error-middleware.ts +++ b/src/shared/store/rtk-query-error-middleware.ts @@ -5,9 +5,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Middleware, isRejectedWithValue } from '@reduxjs/toolkit'; -import { getErrorMessage } from 'shared/lib/error'; -import { snackRef } from 'shared/lib/snack-ref'; +import { isRejectedWithValue, Middleware } from '@reduxjs/toolkit'; +import { getErrorMessage } from '../lib/error'; +import { snackRef } from '../lib/snack-ref'; type RtkQueryRejectedMetadataArgs = { endpointName?: string; diff --git a/src/shared/store/state.type.ts b/src/shared/store/state.type.ts new file mode 100644 index 0000000..e691f27 --- /dev/null +++ b/src/shared/store/state.type.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; + +// type which is structurally compatible with AppDispatch +export type AnyAppDispatch = ThunkDispatch; diff --git a/src/shared/test-utils/config-params/create-test-config-params.ts b/src/shared/test-utils/config-params/create-test-config-params.ts new file mode 100644 index 0000000..a26d5ee --- /dev/null +++ b/src/shared/test-utils/config-params/create-test-config-params.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +import { configureParams } from '../../config/config-params'; + +export function createTestConfigParams(): void { + configureParams({ + appName: 'testAppName', + tokenSelector: () => 'test-token', + }); +} diff --git a/src/shared/test-utils/config-params/setup-test-config-params.ts b/src/shared/test-utils/config-params/setup-test-config-params.ts new file mode 100644 index 0000000..1a8eee7 --- /dev/null +++ b/src/shared/test-utils/config-params/setup-test-config-params.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { createTestConfigParams } from './create-test-config-params'; +// side-effect module to import before run test +// It gives the same guarantee as importing app/config/app-config.ts in AppProvider.tsx before store/providers created +createTestConfigParams(); diff --git a/src/test-utils/msw/server.ts b/src/shared/test-utils/msw/server.ts similarity index 100% rename from src/test-utils/msw/server.ts rename to src/shared/test-utils/msw/server.ts diff --git a/src/test-utils/msw/setup-msw.ts b/src/shared/test-utils/msw/setup-msw.ts similarity index 100% rename from src/test-utils/msw/setup-msw.ts rename to src/shared/test-utils/msw/setup-msw.ts diff --git a/vitest.setup.ts b/vitest.setup.ts index 39de049..ebf9ea3 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -7,7 +7,9 @@ import { vi } from 'vitest'; import '@testing-library/jest-dom'; -import './src/test-utils/msw/setup-msw'; +// Config params are initialized in side-effect module before other setup imports. +import './src/shared/test-utils/config-params/setup-test-config-params'; +import './src/shared/test-utils/msw/setup-msw'; // TODO: Temporary workaround for Vitest + MUI v6 incompatibilities in tests. // Avoids loading MUI during test execution.