Skip to content

Commit 3deb0b6

Browse files
authored
fix(stories): adjust routing (#95442)
Follow-up to #95365. Previously, legacy URLs would not be redirected. Now, legacy URLs force a redirect. Previously, the sidebar and search pointed to the legacy URLs, which required a redirect. Now, the sidebar and search leverage the new URLs and pass along `state` info.
1 parent df5471b commit 3deb0b6

File tree

4 files changed

+80
-48
lines changed

4 files changed

+80
-48
lines changed

static/app/stories/view/index.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {useMemo} from 'react';
21
import styled from '@emotion/styled';
32

43
import {Alert} from 'sentry/components/core/alert';
@@ -44,10 +43,7 @@ function StoriesLanding() {
4443
function StoryDetail() {
4544
useStoryRedirect();
4645
const location = useLocation<{name: string; query?: string}>();
47-
const files = useMemo(
48-
() => [location.state?.storyPath ?? location.query.name],
49-
[location.state?.storyPath, location.query.name]
50-
);
46+
const files = [location.state?.storyPath ?? location.query.name];
5147
const story = useStoriesLoader({files});
5248

5349
return (

static/app/stories/view/storySearch.tsx

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {Key} from 'react';
2-
import {useCallback, useMemo, useRef, useState} from 'react';
2+
import {useMemo, useRef, useState} from 'react';
33
import styled from '@emotion/styled';
44
import {type AriaComboBoxProps} from '@react-aria/combobox';
55
import {Item, Section} from '@react-stately/collections';
@@ -135,10 +135,10 @@ function SearchInput(
135135

136136
type SearchComboBoxItem<T extends StoryTreeNode> = T | StorySection;
137137

138-
interface SearchComboBoxProps<T extends StoryTreeNode>
139-
extends Omit<AriaComboBoxProps<SearchComboBoxItem<T>>, 'children'> {
140-
children: CollectionChildren<SearchComboBoxItem<T>>;
141-
defaultItems: Array<SearchComboBoxItem<T>>;
138+
interface SearchComboBoxProps
139+
extends Omit<AriaComboBoxProps<SearchComboBoxItem<StoryTreeNode>>, 'children'> {
140+
children: CollectionChildren<SearchComboBoxItem<StoryTreeNode>>;
141+
defaultItems: Array<SearchComboBoxItem<StoryTreeNode>>;
142142
inputRef: React.RefObject<HTMLInputElement | null>;
143143
description?: string | null;
144144
label?: string;
@@ -149,20 +149,23 @@ function filter(textValue: string, inputValue: string): boolean {
149149
return match.score > 0;
150150
}
151151

152-
function SearchComboBox<T extends StoryTreeNode>(props: SearchComboBoxProps<T>) {
152+
function SearchComboBox(props: SearchComboBoxProps) {
153153
const [inputValue, setInputValue] = useState('');
154154
const {inputRef} = props;
155155
const listBoxRef = useRef<HTMLUListElement | null>(null);
156156
const popoverRef = useRef<HTMLDivElement | null>(null);
157157
const navigate = useNavigate();
158-
const handleSelectionChange = useCallback(
159-
(key: Key | null) => {
160-
if (key) {
161-
navigate(`/stories?name=${key}`, {replace: true});
162-
}
163-
},
164-
[navigate]
165-
);
158+
const handleSelectionChange = (key: Key | null) => {
159+
if (!key) {
160+
return;
161+
}
162+
const node = getStoryTreeNodeFromKey(key, props);
163+
if (!node) {
164+
return;
165+
}
166+
const {state, ...to} = node.location;
167+
navigate(to, {replace: true, state});
168+
};
166169

167170
const state = useComboBoxState({
168171
...props,
@@ -175,7 +178,7 @@ function SearchComboBox<T extends StoryTreeNode>(props: SearchComboBoxProps<T>)
175178
});
176179

177180
const {inputProps, listBoxProps, labelProps} = useSearchTokenCombobox<
178-
SearchComboBoxItem<T>
181+
SearchComboBoxItem<StoryTreeNode>
179182
>(
180183
{
181184
...props,
@@ -242,3 +245,20 @@ const SectionTitle = styled('span')`
242245
font-weight: 600;
243246
text-transform: uppercase;
244247
`;
248+
249+
function getStoryTreeNodeFromKey(
250+
key: Key,
251+
props: SearchComboBoxProps
252+
): StoryTreeNode | undefined {
253+
for (const category of props.defaultItems) {
254+
if (isStorySection(category)) {
255+
for (const node of category.options) {
256+
const match = node.find(item => item.filesystemPath === key);
257+
if (match) {
258+
return match;
259+
}
260+
}
261+
}
262+
}
263+
return undefined;
264+
}

static/app/stories/view/storyTree.tsx

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {useMemo, useRef, useState} from 'react';
22
import styled from '@emotion/styled';
3-
import * as qs from 'query-string';
3+
import type {LocationDescriptorObject} from 'history';
4+
import kebabCase from 'lodash/kebabCase';
45

56
import {Flex} from 'sentry/components/core/layout';
67
import {Link} from 'sentry/components/core/link';
@@ -14,6 +15,8 @@ export class StoryTreeNode {
1415
public label: string;
1516
public path: string;
1617
public filesystemPath: string;
18+
public category: StoryCategory;
19+
public location: LocationDescriptorObject;
1720

1821
public visible = true;
1922
public expanded = false;
@@ -26,6 +29,19 @@ export class StoryTreeNode {
2629
this.label = normalizeFilename(name);
2730
this.path = path;
2831
this.filesystemPath = filesystemPath;
32+
this.category = inferFileCategory(filesystemPath);
33+
this.location = this.getLocation();
34+
}
35+
36+
private getLocation(): LocationDescriptorObject {
37+
const state = {storyPath: this.filesystemPath};
38+
if (this.category === 'shared') {
39+
return {pathname: '/stories/', query: {name: this.filesystemPath}, state};
40+
}
41+
return {
42+
pathname: `/stories/${this.category}/${kebabCase(this.label)}`,
43+
state,
44+
};
2945
}
3046

3147
find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined {
@@ -111,10 +127,10 @@ function folderOrSearchScoreFirst(
111127
return a[0].localeCompare(b[0]);
112128
}
113129

114-
const order: FileCategory[] = ['components', 'hooks', 'views', 'assets', 'styles'];
130+
const order: StoryCategory[] = ['foundations', 'core', 'shared'];
115131
function rootCategorySort(
116-
a: [FileCategory | string, StoryTreeNode],
117-
b: [FileCategory | string, StoryTreeNode]
132+
a: [StoryCategory | string, StoryTreeNode],
133+
b: [StoryCategory | string, StoryTreeNode]
118134
) {
119135
if (isFolderNode(a[1]) && isFolderNode(b[1])) {
120136
return a[0].localeCompare(b[0]);
@@ -128,8 +144,8 @@ function rootCategorySort(
128144
return -1;
129145
}
130146

131-
if (order.includes(a[0] as FileCategory) && order.includes(b[0] as FileCategory)) {
132-
return order.indexOf(a[0] as FileCategory) - order.indexOf(b[0] as FileCategory);
147+
if (order.includes(a[0] as StoryCategory) && order.includes(b[0] as StoryCategory)) {
148+
return order.indexOf(a[0] as StoryCategory) - order.indexOf(b[0] as StoryCategory);
133149
}
134150

135151
return a[0].localeCompare(b[0]);
@@ -148,28 +164,26 @@ function normalizeFilename(filename: string) {
148164
);
149165
}
150166

151-
type FileCategory = 'hooks' | 'components' | 'views' | 'styles' | 'assets';
167+
export type StoryCategory = 'foundations' | 'core' | 'shared';
152168

153-
function inferFileCategory(path: string): FileCategory {
154-
const parts = path.split('/');
155-
const filename = parts.at(-1);
156-
if (filename?.startsWith('use')) {
157-
return 'hooks';
169+
function inferFileCategory(path: string): StoryCategory {
170+
if (isCoreFile(path)) {
171+
return 'core';
158172
}
159173

160-
if (parts[1]?.startsWith('icons') || path.endsWith('images.stories.tsx')) {
161-
return 'assets';
174+
if (isFoundationFile(path)) {
175+
return 'foundations';
162176
}
163177

164-
if (parts[1]?.startsWith('views')) {
165-
return 'views';
166-
}
178+
return 'shared';
179+
}
167180

168-
if (parts[1]?.startsWith('styles')) {
169-
return 'styles';
170-
}
181+
function isCoreFile(file: string) {
182+
return file.includes('components/core');
183+
}
171184

172-
return 'components';
185+
function isFoundationFile(file: string) {
186+
return file.includes('app/styles') || file.includes('app/icons');
173187
}
174188

175189
function inferComponentName(path: string): string {
@@ -467,12 +481,13 @@ function Folder(props: {node: StoryTreeNode}) {
467481

468482
function File(props: {node: StoryTreeNode}) {
469483
const location = useLocation();
470-
const query = qs.stringify({...location.query, name: props.node.filesystemPath});
484+
const {state, ...to} = props.node.location;
471485

472486
return (
473487
<li>
474488
<FolderLink
475-
to={`/stories/?${query}`}
489+
to={to}
490+
state={state}
476491
active={
477492
props.node.filesystemPath === (location.state?.storyPath ?? location.query.name)
478493
}

static/app/stories/view/useStoryRedirect.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import {useEffect} from 'react';
1+
import {useLayoutEffect} from 'react';
22
import kebabCase from 'lodash/kebabCase';
33

4-
import {useStoryBookFilesByCategory} from 'sentry/stories/view/storySidebar';
54
import {useLocation} from 'sentry/utils/useLocation';
65
import {useNavigate} from 'sentry/utils/useNavigate';
76

7+
import {useStoryBookFilesByCategory} from './storySidebar';
8+
import type {StoryCategory} from './storyTree';
9+
810
type LegacyStoryQuery = {
911
name: string;
1012
category?: never;
@@ -23,9 +25,9 @@ export function useStoryRedirect() {
2325
const navigate = useNavigate();
2426
const stories = useStoryBookFilesByCategory();
2527

26-
useEffect(() => {
28+
useLayoutEffect(() => {
2729
// If we already have a `storyPath` in state, bail out
28-
if (location.state?.storyPath ?? location.query.name) {
30+
if (location.state?.storyPath) {
2931
return;
3032
}
3133
if (!location.pathname.startsWith('/stories')) {
@@ -49,7 +51,6 @@ export function useStoryRedirect() {
4951
}, [location, navigate, stories]);
5052
}
5153

52-
type StoryCategory = keyof ReturnType<typeof useStoryBookFilesByCategory>;
5354
interface StoryMeta {
5455
category: StoryCategory;
5556
label: string;

0 commit comments

Comments
 (0)