diff --git a/src/js/components/BentoAppRouter.tsx b/src/js/components/BentoAppRouter.tsx index d40e7973..e5041bd8 100644 --- a/src/js/components/BentoAppRouter.tsx +++ b/src/js/components/BentoAppRouter.tsx @@ -31,21 +31,21 @@ const ScopedRoute = () => { useEffect(() => { // Update selectedScope based on URL parameters const valid = validProjectDataset(projects, projectId, datasetId); - if (datasetId === valid.dataset && projectId === valid.project) { - dispatch(selectScope(valid)); + if (datasetId === valid.scope.dataset && projectId === valid.scope.project) { + dispatch(selectScope(valid.scope)); } else { - const oldpath = location.pathname.split('/').filter(Boolean); - const newPath = [oldpath[0]]; + 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); + if (valid.scope.dataset) { + newPath.push('p', valid.scope.project as string, 'd', valid.scope.dataset); + } else if (valid.scope.project) { + newPath.push('p', valid.scope.project); } - const oldPathLength = oldpath.length; - if (oldpath[oldPathLength - 3] === 'p' || oldpath[oldPathLength - 3] === 'd') { - newPath.push(oldpath[oldPathLength - 1]); + 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 }); diff --git a/src/js/components/ChooseProjectModal.tsx b/src/js/components/ChooseProjectModal.tsx deleted file mode 100644 index 0f77f076..00000000 --- a/src/js/components/ChooseProjectModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -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 e7ed6331..5f7106f2 100644 --- a/src/js/components/Overview/Chart.tsx +++ b/src/js/components/Overview/Chart.tsx @@ -19,17 +19,17 @@ interface ChartEvent { const Chart = memo(({ chartConfig, data, units, id, isClickable }: ChartProps) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); - const { selectedScope } = useAppSelector((state) => state.metadata); + const { scope } = useAppSelector((state) => state.metadata.selectedScope); const translateMap = ({ x, y }: { x: string; y: number }) => ({ x: t(x), y }); const removeMissing = ({ x }: { x: string }) => x !== 'missing'; const barChartOnChartClickHandler: BarChartProps['onChartClick'] = (e: ChartEvent) => { if (e.activePayload.length === 0) return; const d = e.activePayload[0]; - navigate(`/${i18n.language}${scopeToUrl(selectedScope)}/search?${id}=${d.payload.x}`); + navigate(`/${i18n.language}${scopeToUrl(scope)}/search?${id}=${d.payload.x}`); }; const pieChartOnClickHandler = (d: { name: string }) => { - navigate(`/${i18n.language}${scopeToUrl(selectedScope)}/search?${id}=${d.name}`); + navigate(`/${i18n.language}${scopeToUrl(scope)}/search?${id}=${d.name}`); }; const { chart_type: type } = chartConfig; diff --git a/src/js/components/Scope/DatasetScopePicker.tsx b/src/js/components/Scope/DatasetScopePicker.tsx new file mode 100644 index 00000000..3a8744a0 --- /dev/null +++ b/src/js/components/Scope/DatasetScopePicker.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import { List, Avatar, Space, Typography } from 'antd'; +import { useAppSelector, useTranslationCustom, useTranslationDefault } from '@/hooks'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { FaDatabase } from 'react-icons/fa'; +import { getCurrentPage } from '@/utils/router'; +import type { Project } from '@/types/metadata'; + +type DatasetScopePickerProps = { + parentProject: Project; +}; + +const DatasetScopePicker = ({ parentProject }: DatasetScopePickerProps) => { + const td = useTranslationDefault(); + const t = useTranslationCustom(); + const location = useLocation(); + const baseURL = '/' + location.pathname.split('/')[1]; + const navigate = useNavigate(); + const page = getCurrentPage(); + + const { selectedScope } = useAppSelector((state) => state.metadata); + const scopeObj = selectedScope.scope; + + const showClearDataset = useMemo( + () => + // only show the clear dataset option if the selected dataset belongs to the parentProject + scopeObj.dataset && parentProject.datasets.some((d) => d.identifier == scopeObj.dataset), + [scopeObj, parentProject] + ); + const showSelectProject = !selectedScope.fixedProject && parentProject.identifier != scopeObj.project; + + const projectURL = `${baseURL}/p/${parentProject.identifier}/${page}`; + + return ( + + + + {td('Project')}: {t(parentProject.title)} + + {showSelectProject && {td('Select')}} + + {t(parentProject.description)} + + + {td('Datasets')} + + {showClearDataset && {td('Clear dataset selection')}} + + { + const datasetURL = `${baseURL}/p/${parentProject.identifier}/d/${dataset.identifier}/${page}`; + const selected = scopeObj.dataset && dataset.identifier === scopeObj.dataset; + return ( + navigate(datasetURL)} + style={{ cursor: 'pointer' }} + > + } />} + title={t(dataset.title)} + description={t(dataset.description)} + /> + + ); + }} + /> + + ); +}; + +export default DatasetScopePicker; diff --git a/src/js/components/Scope/ProjectScopePicker.tsx b/src/js/components/Scope/ProjectScopePicker.tsx new file mode 100644 index 00000000..599f3fe7 --- /dev/null +++ b/src/js/components/Scope/ProjectScopePicker.tsx @@ -0,0 +1,61 @@ +import React, { type CSSProperties, useCallback, useMemo, useState } from 'react'; +import { Tabs, Button } from 'antd'; +import { useAppSelector, useTranslationCustom, useTranslationDefault } from '@/hooks'; +import { useLocation, useNavigate } from 'react-router-dom'; +import DatasetScopePicker from './DatasetScopePicker'; + +const styles: Record = { + tabs: { + // Cancel out padding from modal on left side for button and item alignment + marginLeft: -24, + }, +}; + +const ProjectScopePicker = () => { + const td = useTranslationDefault(); + const t = useTranslationCustom(); + + const location = useLocation(); + const baseURL = '/' + location.pathname.split('/')[1]; + + const navigate = useNavigate(); + + const { projects, selectedScope } = useAppSelector((state) => state.metadata); + const { scope: scopeObj, fixedProject } = selectedScope; + + const [selectedProject, setSelectedProject] = useState( + scopeObj.project ?? projects[0]?.identifier ?? undefined + ); + + const onProjectClear = useCallback(() => navigate(baseURL), [baseURL, navigate]); + const onTabChange = useCallback((key: string) => setSelectedProject(key), []); + const tabItems = useMemo( + () => + projects.map((p) => ({ + key: p.identifier, + label: t(p.title), + children: , + })), + [projects, t] + ); + + return ( + <> + {fixedProject ? ( + + ) : ( + // Project tabs if multiple projects + {td('Clear')}} + items={tabItems} + style={styles.tabs} + /> + )} + + ); +}; + +export default ProjectScopePicker; diff --git a/src/js/components/Scope/ScopePickerModal.tsx b/src/js/components/Scope/ScopePickerModal.tsx new file mode 100644 index 00000000..f9b2c2f4 --- /dev/null +++ b/src/js/components/Scope/ScopePickerModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Modal } from 'antd'; +import { useTranslationDefault } from '@/hooks'; +import ProjectScopePicker from './ProjectScopePicker'; + +interface ScopePickerModalProps { + isModalOpen: boolean; + setIsModalOpen: (isOpen: boolean) => void; +} +const ScopePickerModal = ({ isModalOpen, setIsModalOpen }: ScopePickerModalProps) => { + const td = useTranslationDefault(); + const closeModal = () => setIsModalOpen(false); + return ( + + + + ); +}; + +export default ScopePickerModal; diff --git a/src/js/components/SiteHeader.tsx b/src/js/components/SiteHeader.tsx index b7bd4035..bf2624e9 100644 --- a/src/js/components/SiteHeader.tsx +++ b/src/js/components/SiteHeader.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { Button, Flex, Layout, Typography, Space } from 'antd'; @@ -14,7 +14,7 @@ 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'; +import ScopePickerModal from './Scope/ScopePickerModal'; const { Header } = Layout; @@ -28,15 +28,21 @@ const SiteHeader = () => { const { isFetching: openIdConfigFetching } = useOpenIdConfig(); const { isHandingOffCodeForToken } = useAuthState(); 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 { scope: scopeObj } = selectedScope; + + const scopeSelectionEnabled = !(selectedScope.fixedProject && selectedScope.fixedDataset); + + const scopeProps = useMemo( + () => ({ + projectTitle: projects.find((project) => project.identifier === scopeObj.project)?.title, + datasetTitle: scopeObj.dataset + ? projects + .find((project) => project.identifier === scopeObj.project) + ?.datasets.find((dataset) => dataset.identifier === scopeObj.dataset)?.title + : null, + }), + [projects, scopeObj] + ); const [isModalOpen, setIsModalOpen] = useState(false); @@ -64,7 +70,7 @@ const SiteHeader = () => { src="/public/assets/branding.png" alt="logo" style={{ height: '32px', verticalAlign: 'middle', transform: 'translateY(-3px)' }} - onClick={() => navigate(`/${i18n.language}${scopeToUrl(selectedScope)}`)} + onClick={() => navigate(`/${i18n.language}${scopeToUrl(scopeObj)}`)} /> { > {CLIENT_NAME} - setIsModalOpen(true)} - > - + {scopeSelectionEnabled && ( + setIsModalOpen(true)} + > + - {selectedScope.project && scopeProps.projectTitle} - {scopeProps.datasetTitle ? ` / ${scopeProps.datasetTitle}` : ''} - - + {scopeObj.project && scopeProps.projectTitle} + {scopeProps.datasetTitle ? ` / ${scopeProps.datasetTitle}` : ''} + + )} + diff --git a/src/js/features/metadata/metadata.store.ts b/src/js/features/metadata/metadata.store.ts index 3d3fceac..2c5143b9 100644 --- a/src/js/features/metadata/metadata.store.ts +++ b/src/js/features/metadata/metadata.store.ts @@ -7,12 +7,21 @@ import { printAPIError } from '@/utils/error.util'; import { validProjectDataset } from '@/utils/router'; import { projectsUrl } from '@/constants/configConstants'; +export type DiscoveryScope = + | { project: undefined; dataset: undefined } + | { project: string; dataset: undefined } + | { + project: string; + dataset: string; + }; + export interface MetadataState { projects: Project[]; isFetching: boolean; selectedScope: { - project: string | undefined; - dataset: string | undefined; + scope: DiscoveryScope; + fixedProject: boolean; + fixedDataset: boolean; }; } @@ -20,8 +29,9 @@ const initialState: MetadataState = { projects: [], isFetching: true, selectedScope: { - project: undefined, - dataset: undefined, + scope: { project: undefined, dataset: undefined }, + fixedProject: false, + fixedDataset: false, }, }; @@ -41,6 +51,9 @@ const metadata = createSlice({ initialState, reducers: { selectScope: (state, { payload }: PayloadAction<{ project?: string; dataset?: string }>) => { + // Defaults to the narrowest possible scope if there is only 1 project and only 1 dataset. + // This forces Katsu to resolve the Discovery config with fallbacks from the bottom-up: + // dataset -> project -> whole node state.selectedScope = validProjectDataset(state.projects, payload.project, payload.dataset); }, }, diff --git a/src/js/features/search/query.store.ts b/src/js/features/search/query.store.ts index a732f9d2..138fa176 100644 --- a/src/js/features/search/query.store.ts +++ b/src/js/features/search/query.store.ts @@ -67,7 +67,7 @@ const query = createSlice({ builder.addCase(makeGetKatsuPublic.fulfilled, (state, { payload }: PayloadAction) => { state.isFetchingData = false; state.attemptedFetch = true; - if ('message' in payload) { + if (payload && 'message' in payload) { state.message = payload.message; return; } diff --git a/src/js/utils/requests.ts b/src/js/utils/requests.ts index 18400129..c4c1d0e3 100644 --- a/src/js/utils/requests.ts +++ b/src/js/utils/requests.ts @@ -11,5 +11,5 @@ export const scopedAuthorizedRequestConfig = ( extraParams: Record | undefined = undefined ): AxiosRequestConfig => ({ ...authorizedRequestConfig(state), - params: { ...state.metadata.selectedScope, ...extraParams }, + params: { ...state.metadata.selectedScope.scope, ...extraParams }, }); diff --git a/src/js/utils/router.ts b/src/js/utils/router.ts index 9700aceb..2b1cf659 100644 --- a/src/js/utils/router.ts +++ b/src/js/utils/router.ts @@ -1,5 +1,5 @@ import { BentoRoute } from '@/types/routes'; -import type { MetadataState } from '@/features/metadata/metadata.store'; +import type { DiscoveryScope, MetadataState } from '@/features/metadata/metadata.store'; export const getCurrentPage = (): string => { const pathArray = window.location.pathname.split('/'); @@ -17,26 +17,40 @@ export const validProjectDataset = ( datasetId?: string ): MetadataState['selectedScope'] => { const valid: MetadataState['selectedScope'] = { - project: undefined, - dataset: undefined, + scope: { project: undefined, dataset: undefined }, + fixedProject: false, + fixedDataset: false, }; + if (projects.length === 1) { + // automatic project scoping if only 1 + const defaultProj = projects[0]; + valid.scope.project = defaultProj.identifier; + valid.fixedProject = true; + if (defaultProj.datasets.length === 1) { + // automatic dataset scoping if only 1 + valid.scope.dataset = defaultProj.datasets[0].identifier; + valid.fixedDataset = true; + // early return to ignore redundant projectId and datasetId + return valid; + } + } if (projectId && projects.find(({ identifier }) => identifier === projectId)) { - valid.project = projectId; + valid.scope.project = projectId; if (datasetId) { if ( projects .find(({ identifier }) => identifier === projectId)! .datasets.find(({ identifier }) => identifier === datasetId) ) { - valid.dataset = datasetId; + valid.scope.dataset = datasetId; } } } return valid; }; -export const scopeToUrl = (scope: MetadataState['selectedScope']): string => { +export const scopeToUrl = (scope: DiscoveryScope): string => { if (scope.project && scope.dataset) { return `/p/${scope.project}/d/${scope.dataset}`; } else if (scope.project) { diff --git a/src/styles.css b/src/styles.css index a9faaa22..6ab5c840 100644 --- a/src/styles.css +++ b/src/styles.css @@ -36,11 +36,26 @@ body { cursor: pointer; } -.select-dataset-hover { +.select-dataset-item { transition: all 0.2s; border-radius: 7px; + cursor: pointer; +} +.select-dataset-item .ant-avatar { + background-color: #8c8c8c; +} + +.select-dataset-item:hover { + background-color: #e6f4ff; +} +.select-dataset-item:hover .ant-avatar { + color: #e6f4ff; } -.select-dataset-hover:hover { - background-color: aliceblue !important; +.select-dataset-item.selected, .select-dataset-item.selected:hover { + background-color: #91caff; +} +.select-dataset-item.selected .ant-avatar { + background-color: #0958d9; + color: #91caff; }