Skip to content

Commit 3827265

Browse files
authored
feat(stories): support pretty URLs (#95365)
This PR implements a new routing structure for `/stories`. Closes DE-193. Previously, all `/stories` routes were access via a query param with their full path structure such as `?name=app/components/collapsePanel.stories.tsx`. With this implementation, it was difficult to navigate via URL without understanding our internal file structure (which can also be subject to change). To improve routing ergonomics and stability, the foundations and core components now have permalinks like `/stories/foundations/colors` and `/stories/core/button`. These are automatically picked up by a client redirect, which makes them backwards-compatible with the existing URL structure. For shared components/hooks/etc, the existing `?name=app/components/collapsePanel.stories.tsx` URL structure is maintained. | Type | Before | After | |------|--------|--------| |foundations| `/stories/?name=app/styles/colors.mdx` | `/stories/foundations/colors` | |core component| `/stories?name=app/components/core/button/index.mdx` | `/stories/core/button` | |shared| `/stories/?name=app/utils/useOrganization.stories.tsx` | `/stories/?name=app/utils/useOrganization.stories.tsx` |
1 parent 69490dd commit 3827265

File tree

4 files changed

+114
-7
lines changed

4 files changed

+114
-7
lines changed

static/app/routes.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ function buildRoutes() {
270270
<IndexRoute component={make(() => import('sentry/views/onboarding'))} />
271271
</Route>
272272
<Route
273-
path="/stories/"
273+
path="/stories/:category?/:topic?"
274274
component={make(() => import('sentry/stories/view/index'))}
275275
withOrgPath
276276
/>

static/app/stories/view/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import styled from '@emotion/styled';
44
import {Alert} from 'sentry/components/core/alert';
55
import LoadingIndicator from 'sentry/components/loadingIndicator';
66
import {StorySidebar} from 'sentry/stories/view/storySidebar';
7+
import {useStoryRedirect} from 'sentry/stories/view/useStoryRedirect';
78
import {space} from 'sentry/styles/space';
89
import {useLocation} from 'sentry/utils/useLocation';
910
import OrganizationContainer from 'sentry/views/organizationContainer';
@@ -14,20 +15,21 @@ import {StoryHeader} from './storyHeader';
1415
import {useStoriesLoader, useStoryBookFiles} from './useStoriesLoader';
1516

1617
export default function Stories() {
18+
useStoryRedirect();
1719
const location = useLocation<{name: string; query?: string}>();
1820
const files = useStoryBookFiles();
1921

2022
// If no story is selected, show the landing page stories
2123
const storyFiles = useMemo(() => {
22-
if (!location.query.name) {
24+
if (!(location.state?.storyPath ?? location.query.name)) {
2325
return files.filter(
2426
file =>
2527
file.endsWith('styles/colors.mdx') ||
2628
file.endsWith('styles/typography.stories.tsx')
2729
);
2830
}
29-
return [location.query.name];
30-
}, [files, location.query.name]);
31+
return [location.state?.storyPath ?? location.query.name];
32+
}, [files, location.state?.storyPath, location.query.name]);
3133

3234
const story = useStoriesLoader({files: storyFiles});
3335

static/app/stories/view/storyTree.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export function useStoryTree(
207207
}
208208
) {
209209
const location = useLocation();
210-
const initialName = useRef(location.query.name);
210+
const initialName = useRef(location.state?.storyPath ?? location.query.name);
211211

212212
const tree = useMemo(() => {
213213
const root = new StoryTreeNode('root', '', '');
@@ -412,7 +412,9 @@ function Folder(props: {node: StoryTreeNode}) {
412412
const [expanded, setExpanded] = useState(props.node.expanded);
413413
const location = useLocation();
414414
const hasActiveChild = useMemo(() => {
415-
const child = props.node.find(n => n.filesystemPath === location.query.name);
415+
const child = props.node.find(
416+
n => n.filesystemPath === (location.state?.storyPath ?? location.query.name)
417+
);
416418
return !!child;
417419
}, [location, props.node]);
418420

@@ -471,7 +473,9 @@ function File(props: {node: StoryTreeNode}) {
471473
<li>
472474
<FolderLink
473475
to={`/stories/?${query}`}
474-
active={location.query.name === props.node.filesystemPath}
476+
active={
477+
props.node.filesystemPath === (location.state?.storyPath ?? location.query.name)
478+
}
475479
>
476480
{normalizeFilename(props.node.name)}
477481
</FolderLink>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {useEffect} from 'react';
2+
import kebabCase from 'lodash/kebabCase';
3+
4+
import {useStoryBookFilesByCategory} from 'sentry/stories/view/storySidebar';
5+
import {useLocation} from 'sentry/utils/useLocation';
6+
import {useNavigate} from 'sentry/utils/useNavigate';
7+
8+
type LegacyStoryQuery = {
9+
name: string;
10+
category?: never;
11+
topic?: never;
12+
};
13+
type NewStoryQuery = {
14+
category: StoryCategory;
15+
topic: string;
16+
name?: never;
17+
};
18+
19+
type StoryQuery = LegacyStoryQuery | NewStoryQuery;
20+
21+
export function useStoryRedirect() {
22+
const location = useLocation<StoryQuery>();
23+
const navigate = useNavigate();
24+
const stories = useStoryBookFilesByCategory();
25+
26+
useEffect(() => {
27+
// If we already have a `storyPath` in state, bail out
28+
if (location.state?.storyPath ?? location.query.name) {
29+
return;
30+
}
31+
if (!location.pathname.startsWith('/stories')) {
32+
return;
33+
}
34+
const story = getStoryMeta(location.query, stories);
35+
if (!story) {
36+
return;
37+
}
38+
if (story.category === 'shared') {
39+
navigate(
40+
{pathname: `/stories/`, search: `?name=${encodeURIComponent(story.path)}`},
41+
{replace: true, state: {storyPath: story.path}}
42+
);
43+
} else {
44+
navigate(
45+
{pathname: `/stories/${story.category}/${kebabCase(story.label)}`},
46+
{replace: true, state: {storyPath: story.path}}
47+
);
48+
}
49+
}, [location, navigate, stories]);
50+
}
51+
52+
type StoryCategory = keyof ReturnType<typeof useStoryBookFilesByCategory>;
53+
interface StoryMeta {
54+
category: StoryCategory;
55+
label: string;
56+
path: string;
57+
}
58+
59+
function getStoryMeta(
60+
query: StoryQuery,
61+
stories: ReturnType<typeof useStoryBookFilesByCategory>
62+
) {
63+
if (query.name) {
64+
return legacyGetStoryMetaFromQuery(query, stories);
65+
}
66+
if (query.category && query.topic) {
67+
return getStoryMetaFromQuery(query, stories);
68+
}
69+
return undefined;
70+
}
71+
72+
function legacyGetStoryMetaFromQuery(
73+
query: LegacyStoryQuery,
74+
stories: ReturnType<typeof useStoryBookFilesByCategory>
75+
): StoryMeta | undefined {
76+
for (const category of Object.keys(stories) as StoryCategory[]) {
77+
const nodes = stories[category];
78+
for (const node of nodes) {
79+
const match = node.find(n => n.filesystemPath === query.name);
80+
if (match) {
81+
return {category, label: match.label, path: match.filesystemPath};
82+
}
83+
}
84+
}
85+
return undefined;
86+
}
87+
88+
function getStoryMetaFromQuery(
89+
query: NewStoryQuery,
90+
stories: ReturnType<typeof useStoryBookFilesByCategory>
91+
): StoryMeta | undefined {
92+
const {category, topic} = query;
93+
const nodes = category in stories ? stories[category] : [];
94+
for (const node of nodes) {
95+
const match = node.find(n => kebabCase(n.label) === topic);
96+
if (match) {
97+
return {category, label: match.label, path: match.filesystemPath};
98+
}
99+
}
100+
return undefined;
101+
}

0 commit comments

Comments
 (0)