Skip to content

Dashboard v2: implement breadcrumbs #103294

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

Closed
wants to merge 2 commits into from
Closed
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
3 changes: 2 additions & 1 deletion client/dashboard/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ module.exports = {
'@automattic/components/*',
'!@automattic/components/src',
'@automattic/components/src/*',
'!@automattic/components/src/summary-button',
'!@automattic/components/src/core-badge',
'!@automattic/components/src/breadcrumbs',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clicking the breadcrumb items will trigger a page reload. This is because it's a regular link, not a router link. I don't have any good idea yet how to solve it. Need help from @Automattic/io.

How difficult is it to just create the breadcrumb links ourselves? In this case, it looks like there will only ever be a single breadcrumb link, looking at the designs, so this doesn't seem that complex to add a single {link} / above the header?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I honestly initially thought about that... but then why are we creating a full-blown <Breadcrumbs /> DS component if it's never going to be used beyond the above simple case? That thinking made me end up trying to come up with a more general breadcrumbs system 🥲

Even if we have to hardcode the router link, we will need to copy-paste the styles from the <Breadcrumbs /> component. Not sure if that's a good idea design-wise 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if we're only ever going to add one link then we can remove that component eventually. I don't think we should necessarily import styles from Calypso components, we should focus on using @wordpress styles with limited customisation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, this is not what I anticipated; I thought the idea is to make use of the reusable DS components. I'll see what I can do next week; it's getting late for me 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or I can try to pass something to the link's onClick props and see if I can properly call TanStack router navigation with it.

<a href={ href } onClick={ onClick } className="a8c-components-breadcrumbs__item">

I'd love to get input from @ntsekouras as the component author 🙏

Copy link
Member

@ntsekouras ntsekouras May 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or I can try to pass something to the link's onClick props and see if I can properly call TanStack router navigation with it.

For start that what I'd try to do personally, just for now. Or it might had to do with the path (relative/full, etc..).

Breadcrumbs will be part of the PageHeader component, that will land soon. My plan was to implement what this PR does, after the merge of PageHeader and try to see if we can make it work with a tags, but if it can't, we'll add support in the Breadcrumbs component to pass the element we want instead of a ( in this case Link). This was mentioned in the Breadcrumbs PR already.

So, for me the important part here is to create the functionality to retrieve the proper breadcrumb items and for now we could just use directly a Link and not the Breadcrumbs if not possible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened a PR for this: #103321

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see if I can properly call TanStack router navigation with it.

If you do want to shortcut #103321 you can implement the onClick handler like

export default function Breadcrumbs() {
	const matches = useMatches();
	const navigate = useNavigate();

	const items = [] as BreadcrumbItemProps[];

	let hasParent = false;
	matches.forEach( ( match ) => {
		if ( match.loaderData?.breadcrumbItemLabel || hasParent ) {
			items.push( {
				label: match.loaderData?.breadcrumbItemLabel || '',
				href: match.pathname,
				onClick: ( e ) => {
					e.preventDefault();
					navigate( { to: match.pathname } );
				},
			} );
			hasParent = true;
		}
	} );

	return <BreadcrumbsComponent items={ items } />;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@p-jackson The downside of this approach is that it doesn't support "preloading". See #103283 and same issue also being solved for DataViews #103328

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks all. I understand that we're discussing how we will support route links for the breadcrumbs in the linked PR above. To keep progressing, can we then move forward with the existing Breadcrumbs component behavior in this PR, and then we can follow-up later when we have decided how to support route links? At least, in this PR, we managed to collect the breadcrumbs and show them in the appropriate location.

'!@automattic/components/src/summary-button',
'!@automattic/dataviews',
// Please do not add exceptions unless agreed on
// with the #architecture group.
Expand Down
23 changes: 23 additions & 0 deletions client/dashboard/app/breadcrumbs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Breadcrumbs as BreadcrumbsComponent } from '@automattic/components/src/breadcrumbs';
import { useMatches } from '@tanstack/react-router';
import type { BreadcrumbItemProps } from '@automattic/components/src/breadcrumbs/types';

export default function Breadcrumbs() {
const matches = useMatches();

const items = [] as BreadcrumbItemProps[];

let hasParent = false;
matches.forEach( ( match ) => {
const breadcrumbItemLabel = match.staticData?.breadcrumbItemLabel;
if ( breadcrumbItemLabel || hasParent ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd expect extracting the breadcrumbs based on how we register the routes (route.addChildren). Isn't that possible? It seems weird to have a hasParent flag based on breadcrumbItemLabel existence.

items.push( {
label: breadcrumbItemLabel?.() || '',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wouldn't make any sense to have an empty label, so we could move this check before pushing the item.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a "hack" because the individual settings subpage needs to be in the breadcrumb items; even if it's not shown (because it's the last item).

Let me think on how to make it cleaner...

href: match.pathname,
} );
hasParent = true;
}
} );

return <BreadcrumbsComponent items={ items } />;
}
13 changes: 7 additions & 6 deletions client/dashboard/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
redirect,
createLazyRoute,
} from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
import { fetchTwoStep } from '../data';
import NotFound from './404';
import UnknownError from './500';
Expand Down Expand Up @@ -109,6 +110,9 @@ const siteSettingsRoute = createRoute( {
path: 'settings',
loader: ( { params: { siteSlug } } ) =>
queryClient.ensureQueryData( siteSettingsQuery( siteSlug ) ),
staticData: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have the full picture yet regarding the proper implementation, but here we decouple the route breadcrumb label with the label we use for the menus in the app. Would it be possible and/or preferable to have a single label in the route and extract this in the menus?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already the case right now, we're duplicating the page title (e.g. Overview, Deployments) in the menu and in the individual component for that menu. Let me think how to best address this...

breadcrumbItemLabel: () => __( 'Settings' ),
},
} ).lazy( () =>
import( '../sites/settings' ).then( ( d ) =>
createLazyRoute( 'site-settings' )( {
Expand All @@ -118,10 +122,8 @@ const siteSettingsRoute = createRoute( {
);

const siteSettingsSubscriptionGiftingRoute = createRoute( {
getParentRoute: () => siteRoute,
path: 'settings/subscription-gifting',
loader: ( { params: { siteSlug } } ) =>
queryClient.ensureQueryData( siteSettingsQuery( siteSlug ) ),
getParentRoute: () => siteSettingsRoute,
path: 'subscription-gifting',
} ).lazy( () =>
import( '../sites/settings-subscription-gifting' ).then( ( d ) =>
createLazyRoute( 'site-settings-subscription-gifting' )( {
Expand Down Expand Up @@ -292,8 +294,7 @@ const createRouteTree = ( config: AppConfig ) => {
siteOverviewRoute,
siteDeploymentsRoute,
sitePerformanceRoute,
siteSettingsRoute,
siteSettingsSubscriptionGiftingRoute,
siteSettingsRoute.addChildren( [ siteSettingsSubscriptionGiftingRoute ] ),
] )
);
}
Expand Down
7 changes: 7 additions & 0 deletions client/dashboard/app/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import '@tanstack/react-router';

declare module '@tanstack/react-router' {
interface StaticDataRouteOption {
breadcrumbItemLabel?: () => string;
}
}
2 changes: 2 additions & 0 deletions client/dashboard/components/page-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
__experimentalHeading as Heading,
__experimentalText as Text,
} from '@wordpress/components';
import Breadcrumbs from '../../app/breadcrumbs';
import './style.scss';

const sizes = {
Expand Down Expand Up @@ -31,6 +32,7 @@ function PageLayout( {
return (
<VStack spacing={ 8 } className="dashboard-page-layout" style={ sizes[ size ] }>
<VStack spacing={ 4 }>
<Breadcrumbs />
<HStack justify="space-between" alignment="center">
<Heading level={ 1 } style={ { flexShrink: 0 } }>
{ title }
Expand Down
13 changes: 11 additions & 2 deletions client/dashboard/sites/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { Outlet, useMatch, useMatches } from '@tanstack/react-router';
import {
__experimentalHeading as Heading,
__experimentalVStack as VStack,
Card,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { siteQuery, siteSettingsQuery } from '../../app/queries';
import { siteRoute } from '../../app/router';
import { siteSettingsRoute } from '../../app/router';
import PageLayout from '../../components/page-layout';
import SubscriptionGiftingSettingsSummary from '../settings-subscription-gifting/summary';

export default function SiteSettings() {
const { siteSlug } = siteRoute.useParams();
const { siteSlug } = siteSettingsRoute.useParams();
const { data: siteData } = useQuery( siteQuery( siteSlug ) );
const { data: settings } = useQuery( siteSettingsQuery( siteSlug ) );

const matches = useMatches();
const match = useMatch( { from: siteSettingsRoute.id } );

if ( ! siteData || ! settings ) {
return null;
}

const isExactMatch = match.id === matches[ matches.length - 1 ].id;
if ( ! isExactMatch ) {
return <Outlet />;
Copy link
Contributor

@ellatrix ellatrix May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why <Outlet /> is needed here. It doesn't seem like the right place especially after already calling useQuery. Why is it needed here, but not for ${siteSlug} (Overview) and {$siteSlug}/sub-page?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want the subpage routes (in this case siteSettingsSubscriptionGiftingRoute) to be a child of siteSettingsRoute for some reasons:

  • so that they share the parent route prefix (i.e. settings/)
  • so that they share the parent loader logic (i.e. preloading siteSettingsQuery())
  • (especially for this PR) so that the parent route MATCHES so its route label will be collected properly to construct the breadcrumbs. In other words, we want /sites/${siteSlug}/settings to be always matched in the subpages.

So, if it's an exact match, we want to render the Settings page, otherwise, we should render <Outlet /> so that it renders the child component instead (renders the settings subpages).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it needed here, but not for ${siteSlug} (Overview) and {$siteSlug}/sub-page?

siteRoute is already a parent of its children routes. It's already always shown in the subroutes. It renders as the site navigation menu (Overview, Deployments, etc).

On the other hands, we cant really compose Settings and its subpages. In /sites/${slug}/settings we render the list of setting summaries; in the child pages we render the settings of the subpages only. Hence the conditionals.

}

return (
<PageLayout title={ __( 'Settings' ) } size="small">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be the same label as the breadcrumb? Could it be more generally the route label?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually yes. @ntsekouras also had a similar comment in that it should also match with the nav menus (

<ResponsiveMenu.Item to={ `/sites/${ siteSlug }` } activeOptions={ { exact: true } }>
{ __( 'Overview' ) }
</ResponsiveMenu.Item>
<ResponsiveMenu.Item to={ `/sites/${ siteSlug }/deployments` }>
{ __( 'Deployments' ) }
</ResponsiveMenu.Item>
<ResponsiveMenu.Item to={ `/sites/${ siteSlug }/performance` }>
{ __( 'Performance' ) }
</ResponsiveMenu.Item>
<ResponsiveMenu.Item to={ `/sites/${ siteSlug }/settings` }>
{ __( 'Settings' ) }
</ResponsiveMenu.Item>
).

If we want to use route label, given we want to put the labels in the router level as proposed in this PR, this will then become something like

<PageLayout title={siteSettingsRoute.options.staticData.breadcrumbItemLabel()} />

but I'm not sure if it's a good pattern in TanStack. Another way is maybe write some hook called usePageTitle() then get the label of the last matching route. But then I wonder if this logic should be in <PageLayout /> directly instead.

In any case, it's a good follow-up thing to do, whatever approach we take!

<Heading>{ __( 'General' ) }</Heading>
Expand Down