Skip to content

Commit

Permalink
frontend: Improve breadcrumbs UX (#3176)
Browse files Browse the repository at this point in the history
  • Loading branch information
septum authored Nov 15, 2024
1 parent 6929601 commit 1cc0a32
Show file tree
Hide file tree
Showing 4 changed files with 54 additions and 26 deletions.
2 changes: 1 addition & 1 deletion frontend/packages/core/src/AppProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ const ClutchApp = ({
: workflow.displayName;

const workflowLayoutProps: LayoutProps = {
workflow,
workflowsInPath: workflows.filter(w => w.path === workflow.path),
title: heading,
subtitle: route.description,
variant:
Expand Down
4 changes: 2 additions & 2 deletions frontend/packages/core/src/AppProvider/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ interface WorkflowLayoutConfiguration {
/**
* (Optional) property to pass the defined layout properties to all of its defined routes
*/
defaultLayoutProps?: Omit<LayoutProps, "workflow" | "title" | "subtitle">;
defaultLayoutProps?: Omit<LayoutProps, "workflowsInPath" | "title" | "subtitle">;
}

export interface Workflow
Expand Down Expand Up @@ -105,7 +105,7 @@ export interface Route {
/**
* (Optional) property to define layout properties for a single route
*/
layoutProps?: Omit<LayoutProps, "workflow" | "title" | "subtitle">;
layoutProps?: Omit<LayoutProps, "workflowsInPath" | "title" | "subtitle">;
}

export interface ConfiguredRoute extends Route {
Expand Down
17 changes: 6 additions & 11 deletions frontend/packages/core/src/WorkflowLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import React from "react";
import { matchPath, useParams } from "react-router-dom";
import type { Interpolation } from "@emotion/styled";
import type { CSSObject, Theme } from "@mui/material";
import { alpha } from "@mui/material";

import type { Workflow } from "../AppProvider/workflow";
import Breadcrumbs from "../Breadcrumbs";
import { useLocation } from "../navigation";
import { useLocation, useParams } from "../navigation";
import styled from "../styled";
import { Typography } from "../typography";
import { generateBreadcrumbsEntries } from "../utils";

export type LayoutVariant = "standard" | "wizard";

export type LayoutProps = {
workflow: Workflow;
workflowsInPath: Array<Workflow>;
variant?: LayoutVariant | null;
title?: string | ((params: Record<string, string>) => string);
subtitle?: string;
Expand Down Expand Up @@ -88,7 +87,7 @@ const Subtitle = styled(Typography)(({ theme }: { theme: Theme }) => ({
}));

const WorkflowLayout = ({
workflow,
workflowsInPath,
variant = null,
title = null,
subtitle = null,
Expand All @@ -99,22 +98,18 @@ const WorkflowLayout = ({
const params = useParams();
const location = useLocation();

const entries = generateBreadcrumbsEntries(workflowsInPath, location);

if (variant === null) {
return <>{children}</>;
}

const workflowPaths = workflow.routes.map(({ path }) => `/${workflow.path}/${path}`);
const breadcrumbsEntries = generateBreadcrumbsEntries(
location,
url => !!workflowPaths.find(path => !!matchPath({ path }, url))
);

return (
<LayoutContainer $variant={variant}>
{!hideHeader && (
<PageHeader $variant={variant}>
<PageHeaderBreadcrumbsWrapper>
<Breadcrumbs entries={breadcrumbsEntries} />
<Breadcrumbs entries={entries} />
</PageHeaderBreadcrumbsWrapper>
{!breadcrumbsOnly && (title || subtitle) && (
<PageHeaderMainContainer>
Expand Down
57 changes: 45 additions & 12 deletions frontend/packages/core/src/utils/generateBreadcrumbsEntries.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,56 @@
import type { Location } from "react-router-dom";
import { Location, matchPath } from "react-router-dom";

import type { Workflow } from "../AppProvider/workflow";
import type { BreadcrumbEntry } from "../Breadcrumbs";

const generateBreadcrumbsEntries = (location: Location, validateUrl: (url: string) => boolean) => {
const labels = decodeURIComponent(location.pathname)
const HOME_ENTRY = { label: "Home", url: "/" };

const generateBreadcrumbsEntries = (workflowsInPath: Array<Workflow>, location: Location) => {
// The first workflow in the will contain
// the same path and displayName as the others
const firstWorkflow = workflowsInPath[0];

if (!firstWorkflow) {
return [HOME_ENTRY];
}

// Get a single level list of the routes available
const allRoutes = workflowsInPath.flatMap(w => w.routes);

// Add to every item in the routes list the workflow path prefix
const fullPaths = allRoutes.map(({ path }) => `/${firstWorkflow.path}/${path}`);

// Generate a list of path segments from the location
const pathSegments = decodeURIComponent(location.pathname)
.split("/")
.slice(1, location.pathname.endsWith("/") ? -1 : undefined);
.slice(1, location.pathname.endsWith("/") ? -1 : undefined); // in case of a trailing `/`

const entries: Array<BreadcrumbEntry> = [HOME_ENTRY].concat(
pathSegments.map((segment, index) => {
const nextIndex = index + 1;
const url = `/${pathSegments.slice(0, nextIndex).join("/")}`;

const entries: Array<BreadcrumbEntry> = [{ label: "Home", url: "/" }].concat(
labels.map((label, index) => {
let url = `/${labels.slice(0, index + 1).join("/")}`;
const path = fullPaths.find(p => !!matchPath(p, url));

if (!validateUrl(url)) {
url = undefined;
}
// If there is a matched path, it's used to find the route that contains its displayName
const route = path
? allRoutes.find(r =>
r.path.startsWith("/")
? r.path
: // Done in case of an empty path or missing a leading `/`
`/${r.path}` === `/${path.split("/").slice(2).join("/")}`
)
: null;

return {
label,
url,
// For the label:
// - Prioritize the display name
// - Handle the case of a single route with an unusual long name
// - Default to the path segment
label:
route?.displayName || (allRoutes.length === 1 && firstWorkflow.displayName) || segment,
// Set a null url if there is no path or for the last segment
url: !!path && pathSegments.length !== nextIndex ? url : null,
};
})
);
Expand Down

0 comments on commit 1cc0a32

Please sign in to comment.