Skip to content

Commit

Permalink
refactor(breadcrumbs): simplify and refactor breadcrumbs (#434)
Browse files Browse the repository at this point in the history
* refactor: simplify and improve breadcrumbitem

* fix: fix tests

* fix: improve route handle type
  • Loading branch information
Birkbjo authored Nov 11, 2024
1 parent 791ecb2 commit e2dc678
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 66 deletions.
31 changes: 29 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2024-11-05T07:23:15.731Z\n"
"PO-Revision-Date: 2024-11-05T07:23:15.732Z\n"
"POT-Creation-Date: 2024-11-05T14:43:19.245Z\n"
"PO-Revision-Date: 2024-11-05T14:43:19.245Z\n"

msgid "schemas"
msgstr "schemas"
Expand All @@ -33,6 +33,12 @@ msgstr "Not found"
msgid "The page you are looking for does not exist."
msgstr "The page you are looking for does not exist."

msgid "New {{modelName}}"
msgstr "New {{modelName}}"

msgid "Edit {{modelName}}"
msgstr "Edit {{modelName}}"

msgid "Metadata Overview"
msgstr "Metadata Overview"

Expand Down Expand Up @@ -1154,6 +1160,27 @@ msgstr ""
"included. PHU will still be available for the PHU level, but not included "
"in the aggregations to the levels above."

msgid "Setup"
msgstr "Setup"

msgid "Data"
msgstr "Data"

msgid "Data Elements"
msgstr "Data Elements"

msgid "Periods"
msgstr "Periods"

msgid "Organisation Units"
msgstr "Organisation Units"

msgid "Form"
msgstr "Form"

msgid "Advanced"
msgstr "Advanced"

msgid "Longitude"
msgstr "Longitude"

Expand Down
18 changes: 11 additions & 7 deletions src/app/layout/Breadcrumb.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,32 @@
margin-bottom: 8px;
}

