diff --git a/src/js/components/BentoAppRouter.tsx b/src/js/components/BentoAppRouter.tsx index 67ab9e5d..300b1fba 100644 --- a/src/js/components/BentoAppRouter.tsx +++ b/src/js/components/BentoAppRouter.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, useNavigate, useParams, Outlet } from 'react-router-dom'; import { useAutoAuthenticate, useIsAuthenticated } from 'bento-auth-js'; -import { useAppDispatch } from '@/hooks'; +import { useAppDispatch, useAppSelector } from '@/hooks'; import { makeGetConfigRequest, makeGetServiceInfoRequest } from '@/features/config/config.store'; import { makeGetAboutRequest } from '@/features/content/content.store'; @@ -11,6 +11,7 @@ import { makeGetProvenanceRequest } from '@/features/provenance/provenance.store import { getBeaconConfig } from '@/features/beacon/beaconConfig.store'; import { fetchGohanData, fetchKatsuData } from '@/features/ingestion/lastIngestion.store'; import { makeGetDataTypes } from '@/features/dataTypes/dataTypes.store'; +import { getProjects, selectScope } from '@/features/metadata/metadata.store'; import PublicOverview from './Overview/PublicOverview'; import Search from './Search/Search'; @@ -18,12 +19,48 @@ import ProvenanceTab from './Provenance/ProvenanceTab'; import BeaconQueryUi from './Beacon/BeaconQueryUi'; import { BentoRoute } from '@/types/routes'; import Loader from '@/components/Loader'; +import { validProjectDataset } from '@/utils/router'; +import DefaultLayout from '@/components/Util/DefaultLayout'; + +const ScopedRoute = () => { + const { projectId, datasetId } = useParams(); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { projects } = useAppSelector((state) => state.metadata); + + useEffect(() => { + // Update selectedScope based on URL parameters + const valid = validProjectDataset(projects, projectId, datasetId); + if (datasetId === valid.dataset && projectId === valid.project) { + dispatch(selectScope(valid)); + } else { + const oldpath = location.pathname.split('/').filter(Boolean); + const newPath = [oldpath[0]]; + + if (valid.dataset) { + newPath.push('p', valid.project as string, 'd', valid.dataset); + } else if (valid.project) { + newPath.push('p', valid.project); + } + + const oldPathLength = oldpath.length; + if (oldpath[oldPathLength - 3] === 'p' || oldpath[oldPathLength - 3] === 'd') { + newPath.push(oldpath[oldPathLength - 1]); + } + const newPathString = '/' + newPath.join('/'); + navigate(newPathString, { replace: true }); + } + }, [projects, projectId, datasetId, dispatch]); + + return ; +}; const BentoAppRouter = () => { const dispatch = useAppDispatch(); const { isAutoAuthenticating } = useAutoAuthenticate(); const isAuthenticated = useIsAuthenticated(); + const { selectedScope, isFetching: isFetchingProjects } = useAppSelector((state) => state.metadata); useEffect(() => { dispatch(makeGetConfigRequest()).then(() => dispatch(getBeaconConfig())); @@ -33,25 +70,49 @@ const BentoAppRouter = () => { dispatch(makeGetProvenanceRequest()); dispatch(makeGetKatsuPublic()); dispatch(fetchKatsuData()); + }, [selectedScope]); + + useEffect(() => { + dispatch(getProjects()); + dispatch(makeGetAboutRequest()); dispatch(fetchGohanData()); dispatch(makeGetServiceInfoRequest()); - if (isAuthenticated) { dispatch(makeGetDataTypes()); } }, [isAuthenticated]); - if (isAutoAuthenticating) { + if (isAutoAuthenticating || isFetchingProjects) { return ; } return ( - } /> - } /> - } /> - } /> - } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + + + }> + } /> + } /> + } /> + } /> + } /> + + ); }; diff --git a/src/js/components/ChooseProjectModal.tsx b/src/js/components/ChooseProjectModal.tsx new file mode 100644 index 00000000..0f77f076 --- /dev/null +++ b/src/js/components/ChooseProjectModal.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { Tabs, List, Avatar, Modal, Button, Space, Typography } from 'antd'; +import { useAppSelector, useTranslationCustom, useTranslationDefault } from '@/hooks'; +import { Link, useLocation } from 'react-router-dom'; +import { FaDatabase } from 'react-icons/fa'; +import { getCurrentPage } from '@/utils/router'; + +const ChooseProjectModal = ({ isModalOpen, setIsModalOpen }: ChooseProjectModalProps) => { + const td = useTranslationDefault(); + const t = useTranslationCustom(); + const location = useLocation(); + const { projects, selectedScope } = useAppSelector((state) => state.metadata); + const [selectedProject, setSelectedProject] = useState( + selectedScope.project ?? projects[0]?.identifier ?? undefined + ); + + const baseURL = '/' + location.pathname.split('/')[1]; + const page = getCurrentPage(); + + const closeModal = () => setIsModalOpen(false); + + return ( + + setSelectedProject(key)} + tabBarExtraContent={ + + + + } + items={projects.map(({ identifier, title, datasets, description }) => { + return { + key: identifier, + label: t(title), + children: ( + + + + {td('About')} {t(title)} + + + {td('Select')} + + + {t(description)} + + {td('Datasets')} + + ( + + + } />} + title={ + {t(item.title)} + } + description={t(item.description)} + /> + + + )} + /> + + ), + }; + })} + /> + + ); +}; + +interface ChooseProjectModalProps { + isModalOpen: boolean; + setIsModalOpen: (isOpen: boolean) => void; +} + +export default ChooseProjectModal; diff --git a/src/js/components/Overview/Chart.tsx b/src/js/components/Overview/Chart.tsx index 18e54561..f8a729c9 100644 --- a/src/js/components/Overview/Chart.tsx +++ b/src/js/components/Overview/Chart.tsx @@ -13,17 +13,20 @@ import { CHART_TYPE_PIE, ChartConfig, } from '@/types/chartConfig'; +import { useAppSelector } from '@/hooks'; +import { scopeToUrl } from '@/utils/router'; const Chart = memo(({ chartConfig, data, units, id, isClickable }: ChartProps) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const { selectedScope } = useAppSelector((state) => state.metadata); const translateMap = ({ x, y }: { x: string; y: number }) => ({ x: t(x), y }); const removeMissing = ({ x }: { x: string }) => x !== 'missing'; const barChartOnClickHandler = (d: { payload: { x: string } }) => { - navigate(`/${i18n.language}/search?${id}=${d.payload.x}`); + navigate(`/${i18n.language}${scopeToUrl(selectedScope)}/search?${id}=${d.payload.x}`); }; const pieChartOnClickHandler = (d: { name: string }) => { - navigate(`/${i18n.language}/search?${id}=${d.name}`); + navigate(`/${i18n.language}${scopeToUrl(selectedScope)}/search?${id}=${d.name}`); }; const { chart_type: type } = chartConfig; @@ -58,7 +61,7 @@ const Chart = memo(({ chartConfig, data, units, id, isClickable }: ChartProps) = height={PIE_CHART_HEIGHT} preFilter={removeMissing} dataMap={translateMap} - {...(isClickable ? { onClick: pieChartOnClickHandler } : {})} + onClick={pieChartOnClickHandler} /> ); case CHART_TYPE_CHOROPLETH: { diff --git a/src/js/components/SiteHeader.tsx b/src/js/components/SiteHeader.tsx index 1534869a..3105c4f8 100644 --- a/src/js/components/SiteHeader.tsx +++ b/src/js/components/SiteHeader.tsx @@ -1,14 +1,22 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; + import { Button, Flex, Layout, Typography, Space } from 'antd'; -const { Header } = Layout; import { useTranslation } from 'react-i18next'; -import { DEFAULT_TRANSLATION, LNG_CHANGE, LNGS_FULL_NAMES } from '@/constants/configConstants'; -import { useAppSelector } from '@/hooks'; -import { useNavigate, useLocation } from 'react-router-dom'; import { useIsAuthenticated, usePerformAuth, usePerformSignOut } from 'bento-auth-js'; -import { CLIENT_NAME, PORTAL_URL, TRANSLATED } from '@/config'; + import { RiTranslate } from 'react-icons/ri'; -import { ExportOutlined, LinkOutlined, LoginOutlined, LogoutOutlined } from '@ant-design/icons'; +import { ExportOutlined, LinkOutlined, LoginOutlined, LogoutOutlined, ProfileOutlined } from '@ant-design/icons'; + +import { useAppSelector } from '@/hooks'; +import { scopeToUrl } from '@/utils/router'; + +import { DEFAULT_TRANSLATION, LNG_CHANGE, LNGS_FULL_NAMES } from '@/constants/configConstants'; +import { CLIENT_NAME, PORTAL_URL, TRANSLATED } from '@/config'; + +import ChooseProjectModal from '@/components/ChooseProjectModal'; + +const { Header } = Layout; const openPortalWindow = () => window.open(PORTAL_URL, '_blank'); @@ -19,6 +27,22 @@ const SiteHeader = () => { const { isFetching: openIdConfigFetching } = useAppSelector((state) => state.openIdConfiguration); const { isHandingOffCodeForToken } = useAppSelector((state) => state.auth); + const { projects, selectedScope } = useAppSelector((state) => state.metadata); + + const scopeProps = { + projectTitle: projects.find((project) => project.identifier === selectedScope.project)?.title, + datasetTitle: selectedScope.dataset + ? projects + .find((project) => project.identifier === selectedScope.project) + ?.datasets.find((dataset) => dataset.identifier === selectedScope.dataset)?.title + : null, + }; + + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + setIsModalOpen(false); + }, [location]); const isAuthenticated = useIsAuthenticated(); const performSignOut = usePerformSignOut(); @@ -40,16 +64,27 @@ const SiteHeader = () => { src="/public/assets/branding.png" alt="logo" style={{ height: '32px', verticalAlign: 'middle', transform: 'translateY(-3px)' }} - onClick={() => navigate('/')} + onClick={() => navigate(`/${i18n.language}${scopeToUrl(selectedScope)}`)} /> - {CLIENT_NAME} + setIsModalOpen(true)} + > + + + {selectedScope.project && scopeProps.projectTitle} + {scopeProps.datasetTitle ? ` / ${scopeProps.datasetTitle}` : ''} + + diff --git a/src/js/components/SiteSider.tsx b/src/js/components/SiteSider.tsx index 76f91679..8de43c5b 100644 --- a/src/js/components/SiteSider.tsx +++ b/src/js/components/SiteSider.tsx @@ -31,10 +31,17 @@ const SiteSider: React.FC<{ const handleMenuClick: OnClick = useCallback( ({ key }: { key: string }) => { - const currentPath = location.pathname.split('/'); - const currentLang = currentPath[1]; - const newPath = `/${currentLang}/${key === BentoRoute.Overview ? '' : key}`; - navigate(key === BentoRoute.Search ? buildQueryParamsUrl(newPath, queryParams) : newPath); + const currentPath = location.pathname.split('/').filter(Boolean); + const newPath = [currentPath[0]]; + if (currentPath[1] == 'p') { + newPath.push('p', currentPath[2]); + } + if (currentPath[3] == 'd') { + newPath.push('d', currentPath[4]); + } + newPath.push(key); + const newPathString = '/' + newPath.join('/'); + navigate(key === BentoRoute.Search ? buildQueryParamsUrl(newPathString, queryParams) : newPathString); }, [navigate, queryParams, location.pathname] ); diff --git a/src/js/components/Util/AuthOutlet.tsx b/src/js/components/Util/AuthOutlet.tsx new file mode 100644 index 00000000..f464bf33 --- /dev/null +++ b/src/js/components/Util/AuthOutlet.tsx @@ -0,0 +1,74 @@ +import React, { useRef, useState } from 'react'; +import { useQueryWithAuthIfAllowed, useTranslationDefault } from '@/hooks'; +import { + checkIsInAuthPopup, + nop, + useHandleCallback, + useOpenSignInWindowCallback, + usePopupOpenerAuthCallback, + useSessionWorkerTokenRefresh, + useSignInPopupTokenHandoff, +} from 'bento-auth-js'; +import { PUBLIC_URL_NO_TRAILING_SLASH } from '@/config'; +import { Button, message, Modal } from 'antd'; +import { Outlet } from 'react-router-dom'; + +const SIGN_IN_WINDOW_FEATURES = 'scrollbars=no, toolbar=no, menubar=no, width=800, height=600'; +const CALLBACK_PATH = '/callback'; + +const createSessionWorker = () => new Worker(new URL('../../workers/tokenRefresh.ts', import.meta.url)); + +const AuthOutlet = () => { + const t = useTranslationDefault(); + + // AUTHENTICATION + const [signedOutModal, setSignedOutModal] = useState(false); + + const sessionWorker = useRef(null); + const signInWindow = useRef(null); + const windowMessageHandler = useRef<((event: MessageEvent) => void) | null>(null); + + const openSignInWindow = useOpenSignInWindowCallback(signInWindow, SIGN_IN_WINDOW_FEATURES); + + const popupOpenerAuthCallback = usePopupOpenerAuthCallback(); + const isInAuthPopup = checkIsInAuthPopup(PUBLIC_URL_NO_TRAILING_SLASH); + + useHandleCallback( + CALLBACK_PATH, + () => { + console.debug('authenticated'); + }, + isInAuthPopup ? popupOpenerAuthCallback : undefined, + (msg) => message.error(msg) + ); + + // Set up message handling from sign-in popup + useSignInPopupTokenHandoff(windowMessageHandler); + + useSessionWorkerTokenRefresh(sessionWorker, createSessionWorker, nop); + + useQueryWithAuthIfAllowed(); + + return ( + <> + { + setSignedOutModal(false); + }} + open={signedOutModal} + footer={[ + , + ]} + > + {t('Please sign in to the research portal.')} +
+
+ + + ); +}; + +export default AuthOutlet; diff --git a/src/js/components/Util/DefaultLayout.tsx b/src/js/components/Util/DefaultLayout.tsx new file mode 100644 index 00000000..37d0fe8a --- /dev/null +++ b/src/js/components/Util/DefaultLayout.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; +import { Outlet } from 'react-router-dom'; +import { Layout } from 'antd'; +import SiteHeader from '@/components/SiteHeader'; +import SiteSider from '@/components/SiteSider'; +import SiteFooter from '@/components/SiteFooter'; + +const { Content } = Layout; + +const DefaultLayout = () => { + const [collapsed, setCollapsed] = useState(false); + + return ( + + + + + + + + + + + + + ); +}; + +export default DefaultLayout; diff --git a/src/js/components/Util/LanguageHandler.tsx b/src/js/components/Util/LanguageHandler.tsx new file mode 100644 index 00000000..b478040e --- /dev/null +++ b/src/js/components/Util/LanguageHandler.tsx @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; +import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_TRANSLATION, SUPPORTED_LNGS } from '@/constants/configConstants'; + +const LNGS_ARRAY = Object.values(SUPPORTED_LNGS); + +const LanguageHandler = () => { + const { lang } = useParams<{ lang?: string }>(); + const navigate = useNavigate(); + const { i18n } = useTranslation(DEFAULT_TRANSLATION); + + useEffect(() => { + const setLanguage = (newLang: string) => { + i18n.changeLanguage(newLang); + if (lang !== newLang) { + navigate(`/${newLang}/`, { replace: true }); + } + }; + + if (lang && LNGS_ARRAY.includes(lang)) { + setLanguage(lang); + } else { + const browserLang = navigator.language.split('-')[0]; + const defaultLang = LNGS_ARRAY.includes(browserLang) ? browserLang : SUPPORTED_LNGS.ENGLISH; + setLanguage(defaultLang); + } + }, [lang, i18n, navigate]); + + return ; +}; + +export default LanguageHandler; diff --git a/src/js/constants/configConstants.ts b/src/js/constants/configConstants.ts index 2d3cb501..c304ddd1 100644 --- a/src/js/constants/configConstants.ts +++ b/src/js/constants/configConstants.ts @@ -7,6 +7,7 @@ export const katsuPublicRulesUrl = `${PORTAL_URL}/api/metadata/api/public_rules` export const searchFieldsUrl = `${PORTAL_URL}/api/metadata/api/public_search_fields`; export const katsuUrl = `${PORTAL_URL}/api/metadata/api/public`; export const provenanceUrl = `${PORTAL_URL}/api/metadata/api/public_dataset`; +export const projectsUrl = `${PORTAL_URL}/api/metadata/api/projects`; export const katsuLastIngestionsUrl = '/katsu/data-types'; export const gohanLastIngestionsUrl = '/gohan/data-types'; diff --git a/src/js/features/config/config.store.ts b/src/js/features/config/config.store.ts index 6fdbd6eb..6b6a5311 100644 --- a/src/js/features/config/config.store.ts +++ b/src/js/features/config/config.store.ts @@ -7,14 +7,14 @@ import { RootState } from '@/store'; import { PUBLIC_URL } from '@/config'; import { DiscoveryRules } from '@/types/configResponse'; -export const makeGetConfigRequest = createAsyncThunk( +export const makeGetConfigRequest = createAsyncThunk( 'config/getConfigData', - (_, { rejectWithValue }) => - // TODO: should be project/dataset scoped with url params - axios - .get(katsuPublicRulesUrl) + (_, { rejectWithValue, getState }) => { + return axios + .get(katsuPublicRulesUrl, { params: getState().metadata.selectedScope }) .then((res) => res.data) - .catch(printAPIError(rejectWithValue)) + .catch(printAPIError(rejectWithValue)); + } ); export const makeGetServiceInfoRequest = createAsyncThunk< diff --git a/src/js/features/data/makeGetDataRequest.thunk.ts b/src/js/features/data/makeGetDataRequest.thunk.ts index 9e5022a0..327b2747 100644 --- a/src/js/features/data/makeGetDataRequest.thunk.ts +++ b/src/js/features/data/makeGetDataRequest.thunk.ts @@ -10,14 +10,15 @@ import { ChartConfig } from '@/types/chartConfig'; import { ChartDataField, LocalStorageData, Sections } from '@/types/data'; import { Counts, OverviewResponse } from '@/types/overviewResponse'; import { printAPIError } from '@/utils/error.util'; +import { RootState } from '@/store'; export const makeGetDataRequestThunk = createAsyncThunk< { sectionData: Sections; counts: Counts; defaultData: Sections }, void, - { rejectValue: string } ->('data/makeGetDataRequest', async (_, { rejectWithValue }) => { + { rejectValue: string; state: RootState } +>('data/makeGetDataRequest', async (_, { rejectWithValue, getState }) => { const overviewResponse = (await axios - .get(katsuPublicOverviewUrl) + .get(katsuPublicOverviewUrl, { params: getState().metadata.selectedScope }) .then((res) => res.data) .catch(printAPIError(rejectWithValue))) as OverviewResponse['overview']; diff --git a/src/js/features/metadata/metadata.store.ts b/src/js/features/metadata/metadata.store.ts new file mode 100644 index 00000000..1261dcf0 --- /dev/null +++ b/src/js/features/metadata/metadata.store.ts @@ -0,0 +1,61 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { PaginatedResponse, Project } from '@/types/metadata'; +import { RootState } from '@/store'; +import { printAPIError } from '@/utils/error.util'; +import { validProjectDataset } from '@/utils/router'; +import { projectsUrl } from '@/constants/configConstants'; + +export interface MetadataState { + projects: Project[]; + isFetching: boolean; + selectedScope: { + project: string | undefined; + dataset: string | undefined; + }; +} + +const initialState: MetadataState = { + projects: [], + isFetching: true, + selectedScope: { + project: undefined, + dataset: undefined, + }, +}; + +export const getProjects = createAsyncThunk< + PaginatedResponse, + void, + { state: RootState; rejectValue: string } +>('metadata/getProjects', (_, { rejectWithValue }) => { + return axios + .get(projectsUrl) + .then((res) => res.data) + .catch(printAPIError(rejectWithValue)); +}); + +const metadata = createSlice({ + name: 'metadata', + initialState, + reducers: { + selectScope: (state, { payload }: PayloadAction<{ project?: string; dataset?: string }>) => { + state.selectedScope = validProjectDataset(state.projects, payload.project, payload.dataset); + }, + }, + extraReducers(builder) { + builder.addCase(getProjects.pending, (state) => { + state.isFetching = true; + }); + builder.addCase(getProjects.fulfilled, (state, { payload }) => { + state.projects = payload?.results ?? []; + state.isFetching = false; + }); + builder.addCase(getProjects.rejected, (state) => { + state.isFetching = false; + }); + }, +}); + +export const { selectScope } = metadata.actions; +export default metadata.reducer; diff --git a/src/js/features/search/makeGetSearchFields.thunk.ts b/src/js/features/search/makeGetSearchFields.thunk.ts index 6d9135d1..81a21a2a 100644 --- a/src/js/features/search/makeGetSearchFields.thunk.ts +++ b/src/js/features/search/makeGetSearchFields.thunk.ts @@ -3,12 +3,15 @@ import axios from 'axios'; import { searchFieldsUrl } from '@/constants/configConstants'; import { printAPIError } from '@/utils/error.util'; import { SearchFieldResponse } from '@/types/search'; +import { RootState } from '@/store'; -export const makeGetSearchFields = createAsyncThunk( - 'query/makeGetSearchFields', - (_, { rejectWithValue }) => - axios - .get(searchFieldsUrl) - .then((res) => res.data) - .catch(printAPIError(rejectWithValue)) -); +export const makeGetSearchFields = createAsyncThunk< + SearchFieldResponse, + void, + { rejectValue: string; state: RootState } +>('query/makeGetSearchFields', async (_, { rejectWithValue, getState }) => { + return await axios + .get(searchFieldsUrl, { params: getState().metadata.selectedScope }) + .then((res) => res.data) + .catch(printAPIError(rejectWithValue)); +}); diff --git a/src/js/index.tsx b/src/js/index.tsx index 21a556cc..5fadcb20 100644 --- a/src/js/index.tsx +++ b/src/js/index.tsx @@ -1,37 +1,26 @@ // React and ReactDOM imports -import React, { Suspense, useEffect, useRef, useState } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom/client'; // Redux and routing imports import { Provider } from 'react-redux'; -import { Routes, Route, useParams, useNavigate, BrowserRouter } from 'react-router-dom'; +import { Routes, Route, BrowserRouter, Navigate } from 'react-router-dom'; // i18n and constants imports import { useTranslation } from 'react-i18next'; import { NEW_BENTO_PUBLIC_THEME } from '@/constants/overviewConstants'; -import { DEFAULT_TRANSLATION, SUPPORTED_LNGS } from '@/constants/configConstants'; +import { SUPPORTED_LNGS } from '@/constants/configConstants'; // Component imports -import { Button, ConfigProvider, Layout, Modal, message } from 'antd'; +import { ConfigProvider } from 'antd'; import { ChartConfigProvider } from 'bento-charts'; -import SiteHeader from '@/components/SiteHeader'; -import SiteFooter from '@/components/SiteFooter'; -import SiteSider from '@/components/SiteSider'; import Loader from '@/components/Loader'; import BentoAppRouter from '@/components/BentoAppRouter'; +import LanguageHandler from '@/components/Util/LanguageHandler'; +import AuthOutlet from '@/components/Util/AuthOutlet'; // Hooks and utilities imports -import { - useHandleCallback, - checkIsInAuthPopup, - useOpenSignInWindowCallback, - usePopupOpenerAuthCallback, - useSignInPopupTokenHandoff, - useSessionWorkerTokenRefresh, - BentoAuthContextProvider, - nop, -} from 'bento-auth-js'; -import { useQueryWithAuthIfAllowed } from '@/hooks'; +import { BentoAuthContextProvider } from 'bento-auth-js'; // Store and configuration imports import { store } from './store'; @@ -43,135 +32,46 @@ import 'bento-charts/src/styles.css'; import './i18n'; import '../styles.css'; -const SIGN_IN_WINDOW_FEATURES = 'scrollbars=no, toolbar=no, menubar=no, width=800, height=600'; -const CALLBACK_PATH = '/callback'; - -const LNGS_ARRAY = Object.values(SUPPORTED_LNGS); -const { Content } = Layout; - -const createSessionWorker = () => new Worker(new URL('./workers/tokenRefresh.ts', import.meta.url)); - -const App = () => { - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); - - // TRANSLATION - const { lang } = useParams<{ lang?: string }>(); - const { t, i18n } = useTranslation(DEFAULT_TRANSLATION); - - useEffect(() => { - console.debug('lang', lang); - if (lang && lang == 'callback') return; - if (lang && LNGS_ARRAY.includes(lang)) { - i18n.changeLanguage(lang); - } else if (i18n.language) { - navigate(`/${i18n.language}/`, { replace: true }); - } else { - navigate(`/${SUPPORTED_LNGS.ENGLISH}/`, { replace: true }); - } - }, [lang, i18n.language, navigate]); - - // AUTHENTICATION - const [signedOutModal, setSignedOutModal] = useState(false); - - const sessionWorker = useRef(null); - const signInWindow = useRef(null); - const windowMessageHandler = useRef<((event: MessageEvent) => void) | null>(null); - - const openSignInWindow = useOpenSignInWindowCallback(signInWindow, SIGN_IN_WINDOW_FEATURES); - - const popupOpenerAuthCallback = usePopupOpenerAuthCallback(); - const isInAuthPopup = checkIsInAuthPopup(PUBLIC_URL_NO_TRAILING_SLASH); - - useHandleCallback( - CALLBACK_PATH, - () => { - console.debug('authenticated'); - }, - isInAuthPopup ? popupOpenerAuthCallback : undefined, - (msg) => message.error(msg) - ); - - // Set up message handling from sign-in popup - useSignInPopupTokenHandoff(windowMessageHandler); - - useSessionWorkerTokenRefresh(sessionWorker, createSessionWorker, nop); - - useQueryWithAuthIfAllowed(); - +const BaseRoutes = () => { return ( - - { - setSignedOutModal(false); - }} - open={signedOutModal} - footer={[ - , - ]} - > - {t('Please sign in to the research portal.')} -
-
- - - - - - - }> - - } /> - } /> - - - - - - - -
+ + }> + } /> + }> + } /> + } /> + + + ); }; -const BentoApp = () => { +const RootApp = () => { const { i18n } = useTranslation(); - console.log('i18n.language', i18n.language); return ( - - - } /> - - + + + + + + + + + + + ); }; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); -root.render( - - - - - - - -); +root.render(); diff --git a/src/js/store.ts b/src/js/store.ts index a92d517b..5642f05c 100644 --- a/src/js/store.ts +++ b/src/js/store.ts @@ -14,8 +14,9 @@ import dataReducer from '@/features/data/data.store'; import queryReducer from '@/features/search/query.store'; import lastIngestionDataReducer from '@/features/ingestion/lastIngestion.store'; import provenanceReducer from '@/features/provenance/provenance.store'; -import beaconConfigReducer from './features/beacon/beaconConfig.store'; -import beaconQueryReducer from './features/beacon/beaconQuery.store'; +import beaconConfigReducer from '@/features/beacon/beaconConfig.store'; +import beaconQueryReducer from '@/features/beacon/beaconQuery.store'; +import metadataReducer from '@/features/metadata/metadata.store'; import { getValue, saveValue } from './utils/localStorage'; interface PersistedState { @@ -41,6 +42,7 @@ export const store = configureStore({ lastIngestionData: lastIngestionDataReducer, beaconConfig: beaconConfigReducer, beaconQuery: beaconQueryReducer, + metadata: metadataReducer, }, preloadedState: persistedState, }); diff --git a/src/js/types/metadata.ts b/src/js/types/metadata.ts new file mode 100644 index 00000000..66a8e3c1 --- /dev/null +++ b/src/js/types/metadata.ts @@ -0,0 +1,35 @@ +import { Layout as DiscoveryOverview, Fields as DiscoveryFields } from '@/types/overviewResponse'; +import { Section as DiscoverySearch } from '@/types/search'; +import { DiscoveryRules } from '@/types/configResponse'; + +export interface Discovery { + overview: DiscoveryOverview[]; + search: DiscoverySearch[]; + fields: DiscoveryFields; + rules: DiscoveryRules; +} + +export interface Project { + identifier: string; + title: string; + description: string; + discovery: Discovery | null; + datasets: Dataset[]; +} + +export interface Dataset { + identifier: string; + title: string; + description: string; + discovery: Discovery | null; + dats_file: object; +} + +export interface PaginatedResponse { + count: number; + next: T; + previous: T; + results: T[]; +} + +export type ProjectsResponse = PaginatedResponse; diff --git a/src/js/types/overviewResponse.ts b/src/js/types/overviewResponse.ts index 999a2c4d..e1755685 100644 --- a/src/js/types/overviewResponse.ts +++ b/src/js/types/overviewResponse.ts @@ -48,7 +48,7 @@ export interface Datum { value: number; } -interface Layout { +export interface Layout { charts: ChartConfig[]; section_title: string; } diff --git a/src/js/utils/router.ts b/src/js/utils/router.ts index 534c8a6d..6c12f392 100644 --- a/src/js/utils/router.ts +++ b/src/js/utils/router.ts @@ -1,11 +1,47 @@ import { BentoRoute } from '@/types/routes'; +import { MetadataState } from '@/features/metadata/metadata.store'; export const getCurrentPage = (): BentoRoute => { const pathArray = window.location.pathname.split('/'); const validPages = Object.values(BentoRoute); - if (pathArray.length > 2 && validPages.includes(pathArray[2] as BentoRoute)) { - return pathArray[2] as BentoRoute; + if (validPages.includes(pathArray[pathArray.length - 1] as BentoRoute)) { + return pathArray[pathArray.length - 1] as BentoRoute; } else { return BentoRoute.Overview; } }; + +export const validProjectDataset = ( + projects: MetadataState['projects'], + projectId?: string, + datasetId?: string +): MetadataState['selectedScope'] => { + const valid: MetadataState['selectedScope'] = { + project: undefined, + dataset: undefined, + }; + + if (projectId && projects.find(({ identifier }) => identifier === projectId)) { + valid.project = projectId; + if (datasetId) { + if ( + projects + .find(({ identifier }) => identifier === projectId)! + .datasets.find(({ identifier }) => identifier === datasetId) + ) { + valid.dataset = datasetId; + } + } + } + return valid; +}; + +export const scopeToUrl = (scope: MetadataState['selectedScope']): string => { + if (scope.project && scope.dataset) { + return `/p/${scope.project}/d/${scope.dataset}`; + } else if (scope.project) { + return `/p/${scope.project}`; + } else { + return ''; + } +}; diff --git a/src/public/locales/en/default_translation_en.json b/src/public/locales/en/default_translation_en.json index c7d0db78..f1f519a7 100644 --- a/src/public/locales/en/default_translation_en.json +++ b/src/public/locales/en/default_translation_en.json @@ -91,5 +91,10 @@ "You have been signed out": "You have been signed out", "Please sign in to the research portal.": "Please sign in to the research portal.", "Sign In": "Sign In", - "Sign Out": "Sign Out" + "Sign Out": "Sign Out", + "Select Scope": "Select Scope", + "About": "About", + "Datasets": "Datasets", + "Clear": "Clear", + "Select": "Select" } diff --git a/src/public/locales/fr/default_translation_fr.json b/src/public/locales/fr/default_translation_fr.json index 852a5060..37a2abdf 100644 --- a/src/public/locales/fr/default_translation_fr.json +++ b/src/public/locales/fr/default_translation_fr.json @@ -91,5 +91,10 @@ "You have been signed out": "Vous avez été déconnecté", "Please sign in to the research portal.": "Veuillez vous connecter au portail de recherche.", "Sign In": "Se connecter", - "Sign Out": "Se déconnecter" + "Sign Out": "Se déconnecter", + "Select Scope": "Sélectionner un périmètre", + "About": "À propos", + "Datasets": "Jeux de données", + "Clear": "Effacer", + "Select": "Sélectionner" } diff --git a/src/styles.css b/src/styles.css index 994e396b..a9faaa22 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,6 +5,10 @@ body { margin: 0; } +.no-margin-top { + margin-top: 0 !important; +} + .container { width: 100%; max-width: 1325px; @@ -22,3 +26,21 @@ body { .ant-layout-sider-trigger { border-right: 1px solid #f0f0f0; } + +.select-project-title { + transition: all 0.2s; +} + +.select-project-title:hover { + color: white !important; + cursor: pointer; +} + +.select-dataset-hover { + transition: all 0.2s; + border-radius: 7px; +} + +.select-dataset-hover:hover { + background-color: aliceblue !important; +}