diff --git a/README.md b/README.md index 20733a8b..9630b782 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,32 @@ Library for sharing GridSuite apps commons components -#### For developers +## For developers The commons-ui library have a demo app in which you can call your components to test them. The `npm start` command install the library's dependencies then launches the demo app. If you want to test your library integration with a consumer application my-app you have first -to build commons-ui via +to build commons-ui via - `npm install` (if not already done to get `tsc`) - `npm run build:pack` Then in the my-app project : -- Change the commons-ui dependency in my-app's package.json from -`@gridsuite/commons-ui:"^x.x.x"` -to -`@gridsuite/commons-ui:"file:{PATH_TO_LIBRARY}/gridsuite-commons-ui-{LIBRARY_VERSION}.tgz"` +- Change the commons-ui dependency in my-app's package.json from `@gridsuite/commons-ui:"^x.x.x"` + to `@gridsuite/commons-ui:"file:{PATH_TO_LIBRARY}/gridsuite-commons-ui-{LIBRARY_VERSION}.tgz"` - `npm install` - `npm start` *Warning* : with Create React App, we realised the library was not updating correctly if you try to install the library multiple times. To fix this, run this command from the app **after** running "npm install" - rm -Rf node_modules/.cache - -#### For integrators +### Imports +Anything not exported at the root level of commons-ui is considered as internal and +not safe to use. + + +## For integrators If you want to deploy a new version of commons-ui in the [NPM package registry](https://www.npmjs.com/package/@gridsuite/commons-ui), you need to follow the steps below: @@ -40,7 +42,8 @@ you need to follow the steps below: - Click on "Publish release" - It will trigger a job that will publish the release on NPM -#### License Headers and dependencies checking + +## License Headers and dependencies checking To check dependencies license compatibility with this project one locally, please run the following command : @@ -48,6 +51,7 @@ To check dependencies license compatibility with this project one locally, pleas npm run licenses-check ``` -Notes : +Notes : * Check [license-checker-config.json](license-checker-config.json) for license white list and exclusion. + If you need to update this list, please inform organization's owners. diff --git a/demo/src/app.jsx b/demo/src/app.jsx index 3a30de0a..2d0af433 100644 --- a/demo/src/app.jsx +++ b/demo/src/app.jsx @@ -92,7 +92,7 @@ import searchEquipments from '../data/EquipmentSearchBar'; import { EquipmentItem } from '../../src/components/ElementSearchDialog/equipment-item'; import OverflowableText from '../../src/components/OverflowableText'; -import { setShowAuthenticationRouterLogin } from '../../src/redux/authActions'; +import { setShowAuthenticationRouterLogin } from '../../src/utils/AuthActions'; import TableTab from './TableTab'; import FlatParametersTab from './FlatParametersTab'; diff --git a/package-lock.json b/package-lock.json index f50b2d3e..99323735 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-querybuilder": "^7.2.0", "react-virtualized": "^9.22.5", + "type-fest": "^4.21.0", "uuid": "^9.0.1" }, "devDependencies": { @@ -89,7 +90,6 @@ "react-resizable": "^3.0.5", "react-router-dom": "^6.22.3", "ts-node": "^10.9.2", - "type-fest": "^4.14.0", "typescript": "5.1.6", "utf-8-validate": "^6.0.3", "vite": "^5.2.7", @@ -122,6 +122,7 @@ "react-intl": "^6.6.4", "react-papaparse": "^4.1.0", "react-router-dom": "^6.22.3", + "reconnecting-websocket": "^4.4.0", "yup": "^1.4.0" } }, @@ -14120,6 +14121,12 @@ "once": "^1.3.0" } }, + "node_modules/reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==", + "peer": true + }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -15312,10 +15319,9 @@ } }, "node_modules/type-fest": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.15.0.tgz", - "integrity": "sha512-tB9lu0pQpX5KJq54g+oHOLumOx+pMep4RaM6liXh2PKmVRFF+/vAtUP0ZaJ0kOySfVNjF6doBWPHhBhISKdlIA==", - "dev": true, + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", + "integrity": "sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==", "engines": { "node": ">=16" }, diff --git a/package.json b/package.json index 1c47ea49..23f03f73 100644 --- a/package.json +++ b/package.json @@ -42,27 +42,29 @@ "react-dnd-html5-backend": "^16.0.1", "react-querybuilder": "^7.2.0", "react-virtualized": "^9.22.5", + "type-fest": "^4.21.0", "uuid": "^9.0.1" }, "peerDependencies": { - "@mui/system": "^5.15.15", - "@mui/x-tree-view": "^6.17.0", - "papaparse": "^5.4.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", "@hookform/resolvers": "^3.3.4", "@mui/icons-material": "^5.15.14", "@mui/lab": "5.0.0-alpha.169", "@mui/material": "^5.15.14", + "@mui/system": "^5.15.15", + "@mui/x-tree-view": "^6.17.0", "ag-grid-community": "^31.0.0", "ag-grid-react": "^31.2.0", "notistack": "^3.0.1", + "papaparse": "^5.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.2", "react-intl": "^6.6.4", "react-papaparse": "^4.1.0", "react-router-dom": "^6.22.3", + "reconnecting-websocket": "^4.4.0", "yup": "^1.4.0" }, "devDependencies": { @@ -129,7 +131,6 @@ "react-resizable": "^3.0.5", "react-router-dom": "^6.22.3", "ts-node": "^10.9.2", - "type-fest": "^4.14.0", "typescript": "5.1.6", "utf-8-validate": "^6.0.3", "vite": "^5.2.7", diff --git a/src/components/AuthenticationRouter/AuthenticationRouter.tsx b/src/components/AuthenticationRouter/AuthenticationRouter.tsx index 204345ef..9cdf1f16 100644 --- a/src/components/AuthenticationRouter/AuthenticationRouter.tsx +++ b/src/components/AuthenticationRouter/AuthenticationRouter.tsx @@ -11,13 +11,12 @@ import { Alert, AlertTitle, Grid } from '@mui/material'; import { FormattedMessage } from 'react-intl'; import { UserManager } from 'oidc-client'; import SignInCallbackHandler from '../SignInCallbackHandler'; +import { AuthenticationActions } from '../../utils/AuthActions'; import { handleSigninCallback, handleSilentRenewCallback, login, logout } from '../../utils/AuthService'; import SilentRenewCallbackHandler from '../SilentRenewCallbackHandler'; import Login from '../Login'; import Logout from '../Login/Logout'; -import { AuthenticationActions } from '../../redux/authActions'; - export type AuthenticationRouterErrorState = { userName?: string; userValidationError?: { error: Error }; diff --git a/src/components/DirectoryItemSelector/directory-item-selector.tsx b/src/components/DirectoryItemSelector/directory-item-selector.tsx index f47e09c4..af6d1407 100644 --- a/src/components/DirectoryItemSelector/directory-item-selector.tsx +++ b/src/components/DirectoryItemSelector/directory-item-selector.tsx @@ -12,7 +12,7 @@ import getFileIcon from '../../utils/ElementIcon'; import { ElementType } from '../../utils/ElementType'; import TreeViewFinder, { TreeViewFinderNodeProps, TreeViewFinderProps } from '../TreeViewFinder/TreeViewFinder'; import { useSnackMessage } from '../../hooks/useSnackMessage'; -import { fetchDirectoryContent, fetchElementsInfos, fetchRootFolders } from '../../services'; +import { directorySvc, exploreSvc } from '../../services/instances'; const styles = { icon: (theme: Theme) => ({ @@ -219,7 +219,8 @@ function DirectoryItemSelector({ ); const updateRootDirectories = useCallback(() => { - fetchRootFolders(types) + directorySvc + .fetchRootFolders(types) .then((newData) => { const [nrs, mdr] = updatedTree(rootsRef.current, nodeMap.current, null, newData); setRootDirectories(nrs); @@ -237,29 +238,32 @@ function DirectoryItemSelector({ const fetchDirectory = useCallback( (nodeId: UUID): void => { const typeList = types.includes(ElementType.DIRECTORY) ? [] : types; - fetchDirectoryContent(nodeId, typeList) + directorySvc + .fetchDirectoryContent(nodeId, typeList) .then((children) => { const childrenMatchedTypes = children.filter((item: any) => contentFilter().has(item.type)); if (childrenMatchedTypes.length > 0 && equipmentTypes && equipmentTypes.length > 0) { - fetchElementsInfos( - childrenMatchedTypes.map((e: any) => e.elementUuid), - types, - equipmentTypes - ).then((childrenWithMetadata) => { - const filtredChildren = itemFilter - ? childrenWithMetadata.filter((val: any) => { - // Accept every directory - if (val.type === ElementType.DIRECTORY) { - return true; - } - // otherwise filter with the custom itemFilter func - return itemFilter(val); - }) - : childrenWithMetadata; - // update directory content - addToDirectory(nodeId, filtredChildren); - }); + exploreSvc + .fetchElementsInfos( + childrenMatchedTypes.map((e: any) => e.elementUuid), + types, + equipmentTypes + ) + .then((childrenWithMetadata) => { + const filtredChildren = itemFilter + ? childrenWithMetadata.filter((val: any) => { + // Accept every directory + if (val.type === ElementType.DIRECTORY) { + return true; + } + // otherwise filter with the custom itemFilter func + return itemFilter(val); + }) + : childrenWithMetadata; + // update directory content + addToDirectory(nodeId, filtredChildren); + }); } else { // update directory content addToDirectory(nodeId, childrenMatchedTypes); diff --git a/src/components/TopBar/AboutDialog.tsx b/src/components/TopBar/AboutDialog.tsx index e787f575..d72725a7 100644 --- a/src/components/TopBar/AboutDialog.tsx +++ b/src/components/TopBar/AboutDialog.tsx @@ -34,6 +34,8 @@ import { LoadingButton } from '@mui/lab'; import { Apps, DnsOutlined, ExpandMore, Gavel, QuestionMark, Refresh, WidgetsOutlined } from '@mui/icons-material'; import { FormattedMessage } from 'react-intl'; import { LogoText } from './GridLogo'; +import { studySvc } from '../../services/instances'; +import { GridSuiteModule, ModuleType, moduleTypeSort } from './modules'; const styles = { general: { @@ -104,14 +106,6 @@ function getGlobalVersion( return Promise.resolve(null); } -const moduleTypeSort = { - app: 1, - server: 10, - other: 20, -}; - -type ModuleType = keyof typeof moduleTypeSort; - type ModuleDefinition = { name: string; type: ModuleType }; function compareModules(c1: ModuleDefinition, c2: ModuleDefinition) { @@ -122,13 +116,7 @@ function compareModules(c1: ModuleDefinition, c2: ModuleDefinition) { ); } -export type GridSuiteModule = { - name: string; - type: ModuleType; - version?: string; - gitTag?: string; - // license?: string; -}; +export type { GridSuiteModule } from './modules'; export interface AboutDialogProps { open: boolean; @@ -138,7 +126,7 @@ export interface AboutDialogProps { appVersion?: string; appGitTag?: string; appLicense?: string; - additionalModulesPromise?: () => Promise; + additionalModulesPromise?: string | (() => Promise); logo?: ReactNode; } @@ -273,7 +261,7 @@ function Module({ type, name, version, gitTag }: GridSuiteModule) { ); } -function AboutDialog({ +export default function AboutDialog({ open, onClose, globalVersionPromise, @@ -283,7 +271,7 @@ function AboutDialog({ appLicense, additionalModulesPromise, logo, -}: AboutDialogProps) { +}: Readonly) { const theme = useTheme(); const [isRefreshing, setIsRefreshing] = useState(false); const [loadingGlobalVersion, setLoadingGlobalVersion] = useState(false); @@ -319,7 +307,11 @@ function AboutDialog({ // license: appLicense, }; (additionalModulesPromise - ? Promise.resolve(setLoadingAdditionalModules(true)).then(() => additionalModulesPromise()) + ? Promise.resolve(setLoadingAdditionalModules(true)).then(() => + typeof additionalModulesPromise === 'string' + ? studySvc.getServersInfos(additionalModulesPromise) + : additionalModulesPromise() + ) : Promise.reject(new Error('no getter')) ) .then( @@ -482,5 +474,3 @@ function AboutDialog({ ); } - -export default AboutDialog; diff --git a/src/components/TopBar/TopBar.test.tsx b/src/components/TopBar/TopBar.test.tsx index 70fea77a..e063711b 100644 --- a/src/components/TopBar/TopBar.test.tsx +++ b/src/components/TopBar/TopBar.test.tsx @@ -7,16 +7,16 @@ import { render } from '@testing-library/react'; import { IntlProvider } from 'react-intl'; - import { red } from '@mui/material/colors'; import { createTheme, ThemeProvider } from '@mui/material'; import { expect, it } from '@jest/globals'; -import TopBar, { LANG_ENGLISH } from './TopBar'; -import { CommonMetadata, top_bar_en } from '../..'; - +import TopBar from './TopBar'; +import { LANG_ENGLISH } from '../../utils/language'; +import top_bar_en from '../translations/top-bar-en'; +import { AppMetadataCommon } from '../../services'; import PowsyblLogo from '../images/powsybl_logo.svg?react'; -const apps: CommonMetadata[] = [ +const apps: AppMetadataCommon[] = [ { name: 'App1', url: '/app1', diff --git a/src/components/TopBar/TopBar.tsx b/src/components/TopBar/TopBar.tsx index 13188aa2..7bf8c1eb 100644 --- a/src/components/TopBar/TopBar.tsx +++ b/src/components/TopBar/TopBar.tsx @@ -47,7 +47,9 @@ import { User } from 'oidc-client'; import GridLogo, { GridLogoProps } from './GridLogo'; import AboutDialog, { AboutDialogProps } from './AboutDialog'; import { LogoutProps } from '../Login/Logout'; -import { CommonMetadata } from '../../services'; +import { AppMetadataCommon } from '../../services'; +import { GsLang, LANG_ENGLISH, LANG_FRENCH, LANG_SYSTEM } from '../../utils/language'; +import { DARK_THEME, GsTheme, LIGHT_THEME } from '../../utils/theme'; const styles = { grow: { @@ -145,18 +147,9 @@ const CustomListItemIcon = styled(ListItemIcon)({ borderRadius: '25px', }); -export const DARK_THEME = 'Dark'; -export const LIGHT_THEME = 'Light'; -export const LANG_SYSTEM = 'sys'; -export const LANG_ENGLISH = 'en'; -export const LANG_FRENCH = 'fr'; const EN = 'EN'; const FR = 'FR'; -export type GsLangUser = typeof LANG_ENGLISH | typeof LANG_FRENCH; -export type GsLang = GsLangUser | typeof LANG_SYSTEM; -export type GsTheme = typeof LIGHT_THEME | typeof DARK_THEME; - export type TopBarProps = Omit & Omit & Omit & { @@ -165,7 +158,7 @@ export type TopBarProps = Omit & user?: User; onAboutClick?: () => void; logoAboutDialog?: ReactNode; - appsAndUrls: CommonMetadata[]; + appsAndUrls: AppMetadataCommon[]; onThemeClick?: (theme: GsTheme) => void; theme?: GsTheme; onEquipmentLabellingClick?: (toggle: boolean) => void; diff --git a/src/components/TopBar/modules.ts b/src/components/TopBar/modules.ts new file mode 100644 index 00000000..d01f000c --- /dev/null +++ b/src/components/TopBar/modules.ts @@ -0,0 +1,22 @@ +/* + * Copyright © 2024, 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/. + */ + +export const moduleTypeSort = { + app: 1, + server: 10, + other: 20, +}; + +export type ModuleType = keyof typeof moduleTypeSort; + +export type GridSuiteModule = { + name: string; + type: ModuleType; + version?: string; + gitTag?: string; + // license?: string; +}; diff --git a/src/components/dialogs/custom-mui-dialog.tsx b/src/components/dialogs/custom-mui-dialog.tsx index 7a5d4f59..5eff1e49 100644 --- a/src/components/dialogs/custom-mui-dialog.tsx +++ b/src/components/dialogs/custom-mui-dialog.tsx @@ -13,6 +13,7 @@ import * as yup from 'yup'; import SubmitButton from '../inputs/react-hook-form/utils/submit-button'; import CancelButton from '../inputs/react-hook-form/utils/cancel-button'; import CustomFormProvider, { MergedFormContextProps } from '../inputs/react-hook-form/provider/custom-form-provider'; +import { GsLangUser } from '../../utils/language'; interface ICustomMuiDialog { open: boolean; @@ -27,7 +28,7 @@ interface ICustomMuiDialog { onCancel?: () => void; children: React.ReactNode; isDataFetching?: boolean; - language?: string; + language?: GsLangUser; } const styles = { diff --git a/src/components/dialogs/description-modification-dialog.tsx b/src/components/dialogs/description-modification-dialog.tsx index 2db39941..993ac73a 100644 --- a/src/components/dialogs/description-modification-dialog.tsx +++ b/src/components/dialogs/description-modification-dialog.tsx @@ -9,18 +9,19 @@ import { useCallback } from 'react'; import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { Box } from '@mui/material'; +import { UUID } from 'crypto'; import yup from '../../utils/yup-config'; import FieldConstants from '../../utils/field-constants'; import { useSnackMessage } from '../../hooks/useSnackMessage'; import CustomMuiDialog from './custom-mui-dialog'; import ExpandingTextField from '../inputs/react-hook-form/ExpandingTextField'; +import { exploreSvc } from '../../services/instances'; export interface IDescriptionModificationDialog { - elementUuid: string; + elementUuid: UUID; description: string; open: boolean; onClose: () => void; - updateElement: (uuid: string, data: Record) => Promise; } const schema = yup.object().shape({ @@ -32,8 +33,7 @@ function DescriptionModificationDialog({ description, open, onClose, - updateElement, -}: IDescriptionModificationDialog) { +}: Readonly) { const { snackError } = useSnackMessage(); const emptyFormData = { @@ -56,16 +56,18 @@ function DescriptionModificationDialog({ const onSubmit = useCallback( (data: { description: string }) => { - updateElement(elementUuid, { - [FieldConstants.DESCRIPTION]: data[FieldConstants.DESCRIPTION].trim(), - }).catch((error: any) => { - snackError({ - messageTxt: error.message, - headerId: 'descriptionModificationError', + exploreSvc + .updateElement(elementUuid, { + [FieldConstants.DESCRIPTION]: data[FieldConstants.DESCRIPTION].trim(), + }) + .catch((error: any) => { + snackError({ + messageTxt: error.message, + headerId: 'descriptionModificationError', + }); }); - }); }, - [elementUuid, updateElement, snackError] + [elementUuid, snackError] ); return ( diff --git a/src/components/dialogs/modify-element-selection.tsx b/src/components/dialogs/modify-element-selection.tsx index 3a873683..684d386e 100644 --- a/src/components/dialogs/modify-element-selection.tsx +++ b/src/components/dialogs/modify-element-selection.tsx @@ -14,7 +14,7 @@ import { TreeViewFinderNodeProps } from '../TreeViewFinder'; import FieldConstants from '../../utils/field-constants'; import DirectoryItemSelector from '../DirectoryItemSelector/directory-item-selector'; import { ElementType } from '../../utils/ElementType'; -import { fetchDirectoryElementPath } from '../../services'; +import { directorySvc } from '../../services/instances'; export interface ModifyElementSelectionProps { elementType: ElementType; @@ -46,7 +46,7 @@ function ModifyElementSelection(props: ModifyElementSelectionProps) { useEffect(() => { if (directory) { - fetchDirectoryElementPath(directory).then((res: any) => { + directorySvc.fetchDirectoryElementPath(directory).then((res: any) => { setActiveDirectoryName(res.map((element: any) => element.elementName.trim()).join('/')); }); } diff --git a/src/components/filter/criteria-based/criteria-based-filter-edition-dialog.tsx b/src/components/filter/criteria-based/criteria-based-filter-edition-dialog.tsx index d3e2b1bf..7b10409e 100644 --- a/src/components/filter/criteria-based/criteria-based-filter-edition-dialog.tsx +++ b/src/components/filter/criteria-based/criteria-based-filter-edition-dialog.tsx @@ -16,10 +16,10 @@ import { useSnackMessage } from '../../../hooks/useSnackMessage'; import { criteriaBasedFilterSchema } from './criteria-based-filter-form'; import yup from '../../../utils/yup-config'; import { FilterType } from '../constants/filter-constants'; -import FetchStatus from '../../../utils/FetchStatus'; -import { saveFilter } from '../../../services/explore'; -import { ElementExistsType } from '../../../utils/ElementType'; +import FetchStatus, { FetchStatusType } from '../../../utils/FetchStatus'; +import { exploreSvc, filterSvc } from '../../../services/instances'; import FilterForm from '../filter-form'; +import { GsLangUser } from '../../../utils/language'; export type SelectionCopy = { sourceItemUuid: UUID | null; @@ -52,12 +52,10 @@ interface CriteriaBasedFilterEditionDialogProps { open: boolean; onClose: () => void; broadcastChannel: BroadcastChannel; - getFilterById: (id: string) => Promise; selectionForCopy: SelectionCopy; - setSelelectionForCopy: (selection: SelectionCopy) => Dispatch>; + setSelectionForCopy: (selection: SelectionCopy) => Dispatch>; activeDirectory?: UUID; - elementExists?: ElementExistsType; - language?: string; + language?: GsLangUser; } function CriteriaBasedFilterEditionDialog({ @@ -67,15 +65,13 @@ function CriteriaBasedFilterEditionDialog({ open, onClose, broadcastChannel, - getFilterById, selectionForCopy, - setSelelectionForCopy, + setSelectionForCopy, activeDirectory, - elementExists, language, -}: CriteriaBasedFilterEditionDialogProps) { +}: Readonly) { const { snackError } = useSnackMessage(); - const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); + const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); // default values are set via reset when we fetch data const formMethods = useForm({ @@ -94,8 +90,9 @@ function CriteriaBasedFilterEditionDialog({ useEffect(() => { if (id && open) { setDataFetchStatus(FetchStatus.FETCHING); - getFilterById(id) - .then((response: any) => { + filterSvc + .getFilterById(id) + .then((response) => { setDataFetchStatus(FetchStatus.FETCH_SUCCESS); reset({ [FieldConstants.NAME]: name, @@ -111,14 +108,15 @@ function CriteriaBasedFilterEditionDialog({ }); }); } - }, [id, name, open, reset, snackError, getFilterById]); + }, [id, name, open, reset, snackError]); const onSubmit = useCallback( (filterForm: any) => { - saveFilter(frontToBackTweak(id, filterForm), filterForm[FieldConstants.NAME]) + exploreSvc + .saveFilter(frontToBackTweak(id, filterForm), filterForm[FieldConstants.NAME]) .then(() => { if (selectionForCopy.sourceItemUuid === id) { - setSelelectionForCopy(noSelectionForCopy); + setSelectionForCopy(noSelectionForCopy); broadcastChannel.postMessage({ noSelectionForCopy, }); @@ -130,7 +128,7 @@ function CriteriaBasedFilterEditionDialog({ }); }); }, - [broadcastChannel, id, selectionForCopy.sourceItemUuid, snackError, setSelelectionForCopy] + [broadcastChannel, id, selectionForCopy.sourceItemUuid, snackError, setSelectionForCopy] ); const isDataReady = dataFetchStatus === FetchStatus.FETCH_SUCCESS; @@ -148,7 +146,7 @@ function CriteriaBasedFilterEditionDialog({ isDataFetching={dataFetchStatus === FetchStatus.FETCHING} language={language} > - {isDataReady && } + {isDataReady && } ); } diff --git a/src/components/filter/expert/expert-filter-edition-dialog.tsx b/src/components/filter/expert/expert-filter-edition-dialog.tsx index 967c291e..56d4ef09 100644 --- a/src/components/filter/expert/expert-filter-edition-dialog.tsx +++ b/src/components/filter/expert/expert-filter-edition-dialog.tsx @@ -19,8 +19,9 @@ import { EXPERT_FILTER_QUERY, expertFilterSchema } from './expert-filter-form'; import { saveExpertFilter } from '../utils/filter-api'; import { importExpertRules } from './expert-filter-utils'; import { FilterType } from '../constants/filter-constants'; -import FetchStatus from '../../../utils/FetchStatus'; -import { ElementExistsType } from '../../../utils/ElementType'; +import FetchStatus, { FetchStatusType } from '../../../utils/FetchStatus'; +import { GsLangUser } from '../../../utils/language'; +import { filterSvc } from '../../../services/instances'; const formSchema = yup .object() @@ -39,13 +40,10 @@ export interface ExpertFilterEditionDialogProps { open: boolean; onClose: () => void; broadcastChannel: BroadcastChannel; - selectionForCopy: any; - getFilterById: (id: string) => Promise<{ [prop: string]: any }>; setSelectionForCopy: (selection: any) => void; activeDirectory?: UUID; - elementExists?: ElementExistsType; - language?: string; + language?: GsLangUser; } function ExpertFilterEditionDialog({ @@ -56,14 +54,12 @@ function ExpertFilterEditionDialog({ onClose, broadcastChannel, selectionForCopy, - getFilterById, setSelectionForCopy, activeDirectory, - elementExists, language, -}: ExpertFilterEditionDialogProps) { +}: Readonly) { const { snackError } = useSnackMessage(); - const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); + const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); // default values are set via reset when we fetch data const formMethods = useForm({ @@ -82,8 +78,9 @@ function ExpertFilterEditionDialog({ useEffect(() => { if (id && open) { setDataFetchStatus(FetchStatus.FETCHING); - getFilterById(id) - .then((response: { [prop: string]: any }) => { + filterSvc + .getFilterById(id) + .then((response) => { setDataFetchStatus(FetchStatus.FETCH_SUCCESS); reset({ [FieldConstants.NAME]: name, @@ -100,7 +97,7 @@ function ExpertFilterEditionDialog({ }); }); } - }, [id, name, open, reset, snackError, getFilterById]); + }, [id, name, open, reset, snackError]); const onSubmit = useCallback( (filterForm: { [prop: string]: any }) => { @@ -144,7 +141,7 @@ function ExpertFilterEditionDialog({ isDataFetching={dataFetchStatus === FetchStatus.FETCHING} language={language} > - {isDataReady && } + {isDataReady && } ); } diff --git a/src/components/filter/explicit-naming/explicit-naming-filter-edition-dialog.tsx b/src/components/filter/explicit-naming/explicit-naming-filter-edition-dialog.tsx index 7933b862..bbe53515 100644 --- a/src/components/filter/explicit-naming/explicit-naming-filter-edition-dialog.tsx +++ b/src/components/filter/explicit-naming/explicit-naming-filter-edition-dialog.tsx @@ -17,12 +17,12 @@ import CustomMuiDialog from '../../dialogs/custom-mui-dialog'; import yup from '../../../utils/yup-config'; import { explicitNamingFilterSchema, FILTER_EQUIPMENTS_ATTRIBUTES } from './explicit-naming-filter-form'; import FieldConstants from '../../../utils/field-constants'; - import FilterForm from '../filter-form'; import { noSelectionForCopy } from '../../../utils/equipment-types'; import { FilterType } from '../constants/filter-constants'; -import FetchStatus from '../../../utils/FetchStatus'; -import { ElementExistsType } from '../../../utils/ElementType'; +import FetchStatus, { FetchStatusType } from '../../../utils/FetchStatus'; +import { GsLangUser } from '../../../utils/language'; +import { filterSvc } from '../../../services/instances'; const formSchema = yup .object() @@ -43,10 +43,8 @@ interface ExplicitNamingFilterEditionDialogProps { broadcastChannel: BroadcastChannel; selectionForCopy: any; setSelectionForCopy: (selection: any) => void; - getFilterById: (id: string) => Promise; activeDirectory?: UUID; - elementExists?: ElementExistsType; - language?: string; + language?: GsLangUser; } function ExplicitNamingFilterEditionDialog({ @@ -58,13 +56,11 @@ function ExplicitNamingFilterEditionDialog({ broadcastChannel, selectionForCopy, setSelectionForCopy, - getFilterById, activeDirectory, - elementExists, language, -}: ExplicitNamingFilterEditionDialogProps) { +}: Readonly) { const { snackError } = useSnackMessage(); - const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); + const [dataFetchStatus, setDataFetchStatus] = useState(FetchStatus.IDLE); // default values are set via reset when we fetch data const formMethods = useForm({ @@ -82,7 +78,8 @@ function ExplicitNamingFilterEditionDialog({ useEffect(() => { if (id && open) { setDataFetchStatus(FetchStatus.FETCHING); - getFilterById(id) + filterSvc + .getFilterById(id) .then((response) => { setDataFetchStatus(FetchStatus.FETCH_SUCCESS); reset({ @@ -103,7 +100,7 @@ function ExplicitNamingFilterEditionDialog({ }); }); } - }, [id, name, open, reset, snackError, getFilterById]); + }, [id, name, open, reset, snackError]); const onSubmit = useCallback( (filterForm: any) => { @@ -146,7 +143,7 @@ function ExplicitNamingFilterEditionDialog({ isDataFetching={dataFetchStatus === FetchStatus.FETCHING} language={language} > - {isDataReady && } + {isDataReady && } ); } diff --git a/src/components/filter/explicit-naming/explicit-naming-filter-form.tsx b/src/components/filter/explicit-naming/explicit-naming-filter-form.tsx index 40bb181e..5ab368b5 100644 --- a/src/components/filter/explicit-naming/explicit-naming-filter-form.tsx +++ b/src/components/filter/explicit-naming/explicit-naming-filter-form.tsx @@ -26,7 +26,7 @@ import { FILTER_EQUIPMENTS } from '../utils/filter-form-utils'; import { useSnackMessage } from '../../../hooks/useSnackMessage'; import { ElementType } from '../../../utils/ElementType'; import ModifyElementSelection from '../../dialogs/modify-element-selection'; -import { exportFilter } from '../../../services/study'; +import { studySvc } from '../../../services/instances'; import { EquipmentType } from '../../../utils/EquipmentType'; export const FILTER_EQUIPMENTS_ATTRIBUTES = 'filterEquipmentsAttributes'; @@ -190,7 +190,8 @@ function ExplicitNamingFilterForm({ sourceFilterForExplicitNamingConversion }: E }; const onStudySelected = (studyUuid: UUID) => { - exportFilter(studyUuid, sourceFilterForExplicitNamingConversion?.id) + studySvc + .exportFilter(studyUuid, sourceFilterForExplicitNamingConversion?.id) .then((matchingEquipments: any) => { setValue( FILTER_EQUIPMENTS_ATTRIBUTES, diff --git a/src/components/filter/filter-creation-dialog.tsx b/src/components/filter/filter-creation-dialog.tsx index b9fa4fc8..3c9aab80 100644 --- a/src/components/filter/filter-creation-dialog.tsx +++ b/src/components/filter/filter-creation-dialog.tsx @@ -26,7 +26,7 @@ import yup from '../../utils/yup-config'; import FilterForm from './filter-form'; import { EXPERT_FILTER_QUERY, expertFilterSchema, getExpertFilterEmptyFormData } from './expert/expert-filter-form'; import { FilterType } from './constants/filter-constants'; -import { ElementExistsType } from '../../utils/ElementType'; +import { GsLangUser } from '../../utils/language'; const emptyFormData = { [FieldConstants.NAME]: '', @@ -56,8 +56,7 @@ export interface FilterCreationDialogProps { open: boolean; onClose: () => void; activeDirectory?: UUID; - elementExists?: ElementExistsType; - language?: string; + language?: GsLangUser; sourceFilterForExplicitNamingConversion?: { id: UUID; equipmentType: string; @@ -68,7 +67,6 @@ function FilterCreationDialog({ open, onClose, activeDirectory, - elementExists, language, sourceFilterForExplicitNamingConversion = undefined, }: FilterCreationDialogProps) { @@ -146,7 +144,6 @@ function FilterCreationDialog({ diff --git a/src/components/filter/filter-form.tsx b/src/components/filter/filter-form.tsx index fcc3f23c..7a772842 100644 --- a/src/components/filter/filter-form.tsx +++ b/src/components/filter/filter-form.tsx @@ -16,14 +16,13 @@ import ExpertFilterForm from './expert/expert-filter-form'; import { FilterType } from './constants/filter-constants'; import ExpandableGroup from '../ExpandableGroup'; import RadioInput from '../inputs/react-hook-form/radio-input'; -import { ElementExistsType, ElementType } from '../../utils/ElementType'; +import { ElementType } from '../../utils/ElementType'; import ExpandingTextField from '../inputs/react-hook-form/ExpandingTextField'; import UniqueNameInput from '../inputs/react-hook-form/unique-name-input'; interface FilterFormProps { creation?: boolean; activeDirectory?: UUID; - elementExists?: ElementExistsType; sourceFilterForExplicitNamingConversion?: { id: UUID; equipmentType: string; @@ -31,7 +30,7 @@ interface FilterFormProps { } function FilterForm(props: FilterFormProps) { - const { sourceFilterForExplicitNamingConversion, creation, activeDirectory, elementExists } = props; + const { sourceFilterForExplicitNamingConversion, creation, activeDirectory } = props; const { setValue } = useFormContext(); const filterType = useWatch({ name: FieldConstants.FILTER_TYPE }); @@ -56,7 +55,6 @@ function FilterForm(props: FilterFormProps) { elementType={ElementType.FILTER} autoFocus={creation} activeDirectory={activeDirectory} - elementExists={elementExists} /> {creation && ( diff --git a/src/components/filter/utils/filter-api.ts b/src/components/filter/utils/filter-api.ts index 663dcda8..62df1f4b 100644 --- a/src/components/filter/utils/filter-api.ts +++ b/src/components/filter/utils/filter-api.ts @@ -11,7 +11,7 @@ import { frontToBackTweak } from '../criteria-based/criteria-based-filter-utils' import { Generator, Load } from '../../../utils/equipment-types'; import { exportExpertRules } from '../expert/expert-filter-utils'; import { DISTRIBUTION_KEY, FilterType } from '../constants/filter-constants'; -import { createFilter, saveFilter } from '../../../services/explore'; +import { exploreSvc } from '../../../services/instances'; export const saveExplicitNamingFilter = ( tableValues: any[], @@ -22,8 +22,7 @@ export const saveExplicitNamingFilter = ( id: string | null, setCreateFilterErr: (value: any) => void, handleClose: () => void, - activeDirectory?: UUID, - token?: string + activeDirectory?: UUID ) => { // we remove unnecessary fields from the table let cleanedTableValues; @@ -39,17 +38,17 @@ export const saveExplicitNamingFilter = ( })); } if (isFilterCreation) { - createFilter( - { - type: FilterType.EXPLICIT_NAMING.id, - equipmentType, - filterEquipmentsAttributes: cleanedTableValues, - }, - name, - description, - activeDirectory, - token - ) + exploreSvc + .createFilter( + { + type: FilterType.EXPLICIT_NAMING.id, + equipmentType, + filterEquipmentsAttributes: cleanedTableValues, + }, + name, + description, + activeDirectory + ) .then(() => { handleClose(); }) @@ -57,16 +56,16 @@ export const saveExplicitNamingFilter = ( setCreateFilterErr(error.message); }); } else { - saveFilter( - { - id, - type: FilterType.EXPLICIT_NAMING.id, - equipmentType, - filterEquipmentsAttributes: cleanedTableValues, - }, - name, - token - ) + exploreSvc + .saveFilter( + { + id, + type: FilterType.EXPLICIT_NAMING.id, + equipmentType, + filterEquipmentsAttributes: cleanedTableValues, + }, + name + ) .then(() => { handleClose(); }) @@ -80,11 +79,11 @@ export const saveCriteriaBasedFilter = ( filter: any, activeDirectory: any, onClose: () => void, - onError: (message: string) => void, - token?: string + onError: (message: string) => void ) => { const filterForBack = frontToBackTweak(undefined, filter); // no need ID for creation - createFilter(filterForBack, filter[FieldConstants.NAME], filter[FieldConstants.DESCRIPTION], activeDirectory, token) + exploreSvc + .createFilter(filterForBack, filter[FieldConstants.NAME], filter[FieldConstants.DESCRIPTION], activeDirectory) .then(() => { onClose(); }) @@ -102,21 +101,20 @@ export const saveExpertFilter = ( isFilterCreation: boolean, activeDirectory: any, onClose: () => void, - onError: (message: string) => void, - token?: string + onError: (message: string) => void ) => { if (isFilterCreation) { - createFilter( - { - type: FilterType.EXPERT.id, - equipmentType, - rules: exportExpertRules(query), - }, - name, - description, - activeDirectory, - token - ) + exploreSvc + .createFilter( + { + type: FilterType.EXPERT.id, + equipmentType, + rules: exportExpertRules(query), + }, + name, + description, + activeDirectory + ) .then(() => { onClose(); }) @@ -124,16 +122,16 @@ export const saveExpertFilter = ( onError(error.message); }); } else { - saveFilter( - { - id, - type: FilterType.EXPERT.id, - equipmentType, - rules: exportExpertRules(query), - }, - name, - token - ) + exploreSvc + .saveFilter( + { + id, + type: FilterType.EXPERT.id, + equipmentType, + rules: exportExpertRules(query), + }, + name + ) .then(() => { onClose(); }) diff --git a/src/components/inputs/react-hook-form/directory-items-input.tsx b/src/components/inputs/react-hook-form/directory-items-input.tsx index 372e1ba5..49350152 100644 --- a/src/components/inputs/react-hook-form/directory-items-input.tsx +++ b/src/components/inputs/react-hook-form/directory-items-input.tsx @@ -22,7 +22,7 @@ import { mergeSx } from '../../../utils/styles'; import OverflowableText from '../../OverflowableText'; import MidFormError from './error-management/mid-form-error'; import DirectoryItemSelector from '../../DirectoryItemSelector/directory-item-selector'; -import { fetchDirectoryElementPath } from '../../../services'; +import { directorySvc } from '../../../services/instances'; export const NAME = 'name'; @@ -151,7 +151,7 @@ function DirectoryItemsInput({ const chips = getValues(name) as any[]; const chip = chips.at(index)?.id; if (chip) { - fetchDirectoryElementPath(chip).then((response: any[]) => { + directorySvc.fetchDirectoryElementPath(chip).then((response: any[]) => { const path = response.filter((e) => e.elementUuid !== chip).map((e) => e.elementUuid); setExpanded(path); diff --git a/src/components/inputs/react-hook-form/provider/custom-form-provider.tsx b/src/components/inputs/react-hook-form/provider/custom-form-provider.tsx index f9f99737..20c98b25 100644 --- a/src/components/inputs/react-hook-form/provider/custom-form-provider.tsx +++ b/src/components/inputs/react-hook-form/provider/custom-form-provider.tsx @@ -8,12 +8,12 @@ import React, { createContext, PropsWithChildren } from 'react'; import { FormProvider, UseFormReturn } from 'react-hook-form'; import * as yup from 'yup'; -import { getSystemLanguage } from '../../../../hooks/localized-countries-hook'; +import { getSystemLanguage, GsLangUser } from '../../../../utils/language'; type CustomFormContextProps = { removeOptional?: boolean; validationSchema: yup.AnySchema; - language?: string; + language?: GsLangUser; }; export type MergedFormContextProps = UseFormReturn & CustomFormContextProps; diff --git a/src/components/inputs/react-hook-form/select-inputs/countries-input.tsx b/src/components/inputs/react-hook-form/select-inputs/countries-input.tsx index f8917391..8832e9db 100644 --- a/src/components/inputs/react-hook-form/select-inputs/countries-input.tsx +++ b/src/components/inputs/react-hook-form/select-inputs/countries-input.tsx @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import { Chip } from '@mui/material'; import AutocompleteInput from '../autocomplete-inputs/autocomplete-input'; -import { useLocalizedCountries } from '../../../../hooks/localized-countries-hook'; +import useLocalizedCountries from '../../../../hooks/localized-countries-hook'; import useCustomFormContext from '../provider/use-custom-form-context'; import { Option } from '../../../../utils/types'; diff --git a/src/components/inputs/react-hook-form/unique-name-input.tsx b/src/components/inputs/react-hook-form/unique-name-input.tsx index 0a086893..7d08a553 100644 --- a/src/components/inputs/react-hook-form/unique-name-input.tsx +++ b/src/components/inputs/react-hook-form/unique-name-input.tsx @@ -15,7 +15,8 @@ import TextField from '@mui/material/TextField'; import { UUID } from 'crypto'; import useDebounce from '../../../hooks/useDebounce'; import FieldConstants from '../../../utils/field-constants'; -import { ElementExistsType, ElementType } from '../../../utils/ElementType'; +import { ElementType } from '../../../utils/ElementType'; +import { directorySvc } from '../../../services/instances'; interface UniqueNameInputProps { name: string; @@ -28,7 +29,6 @@ interface UniqueNameInputProps { 'value' | 'onChange' | 'name' | 'label' | 'inputRef' | 'inputProps' | 'InputProps' >; activeDirectory?: UUID; - elementExists?: ElementExistsType; } /** @@ -42,7 +42,6 @@ function UniqueNameInput({ onManualChangeCallback, formProps, activeDirectory, - elementExists, }: UniqueNameInputProps) { const { field: { onChange, onBlur, value, ref }, @@ -71,7 +70,8 @@ function UniqueNameInput({ const handleCheckName = useCallback( (nameValue: string) => { if (nameValue) { - elementExists?.(directory, nameValue, elementType) + directorySvc + .elementExists(directory, nameValue, elementType) .then((alreadyExist) => { if (alreadyExist) { setError(name, { @@ -92,7 +92,7 @@ function UniqueNameInput({ }); } }, - [setError, clearErrors, name, elementType, elementExists, directory] + [setError, clearErrors, name, elementType, directory] ); const debouncedHandleCheckName = useDebounce(handleCheckName, 700); diff --git a/src/components/inputs/react-query-builder/country-value-editor.tsx b/src/components/inputs/react-query-builder/country-value-editor.tsx index 768d4331..37b3d93a 100644 --- a/src/components/inputs/react-query-builder/country-value-editor.tsx +++ b/src/components/inputs/react-query-builder/country-value-editor.tsx @@ -11,7 +11,7 @@ import { Autocomplete, TextField } from '@mui/material'; import { useMemo } from 'react'; import useConvertValue from './use-convert-value'; import useValid from './use-valid'; -import { useLocalizedCountries } from '../../../hooks/localized-countries-hook'; +import useLocalizedCountries from '../../../hooks/localized-countries-hook'; import useCustomFormContext from '../react-hook-form/provider/use-custom-form-context'; function CountryValueEditor(props: ValueEditorProps) { diff --git a/src/components/inputs/react-query-builder/element-value-editor.tsx b/src/components/inputs/react-query-builder/element-value-editor.tsx index e189d4f8..3d55ecda 100644 --- a/src/components/inputs/react-query-builder/element-value-editor.tsx +++ b/src/components/inputs/react-query-builder/element-value-editor.tsx @@ -8,7 +8,7 @@ import { validate as uuidValidate } from 'uuid'; import { useEffect } from 'react'; import useCustomFormContext from '../react-hook-form/provider/use-custom-form-context'; -import { fetchElementsInfos } from '../../../services'; +import { exploreSvc } from '../../../services/instances'; import DirectoryItemsInput from '../react-hook-form/directory-items-input'; interface ElementValueEditorProps { @@ -34,7 +34,7 @@ function ElementValueEditor(props: ElementValueEditorProps) { defaultValue[0].length > 0 && uuidValidate(defaultValue[0]) ) { - fetchElementsInfos(defaultValue).then((childrenWithMetadata) => { + exploreSvc.fetchElementsInfos(defaultValue).then((childrenWithMetadata) => { setValue( name, childrenWithMetadata.map((v: any) => { @@ -63,4 +63,5 @@ function ElementValueEditor(props: ElementValueEditorProps) { /> ); } + export default ElementValueEditor; diff --git a/src/hooks/localized-countries-hook.ts b/src/hooks/localized-countries-hook.ts index 226d6e0d..df6e7bf2 100644 --- a/src/hooks/localized-countries-hook.ts +++ b/src/hooks/localized-countries-hook.ts @@ -7,29 +7,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import localizedCountries, { LocalizedCountries } from 'localized-countries'; -// @ts-ignore -import countriesFr from 'localized-countries/data/fr'; -// @ts-ignore -import countriesEn from 'localized-countries/data/en'; -import { LANG_FRENCH, LANG_ENGLISH, LANG_SYSTEM } from '../components/TopBar/TopBar'; +import countriesFr from 'localized-countries/data/fr.json'; +import countriesEn from 'localized-countries/data/en.json'; +import { getComputedLanguage, GsLang, LANG_ENGLISH } from '../utils/language'; -const supportedLanguages = [LANG_FRENCH, LANG_ENGLISH]; - -export const getSystemLanguage = () => { - const systemLanguage = navigator.language.split(/[-_]/)[0]; - return supportedLanguages.includes(systemLanguage) ? systemLanguage : LANG_ENGLISH; -}; - -export const getComputedLanguage = (language: string | undefined) => { - return language === LANG_SYSTEM ? getSystemLanguage() : language ?? LANG_ENGLISH; -}; - -export const useLocalizedCountries = (language: string | undefined) => { +export default function useLocalizedCountries(language: GsLang | undefined) { const [localizedCountriesModule, setLocalizedCountriesModule] = useState(); // TODO FM this is disgusting, can we make it better ? useEffect(() => { - const lang = getComputedLanguage(language).substring(0, 2); + const lang = getComputedLanguage(language ?? LANG_ENGLISH).substring(0, 2); let localizedCountriesResult; // vite does not support ESM dynamic imports on node_modules, so we have to imports the languages before and do this // https://github.com/vitejs/vite/issues/14102 @@ -55,4 +42,4 @@ export const useLocalizedCountries = (language: string | undefined) => { ); return { translate, countryCodes }; -}; +} diff --git a/src/hooks/predefined-properties-hook.ts b/src/hooks/predefined-properties-hook.ts index c77a2dee..8583c601 100644 --- a/src/hooks/predefined-properties-hook.ts +++ b/src/hooks/predefined-properties-hook.ts @@ -8,15 +8,15 @@ import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import mapEquipmentTypeForPredefinedProperties from '../utils/equipment-types-for-predefined-properties-mapper'; import { useSnackMessage } from './useSnackMessage'; import { EquipmentType, PredefinedProperties } from '../utils/types'; -import { fetchStudyMetadata } from '../services'; +import { appsMetadataSvc } from '../services/instances'; const fetchPredefinedProperties = async (equipmentType: EquipmentType): Promise => { const networkEquipmentType = mapEquipmentTypeForPredefinedProperties(equipmentType); if (networkEquipmentType === undefined) { return Promise.resolve(undefined); } - const studyMetadata = await fetchStudyMetadata(); - return studyMetadata.predefinedEquipmentProperties?.[networkEquipmentType]; + const studyMetadata = await appsMetadataSvc.fetchStudyMetadata(); + return studyMetadata.predefinedEquipmentProperties?.[networkEquipmentType] ?? undefined; }; const usePredefinedProperties = ( diff --git a/src/hooks/useDebugLog.ts b/src/hooks/useDebugLog.ts new file mode 100644 index 00000000..1c0d511c --- /dev/null +++ b/src/hooks/useDebugLog.ts @@ -0,0 +1,15 @@ +/* + * Copyright © 2024, 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/. + */ + +export default function useDebugRender(label: string) { + // uncomment when you want the output in the console + if (`${import.meta.env.VITE_DEBUG_HOOK_RENDER}` === 'true') { + const logLabel = `${label} render`; + console.count?.(logLabel); + console.timeStamp?.(logLabel); + } +} diff --git a/src/index.ts b/src/index.ts index 457ea6f5..21f49cbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,7 +78,8 @@ export { unitToMicroUnit, microUnitToUnit, } from './utils/conversion-utils'; - +export { default as FetchStatus } from './utils/FetchStatus'; +export type { FetchStatusType } from './utils/FetchStatus'; export { ElementType } from './utils/ElementType'; export type { ElementAttributes, Option, Equipment } from './utils/types'; @@ -91,6 +92,7 @@ export { dispatchUser, getPreLoginPath, } from './utils/AuthService'; +export type { IdpSettings } from './utils/AuthService'; export { default as getFileIcon } from './utils/ElementIcon'; @@ -100,8 +102,10 @@ export { DEFAULT_ROW_HEIGHT, } from './components/MuiVirtualizedTable/MuiVirtualizedTable'; -export { DARK_THEME, LIGHT_THEME, LANG_SYSTEM, LANG_ENGLISH, LANG_FRENCH } from './components/TopBar/TopBar'; -export type { GsLang, GsLangUser, GsTheme } from './components/TopBar/TopBar'; +export { DARK_THEME, LIGHT_THEME } from './utils/theme'; +export type { GsTheme } from './utils/theme'; +export { LANG_SYSTEM, LANG_ENGLISH, LANG_FRENCH, getSystemLanguage, getComputedLanguage } from './utils/language'; +export type { GsLang, GsLangUser } from './utils/language'; export { USER, @@ -113,7 +117,7 @@ export { USER_VALIDATION_ERROR, RESET_AUTHENTICATION_ROUTER_ERROR, SHOW_AUTH_INFO_LOGIN, -} from './redux/authActions'; +} from './utils/AuthActions'; export type { AuthenticationActions, AuthenticationRouterErrorBase, @@ -124,7 +128,7 @@ export type { UnauthorizedUserAction, UserAction, UserValidationErrorAction, -} from './redux/authActions'; +} from './utils/AuthActions'; export { default as report_viewer_en } from './components/translations/report-viewer-en'; export { default as report_viewer_fr } from './components/translations/report-viewer-fr'; export { default as login_en } from './components/translations/login-en'; @@ -163,6 +167,7 @@ export { default as useIntlRef } from './hooks/useIntlRef'; export { useSnackMessage } from './hooks/useSnackMessage'; export { default as useDebounce } from './hooks/useDebounce'; export { default as usePrevious } from './hooks/usePrevious'; +export { default as useDebugLog } from './hooks/useDebugLog'; export { default as SelectClearable } from './components/inputs/select-clearable'; export { default as useCustomFormContext } from './components/inputs/react-hook-form/provider/use-custom-form-context'; export { default as CustomFormProvider } from './components/inputs/react-hook-form/provider/custom-form-provider'; @@ -216,7 +221,7 @@ export { export { default as InputWithPopupConfirmation } from './components/inputs/react-hook-form/select-inputs/input-with-popup-confirmation'; export { default as MuiSelectInput } from './components/inputs/react-hook-form/select-inputs/mui-select-input'; export { default as CountriesInput } from './components/inputs/react-hook-form/select-inputs/countries-input'; -export { getSystemLanguage, getComputedLanguage, useLocalizedCountries } from './hooks/localized-countries-hook'; +export { default as useLocalizedCountries } from './hooks/localized-countries-hook'; export { default as MultipleAutocompleteInput } from './components/inputs/react-hook-form/autocomplete-inputs/multiple-autocomplete-input'; export { default as CsvUploader } from './components/inputs/react-hook-form/ag-grid-table/csv-uploader/csv-uploader'; export { default as UniqueNameInput } from './components/inputs/react-hook-form/unique-name-input'; @@ -230,8 +235,9 @@ export { } from './components/filter/criteria-based/criteria-based-filter-utils'; export { mergeSx } from './utils/styles'; -export { setCommonStore } from './redux/commonStore'; -export type { CommonStoreState } from './redux/commonStore'; export type { EquipmentInfos } from './utils/EquipmentType'; +export { getErrorMessage } from './utils/error'; +export * from './utils/api'; export * from './services'; +export * from './local-storage'; diff --git a/src/local-storage/index.ts b/src/local-storage/index.ts new file mode 100644 index 00000000..60872260 --- /dev/null +++ b/src/local-storage/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright © 2024, 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/. + */ + +export * from './theme'; +export * from './lang'; diff --git a/src/local-storage/lang.ts b/src/local-storage/lang.ts new file mode 100644 index 00000000..e7d548a3 --- /dev/null +++ b/src/local-storage/lang.ts @@ -0,0 +1,24 @@ +/* + * Copyright © 2024, 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 { getComputedLanguage, GsLang, LANG_SYSTEM } from '../utils/language'; + +function getLocalStorageLanguageKey(appName: string) { + return `${appName.toUpperCase()}_LANGUAGE`; +} + +export function getLocalStorageLanguage(appName: string) { + return (localStorage.getItem(getLocalStorageLanguageKey(appName)) as GsLang) || LANG_SYSTEM; +} + +export function saveLocalStorageLanguage(appName: string, language: GsLang): void { + localStorage.setItem(getLocalStorageLanguageKey(appName), language); +} + +export function getLocalStorageComputedLanguage(appName: string) { + return getComputedLanguage(getLocalStorageLanguage(appName)); +} diff --git a/src/local-storage/theme.ts b/src/local-storage/theme.ts new file mode 100644 index 00000000..283551fb --- /dev/null +++ b/src/local-storage/theme.ts @@ -0,0 +1,20 @@ +/* + * Copyright © 2024, 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, GsTheme } from '../utils/theme'; + +function getLocalStorageThemeKey(appName: string) { + return `${appName.toUpperCase()}_THEME`; +} + +export function getLocalStorageTheme(appName: string) { + return (localStorage.getItem(getLocalStorageThemeKey(appName)) as GsTheme) || DARK_THEME; +} + +export function saveLocalStorageTheme(appName: string, theme: GsTheme) { + localStorage.setItem(getLocalStorageThemeKey(appName), theme); +} diff --git a/src/redux/commonStore.ts b/src/redux/commonStore.ts deleted file mode 100644 index d9a53c09..00000000 --- a/src/redux/commonStore.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright (c) 2024, 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 { User } from 'oidc-client'; - -export type CommonStoreState = { - user: User | null; -}; - -interface CommonStore { - getState(): CommonStoreState; -} - -let commonStore: CommonStore | undefined; - -/** - * Set a copy of the reference to the store to be able to access it from this library. - * It's useful to get access to the user token outside of the React context in API files. - * NB : temporary solution before refactoring the token management in the whole gridsuite stack. - */ -export function setCommonStore(store: CommonStore): void { - commonStore = store; -} - -export function getUserToken() { - return commonStore?.getState().user?.id_token; -} diff --git a/src/services/app-local.ts b/src/services/app-local.ts new file mode 100644 index 00000000..8756a8e9 --- /dev/null +++ b/src/services/app-local.ts @@ -0,0 +1,32 @@ +/* + * Copyright © 2024, 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 { Url } from '../utils/api'; +import { IdpSettings } from '../utils/AuthService'; + +// TODO: permit to extend, with a module (like react-intl, emotion, etc...) +export type Env = { + appsMetadataServerUrl?: Url; + mapBoxToken?: string; + // https://github.com/gridsuite/deployment/blob/main/docker-compose/env.json + // https://github.com/gridsuite/deployment/blob/main/k8s/live/azure-dev/env.json + // https://github.com/gridsuite/deployment/blob/main/k8s/live/azure-integ/env.json + // https://github.com/gridsuite/deployment/blob/main/k8s/live/local/env.json + // [key: string]: string; +}; + +export default class AppLocalComSvc { + // eslint-disable-next-line class-methods-use-this + public async fetchEnv(): Promise { + return (await fetch('env.json')).json(); + } + + // eslint-disable-next-line class-methods-use-this + public async fetchIdpSettings() { + return (await (await fetch('idpSettings.json')).json()) as IdpSettings; + } +} diff --git a/src/services/apps-metadata.ts b/src/services/apps-metadata.ts index a593342b..942fecd0 100644 --- a/src/services/apps-metadata.ts +++ b/src/services/apps-metadata.ts @@ -1,44 +1,48 @@ -/** - * Copyright (c) 2024, RTE (http://www.rte-france.com) +/* + * Copyright © 2024, 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 { PredefinedProperties } from '../utils/types'; -// https://github.com/gridsuite/deployment/blob/main/docker-compose/docker-compose.base.yml -// https://github.com/gridsuite/deployment/blob/main/k8s/resources/common/config/apps-metadata.json -export type Url = string | URL; +import { PredefinedProperties } from '../utils/types'; +import { Url } from '../utils/api'; +import AppLocalComSvc from './app-local'; -export type Env = { - appsMetadataServerUrl?: Url; - mapBoxToken?: string; - // https://github.com/gridsuite/deployment/blob/main/docker-compose/env.json - // https://github.com/gridsuite/deployment/blob/main/k8s/live/azure-dev/env.json - // https://github.com/gridsuite/deployment/blob/main/k8s/live/azure-integ/env.json - // https://github.com/gridsuite/deployment/blob/main/k8s/live/local/env.json - // [key: string]: string; +// https://github.com/gridsuite/deployment/blob/main/docker-compose/version.json +// https://github.com/gridsuite/deployment/blob/main/k8s/resources/common/config/version.json +export type VersionJson = { + deployVersion?: string; }; -export async function fetchEnv(): Promise { - return (await fetch('env.json')).json(); -} +// https://github.com/gridsuite/deployment/blob/main/docker-compose/apps-metadata.json +// https://github.com/gridsuite/deployment/blob/main/k8s/resources/common/config/apps-metadata.json +export type AppMetadata = AppMetadataCommon | AppMetadataStudy; -export type CommonMetadata = { +export type AppMetadataCommon = { name: string; url: Url; appColor: string; hiddenInAppsMenu: boolean; }; -export type StudyMetadata = CommonMetadata & { +export type AppMetadataStudy = AppMetadataCommon & { readonly name: 'Study'; resources?: { types: string[]; path: string; }[]; predefinedEquipmentProperties?: { - [networkElementType: string]: PredefinedProperties; + [networkElementType: string]: PredefinedProperties | null | undefined; + /* substation?: { + region?: string[]; + tso?: string[]; + totallyFree?: unknown[]; + Demo?: string[]; + }; + load?: { + codeOI?: string[]; + }; */ }; defaultParametersValues?: { fluxConvention?: string; @@ -48,23 +52,46 @@ export type StudyMetadata = CommonMetadata & { defaultCountry?: string; }; -export async function fetchAppsMetadata(): Promise { - console.info(`Fetching apps and urls...`); - const env = await fetchEnv(); - const res = await fetch(`${env.appsMetadataServerUrl}/apps-metadata.json`); - return res.json(); +function isStudyMetadata(metadata: AppMetadataCommon): metadata is AppMetadataStudy { + return metadata.name === 'Study'; } -const isStudyMetadata = (metadata: CommonMetadata): metadata is StudyMetadata => { - return metadata.name === 'Study'; -}; +export default class AppsMetadataComSvc { + private readonly appLocalSvc: AppLocalComSvc; + + public constructor(appLocalSvc?: AppLocalComSvc) { + this.appLocalSvc = appLocalSvc ?? new AppLocalComSvc(); + } + + public async fetchAppsMetadata(): Promise { + console.debug(`Fetching apps and urls...`); + const env = await this.appLocalSvc.fetchEnv(); + const res = await fetch(`${env.appsMetadataServerUrl}/apps-metadata.json`); + return res.json(); + } + + public async fetchStudyMetadata(): Promise { + console.debug(`Fetching study metadata...`); + const studyMetadata = (await this.fetchAppsMetadata()).filter(isStudyMetadata); + if (!studyMetadata) { + throw new Error('Study entry could not be found in metadata'); + } else { + return studyMetadata[0]; // There should be only one study metadata + } + } + + public async fetchDefaultParametersValues(): Promise { + console.debug('fetching default parameters values from apps-metadata file'); + return (await this.fetchStudyMetadata()).defaultParametersValues; + } + + public async fetchVersion(): Promise { + console.debug('Fetching global version...'); + const envData = await this.appLocalSvc.fetchEnv(); + return (await fetch(`${envData.appsMetadataServerUrl}/version.json`)).json(); + } -export async function fetchStudyMetadata(): Promise { - console.info(`Fetching study metadata...`); - const studyMetadata = (await fetchAppsMetadata()).filter(isStudyMetadata); - if (!studyMetadata) { - throw new Error('Study entry could not be found in metadata'); - } else { - return studyMetadata[0]; // There should be only one study metadata + public async fetchDeployedVersion() { + return (await this.fetchVersion())?.deployVersion; } } diff --git a/src/services/base-service.ts b/src/services/base-service.ts new file mode 100644 index 00000000..b6b9d541 --- /dev/null +++ b/src/services/base-service.ts @@ -0,0 +1,247 @@ +/* + * Copyright © 2024, 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/. + */ + +/* eslint-disable max-classes-per-file */ + +import { User } from 'oidc-client'; +import { HttpContentType, InitRequest, setRequestHeader, Token, Url, UrlString } from '../utils/api'; +import { expandInitRequest, InitRequestSend, safeFetch } from '../utils/api/api-rest'; + +export type UserGetter = () => User | undefined; + +/* The first problem we have here is that some front apps don't use Vite, and by consequence using VITE_* vars don't work... + * What we do here is to try to use these variables as default, while permitting devs to overwrite these constants. + * + * The second problem is to how to keep organized the fetcher by service while letting devs add to others in apps. + * Using named export was tried but isn't extendable (some sort of "import namespace"). + * + * The third problem is how to manage the user token ID that comes from the app's side for now. + * We can't use React context, and using a pseudo store copy isn't a satisfying solution. + */ + +/* Note: some utilities functions are moved in the class as it's a dependant of runtime data + * Note: the baseUrlPrefix isn't in the base because websocket services haven't a version in url + */ +abstract class BaseService { + private readonly getUser: UserGetter; + + protected constructor(userGetter: UserGetter) { + this.getUser = userGetter; + } + + protected getUserToken(user?: User) { + return (user ?? this.getUser())?.id_token; + } +} + +export abstract class WsService extends BaseService { + protected readonly queryPrefix: string; + + protected constructor( + userGetter: UserGetter, + service: string, + wsGatewayPath: UrlString = import.meta.env.VITE_WS_GATEWAY + ) { + super(userGetter); + this.queryPrefix = `${WsService.getWsBase(wsGatewayPath)}/${service}`; + } + + private static getWsBase(wsGatewayPath: string): string { + // We use the `baseURI` (from `` in index.html) to build the URL, which is corrected by httpd/nginx + return document.baseURI.replace(/^http(s?):\/\//, 'ws$1://').replace(/\/+$/, '') + wsGatewayPath; + } + + protected getUrlWithToken(baseUrl: string) { + const querySymbol = baseUrl.includes('?') ? '&' : '?'; + return `${baseUrl}${querySymbol}access_token=${this.getUserToken()}`; + } +} + +export abstract class ApiService extends BaseService { + private readonly basePrefix: string; + + protected constructor( + userGetter: UserGetter, + service: string, + restGatewayPath: UrlString = import.meta.env.VITE_API_GATEWAY + ) { + super(userGetter); + this.basePrefix = `${ApiService.getRestBase(restGatewayPath)}/${service}`; + } + + private static getRestBase(restGatewayPath: string): string { + // We use the `baseURI` (from `` in index.html) to build the URL, which is corrected by httpd/nginx + return document.baseURI.replace(/\/+$/, '') + restGatewayPath; + } + + /** + * Return the base API prefix to the server + * @param vApi the version of api to use + */ + protected getPrefix(vApi: number) { + return `${this.basePrefix}/v${vApi}`; + } + + private finalizeRequest(init?: InitRequest, token?: Token): RequestInit { + if (typeof init !== 'undefined' && typeof init !== 'string' && typeof init !== 'object') { + throw new TypeError(`First argument of prepareRequest is not an object: ${typeof init}`); + } + // initCopy.cache = 'default'; + return setRequestHeader(init, 'Authorization', `Bearer ${token ?? this.getUserToken()}`); + } + + /** + * Fetch response from the server + * @return the raw HTTP response + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected backendFetch(url: Url, init?: InitRequest, token?: Token) { + return safeFetch(url, this.finalizeRequest(init, token)); + } + + /** + * Fetch JSON from the server + * @template TReturn the type of data fetched + * @return {Promise} the object deserialized from the response + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected async backendFetchJson( + url: Url, + init?: InitRequest, + token?: Token + ): Promise { + const reqInit = setRequestHeader(init, 'accept', HttpContentType.APPLICATION_JSON); + return (await this.backendFetch(url, reqInit, token)).json(); + } + + /** + * Fetch text from the server + * @return the text from the response + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected async backendFetchText( + url: Url, + init?: InitRequest, + token?: Token + ) { + const reqInit = setRequestHeader(init, 'accept', HttpContentType.TEXT_PLAIN); + return (await this.backendFetch(url, reqInit, token)).text() as Promise; + } + + /** + * Fetch raw data from the server + * @return the raw HTTP response body + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected async backendFetchFile(url: Url, init?: InitRequest, token?: Token) { + return (await this.backendFetch(url, init, token)).blob(); + } + + /** + * Send data in the request and fetch the response + * @return the raw HTTP response + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param body the data to send + * @param contentType the content type of the {@link #body} + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected async backendSendFetch( + url: Url, + init: InitRequestSend, + body: BodyInit, + contentType?: HttpContentType, + token?: Token + ) { + let reqInit = expandInitRequest(init); + let cType = contentType ?? reqInit.headers.get('content-type') ?? undefined; + if (!(body instanceof FormData) && contentType === undefined) { + cType = HttpContentType.APPLICATION_JSON; + } + if (cType !== undefined) { + reqInit = setRequestHeader(init, 'content-type', cType); + } + reqInit.body = body; + return safeFetch(url, this.finalizeRequest(reqInit, token)); + } + + /** + * Send data in the request and fetch the JSON response + * @template TReturn the type of data fetched + * @return {Promise} the object deserialized from the response + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param body the data to send + * @param contentType the content type of the {@link #body} + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @type TReturn sdfgv + * @protected + */ + protected async backendSendFetchJson( + url: Url, + init: InitRequestSend, + body: BodyInit, + contentType?: HttpContentType, + token?: Token + ): Promise { + const reqInit = setRequestHeader(init, 'accept', HttpContentType.APPLICATION_JSON); + return (await this.backendSendFetch(url, reqInit, body, contentType, token)).json(); + } + + /** + * Send data in the request and fetch the text response + * @return the text from the response + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param body the data to send + * @param contentType the content type of the {@link #body} + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected async backendSendFetchText( + url: Url, + init: InitRequestSend, + body: BodyInit, + contentType?: HttpContentType, + token?: Token + ) { + const reqInit = setRequestHeader(init, 'accept', HttpContentType.TEXT_PLAIN); + return (await this.backendSendFetch(url, reqInit, body, contentType, token)).text() as Promise; + } + + /** + * Send data in the request (and return nothing) + * @param url the {@link URL} to fetch on + * @param init the HTTP method or request configuration + * @param body the data to send + * @param contentType the content type of the {@link #body} + * @param token the token to used instead of getting it from {@link getUserToken the getter} + * @protected + */ + protected async backendSend( + url: Url, + init: InitRequestSend, + body: BodyInit, + contentType?: HttpContentType, + token?: Token + ) { + await this.backendSendFetch(url, init, body, contentType, token); + } +} diff --git a/src/services/config-notification.ts b/src/services/config-notification.ts new file mode 100644 index 00000000..91847840 --- /dev/null +++ b/src/services/config-notification.ts @@ -0,0 +1,30 @@ +/* + * Copyright © 2024, 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 ReconnectingWebSocket, { Event } from 'reconnecting-websocket'; +import { UrlString } from '../utils/api'; +import { UserGetter, WsService } from './base-service'; + +export default class ConfigNotificationComSvc extends WsService { + public constructor(userGetter: UserGetter, wsGatewayPath?: UrlString) { + super(userGetter, 'config-notification', wsGatewayPath); + } + + public connectNotificationsWsUpdateConfig(appName: string): ReconnectingWebSocket { + const webSocketUrl = `${this.queryPrefix}/notify?appName=${appName}`; + const reconnectingWebSocket = new ReconnectingWebSocket(() => this.getUrlWithToken(webSocketUrl), undefined, { + debug: `${import.meta.env.VITE_DEBUG_REQUESTS}` === 'true', + }); + reconnectingWebSocket.onopen = (event: Event) => { + console.groupCollapsed(`Connected Websocket update config ui: ${appName}`); + console.debug(`Websocket URL: ${webSocketUrl}`); + console.dir(event); + console.groupEnd(); + }; + return reconnectingWebSocket; + } +} diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 00000000..1b065ca7 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024, 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 { LiteralUnion } from 'type-fest'; +import { UrlString } from '../utils/api'; +import { ApiService, UserGetter } from './base-service'; +import { GsLang } from '../utils/language'; +import { GsTheme } from '../utils/theme'; + +export const COMMON_APP_NAME = 'common'; + +export const PARAM_THEME = 'theme'; +export const PARAM_LANGUAGE = 'language'; + +const COMMON_CONFIG_PARAMS_NAMES = new Set([PARAM_THEME, PARAM_LANGUAGE]); + +// 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 ConfigParameters = ConfigParameter[]; + +type AppConfigParameter = LiteralUnion; + +// TODO: how to test it's a fixed value and not any string? +type AppConfigType = TAppName extends string + ? typeof COMMON_APP_NAME | TAppName + : LiteralUnion; + +/** + * Permit knowing if a parameter is common/shared between webapps or is specific to this application. + * @param appName the current application name/identifier + * @param paramName the parameter name/key + */ +function getAppName(appName: TAppName, paramName: AppConfigParameter) { + return (COMMON_CONFIG_PARAMS_NAMES.has(paramName) ? COMMON_APP_NAME : appName) as AppConfigType; +} + +export default class ConfigComSvc extends ApiService { + private readonly appName: TAppName; + + public constructor(appName: TAppName, userGetter: UserGetter, restGatewayPath?: UrlString) { + super(userGetter, 'config', restGatewayPath); + this.appName = appName; + } + + public async fetchConfigParameters(appName: AppConfigType) { + console.debug(`Fetching UI configuration params for app : ${appName}`); + const fetchParams = `${this.getPrefix(1)}/applications/${appName}/parameters`; + return this.backendFetchJson(fetchParams); + } + + public async fetchConfigParameter(paramName: AppConfigParameter) { + const appName = getAppName(this.appName, paramName); + console.debug(`Fetching UI config parameter '${paramName}' for app '${appName}'`); + const fetchParams = `${this.getPrefix(1)}/applications/${appName}/parameters/${paramName}`; + return this.backendFetchJson(fetchParams); + } + + public async updateConfigParameter(paramName: AppConfigParameter, value: Parameters[0]) { + const appName = getAppName(this.appName, paramName); + console.debug(`Updating config parameter '${paramName}=${value}' for app '${appName}'`); + const updateParams = `${this.getPrefix( + 1 + )}/applications/${appName}/parameters/${paramName}?value=${encodeURIComponent(value)}`; + // TODO will always return true because of safeFetch() control + return (await this.backendFetch(updateParams, 'PUT')).ok; + } +} diff --git a/src/services/directory.ts b/src/services/directory.ts index 2166663b..e420b5e7 100644 --- a/src/services/directory.ts +++ b/src/services/directory.ts @@ -1,49 +1,53 @@ -/** - * Copyright (c) 2024, RTE (http://www.rte-france.com) +/* + * Copyright © 2024, 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 { UUID } from 'crypto'; -import { backendFetchJson, getRequestParamFromList } from './utils'; +import { appendSearchParam, getRequestParam, UrlString } from '../utils/api'; import { ElementAttributes } from '../utils/types'; - -const PREFIX_DIRECTORY_SERVER_QUERIES = `${import.meta.env.VITE_API_GATEWAY}/directory`; - -export function fetchRootFolders(types: string[]): Promise { - console.info('Fetching Root Directories'); - - // Add params to Url - const urlSearchParams = getRequestParamFromList('elementTypes', types).toString(); - const fetchRootFoldersUrl = `${PREFIX_DIRECTORY_SERVER_QUERIES}/v1/root-directories?${urlSearchParams}`; - return backendFetchJson(fetchRootFoldersUrl, { - method: 'get', - headers: { 'Content-Type': 'application/json' }, - }); -} - -export function fetchDirectoryContent(directoryUuid: UUID, types?: string[]): Promise { - console.info("Fetching Folder content '%s'", directoryUuid); - - // Add params to Url - const urlSearchParams = getRequestParamFromList('elementTypes', types).toString(); - - const fetchDirectoryContentUrl = `${PREFIX_DIRECTORY_SERVER_QUERIES}/v1/directories/${directoryUuid}/elements${ - urlSearchParams ? `?${urlSearchParams}` : '' - }`; - return backendFetchJson(fetchDirectoryContentUrl, { - method: 'get', - headers: { 'Content-Type': 'application/json' }, - }); -} - -export function fetchDirectoryElementPath(elementUuid: UUID): Promise { - console.info(`Fetching element '${elementUuid}' and its parents info ...`); - const fetchPathUrl = `${PREFIX_DIRECTORY_SERVER_QUERIES}/v1/elements/${encodeURIComponent(elementUuid)}/path`; - console.debug(fetchPathUrl); - return backendFetchJson(fetchPathUrl, { - method: 'get', - headers: { 'Content-Type': 'application/json' }, - }); +import { ApiService, UserGetter } from './base-service'; + +export default class DirectoryComSvc extends ApiService { + public constructor(userGetter: UserGetter, restGatewayPath?: UrlString) { + super(userGetter, 'directory', restGatewayPath); + } + + public async fetchRootFolders(types: string[]) { + console.debug('Fetching Root Directories'); + const urlSearchParams = getRequestParam('elementTypes', types).toString(); + return this.backendFetchJson( + `${this.getPrefix(1)}/root-directories?${urlSearchParams}`, + 'GET' + ); + } + + public async fetchDirectoryContent(directoryUuid: UUID, types?: string[]) { + console.debug("Fetching Folder content '%s'", directoryUuid); + return this.backendFetchJson( + appendSearchParam( + `${this.getPrefix(1)}/directories/${directoryUuid}/elements`, + getRequestParam('elementTypes', types) + ), + 'GET' + ); + } + + public async fetchDirectoryElementPath(elementUuid: UUID) { + console.debug(`Fetching element '${elementUuid}' and its parents info ...`); + const fetchPathUrl = `${this.getPrefix(1)}/elements/${encodeURIComponent(elementUuid)}/path`; + return this.backendFetchJson(fetchPathUrl, 'GET'); + } + + public async elementExists(directoryUuid: UUID, elementName: string, type: string) { + const response = await this.backendFetch( + `${this.getPrefix(1)}/directories/${directoryUuid}/elements/${encodeURIComponent( + elementName + )}/types/${type}`, + 'HEAD' + ); + return response.status !== 204; // HTTP 204 : No-content + } } diff --git a/src/services/explore.ts b/src/services/explore.ts index 2cf7d882..da279f93 100644 --- a/src/services/explore.ts +++ b/src/services/explore.ts @@ -1,81 +1,63 @@ -/** - * Copyright (c) 2024, RTE (http://www.rte-france.com) +/* + * Copyright © 2024, 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 { UUID } from 'crypto'; -import { backendFetch, backendFetchJson, getRequestParamFromList } from './utils'; +import { appendSearchParam, getRequestParams, UrlString } from '../utils/api'; import { ElementAttributes } from '../utils/types'; +import { ApiService, UserGetter } from './base-service'; -const PREFIX_EXPLORE_SERVER_QUERIES = `${import.meta.env.VITE_API_GATEWAY}/explore`; - -export function createFilter( - newFilter: any, - name: string, - description: string, - parentDirectoryUuid?: UUID, - token?: string -) { - const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('name', name); - urlSearchParams.append('description', description); - if (parentDirectoryUuid) { - urlSearchParams.append('parentDirectoryUuid', parentDirectoryUuid); +export default class ExploreComSvc extends ApiService { + public constructor(userGetter: UserGetter, restGatewayPath?: UrlString) { + super(userGetter, 'explore', restGatewayPath); } - return backendFetch( - `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/filters?${urlSearchParams.toString()}`, - { - method: 'post', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newFilter), - }, - token - ); -} - -export function saveFilter(filter: any, name: string, token?: string) { - const urlSearchParams = new URLSearchParams(); - urlSearchParams.append('name', name); - return backendFetch( - `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/filters/${filter.id}?${urlSearchParams.toString()}`, - { - method: 'put', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(filter), - }, - token - ); -} - -export function fetchElementsInfos( - ids: UUID[], - elementTypes?: string[], - equipmentTypes?: string[] -): Promise { - console.info('Fetching elements metadata'); - // Add params to Url - const idsParams = getRequestParamFromList( - 'ids', - ids.filter((id) => id) // filter falsy elements - ); - - const equipmentTypesParams = getRequestParamFromList('equipmentTypes', equipmentTypes); + public async createFilter(newFilter: any, name: string, description: string, parentDirectoryUuid?: UUID) { + const urlSearchParams = new URLSearchParams(); + urlSearchParams.append('name', name); + urlSearchParams.append('description', description); + if (parentDirectoryUuid) { + urlSearchParams.append('parentDirectoryUuid', parentDirectoryUuid); + } + await this.backendSend( + `${this.getPrefix(1)}/explore/filters?${urlSearchParams}`, + 'POST', + JSON.stringify(newFilter) + ); + } - const elementTypesParams = getRequestParamFromList('elementTypes', elementTypes); + public async saveFilter(filter: Record, name: string) { + await this.backendSend( + `${this.getPrefix(1)}/explore/filters/${filter.id}?${new URLSearchParams({ + name, + })}`, + 'PUT', + JSON.stringify(filter) + ); + } - const urlSearchParams = new URLSearchParams([ - ...idsParams, - ...equipmentTypesParams, - ...elementTypesParams, - ]).toString(); + public async fetchElementsInfos(ids: UUID[], elementTypes?: string[], equipmentTypes?: string[]) { + console.debug('Fetching elements metadata'); + const urlSearchParams = getRequestParams({ + ids: ids.filter((id) => id), // filter falsy elements + equipmentTypes: equipmentTypes ?? [], + elementTypes: elementTypes ?? [], + }); + return this.backendFetchJson( + appendSearchParam(`${this.getPrefix(1)}/explore/elements/metadata?${urlSearchParams}`, urlSearchParams), + 'GET' + ); + } - const url = `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/elements/metadata?${urlSearchParams}`; - console.debug(url); - return backendFetchJson(url, { - method: 'get', - headers: { 'Content-Type': 'application/json' }, - }); + public async updateElement(elementUuid: UUID, element: unknown) { + console.debug(`Updating element info for ${elementUuid}`); + return this.backendSendFetchJson( + `${this.getPrefix(1)}/explore/elements/${elementUuid}`, + 'PUT', + JSON.stringify(element) + ); + } } diff --git a/src/services/filter.ts b/src/services/filter.ts new file mode 100644 index 00000000..ca822007 --- /dev/null +++ b/src/services/filter.ts @@ -0,0 +1,23 @@ +/* + * Copyright © 2024, 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 { ApiService, UserGetter } from './base-service'; +import { UrlString } from '../utils/api'; + +export default class FilterComSvc extends ApiService { + public constructor(userGetter: UserGetter, restGatewayPath?: UrlString) { + super(userGetter, 'filter', restGatewayPath); + } + + /** + * Get filter by id + * @returns {Promise} + */ + public async getFilterById(id: string) { + return this.backendFetchJson>(`${this.getPrefix(1)}/filters/${id}`); + } +} diff --git a/src/services/index.ts b/src/services/index.ts index b9307db9..5b2e51e2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,11 +1,33 @@ -/** - * Copyright (c) 2024, RTE (http://www.rte-france.com) +/* + * Copyright © 2024, 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/. */ -export * from './utils'; -export * from './explore'; -export * from './apps-metadata'; -export * from './directory'; -export * from './study'; + +export { ApiService, WsService } from './base-service'; +export type { UserGetter } from './base-service'; + +export { setCommonServices, initCommonServices } from './instances'; + +export { default as AppLocalComSvc } from './app-local'; +export type { Env } from './app-local'; + +export { default as AppsMetadataComSvc } from './apps-metadata'; +export type { AppMetadata, AppMetadataCommon, AppMetadataStudy, VersionJson } from './apps-metadata'; + +export { default as ConfigComSvc, COMMON_APP_NAME, PARAM_THEME, PARAM_LANGUAGE } from './config'; +export type { ConfigParameter, ConfigParameters } from './config'; + +export { default as ConfigNotificationComSvc } from './config-notification'; + +export { default as DirectoryComSvc } from './directory'; + +export { default as ExploreComSvc } from './explore'; + +export { default as FilterComSvc } from './filter'; + +export { default as StudyComSvc, IdentifiableType } from './study'; +export type { IdentifiableAttributes } from './study'; + +export { default as UserAdminComSvc } from './user-admin'; diff --git a/src/services/instances.ts b/src/services/instances.ts new file mode 100644 index 00000000..cd516588 --- /dev/null +++ b/src/services/instances.ts @@ -0,0 +1,80 @@ +/* + * Copyright © 2024, 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 { UserGetter } from './base-service'; +import AppLocalComSvc from './app-local'; +import AppsMetadataComSvc from './apps-metadata'; +import ConfigComSvc from './config'; +import ConfigNotificationComSvc from './config-notification'; +import DirectoryComSvc from './directory'; +import ExploreComSvc from './explore'; +import FilterComSvc from './filter'; +import StudyComSvc from './study'; +import UserAdminComSvc from './user-admin'; + +/* + * This "local" instances are means to be used only internally in commons-ui library, not by external apps. + * On the other hand, it's up to the app side to give services instances, it uses a component who needs the API. + */ + +// eslint-disable-next-line one-var, import/no-mutable-exports +export let appLocalSvc: AppLocalComSvc, + appsMetadataSvc: AppsMetadataComSvc, + configSvc: ConfigComSvc, + configNotificationSvc: ConfigNotificationComSvc, + directorySvc: DirectoryComSvc, + exploreSvc: ExploreComSvc, + filterSvc: FilterComSvc, + studySvc: StudyComSvc, + userAdminSvc: UserAdminComSvc; + +/** + * Set services instances use by commons-ui components. + * + * Intended to be used if front app has override some implementations. + */ +export function setCommonServices( + appLocalService: AppLocalComSvc, + appsMetadataService: AppsMetadataComSvc, + configService: ConfigComSvc, + configNotificationService: ConfigNotificationComSvc, + directoryService: DirectoryComSvc, + exploreService: ExploreComSvc, + filterService: FilterComSvc, + studyService: StudyComSvc, + userAdminService: UserAdminComSvc +) { + appLocalSvc = appLocalService; + appsMetadataSvc = appsMetadataService; + configSvc = configService; + configNotificationSvc = configNotificationService; + directorySvc = directoryService; + exploreSvc = exploreService; + filterSvc = filterService; + studySvc = studyService; + userAdminSvc = userAdminService; +} + +/** + * Set services instances use by commons-ui components using base implementation + * @param appName the application name use in configuration + * @param userGetter the getter for the user token + */ +export function initCommonServices(appName: TAppName, userGetter: UserGetter) { + const tmpAppLocal = new AppLocalComSvc(); + setCommonServices( + tmpAppLocal, + new AppsMetadataComSvc(tmpAppLocal), + new ConfigComSvc(appName, userGetter), + new ConfigNotificationComSvc(userGetter), + new DirectoryComSvc(userGetter), + new ExploreComSvc(userGetter), + new FilterComSvc(userGetter), + new StudyComSvc(userGetter), + new UserAdminComSvc(userGetter) + ); +} diff --git a/src/services/study.ts b/src/services/study.ts index 96c5191a..74a33d32 100644 --- a/src/services/study.ts +++ b/src/services/study.ts @@ -1,24 +1,62 @@ -/** - * Copyright (c) 2024, RTE (http://www.rte-france.com) +/* + * Copyright © 2024, 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 { UUID } from 'crypto'; -import { backendFetchJson } from './utils'; +import { GridSuiteModule } from '../components/TopBar/modules'; +import { ApiService, UserGetter } from './base-service'; +import { UrlString } from '../utils/api'; -const PREFIX_STUDY_QUERIES = `${import.meta.env.VITE_API_GATEWAY}/study`; +// https://github.com/powsybl/powsybl-core/blob/main/iidm/iidm-api/src/main/java/com/powsybl/iidm/network/IdentifiableType.java#L14 +export enum IdentifiableType { + NETWORK = 'NETWORK', + SUBSTATION = 'SUBSTATION', + VOLTAGE_LEVEL = 'VOLTAGE_LEVEL', + AREA = 'AREA', + HVDC_LINE = 'HVDC_LINE', + BUS = 'BUS', + SWITCH = 'SWITCH', + BUSBAR_SECTION = 'BUSBAR_SECTION', + LINE = 'LINE', + TIE_LINE = 'TIE_LINE', + TWO_WINDINGS_TRANSFORMER = 'TWO_WINDINGS_TRANSFORMER', + THREE_WINDINGS_TRANSFORMER = 'THREE_WINDINGS_TRANSFORMER', + GENERATOR = 'GENERATOR', + BATTERY = 'BATTERY', + LOAD = 'LOAD', + SHUNT_COMPENSATOR = 'SHUNT_COMPENSATOR', + DANGLING_LINE = 'DANGLING_LINE', + STATIC_VAR_COMPENSATOR = 'STATIC_VAR_COMPENSATOR', + HVDC_CONVERTER_STATION = 'HVDC_CONVERTER_STATION', + OVERLOAD_MANAGEMENT_SYSTEM = 'OVERLOAD_MANAGEMENT_SYSTEM', + GROUND = 'GROUND', +} + +// https://github.com/gridsuite/filter/blob/main/src/main/java/org/gridsuite/filter/identifierlistfilter/IdentifiableAttributes.java#L20 +export type IdentifiableAttributes = { + id: string; + type: IdentifiableType; + distributionKey: number; // double +}; + +export default class StudyComSvc extends ApiService { + public constructor(userGetter: UserGetter, restGatewayPath?: UrlString) { + super(userGetter, 'explore', restGatewayPath); + } + + public async exportFilter(studyUuid: UUID, filterUuid?: UUID) { + console.debug('get filter export on study root node'); + return this.backendFetchJson( + `${this.getPrefix(1)}/studies/${studyUuid}/filters/${filterUuid}/elements`, + 'GET' + ); + } -// eslint-disable-next-line import/prefer-default-export -export function exportFilter(studyUuid: UUID, filterUuid?: UUID, token?: string) { - console.info('get filter export on study root node'); - return backendFetchJson( - `${PREFIX_STUDY_QUERIES}/v1/studies/${studyUuid}/filters/${filterUuid}/elements`, - { - method: 'get', - headers: { 'Content-Type': 'application/json' }, - }, - token - ); + public async getServersInfos(viewName: string) { + console.debug('get backend servers informations'); + return this.backendFetchJson(`${this.getPrefix(1)}/servers/about?view=${viewName}`); + } } diff --git a/src/services/user-admin.ts b/src/services/user-admin.ts new file mode 100644 index 00000000..b055cf5b --- /dev/null +++ b/src/services/user-admin.ts @@ -0,0 +1,38 @@ +/* + * Copyright © 2024, 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 { User } from 'oidc-client'; +import { ApiService, UserGetter } from './base-service'; +import { extractUserSub, UrlString } from '../utils/api'; + +export default class UserAdminComSvc extends ApiService { + public constructor(userGetter: UserGetter, restGatewayPath?: UrlString) { + super(userGetter, 'user-admin', restGatewayPath); + } + + /** + * Note: is called from commons-ui AuthServices to validate user infos before setting state.user! + */ + public async fetchValidateUser(user: User) { + try { + const userSub = extractUserSub(user); + console.debug(`Fetching access for user "${userSub}"...`); + const response = await this.backendFetch( + `${this.getPrefix(1)}/users/${userSub}`, + 'HEAD', + this.getUserToken(user) + ); + // if the response is ok, the responseCode will be either 200 or 204 otherwise it's an HTTP error and it will be caught + return response.status === 200; + } catch (error: any) { + if (error.status === 403) { + return false; + } + throw error; + } + } +} diff --git a/src/services/utils.ts b/src/services/utils.ts deleted file mode 100644 index 63400c8f..00000000 --- a/src/services/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) 2024, 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 { getUserToken } from '../redux/commonStore'; - -const parseError = (text: string) => { - try { - return JSON.parse(text); - } catch (err) { - return null; - } -}; - -const prepareRequest = (init: any, token?: string) => { - if (!(typeof init === 'undefined' || typeof init === 'object')) { - throw new TypeError(`First argument of prepareRequest is not an object : ${typeof init}`); - } - const initCopy = { ...init }; - initCopy.headers = new Headers(initCopy.headers || {}); - const tokenCopy = token ?? getUserToken(); - initCopy.headers.append('Authorization', `Bearer ${tokenCopy}`); - return initCopy; -}; - -const handleError = (response: any) => { - return response.text().then((text: string) => { - const errorName = 'HttpResponseError : '; - const errorJson = parseError(text); - let customError: Error & { status?: string }; - if (errorJson && errorJson.status && errorJson.error && errorJson.message) { - customError = new Error( - `${errorName + errorJson.status} ${errorJson.error}, message : ${errorJson.message}` - ); - customError.status = errorJson.status; - } else { - customError = new Error(`${errorName + response.status} ${response.statusText}, message : ${text}`); - customError.status = response.status; - } - throw customError; - }); -}; - -const safeFetch = (url: string, initCopy: any) => { - return fetch(url, initCopy).then((response) => (response.ok ? response : handleError(response))); -}; - -export const backendFetch = (url: string, init: any, token?: string) => { - const initCopy = prepareRequest(init, token); - return safeFetch(url, initCopy); -}; - -export const backendFetchJson = (url: string, init: any, token?: string) => { - const initCopy = prepareRequest(init, token); - return safeFetch(url, initCopy).then((safeResponse) => (safeResponse.status === 204 ? null : safeResponse.json())); -}; - -export const getRequestParamFromList = (paramName: string, params: string[] = []) => { - return new URLSearchParams(params.map((param) => [paramName, param])); -}; diff --git a/src/redux/authActions.ts b/src/utils/AuthActions.ts similarity index 97% rename from src/redux/authActions.ts rename to src/utils/AuthActions.ts index 298cf5bd..b52c6e27 100644 --- a/src/redux/authActions.ts +++ b/src/utils/AuthActions.ts @@ -14,11 +14,11 @@ type ReadonlyAction = Readonly>; export const USER = 'USER'; export type UserAction = ReadonlyAction & { - user: User | null; + user: User | undefined; }; export function setLoggedUser(user: User | null): UserAction { - return { type: USER, user }; + return { type: USER, user: user ?? undefined }; } export const SIGNIN_CALLBACK_ERROR = 'SIGNIN_CALLBACK_ERROR'; diff --git a/src/utils/AuthService.ts b/src/utils/AuthService.ts index b3455ecb..8cb20f5b 100644 --- a/src/utils/AuthService.ts +++ b/src/utils/AuthService.ts @@ -18,7 +18,7 @@ import { setSignInCallbackError, setUnauthorizedUserInfo, setUserValidationError, -} from '../redux/authActions'; +} from './AuthActions'; type UserValidationFunc = (user: User) => Promise; type IdpSettingsGetter = () => Promise; diff --git a/src/utils/ElementType.ts b/src/utils/ElementType.ts index 70226b1d..73622383 100644 --- a/src/utils/ElementType.ts +++ b/src/utils/ElementType.ts @@ -5,8 +5,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { UUID } from 'crypto'; - +// eslint-disable-next-line import/prefer-default-export -- TODO export enum ElementType { DIRECTORY = 'DIRECTORY', STUDY = 'STUDY', @@ -20,5 +19,3 @@ export enum ElementType { SENSITIVITY_PARAMETERS = 'SENSITIVITY_PARAMETERS', SHORT_CIRCUIT_PARAMETERS = 'SHORT_CIRCUIT_PARAMETERS', } - -export type ElementExistsType = (directory: UUID, value: string, elementType: ElementType) => Promise; diff --git a/src/utils/EquipmentType.ts b/src/utils/EquipmentType.ts index c66bea58..4b7121f0 100644 --- a/src/utils/EquipmentType.ts +++ b/src/utils/EquipmentType.ts @@ -6,7 +6,7 @@ */ import { Theme } from '@mui/material'; -import { LIGHT_THEME } from '../components/TopBar/TopBar'; +import { LIGHT_THEME } from './theme'; export const TYPE_TAG_MAX_SIZE = '90px'; export const VL_TAG_MAX_SIZE = '100px'; diff --git a/src/utils/FetchStatus.ts b/src/utils/FetchStatus.ts index 0d7a9ce7..d1c11032 100644 --- a/src/utils/FetchStatus.ts +++ b/src/utils/FetchStatus.ts @@ -5,11 +5,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* We can't use an enum here because we have this error in apps using tsc with isolatedModules set to true + * TS2748: Cannot access ambient const enums when isolatedModules is enabled. + */ const FetchStatus = { IDLE: 'IDLE', FETCHING: 'FETCHING', FETCH_SUCCESS: 'FETCH_SUCCESS', FETCH_ERROR: 'FETCH_ERROR', -}; - +} as const; export default FetchStatus; + +// 'enum like' "TS type" creation +export type FetchStatusType = keyof typeof FetchStatus; diff --git a/src/utils/UserManagerMock.ts b/src/utils/UserManagerMock.ts index f6f4875a..5d2891ef 100644 --- a/src/utils/UserManagerMock.ts +++ b/src/utils/UserManagerMock.ts @@ -126,7 +126,7 @@ export default class UserManagerMock implements UserManager { } async signinSilent(): Promise { - console.info('signinSilent..............'); + console.debug('signinSilent..............'); const localStorageUser = JSON.parse(localStorage.getItem('powsybl-gridsuite-mock-user') ?? 'null'); if (localStorageUser === null) { throw new Error('End-User authentication required'); diff --git a/src/utils/api/api-rest.ts b/src/utils/api/api-rest.ts new file mode 100644 index 00000000..fc4e7c5b --- /dev/null +++ b/src/utils/api/api-rest.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2024, 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 { IncomingHttpHeaders } from 'node:http'; +import { LiteralUnion } from 'type-fest'; +import { FileType } from './utils'; +import { KeyOfWithoutIndexSignature } from '../types'; + +export type UrlString = `${string}://${string}` | `/${string}` | `./${string}`; +export type Url = (Check extends true ? UrlString : string) | URL; +export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'; +type StandardHeader = keyof KeyOfWithoutIndexSignature; +export type HttpHeaderName = LiteralUnion; +type HeadersInitExt = [HttpHeaderName, string][] | Partial> | Headers; + +export enum HttpContentType { + APPLICATION_OCTET_STREAM = 'application/octet-stream', + APPLICATION_JSON = 'application/json', + TEXT_PLAIN = 'text/plain', +} + +type RequestInitExt = RequestInit & { + method?: HttpMethod; + headers?: HeadersInitExt; +}; +export type InitRequest = HttpMethod | Partial; +export type InitRequestSend = HttpMethod | Partial>; +export type Token = string; + +export interface ErrorWithStatus extends Error { + status?: number; +} + +function parseError(text: string) { + try { + return JSON.parse(text); + } catch (err) { + return null; + } +} + +async function handleError(response: Response): Promise { + const errorName = 'HttpResponseError : '; + let error: ErrorWithStatus; + const errorJson = parseError(await response.text()); + if (errorJson?.status && errorJson?.error && errorJson?.message) { + error = new Error( + `${errorName}${errorJson.status} ${errorJson.error}, message: ${errorJson.message}` + ) as ErrorWithStatus; + error.status = errorJson.status; + } else { + error = new Error(`${errorName}${response.status} ${response.statusText}`) as ErrorWithStatus; + error.status = response.status; + } + throw error; +} + +export async function safeFetch(url: Url, reqInit: RequestInit) { + const response = await fetch(url, reqInit); + return response.ok ? response : handleError(response); +} + +type ExpandedInitRequest = Partial & { + headers: Headers; +}; +export function expandInitRequest(initReq: InitRequest | undefined) { + const result: Partial = + typeof initReq === 'string' + ? { + method: initReq, + } + : initReq ?? {}; + if (!(result.headers instanceof Headers)) { + // && result.headers?.constructor?.name !== 'Headers' + result.headers = new Headers(result.headers); + } + return result as ExpandedInitRequest; +} + +export function setRequestHeader(initReq: InitRequest | undefined, name: HttpHeaderName, value: string) { + const result = expandInitRequest(initReq); + result.headers.set(name, value); + return result; +} + +export function downloadFile(blob: Blob, filename: string, type?: FileType) { + let contentType; + if (type === FileType.ZIP) { + contentType = HttpContentType.APPLICATION_OCTET_STREAM; + } + const href = window.URL.createObjectURL(new Blob([blob], { type: contentType })); + const link = document.createElement('a'); + link.href = href; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts new file mode 100644 index 00000000..45871515 --- /dev/null +++ b/src/utils/api/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2024, 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/. + */ + +export { HttpContentType, setRequestHeader, downloadFile } from './api-rest'; +export type { UrlString, Url, HttpMethod, HttpHeaderName, InitRequest, Token, ErrorWithStatus } from './api-rest'; +export * from './utils'; diff --git a/src/utils/api/utils.ts b/src/utils/api/utils.ts new file mode 100644 index 00000000..ff637a1f --- /dev/null +++ b/src/utils/api/utils.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2024, 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 { User } from 'oidc-client'; + +export enum FileType { + ZIP = 'ZIP', +} + +export function getRequestParam(paramName: string, params: string[] = []) { + return new URLSearchParams(params.map((param) => [paramName, param])); +} + +export function getRequestParams(parameters: Record) { + const searchParams = new URLSearchParams(); + Object.entries(parameters) + .flatMap(([paramName, params]) => params.map((param) => [paramName, param])) + .forEach(([paramName, param]) => searchParams.append(paramName, param)); + return searchParams; +} + +export function appendSearchParam(url: string, searchParams: URLSearchParams | string | null) { + return searchParams ? `${url}${url.includes('?') ? '&' : '?'}${searchParams.toString()}` : url; +} + +export function extractUserSub(user: User | undefined) { + const sub = user?.profile?.sub; + if (!sub) { + throw new Error(`Fetching access for missing user.profile.sub : ${JSON.stringify(user)}`); + } else { + return sub; + } +} diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 00000000..1f2c7417 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2024, 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/. + */ + +/* eslint-disable import/prefer-default-export -- utility class */ + +/** + * Function to convert in the best-effort way an error to string + * @param error the "error" to stringify + */ +export function getErrorMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message; + } + if (error instanceof Object && 'message' in error) { + if ( + typeof error.message === 'string' || + typeof error.message === 'number' || + typeof error.message === 'boolean' + ) { + return `${error.message}`; + } + return JSON.stringify(error.message ?? undefined) ?? null; + } + if (typeof error === 'string') { + return error; + } + return JSON.stringify(error ?? undefined) ?? null; +} diff --git a/src/utils/language.ts b/src/utils/language.ts new file mode 100644 index 00000000..2a128ecd --- /dev/null +++ b/src/utils/language.ts @@ -0,0 +1,24 @@ +/* + * Copyright © 2024, 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/. + */ + +export const LANG_SYSTEM = 'sys'; +export const LANG_ENGLISH = 'en'; +export const LANG_FRENCH = 'fr'; + +const supportedLanguages = [LANG_FRENCH, LANG_ENGLISH]; + +export type GsLangUser = typeof LANG_ENGLISH | typeof LANG_FRENCH; +export type GsLang = GsLangUser | typeof LANG_SYSTEM; + +export function getSystemLanguage() { + const systemLanguage = navigator.language.split(/[-_]/)[0]; + return supportedLanguages.includes(systemLanguage) ? (systemLanguage as GsLangUser) : LANG_ENGLISH; +} + +export function getComputedLanguage(language: GsLang) { + return language === LANG_SYSTEM ? getSystemLanguage() : language; +} diff --git a/src/utils/theme.ts b/src/utils/theme.ts new file mode 100644 index 00000000..dc29a9b9 --- /dev/null +++ b/src/utils/theme.ts @@ -0,0 +1,11 @@ +/* + * Copyright © 2024, 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/. + */ + +export const DARK_THEME = 'Dark'; +export const LIGHT_THEME = 'Light'; + +export type GsTheme = typeof LIGHT_THEME | typeof DARK_THEME; diff --git a/src/utils/types.ts b/src/utils/types.ts index 081e37b2..c5c40b33 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -24,6 +24,12 @@ import { VSC, } from './equipment-types'; +// trick found here https://dev.to/tmlr/til-get-strongly-typed-http-headers-with-typescript-3e33 +export type KeyOfWithoutIndexSignature = { + // copy every declared property from T but remove index signatures + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; + export type Input = string | number; export type ElementAttributes = { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index db95603f..c42380c2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,3 +7,19 @@ /// /// + +/* Don't know why but seem that TypeScript merge definitions of these two interfaces with existing ones. + * https://vitejs.dev/guide/env-and-mode#intellisense-for-typescript + */ +import { UrlString } from './utils/api'; + +interface ImportMetaEnv { + readonly VITE_API_GATEWAY: UrlString; + readonly VITE_WS_GATEWAY: UrlString; + readonly VITE_DEBUG_REQUESTS?: boolean; + readonly VITE_DEBUG_HOOK_RENDER?: boolean; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.mts b/vite.config.mts index 323224e8..6cd9de20 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -60,6 +60,19 @@ export default defineConfig((config) => ({ }, }, minify: false, // easier to debug on the apps using this lib + define: + config.command === 'build' + ? { + /* We want to keep some variables in the final build to be resolved by the application using this library. + * https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/define.ts + * If the plugin "vite:define" change how it works, we probably will need to write plugins to obfuscate before then restore after. + */ + 'import.meta.env.VITE_API_GATEWAY': 'import.meta.env.VITE_API_GATEWAY', + 'import.meta.env.VITE_WS_GATEWAY': 'import.meta.env.VITE_WS_GATEWAY', + 'import.meta.env.VITE_DEBUG_REQUESTS': 'import.meta.env.VITE_DEBUG_REQUESTS', + 'import.meta.env.VITE_DEBUG_HOOK_RENDER': 'import.meta.env.VITE_DEBUG_HOOK_RENDER', + } + : undefined, }, }));