.breadcrumbItem {
color: var(--colors-blue600);
text-decoration: none;
.breadCrumbItem {
font-size: 14px;
line-height: 18px;
}

span.breadcrumbItem {
.breadcrumbItemLink {
composes: breadCrumbItem;
color: var(--colors-blue600);
text-decoration: none;
}

span.breadcrumbItemLink {
cursor: pointer;
}

.breadcrumbItem:hover {
.breadcrumbItemLink:hover {
text-decoration: underline;
color: var(--colors-blue700);
}

.breadcrumbItem:active {
.breadcrumbItemLink:active {
text-decoration: underline;
color: var(--colors-blue900);
}

.breadcrumbItem:focus {
.breadcrumbItemLink:focus {
outline: 1px solid var(--colors-blue600);
}

Expand Down
45 changes: 24 additions & 21 deletions src/app/layout/Breadcrumb.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import '@testing-library/jest-dom'
import { configure, render } from '@testing-library/react'
import React from 'react'
import { useMatches, HashRouter } from 'react-router-dom'
import { OVERVIEW_SECTIONS, SECTIONS_MAP } from '../../lib'
import {
getOverviewPath,
getSectionPath,
OVERVIEW_SECTIONS,
SECTIONS_MAP,
} from '../../lib'
import { MatchRouteHandle, RouteHandle } from '../routes/types'
import { Breadcrumbs, BreadcrumbItem } from './Breadcrumb'

Expand Down Expand Up @@ -34,9 +39,12 @@ beforeEach(() => {
})

describe('BreadcrumbItem', () => {
it('should render a link based on the section title', () => {
it('should render a link based on the given label', () => {
const { getByRole } = render(
<BreadcrumbItem section={SECTIONS_MAP.dataElement} />,
<BreadcrumbItem
label={'Data element'}
to={`/${getSectionPath(SECTIONS_MAP.dataElement)}`}
/>,
{ wrapper: HashRouter }
)

Expand All @@ -46,9 +54,12 @@ describe('BreadcrumbItem', () => {
expect(DataElementLink).toHaveAttribute('href', '#/dataElements')
})

it('should render a link to overview-section using plural title if overview-section', () => {
it('should render a correct link to overview-section using plural title if overview-section', () => {
const { getByRole } = render(
<BreadcrumbItem section={OVERVIEW_SECTIONS.dataElement} />,
<BreadcrumbItem
to={`/${getOverviewPath(OVERVIEW_SECTIONS.dataElement)}`}
label={'Data elements'}
/>,
{ wrapper: HashRouter }
)

Expand All @@ -60,20 +71,6 @@ describe('BreadcrumbItem', () => {
'#/overview/dataElements'
)
})
it('should render a link with label-prop instead of section title if provided', () => {
const { getByRole } = render(
<BreadcrumbItem
section={SECTIONS_MAP.dataElement}
label={'Custom label'}
/>,
{ wrapper: HashRouter }
)

const DataElementLink = getByRole('link')
expect(DataElementLink).toBeDefined()
expect(DataElementLink).toHaveTextContent('Data elements')
expect(DataElementLink).toHaveAttribute('href', '#/dataElements')
})
})
describe('Breadcrumbs', () => {
it('should render crumb components in handle', () => {
Expand All @@ -97,13 +94,19 @@ describe('Breadcrumbs', () => {
mockHandle({
crumb: () => (
<BreadcrumbItem
section={OVERVIEW_SECTIONS.dataElement}
to={`/${getOverviewPath(
OVERVIEW_SECTIONS.dataElement
)}`}
label={OVERVIEW_SECTIONS.dataElement.titlePlural}
/>
),
}),
mockHandle({
crumb: () => (
<BreadcrumbItem section={SECTIONS_MAP.dataElement} />
<BreadcrumbItem
to={`/${getSectionPath(SECTIONS_MAP.dataElement)}`}
label={SECTIONS_MAP.dataElement.titlePlural}
/>
),
}),
])
Expand Down
45 changes: 28 additions & 17 deletions src/app/layout/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,55 @@
import React from 'react'
import { Link, useMatches } from 'react-router-dom'
import {
Section,
isOverviewSection,
getSectionPath,
getOverviewPath,
useToWithSearchState,
} from '../../lib'
import { Link, To, useLocation, useMatches, matchPath } from 'react-router-dom'
import { useToWithSearchState } from '../../lib'
import type { MatchRouteHandle } from '../routes/types'
import css from './Breadcrumb.module.css'

const BreadcrumbSeparator = () => <span className={css.separator}>/</span>

type BreadcrumbItemProps = {
section: Section
label?: string
label: string
to: To
}

export const BreadcrumbItem = ({ section, label }: BreadcrumbItemProps) => {
const isOverview = isOverviewSection(section)
const link = isOverview ? getOverviewPath(section) : getSectionPath(section)
const to = useToWithSearchState(`/${link}`)
export const BreadcrumbItem = ({ label, to }: BreadcrumbItemProps) => {
const resolvedTo = useToWithSearchState(to)
const currentLoc = useLocation()

label = label ?? isOverview ? section.titlePlural : section.title
if (resolvedTo.pathname) {
const match = matchPath(resolvedTo.pathname, currentLoc.pathname)
if (match?.pattern.end) {
return <BreadCrumbEndItem label={label} />
}
}

return (
<Link className={css.breadcrumbItem} to={to}>
<Link
className={css.breadcrumbItemLink}
to={resolvedTo}
state={{ search: resolvedTo.search }}
>
{label}
</Link>
)
}

/** Component that is used for "End links", where the current route is the end of the path
* and thus should not be a link */
export const BreadCrumbEndItem = ({ label }: { label: string }) => (
<span className={css.breadcrumbItem}>{label}</span>
)

export const Breadcrumbs = () => {
const matches = useMatches() as MatchRouteHandle[]

const crumbs = matches
.filter((match) => match.handle?.crumb)
.map((match) => (
<span key={match.id}>
{match.handle?.crumb?.()}
{match.handle?.crumb?.({
params: match.params,
pathname: match.pathname,
})}
<BreadcrumbSeparator />
</span>
))
Expand Down
71 changes: 56 additions & 15 deletions src/app/routes/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import i18n from '@dhis2/d2-i18n'
import React from 'react'
import {
createHashRouter,
Expand All @@ -20,13 +21,14 @@ import {
isModuleNotFoundError,
isValidUid,
routePaths,
getOverviewPath,
} from '../../lib'
import { OverviewSection } from '../../types'
import { Layout, Breadcrumbs, BreadcrumbItem } from '../layout'
import { CheckAuthorityForSection } from './CheckAuthorityForSection'
import { DefaultErrorRoute } from './DefaultErrorRoute'
import { LegacyAppRedirect } from './LegacyAppRedirect'

import { RouteHandle } from './types'
// This loads all the overview routes in the same chunk since they resolve to the same promise
// see https://reactrouter.com/en/main/route/lazy#multiple-routes-in-a-single-file
// Overviews are small, and the AllOverview would load all the other overviews anyway,
Expand Down Expand Up @@ -109,14 +111,22 @@ const schemaSectionRoutes = Object.values(SCHEMA_SECTIONS).map((section) => (
<Route
key={section.namePlural}
path={getSectionPath(section)}
handle={{
section,
crumb: () => (
<BreadcrumbItem
section={OVERVIEW_SECTIONS[section.parentSectionKey]}
/>
),
}}
handle={
{
section,
crumb: () => (
<BreadcrumbItem
label={
OVERVIEW_SECTIONS[section.parentSectionKey]
.titlePlural
}
to={`/${getOverviewPath(
OVERVIEW_SECTIONS[section.parentSectionKey]
)}`}
/>
),
} satisfies RouteHandle
}
element={
<>
<Breadcrumbs />
Expand All @@ -126,21 +136,52 @@ const schemaSectionRoutes = Object.values(SCHEMA_SECTIONS).map((section) => (
>
<Route index lazy={createSectionLazyRouteFunction(section, 'List')} />
<Route
handle={{
hideSidebar: true,
crumb: () => <BreadcrumbItem section={section} />,
}}
handle={
{
hideSidebar: true,
crumb: (matchInfo) => (
<BreadcrumbItem
label={section.title}
to={matchInfo.pathname}
/>
),
} satisfies RouteHandle
}
>
{!sectionsNoNewRoute.has(section) && (
<Route
path={routePaths.sectionNew}
lazy={createSectionLazyRouteFunction(section, 'New')}
handle={
{
crumb: (matchInfo) => (
<BreadcrumbItem
label={i18n.t('New {{modelName}}', {
modelName: section.title,
})}
to={matchInfo.pathname}
/>
),
} satisfies RouteHandle
}
/>
)}
<Route path=":id" element={<VerifyModelId />}>
<Route
index
handle={{ showFooter: true }}
handle={
{
showFooter: true,
crumb: (matchInfo) => (
<BreadcrumbItem
label={i18n.t('Edit {{modelName}}', {
modelName: section.title,
})}
to={matchInfo.pathname}
/>
),
} satisfies RouteHandle
}
lazy={createSectionLazyRouteFunction(section, 'Edit')}
/>
</Route>
Expand Down Expand Up @@ -175,7 +216,7 @@ const routes = createRoutesFromElements(
section.componentName,
section
)}
handle={{ section }}
handle={{ section } satisfies RouteHandle}
/>
))}
</Route>
Expand Down
10 changes: 6 additions & 4 deletions src/app/routes/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { useMatches } from 'react-router-dom'
import type { ModelSection } from '../../types'
import type { UIMatch, useMatches } from 'react-router-dom'
import type { ModelSection, OverviewSection } from '../../types'
// utility type to type a match with a handle-property returned from useMatches
// since handle is unknown, we need to cast it to the correct type
type MatchWithHandle<THandle> = ReturnType<typeof useMatches>[number] & {
handle?: THandle
}

export type BreadCrumbMatchInfo = Pick<UIMatch, 'params' | 'pathname'>

// common type for possible handle-properties used in Route
export type RouteHandle = {
hideSidebar?: boolean
section?: ModelSection
section?: ModelSection | OverviewSection
showFooter?: boolean
crumb?: () => React.ReactNode
crumb?: (matchInfo: BreadCrumbMatchInfo) => React.ReactNode
}

export type MatchRouteHandle = MatchWithHandle<RouteHandle>

0 comments on commit e2dc678

Please sign in to comment.