Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: URL context-aware sider, header, router #242

Merged
merged 12 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions src/js/components/BentoAppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,34 @@ const ScopedRoute = () => {
return;
}

const isFixedProjectAndDataset = valid.fixedProject && valid.fixedDataset;

// If the URL scope is valid, store the scope in the Redux store.
if (datasetId === valid.scope.dataset && projectId === valid.scope.project) {
// We have two subcases here:
// - If the validated scope matches the URL parameters, nothing needs to be done
// - No parameters have been supplied and we have a single-dataset node, in which case we want to keep the "clean"
// / blank URL to avoid visual clutter.
if (
(datasetId === valid.scope.dataset && projectId === valid.scope.project) ||
(!projectId && !datasetId && isFixedProjectAndDataset)
) {
dispatch(selectScope(valid.scope)); // Also marks scope as set
return;
}

// Otherwise: validated scope does not match URL params, so we need to re-locate to a valid path.
// Otherwise: validated scope does not match our desired URL params, so we need to re-locate to a valid path.

const oldPath = location.pathname.split('/').filter(Boolean);
const newPath = [oldPath[0]];

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);
// If we have >1 dataset, we need the URL to match the validated scope, so we create a new path and go there.
// Otherwise (with 1 dataset), keep URL as clean as possible - with no IDs present at all.
if (!isFixedProjectAndDataset) {
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;
Expand Down Expand Up @@ -120,6 +133,7 @@ const BentoAppRouter = () => {
<Route path={BentoRoute.Overview} element={<PublicOverview />} />
<Route path={BentoRoute.Search} element={<Search />} />
{BentoRoute.Beacon && <Route path={BentoRoute.Beacon} element={<BeaconQueryUi />} />}
{/* Beacon network is only available at the top level - scoping does not make sense for it. */}
{BentoRoute.BeaconNetwork && <Route path={BentoRoute.BeaconNetwork} element={<NetworkUi />} />}
<Route path={BentoRoute.Provenance} element={<ProvenanceTab />} />
</Route>
Expand All @@ -129,7 +143,6 @@ const BentoAppRouter = () => {
<Route path={BentoRoute.Overview} element={<PublicOverview />} />
<Route path={BentoRoute.Search} element={<Search />} />
{BentoRoute.Beacon && <Route path={BentoRoute.Beacon} element={<BeaconQueryUi />} />}
{BentoRoute.BeaconNetwork && <Route path={BentoRoute.BeaconNetwork} element={<NetworkUi />} />}
<Route path={BentoRoute.Provenance} element={<ProvenanceTab />} />
</Route>

Expand All @@ -138,7 +151,6 @@ const BentoAppRouter = () => {
<Route path={BentoRoute.Overview} element={<PublicOverview />} />
<Route path={BentoRoute.Search} element={<Search />} />
{BentoRoute.Beacon && <Route path={BentoRoute.Beacon} element={<BeaconQueryUi />} />}
{BentoRoute.BeaconNetwork && <Route path={BentoRoute.BeaconNetwork} element={<NetworkUi />} />}
<Route path={BentoRoute.Provenance} element={<ProvenanceTab />} />
</Route>
</Route>
Expand Down
9 changes: 2 additions & 7 deletions src/js/components/Scope/ProjectScopePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { type CSSProperties } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Tabs, Button } from 'antd';

import { useLocation, useNavigate } from 'react-router-dom';
import { useMetadata } from '@/features/metadata/hooks';
import { useTranslationFn } from '@/hooks';
import { useNavigateToRoot } from '@/hooks/navigation';

import DatasetScopePicker from './DatasetScopePicker';

Expand All @@ -18,19 +18,14 @@ const styles: Record<string, CSSProperties> = {
const ProjectScopePicker = () => {
const t = useTranslationFn();

const location = useLocation();
const baseURL = '/' + location.pathname.split('/')[1];

const navigate = useNavigate();

const { projects, selectedScope } = useMetadata();
const { scope: scopeObj, fixedProject } = selectedScope;

const [selectedProject, setSelectedProject] = useState<string | undefined>(
scopeObj.project ?? projects[0]?.identifier ?? undefined
);

const onProjectClear = useCallback(() => navigate(baseURL), [baseURL, navigate]);
const onProjectClear = useNavigateToRoot();
const onTabChange = useCallback((key: string) => setSelectedProject(key), []);
const tabItems = useMemo(
() =>
Expand Down
43 changes: 18 additions & 25 deletions src/js/components/SiteHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { type CSSProperties, useCallback, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { Button, Flex, Layout, Space, Typography } from 'antd';
Expand All @@ -8,12 +8,13 @@ import { useAuthState, useIsAuthenticated, useOpenIdConfig, usePerformAuth, useP
import { RiTranslate } from 'react-icons/ri';
import { ExportOutlined, LinkOutlined, LoginOutlined, LogoutOutlined, ProfileOutlined } from '@ant-design/icons';

import { useSelectedProject, useSelectedScope } from '@/features/metadata/hooks';
import { useSelectedScope, useSelectedScopeTitles } from '@/features/metadata/hooks';
import { useSmallScreen } from '@/hooks/useResponsiveContext';
import { scopeToUrl } from '@/utils/router';
import { getCurrentPage, scopeToUrl } from '@/utils/router';

import { LNG_CHANGE, LNGS_FULL_NAMES } from '@/constants/configConstants';
import { CLIENT_NAME, PORTAL_URL, TRANSLATED } from '@/config';
import { TOP_LEVEL_ONLY_ROUTES } from '@/types/routes';

import ScopePickerModal from './Scope/ScopePickerModal';

Expand All @@ -34,19 +35,11 @@ const SiteHeader = () => {
const { isFetching: openIdConfigFetching } = useOpenIdConfig();
const { isHandingOffCodeForToken } = useAuthState();
const { fixedProject, fixedDataset, scope: scopeObj } = useSelectedScope();
const selectedProject = useSelectedProject();

const scopeSelectionEnabled = !(fixedProject && fixedDataset);

const scopeProps = useMemo(
() => ({
projectTitle: selectedProject?.title,
datasetTitle: scopeObj.dataset
? selectedProject?.datasets.find((dataset) => dataset.identifier === scopeObj.dataset)?.title
: null,
}),
[selectedProject, scopeObj]
);
const currentPage = getCurrentPage();

const scopeSelectionEnabled = !(fixedProject && fixedDataset) && !TOP_LEVEL_ONLY_ROUTES.includes(currentPage);

const scopeProps = useSelectedScopeTitles();

const [isModalOpen, setIsModalOpen] = useState(false);

Expand All @@ -66,6 +59,11 @@ const SiteHeader = () => {
navigate(path, { replace: true });
};

const navigateToOverview = useCallback(
() => navigate(`/${i18n.language}${scopeToUrl(scopeObj)}`),
[navigate, i18n.language, scopeObj]
);

return (
<Header style={{ position: 'fixed', width: '100%', zIndex: 100, top: 0, ...HEADER_PADDING }}>
<Flex align="center" justify="space-between">
Expand All @@ -76,7 +74,7 @@ const SiteHeader = () => {
data="/public/assets/icon_small.png"
aria-label="logo"
style={{ height: '32px', verticalAlign: 'middle', transform: 'translateY(-3px)', paddingRight: '26px' }}
onClick={() => navigate(`/${i18n.language}${scopeToUrl(scopeObj)}`)}
onClick={navigateToOverview}
>
<img
src="/public/assets/branding.png"
Expand All @@ -87,15 +85,15 @@ const SiteHeader = () => {
transform: 'translateY(-3px)',
paddingLeft: '23px',
}}
onClick={() => navigate(`/${i18n.language}${scopeToUrl(scopeObj)}`)}
onClick={navigateToOverview}
/>
</object>
) : (
<img
src="/public/assets/branding.png"
alt="logo"
style={{ height: '32px', verticalAlign: 'middle', transform: 'translateY(-3px)', paddingLeft: '4px' }}
onClick={() => navigate(`/${i18n.language}${scopeToUrl(scopeObj)}`)}
onClick={navigateToOverview}
/>
)}
<Typography.Title
Expand All @@ -106,12 +104,7 @@ const SiteHeader = () => {
{CLIENT_NAME}
</Typography.Title>
{scopeSelectionEnabled && (
<Typography.Title
className="select-project-title"
level={5}
style={{ fontSize: '16px', margin: 0, lineHeight: '64px', color: 'lightgray' }}
onClick={() => setIsModalOpen(true)}
>
<Typography.Title className="select-project-title" level={2} onClick={() => setIsModalOpen(true)}>
<ProfileOutlined style={{ marginRight: '5px', fontSize: '16px' }} />

{scopeObj.project && scopeProps.projectTitle}
Expand Down
57 changes: 42 additions & 15 deletions src/js/components/SiteSider.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';

import type { MenuProps, SiderProps } from 'antd';
import { Layout, Menu } from 'antd';
import Icon, { PieChartOutlined, SearchOutlined, ShareAltOutlined, SolutionOutlined } from '@ant-design/icons';
import { Button, Divider, Layout, Menu } from 'antd';
import Icon, {
ArrowLeftOutlined,
PieChartOutlined,
SearchOutlined,
ShareAltOutlined,
SolutionOutlined,
} from '@ant-design/icons';

import BeaconSvg from '@/components/Beacon/BeaconSvg';
import { useMetadata, useSelectedScope } from '@/features/metadata/hooks';
import { useSearchQuery } from '@/features/search/hooks';
import { useTranslationFn } from '@/hooks';
import { BentoRoute } from '@/types/routes';
import { useNavigateToRoot } from '@/hooks/navigation';
import { BentoRoute, TOP_LEVEL_ONLY_ROUTES } from '@/types/routes';
import { buildQueryParamsUrl } from '@/utils/search';
import { getCurrentPage } from '@/utils/router';

Expand All @@ -19,27 +28,32 @@ type CustomIconComponentProps = React.ComponentProps<typeof Icon>;
type MenuItem = Required<MenuProps>['items'][number];
type OnClick = MenuProps['onClick'];

const BeaconLogo: React.FC<Partial<CustomIconComponentProps>> = (props) => <Icon component={BeaconSvg} {...props} />;
const BeaconLogo = (props: Partial<CustomIconComponentProps>) => <Icon component={BeaconSvg} {...props} />;

const SiteSider: React.FC<{
collapsed: boolean;
setCollapsed: SiderProps['onCollapse'];
}> = ({ collapsed, setCollapsed }) => {
const SiteSider = ({ collapsed, setCollapsed }: { collapsed: boolean; setCollapsed: SiderProps['onCollapse'] }) => {
const navigate = useNavigate();
const location = useLocation();
const { i18n } = useTranslation();
const t = useTranslationFn();
const { projects } = useMetadata();
const { queryParams } = useSearchQuery();
const currentPage = getCurrentPage();

const navigateToRoot = useNavigateToRoot();
const { fixedProject, scope } = useSelectedScope();

const handleMenuClick: OnClick = useCallback(
({ key }: { key: string }) => {
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]);
if (!TOP_LEVEL_ONLY_ROUTES.includes(key)) {
// Beacon network only works at the top scope level
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('/');
Expand Down Expand Up @@ -69,12 +83,12 @@ const SiteSider: React.FC<{
items.push(createMenuItem('Beacon', BentoRoute.Beacon, <BeaconLogo />));
}

if (BentoRoute.BeaconNetwork) {
if (BentoRoute.BeaconNetwork && (!scope.project || (scope.project && fixedProject))) {
items.push(createMenuItem('Beacon Network', BentoRoute.BeaconNetwork, <ShareAltOutlined />));
}

return items;
}, [createMenuItem]);
}, [createMenuItem, scope, fixedProject]);

return (
<Sider
Expand All @@ -93,6 +107,19 @@ const SiteSider: React.FC<{
borderRight: '1px solid #f0f0f0',
}}
>
{scope.project && projects.length > 1 && (
<>
<Button
type="text"
icon={<ArrowLeftOutlined />}
style={{ margin: 4, width: 'calc(100% - 8px)' }}
onClick={scope.dataset ? () => navigate(`/${i18n.language}/p/${scope.project}`) : navigateToRoot}
>
{collapsed ? null : t(scope.dataset ? 'back_project' : 'back_catalogue')}
</Button>
<Divider style={{ margin: 0 }} />
</>
)}
<Menu
selectedKeys={[currentPage]}
mode="inline"
Expand Down
15 changes: 15 additions & 0 deletions src/js/features/metadata/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,18 @@ export const useSelectedProject = (): Project | undefined => {
[projects, selectedProject]
);
};

export const useSelectedScopeTitles = () => {
const selectedProject = useSelectedProject();
const { scope } = useSelectedScope();

return useMemo(
() => ({
projectTitle: selectedProject?.title,
datasetTitle: scope.dataset
? selectedProject?.datasets.find((dataset) => dataset.identifier === scope.dataset)?.title
: null,
}),
[selectedProject, scope]
);
};
9 changes: 9 additions & 0 deletions src/js/hooks/navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';

export const useNavigateToRoot = () => {
const { i18n } = useTranslation();
const navigate = useNavigate();
return useCallback(() => navigate(`/${i18n.language}`), [navigate, i18n.language]);
};
5 changes: 4 additions & 1 deletion src/js/types/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ const BentoRoute: BentoRoutes = {
Provenance: 'provenance',
};

const TOP_LEVEL_ONLY_ROUTES: string[] = [];

if (BEACON_UI_ENABLED) {
BentoRoute.Beacon = 'beacon';
}

if (BEACON_NETWORK_ENABLED) {
BentoRoute.BeaconNetwork = 'network';
TOP_LEVEL_ONLY_ROUTES.push(BentoRoute.BeaconNetwork);
}

export { BentoRoute };
export { BentoRoute, TOP_LEVEL_ONLY_ROUTES };
2 changes: 2 additions & 0 deletions src/public/locales/en/default_translation_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@
"Datasets": "Datasets",
"Clear": "Clear",
"Select": "Select",
"back_catalogue": "Back to catalogue",
"back_project": "Back to project",
"beacon": {
"search_beacon": "Search Beacon",
"search_network": "Search Network",
Expand Down
2 changes: 2 additions & 0 deletions src/public/locales/fr/default_translation_fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@
"Datasets": "Jeux de données",
"Clear": "Effacer",
"Select": "Sélectionner",
"back_catalogue": "Retourner au catalogue",
"back_project": "Retourner au projet",
"beacon": {
"search_beacon": "Recherche sur le Beacon",
"search_network": "Recherche sur le réseau",
Expand Down
11 changes: 8 additions & 3 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@ body {
border-right: 1px solid #f0f0f0;
}

.select-project-title {
h2.select-project-title {
font-size: 16px;
margin: 0;
line-height: 64px;
color: lightgray;

transition: all 0.2s;
}

.select-project-title:hover {
color: white !important;
h2.select-project-title:hover {
color: white;
cursor: pointer;
}

Expand Down
Loading