Skip to content

Commit 4671995

Browse files
committed
DEP-263: Add dynamic page titles based on route handles
1 parent 793545d commit 4671995

16 files changed

Lines changed: 306 additions & 145 deletions

File tree

CHANGELOG.MD

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
## June 22, 2026
2+
3+
- **Feature** Added dynamic tab titles based on the current chain of route matches [🎟️ DEP-263](https://citz-gdx.atlassian.net/browse/DEP-263)
4+
- Updated the DocumentTitle component to dynamically generate the browser tab title based on the current route matches and their associated handles, similar to the automatic breadcrumb system. This ensures that the page title reflects the current context of the application, including engagement titles, page names, or other relevant data.
5+
- Minor reorganization to some routes to ensure they display correctly in all situations
6+
- Added documentation for hooking into the `handle` / `useMatches` ecosystem as it is used in DEP.
7+
18
## June 16, 2026
29

3-
- **Feature** Merged engagement slug into engagement requests [🎟️ DEP-265](https://citz-gdx.atlassian.net/browse/DEP-265)
10+
- **Feature** Merged engagement slug into engagement requests [ DEP-265](https://citz-gdx.atlassian.net/browse/DEP-265)
411
- Removed the EngagementSlug model, schema, and resource from the API, as they are no longer needed.
512
- Updated the Engagement model to include a `slug` field, which is now used to store the engagement slug directly in the engagement record.
613
- Engagements can now be fetched by slug, rather than fetching the slug then fetching the engagement by ID. This hastens the process of retrieving engagement data based on the slug.

docs/React_Route_Handles.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
## Route Handles in DEP
2+
3+
In our React Router setup, we utilize the `handle` property on route definitions to provide additional metadata that can be used throughout our application. This is particularly useful for features like breadcrumb generation and dynamic document titles.
4+
5+
### Setting up a route handle
6+
7+
When defining a route, you can include a `handle` property that returns an object with a `crumb` function. This function should return an object containing at least a `name` property, which will be used for breadcrumb display and document title generation. You can also include an optional `link` property for breadcrumb navigation and an `isIndex` property to indicate if the route is an index page. You can return a separate `title` property if you want to use a different string inside the document title than the breadcrumb name.
8+
9+
```tsx
10+
{
11+
handle: {
12+
crumb: () => ({ name: 'Engagements', title: 'All Engagements', link: getPath(ROUTES.ENGAGEMENT_LISTING)}),
13+
},
14+
}
15+
16+
// Result:
17+
// - Page title: "All Engagements | DEP"
18+
// - Breadcrumb: "Home > Engagements" (clickable link to the listing page)
19+
```
20+
21+
### Using loader data in route handles
22+
23+
If your breadcrumb name needs to be dynamic based on loader data, your crumb function can expect a `data` parameter, which will be the RESOLVED data returned from the route's loader. This allows you to generate breadcrumb names that reflect the specific content being viewed. This data will never be a promise, as it is processed by the page before the crumb function is called.
24+
25+
```tsx
26+
{
27+
handle: {
28+
crumb: (data) => ({ name: data?.engagement?.title || 'Engagement Details' }),
29+
},
30+
}
31+
// Result:
32+
// - Page title: "<engagement title> | DEP"
33+
// - Breadcrumb: "Home > Engagements > <engagement title>"
34+
```
35+
36+
### Excluding index pages from document titles
37+
38+
If you want to exclude a route from being included in the document title, you can set the `isIndex` property to `true` in the crumb object. This is useful for routes that serve as index pages for their parent routes, such as a listing page that should not appear in the document title when viewing an individual item.
39+
40+
For example, if you had a page `/products/:productId` that shows details for a specific product, you might have a parent route `/products` that lists all products. You would want the breadcrumb to show "Products > Product Name", but the document title should just be "Product Name". In this case, you would set `isIndex: true` on the crumb for the `/products` route.
41+
42+
```tsx
43+
return (
44+
<LazyRoute
45+
path="products"
46+
handle={{
47+
allowedRoles: [USER_ROLES.EDIT_ENGAGEMENT],
48+
crumb: (data?: { engagement?: { id?: number } }) => ({
49+
name: 'Products',
50+
isIndex: true,
51+
}),
52+
}}
53+
>
54+
<LazyRoute
55+
index
56+
ComponentLazy={...}
57+
handle={{
58+
crumb: (data) => ({ name: data?.product?.name || 'Product Details' }),
59+
}}
60+
/>
61+
</LazyRoute>
62+
)
63+
64+
// Result:
65+
// - Breadcrumb: "Home > Products > Product Name"
66+
// - Page title: "Product Name | DEP"
67+
```
68+
69+
### Hooking into the crumb system
70+
71+
If you want to design a component that can leverage the crumb system for consistent breadcrumb and title generation, you can use the `useMatches` hook from React Router to access the current route matches and their associated handles. This allows you to dynamically pull route tree information and generate breadcrumbs or titles based on the current route context.
72+
73+
```tsx
74+
import { useMatches } from "react-router";
75+
76+
const matches = useMatches();
77+
const crumbs = matches
78+
.map((match) => match.handle?.crumb?.(match.data))
79+
.filter((crumb) => crumb?.name && !crumb?.isIndex);
80+
81+
// Example of generating a breadcrumb-style path from the current page:
82+
const my_generated_path = crumbs.map((crumb) => crumb.name).join(" > ");
83+
```

web/src/DocumentTitle.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,68 @@
1-
import React from 'react';
1+
import React, { useEffect } from 'react';
22
import { Helmet } from 'react-helmet-async';
33
import { TenantState } from 'reduxSlices/tenantSlice';
44
import { useAppSelector } from './hooks';
5+
import { useMatches } from 'react-router';
6+
import { BreadcrumbProps, UIMatchWithCrumb } from 'components/common/Navigation/Breadcrumb';
7+
8+
const truncate = (str: string, maxLength: number) => {
9+
if (str.length <= maxLength) return str;
10+
return str.slice(0, maxLength - 1) + '…';
11+
};
12+
513
const DocumentTitle = () => {
614
const tenant: TenantState = useAppSelector((state) => state.tenant);
15+
// Reverse list to prioritize the most specific route matches for title
16+
const matches = useMatches().reverse() as UIMatchWithCrumb[];
17+
const [pageTitle, setPageTitle] = React.useState(tenant.title);
18+
19+
const getResolvedCrumbs = async (matches: UIMatchWithCrumb[]) => {
20+
return await Promise.all(
21+
matches.map(async (match) => {
22+
if (match.handle?.crumb) {
23+
try {
24+
const data = match.loaderData;
25+
const resolvedCrumb = await match.handle.crumb(data);
26+
return resolvedCrumb;
27+
} catch {
28+
return null;
29+
}
30+
}
31+
return null;
32+
}),
33+
).then((crumbs) => crumbs.filter((crumb): crumb is BreadcrumbProps => crumb !== null));
34+
};
35+
36+
useEffect(() => {
37+
let cancelled = false;
38+
39+
const resolveTitle = async () => {
40+
const crumbs = await getResolvedCrumbs(matches);
41+
if (cancelled) return;
42+
console.log('Resolved crumbs for title:', crumbs);
43+
const filteredCrumbs = crumbs
44+
// Exclude crumbs that are marked as index pages UNLESS they are the current page
45+
.filter((crumb, index) => (crumb?.title ?? crumb?.name) && (index == 0 || !crumb?.isIndex))
46+
.map((crumb) => truncate(crumb.title ?? crumb.name, 40)); // Limit individual crumb length to prevent excessively long titles
47+
const title = filteredCrumbs.length > 0 ? filteredCrumbs.join(' | ') : tenant.title;
48+
const truncatedTitle = truncate(title, 65); // Limit total title length to prevent browser truncation
49+
if (truncatedTitle == title) {
50+
setPageTitle(title + ' | ' + 'Digital Engagement Platform');
51+
} else {
52+
setPageTitle(truncatedTitle + ' | ' + 'DEP');
53+
}
54+
};
55+
56+
resolveTitle();
57+
58+
return () => {
59+
cancelled = true;
60+
};
61+
}, [matches, tenant.title]);
62+
763
return (
864
<Helmet>
9-
<title>{tenant.title}</title>
65+
<title>{pageTitle}</title>
1066
</Helmet>
1167
);
1268
};

web/src/components/common/Navigation/Breadcrumb.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@ import { UIMatch, useMatches } from 'react-router';
66
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
77
import { faHome } from '@fortawesome/pro-regular-svg-icons';
88

9-
type BreadcrumbProps = {
9+
export type BreadcrumbProps = {
1010
name: string;
1111
link?: string;
12+
// If true, this crumb will be considered an index page for its parent and will not be used
13+
// when generating the page title, though the breadcrumb will still render unaffected.
14+
title?: string; // Optional custom title for document title generation, if different from the breadcrumb name
15+
isIndex?: boolean;
1216
};
1317

1418
/**
@@ -58,14 +62,15 @@ export const BreadcrumbTrail: React.FC<{ crumbs: BreadcrumbProps[]; smallScreenO
5862
);
5963
};
6064

61-
type UICrumbFunction = (data: unknown) => Promise<BreadcrumbProps> | BreadcrumbProps;
65+
export type UICrumbFunction = (data: unknown) => Promise<BreadcrumbProps> | BreadcrumbProps;
6266

63-
interface UIRouteHandle {
67+
export interface UIRouteHandle {
6468
crumb?: UICrumbFunction;
69+
excludeFromTitle?: boolean; // If true, this route's crumb will be excluded from the document title generation
6570
}
6671

6772
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
68-
interface UIMatchWithCrumb extends UIMatch<unknown, UIRouteHandle> {}
73+
export interface UIMatchWithCrumb extends UIMatch<unknown, UIRouteHandle> {}
6974

7075
/**
7176
* Automatically generates breadcrumbs based on the `handle.crumb` function of the current route and its parents.

web/src/components/engagement/dashboard/comment/CommentsBlock.tsx

Lines changed: 6 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import React, { useContext } from 'react';
22
import { Grid2 as Grid, Paper, Skeleton } from '@mui/material';
3-
import { useNavigate, useParams } from 'react-router';
3+
import { useLocation, useParams } from 'react-router';
44
import { Link } from 'components/common/Navigation';
55
import { Button } from 'components/common/Input/Button';
66
import { CommentViewContext } from './CommentViewContext';
77
import { Heading4 } from 'components/common/Typography/Headings';
88
import CommentTable from './CommentTable';
9-
import { useAppSelector, useAppDispatch } from 'hooks';
10-
import { SubmissionStatus } from 'constants/engagementStatus';
11-
import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice';
129
import { useAppTranslation } from 'hooks';
1310
import { faFileChartPie } from '@fortawesome/pro-regular-svg-icons';
1411
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -22,11 +19,9 @@ interface CommentsBlockProps {
2219
export const CommentsBlock: React.FC<CommentsBlockProps> = ({ dashboardType }) => {
2320
const { t: translate } = useAppTranslation();
2421
const { slug, language } = useParams();
22+
const location = useLocation();
2523
const { engagement, isEngagementLoading } = useContext(CommentViewContext);
26-
const navigate = useNavigate();
27-
const dispatch = useAppDispatch();
28-
const canAccessDashboard = useAppSelector((state) => state.user.roles.includes('access_dashboard'));
29-
const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated);
24+
const isAdminPath = location.pathname === '/manage' || location.pathname.startsWith('/manage/');
3025
const publicEngagementLink = getPath(R.PUBLIC_ENGAGEMENT_BY_SLUG, {
3126
slug: slug ?? '',
3227
language: language ?? sessionStorage.getItem('languageId') ?? AppConfig.language.defaultLanguageId,
@@ -42,36 +37,7 @@ export const CommentsBlock: React.FC<CommentsBlockProps> = ({ dashboardType }) =
4237
engagementId: engagementId ?? '',
4338
dashboardType,
4439
});
45-
46-
const handleViewDashboard = () => {
47-
/* check to ensure that users with role access_dashboard can access the dashboard while engagement not closed*/
48-
if (canAccessDashboard) {
49-
if (!engagementId && isLoggedIn) return;
50-
navigate(isLoggedIn ? engagementDashboardLink : publicEngagementDashboardLink);
51-
return;
52-
}
53-
54-
/* check to ensure that all other users can access the dashboard only after the engagement is closed*/
55-
if (engagement?.submission_status !== SubmissionStatus.Closed) {
56-
dispatch(
57-
openNotificationModal({
58-
open: true,
59-
data: {
60-
header: translate('commentDashboard.block.notification.header'),
61-
subText: [{ text: translate('commentDashboard.block.notification.text') }],
62-
},
63-
type: 'update',
64-
}),
65-
);
66-
return;
67-
}
68-
69-
if (isLoggedIn) {
70-
if (!engagementId) return;
71-
navigate(engagementDashboardLink);
72-
}
73-
navigate(publicEngagementDashboardLink);
74-
};
40+
const dashboardURL = isAdminPath ? engagementDashboardLink : publicEngagementDashboardLink;
7541

7642
if (isEngagementLoading || !engagement) {
7743
return <Skeleton width="100%" height="40em" />;
@@ -80,7 +46,7 @@ export const CommentsBlock: React.FC<CommentsBlockProps> = ({ dashboardType }) =
8046
return (
8147
<>
8248
<Grid size={12} container direction="row" justifyContent="flex-end" paddingBottom={'8px'}>
83-
<Link to={isLoggedIn ? adminEngagementViewLink : publicEngagementLink} style={{ color: '#1A5A96' }}>
49+
<Link to={isAdminPath ? adminEngagementViewLink : publicEngagementLink} sx={{ color: 'text.link' }}>
8450
{translate('commentDashboard.block.engagementLink')}
8551
</Link>
8652
</Grid>
@@ -100,7 +66,7 @@ export const CommentsBlock: React.FC<CommentsBlockProps> = ({ dashboardType }) =
10066
icon={<FontAwesomeIcon icon={faFileChartPie} />}
10167
variant="primary"
10268
size="small"
103-
onClick={handleViewDashboard}
69+
href={dashboardURL}
10470
>
10571
{translate('commentDashboard.block.buttonText')}
10672
</Button>

web/src/components/engagement/preview/EngagementPreview.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useState, useRef, useMemo } from 'react';
2-
import { useBlocker, useLoaderData, useParams, useRevalidator } from 'react-router';
2+
import { useBlocker, useRouteLoaderData, useParams, useRevalidator } from 'react-router';
33
import { Box, Modal } from '@mui/material';
44
import PreviewControlBar from './PreviewControlBar';
55
import PreviewContent from './PreviewContent';
@@ -186,7 +186,7 @@ const MeasurementBar: React.FC = () => {
186186
* modifying the actual engagement.
187187
*/
188188
export const EngagementPreview: React.FC = () => {
189-
const loaderData = useLoaderData() as EngagementLoaderPublicData;
189+
const loaderData = useRouteLoaderData('public-single-engagement') as EngagementLoaderPublicData;
190190
const { engagementId, languageCode } = useParams();
191191
const revalidator = useRevalidator();
192192
const [previewState, setPreviewState] = useState<SubmissionStatusTypes>('Upcoming');

web/src/components/engagement/preview/PreviewLoaderDataContext.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { createContext, useContext } from 'react';
2-
import { useLoaderData } from 'react-router';
2+
import { useRouteLoaderData } from 'react-router';
33
import { EngagementLoaderPublicData } from 'components/engagement/public/view/EngagementLoaderPublic';
44

55
const PreviewLoaderDataContext = createContext<EngagementLoaderPublicData | null>(null);
@@ -15,7 +15,7 @@ export const PreviewLoaderDataProvider: React.FC<PreviewLoaderDataProviderProps>
1515

1616
export const useEngagementLoaderData = (): EngagementLoaderPublicData => {
1717
const previewLoaderData = useContext(PreviewLoaderDataContext);
18-
const routeLoaderData = useLoaderData() as EngagementLoaderPublicData;
18+
const routeLoaderData = useRouteLoaderData('public-single-engagement') as EngagementLoaderPublicData;
1919
return previewLoaderData ?? routeLoaderData;
2020
};
2121

web/src/components/engagement/public/view/EngagementLoaderPublic.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export type EngagementLoaderPublicData = {
2424
translationBundle: Promise<TranslationBundle>;
2525
};
2626

27+
export type AwaitedEngagementLoaderPublicData = {
28+
engagement: Engagement;
29+
widgets: Widget[];
30+
details: EngagementDetailsTab[];
31+
metadata: EngagementMetadata[];
32+
taxa: MetadataTaxon[];
33+
languages: Language[];
34+
translationLanguages?: Language[];
35+
suggestions: SuggestedEngagementWithAttachment[];
36+
translationBundle: TranslationBundle;
37+
};
38+
2739
export const engagementLoaderPublic = async ({ params }: { params: Params<string> }) => {
2840
const { slug: slugParam, engagementId, language } = params;
2941
const defaultLanguageCode = AppConfig.language.defaultLanguageId.toLowerCase();

web/src/components/engagement/public/view/SuggestedEngagements.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Heading2 } from 'components/common/Typography';
66
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
77
import { faArrowLeftLong } from '@fortawesome/pro-regular-svg-icons';
88
import { Link } from 'components/common/Navigation';
9-
import { Await, useLoaderData } from 'react-router';
9+
import { Await, useRouteLoaderData } from 'react-router';
1010

1111
import { EngagementLoaderPublicData } from './EngagementLoaderPublic';
1212
import { EngagementViewSections } from '.';
@@ -19,7 +19,9 @@ import { getPath, ROUTES } from 'routes/routes';
1919
import { TranslationBundle, resolveTranslationValue } from './engagementTranslationResolution';
2020

2121
export const SuggestedEngagements = () => {
22-
const { suggestions, engagement, translationBundle } = useLoaderData() as EngagementLoaderPublicData;
22+
const { suggestions, engagement, translationBundle } = useRouteLoaderData(
23+
'public-single-engagement',
24+
) as EngagementLoaderPublicData;
2325
const engagementSlots = Array.from({ length: 3 });
2426
const { isPreviewMode } = usePreview();
2527

0 commit comments

Comments
 (0)