diff --git a/static/app/stories/view/index.tsx b/static/app/stories/view/index.tsx index 444f810c744637..6641528a1e28c5 100644 --- a/static/app/stories/view/index.tsx +++ b/static/app/stories/view/index.tsx @@ -1,4 +1,3 @@ -import {useMemo} from 'react'; import styled from '@emotion/styled'; import {Alert} from 'sentry/components/core/alert'; @@ -44,10 +43,7 @@ function StoriesLanding() { function StoryDetail() { useStoryRedirect(); const location = useLocation<{name: string; query?: string}>(); - const files = useMemo( - () => [location.state?.storyPath ?? location.query.name], - [location.state?.storyPath, location.query.name] - ); + const files = [location.state?.storyPath ?? location.query.name]; const story = useStoriesLoader({files}); return ( diff --git a/static/app/stories/view/storySearch.tsx b/static/app/stories/view/storySearch.tsx index b0f109a101475c..3864c83297dc65 100644 --- a/static/app/stories/view/storySearch.tsx +++ b/static/app/stories/view/storySearch.tsx @@ -1,5 +1,5 @@ import type {Key} from 'react'; -import {useCallback, useMemo, useRef, useState} from 'react'; +import {useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import {type AriaComboBoxProps} from '@react-aria/combobox'; import {Item, Section} from '@react-stately/collections'; @@ -135,10 +135,10 @@ function SearchInput( type SearchComboBoxItem = T | StorySection; -interface SearchComboBoxProps - extends Omit>, 'children'> { - children: CollectionChildren>; - defaultItems: Array>; +interface SearchComboBoxProps + extends Omit>, 'children'> { + children: CollectionChildren>; + defaultItems: Array>; inputRef: React.RefObject; description?: string | null; label?: string; @@ -149,20 +149,23 @@ function filter(textValue: string, inputValue: string): boolean { return match.score > 0; } -function SearchComboBox(props: SearchComboBoxProps) { +function SearchComboBox(props: SearchComboBoxProps) { const [inputValue, setInputValue] = useState(''); const {inputRef} = props; const listBoxRef = useRef(null); const popoverRef = useRef(null); const navigate = useNavigate(); - const handleSelectionChange = useCallback( - (key: Key | null) => { - if (key) { - navigate(`/stories?name=${key}`, {replace: true}); - } - }, - [navigate] - ); + const handleSelectionChange = (key: Key | null) => { + if (!key) { + return; + } + const node = getStoryTreeNodeFromKey(key, props); + if (!node) { + return; + } + const {state, ...to} = node.location; + navigate(to, {replace: true, state}); + }; const state = useComboBoxState({ ...props, @@ -175,7 +178,7 @@ function SearchComboBox(props: SearchComboBoxProps) }); const {inputProps, listBoxProps, labelProps} = useSearchTokenCombobox< - SearchComboBoxItem + SearchComboBoxItem >( { ...props, @@ -242,3 +245,20 @@ const SectionTitle = styled('span')` font-weight: 600; text-transform: uppercase; `; + +function getStoryTreeNodeFromKey( + key: Key, + props: SearchComboBoxProps +): StoryTreeNode | undefined { + for (const category of props.defaultItems) { + if (isStorySection(category)) { + for (const node of category.options) { + const match = node.find(item => item.filesystemPath === key); + if (match) { + return match; + } + } + } + } + return undefined; +} diff --git a/static/app/stories/view/storyTree.tsx b/static/app/stories/view/storyTree.tsx index 4531c238834ea0..a8fda550c71608 100644 --- a/static/app/stories/view/storyTree.tsx +++ b/static/app/stories/view/storyTree.tsx @@ -1,6 +1,7 @@ import {useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; -import * as qs from 'query-string'; +import type {LocationDescriptorObject} from 'history'; +import kebabCase from 'lodash/kebabCase'; import {Flex} from 'sentry/components/core/layout'; import {Link} from 'sentry/components/core/link'; @@ -14,6 +15,8 @@ export class StoryTreeNode { public label: string; public path: string; public filesystemPath: string; + public category: StoryCategory; + public location: LocationDescriptorObject; public visible = true; public expanded = false; @@ -26,6 +29,19 @@ export class StoryTreeNode { this.label = normalizeFilename(name); this.path = path; this.filesystemPath = filesystemPath; + this.category = inferFileCategory(filesystemPath); + this.location = this.getLocation(); + } + + private getLocation(): LocationDescriptorObject { + const state = {storyPath: this.filesystemPath}; + if (this.category === 'shared') { + return {pathname: '/stories/', query: {name: this.filesystemPath}, state}; + } + return { + pathname: `/stories/${this.category}/${kebabCase(this.label)}`, + state, + }; } find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined { @@ -111,10 +127,10 @@ function folderOrSearchScoreFirst( return a[0].localeCompare(b[0]); } -const order: FileCategory[] = ['components', 'hooks', 'views', 'assets', 'styles']; +const order: StoryCategory[] = ['foundations', 'core', 'shared']; function rootCategorySort( - a: [FileCategory | string, StoryTreeNode], - b: [FileCategory | string, StoryTreeNode] + a: [StoryCategory | string, StoryTreeNode], + b: [StoryCategory | string, StoryTreeNode] ) { if (isFolderNode(a[1]) && isFolderNode(b[1])) { return a[0].localeCompare(b[0]); @@ -128,8 +144,8 @@ function rootCategorySort( return -1; } - if (order.includes(a[0] as FileCategory) && order.includes(b[0] as FileCategory)) { - return order.indexOf(a[0] as FileCategory) - order.indexOf(b[0] as FileCategory); + if (order.includes(a[0] as StoryCategory) && order.includes(b[0] as StoryCategory)) { + return order.indexOf(a[0] as StoryCategory) - order.indexOf(b[0] as StoryCategory); } return a[0].localeCompare(b[0]); @@ -148,28 +164,26 @@ function normalizeFilename(filename: string) { ); } -type FileCategory = 'hooks' | 'components' | 'views' | 'styles' | 'assets'; +export type StoryCategory = 'foundations' | 'core' | 'shared'; -function inferFileCategory(path: string): FileCategory { - const parts = path.split('/'); - const filename = parts.at(-1); - if (filename?.startsWith('use')) { - return 'hooks'; +function inferFileCategory(path: string): StoryCategory { + if (isCoreFile(path)) { + return 'core'; } - if (parts[1]?.startsWith('icons') || path.endsWith('images.stories.tsx')) { - return 'assets'; + if (isFoundationFile(path)) { + return 'foundations'; } - if (parts[1]?.startsWith('views')) { - return 'views'; - } + return 'shared'; +} - if (parts[1]?.startsWith('styles')) { - return 'styles'; - } +function isCoreFile(file: string) { + return file.includes('components/core'); +} - return 'components'; +function isFoundationFile(file: string) { + return file.includes('app/styles') || file.includes('app/icons'); } function inferComponentName(path: string): string { @@ -467,12 +481,13 @@ function Folder(props: {node: StoryTreeNode}) { function File(props: {node: StoryTreeNode}) { const location = useLocation(); - const query = qs.stringify({...location.query, name: props.node.filesystemPath}); + const {state, ...to} = props.node.location; return (
  • { + useLayoutEffect(() => { // If we already have a `storyPath` in state, bail out - if (location.state?.storyPath ?? location.query.name) { + if (location.state?.storyPath) { return; } if (!location.pathname.startsWith('/stories')) { @@ -49,7 +51,6 @@ export function useStoryRedirect() { }, [location, navigate, stories]); } -type StoryCategory = keyof ReturnType; interface StoryMeta { category: StoryCategory; label: string;