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

frontend: Improve breadcrumbs UX #3176

Merged
merged 4 commits into from
Nov 15, 2024
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
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
septum marked this conversation as resolved.
Show resolved Hide resolved
// the same path and displayName as the others
const firstWorkflow = workflowsInPath[0];

septum marked this conversation as resolved.
Show resolved Hide resolved
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
Loading