Skip to content

Commit 3bfd0c0

Browse files
committed
[frontend] Introduce NotionLikeRedirector utility component
1 parent 25dbfc3 commit 3bfd0c0

2 files changed

Lines changed: 156 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { screen } from '@testing-library/react';
3+
import { Route, Routes } from 'react-router-dom';
4+
import testRender from '../utils/tests/test-render';
5+
import NotionLikeRedirector from './NotionLikeRedirector';
6+
7+
describe('NotionLikeRedirector', () => {
8+
it('matches on exact path', () => {
9+
testRender(
10+
<Routes>
11+
<Route
12+
path="*"
13+
element={(
14+
<NotionLikeRedirector
15+
pagesInfo={{
16+
'6e007ff3e3df41c6bfa016862be6cd4d': {
17+
path: 'some-path-6e007ff3e3df41c6bfa016862be6cd4d',
18+
},
19+
}}
20+
renderMatch={() => 'Yes'}
21+
NoMatch="No"
22+
/>
23+
)}
24+
/>
25+
</Routes>,
26+
{
27+
route: 'some-path-6e007ff3e3df41c6bfa016862be6cd4d',
28+
},
29+
);
30+
expect(screen.getByText('Yes')).toBeInTheDocument();
31+
});
32+
33+
it('matches on same id but different slug', () => {
34+
testRender(
35+
<Routes>
36+
<Route
37+
path="*"
38+
element={(
39+
<NotionLikeRedirector
40+
pagesInfo={{
41+
'6e007ff3e3df41c6bfa016862be6cd4d': {
42+
path: 'some-path-6e007ff3e3df41c6bfa016862be6cd4d',
43+
},
44+
}}
45+
renderMatch={() => 'Yes'}
46+
NoMatch="No"
47+
/>
48+
)}
49+
/>
50+
</Routes>,
51+
{
52+
route: 'old-slug-6e007ff3e3df41c6bfa016862be6cd4d',
53+
},
54+
);
55+
expect(screen.getByText('Yes')).toBeInTheDocument();
56+
});
57+
58+
it('renders NoMatch component when no id matches', () => {
59+
testRender(
60+
<Routes>
61+
<Route
62+
path="*"
63+
element={(
64+
<NotionLikeRedirector
65+
pagesInfo={{
66+
'6e007ff3e3df41c6bfa016862be6cd4d': {
67+
path: 'some-path-6e007ff3e3df41c6bfa016862be6cd4d',
68+
},
69+
}}
70+
renderMatch={() => 'Yes'}
71+
NoMatch="No"
72+
/>
73+
)}
74+
/>
75+
</Routes>,
76+
{
77+
route: 'some-path-164a91d4fd574484b29a9c1b3da487eb',
78+
},
79+
);
80+
expect(screen.getByText('No')).toBeInTheDocument();
81+
});
82+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ReactNode } from 'react';
2+
import { Navigate, useParams } from 'react-router-dom';
3+
4+
export interface NotionLikePageInfo {
5+
path: string;
6+
}
7+
8+
interface NotionLikeRedirectorProps {
9+
/** Render prop triggered when a page matches current splat value **/
10+
renderMatch: (pageInfo: NotionLikePageInfo) => ReactNode;
11+
/** Component to render when no page matches **/
12+
NoMatch: ReactNode;
13+
/**
14+
* An object mapping pages' `id`s to a `NotionLikePageInfo`
15+
* that is passed back in the render prop upon match.
16+
*/
17+
pagesInfo: Record<string, NotionLikePageInfo>;
18+
}
19+
20+
/**
21+
* Routing utility that acts on routes ending with a splat ('*')
22+
* to provide a behaviour similar to Notion pages where a page is
23+
* accessible via a path formed by a slug and an id (`/[slug]-[id]`),
24+
* but where providing the correct slug part is not mandatory.
25+
* When the slug part is wrong the redirector redirects to the correct path.
26+
* This behaviour allows having human-friendly URLs while allowing
27+
* changing the page's title (& slug) without the fear of breaking links
28+
* containing previous versions of the slug.
29+
* Important constraint for this to work: the `id` part of the path must
30+
* not contain any hyphens.
31+
*
32+
* @example
33+
* ```
34+
* <Routes>
35+
* <Route path='*' element={
36+
* <NotionLikeRedirector
37+
* renderMatch={({ path }) => `Matched on ${path}`}
38+
* NoMatch="No match :("
39+
* pagesInfo={{
40+
* 'dc6049d4e3fd436b9ed1956c4670517a': {
41+
* path: 'a-great-page-dc6049d4e3fd436b9ed1956c4670517a',
42+
* },
43+
* '73f7840b2b4d444c861bd6a18b9e6d66': {
44+
* path: 'another-great-page-73f7840b2b4d444c861bd6a18b9e6d66',
45+
* },
46+
* }}
47+
* } />
48+
* </Routes>
49+
* ```
50+
*/
51+
const NotionLikeRedirector = ({ renderMatch, NoMatch, pagesInfo }: NotionLikeRedirectorProps) => {
52+
const { '*': splat } = useParams();
53+
if (!splat) {
54+
return NoMatch;
55+
}
56+
let firstSegmentEnd = splat.indexOf('/');
57+
firstSegmentEnd = firstSegmentEnd < 0 ? splat.length : firstSegmentEnd;
58+
const segment = splat.substring(0, firstSegmentEnd);
59+
const dashPos = segment.lastIndexOf('-');
60+
if (dashPos < 0) {
61+
return NoMatch;
62+
}
63+
const id = segment.substring(dashPos + 1);
64+
const pageInfo = pagesInfo[id];
65+
if (!pageInfo) {
66+
return NoMatch;
67+
}
68+
if (pageInfo.path !== segment) {
69+
return <Navigate to={pageInfo.path} />;
70+
}
71+
return renderMatch(pageInfo);
72+
};
73+
74+
export default NotionLikeRedirector;

0 commit comments

Comments
 (0)