diff --git a/eslint.config.mjs b/eslint.config.mjs index 12c421a7c7d891..cf7f41b714edc6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -128,17 +128,17 @@ const restrictedImportPaths = [ { name: 'sentry/views/insights/common/components/insightsTimeSeriesWidget', message: - 'Do not use this directly in your view component, see https://sentry.sentry.io/stories/?name=app%2Fviews%2Fdashboards%2Fwidgets%2FtimeSeriesWidget%2FtimeSeriesWidgetVisualization.stories.tsx&query=timeseries#deeplinking for more information', + 'Do not use this directly in your view component, see https://sentry.sentry.io/stories/stories/shared/views/dashboards/widgets/timeserieswidget/timeserieswidgetvisualization#deeplinking for more information', }, { name: 'sentry/views/insights/common/components/insightsLineChartWidget', message: - 'Do not use this directly in your view component, see https://sentry.sentry.io/stories/?name=app%2Fviews%2Fdashboards%2Fwidgets%2FtimeSeriesWidget%2FtimeSeriesWidgetVisualization.stories.tsx&query=timeseries#deeplinking for more information', + 'Do not use this directly in your view component, see https://sentry.sentry.io/stories/stories/shared/views/dashboards/widgets/timeserieswidget/timeserieswidgetvisualization#deeplinking for more information', }, { name: 'sentry/views/insights/common/components/insightsAreaChartWidget', message: - 'Do not use this directly in your view component, see https://sentry.sentry.io/stories/?name=app%2Fviews%2Fdashboards%2Fwidgets%2FtimeSeriesWidget%2FtimeSeriesWidgetVisualization.stories.tsx&query=timeseries#deeplinking for more information', + 'Do not use this directly in your view component, see https://sentry.sentry.io/stories/stories/shared/views/dashboards/widgets/timeserieswidget/timeserieswidgetvisualization#deeplinking for more information', }, ]; diff --git a/static/app/components/core/button/button.mdx b/static/app/components/core/button/button.mdx index ccbe81224d0975..c7fda7fb2e5a6d 100644 --- a/static/app/components/core/button/button.mdx +++ b/static/app/components/core/button/button.mdx @@ -37,15 +37,12 @@ import ButtonDocumentation from '!!type-loader!@sentry/scraps/button'; export const documentation = { exports: { - module: ButtonDocumentation.exports.module, - exports: { - // Button has some exports that we don't want to document, strip them out until they are removed - ...Object.fromEntries( - Object.entries(ButtonDocumentation.exports.exports).filter( - ([key]) => key !== 'StyledButton' && key !== 'ButtonBar' - ) - ), - }, + ...ButtonDocumentation.exports, + exports: Object.fromEntries( + Object.entries(ButtonDocumentation.exports.exports) + .filter(([key]) => key !== 'StyledButton' && key !== 'ButtonBar') + .map(([key, value]) => [key, value.name]) + ), }, props: { ...ButtonDocumentation.props, diff --git a/static/app/components/core/button/linkButton.tsx b/static/app/components/core/button/linkButton.tsx index 822c8635b8fccd..369420ccf4e6a2 100644 --- a/static/app/components/core/button/linkButton.tsx +++ b/static/app/components/core/button/linkButton.tsx @@ -93,7 +93,6 @@ const StyledLinkButton = styled( prop === 'external' || prop === 'replace' || prop === 'preventScrollReset' || - prop === 'state' || (typeof prop === 'string' && isPropValid(prop)), } )` diff --git a/static/app/components/core/button/types.tsx b/static/app/components/core/button/types.tsx index ef381083dd6281..54c6e8cbfb5062 100644 --- a/static/app/components/core/button/types.tsx +++ b/static/app/components/core/button/types.tsx @@ -1,4 +1,4 @@ -import type {LocationDescriptor, LocationState} from 'history'; +import type {LocationDescriptor} from 'history'; import type {TooltipProps} from 'sentry/components/core/tooltip'; @@ -111,11 +111,6 @@ interface LinkButtonPropsWithTo extends BaseLinkButtonProps { * Determines if the link should replace the current history entry. */ replace?: boolean; - - /** - * The state to pass to the link. - */ - state?: LocationState | undefined; } // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/static/app/routes.tsx b/static/app/routes.tsx index cb153d90604c9a..72bf0063fc1102 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -320,9 +320,9 @@ function buildRoutes(): RouteObject[] { ], }, { - path: '/stories/:storyType?/:storySlug?/', - component: make(() => import('sentry/stories/view/index')), + path: '/stories/*', withOrgPath: true, + component: make(() => import('sentry/stories/view/index')), }, { path: '/debug/notifications/:notificationSource?/', diff --git a/static/app/stories/view/index.tsx b/static/app/stories/view/index.tsx index c9d3cfe5804a66..7da7cf12535c73 100644 --- a/static/app/stories/view/index.tsx +++ b/static/app/stories/view/index.tsx @@ -4,8 +4,11 @@ import styled from '@emotion/styled'; import {Alert} from 'sentry/components/core/alert'; import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {StorySidebar} from 'sentry/stories/view/storySidebar'; -import {useStoryRedirect} from 'sentry/stories/view/useStoryRedirect'; +import { + StorySidebar, + useStoryBookFilesByCategory, +} from 'sentry/stories/view/storySidebar'; +import {StoryTreeNode, type StoryCategory} from 'sentry/stories/view/storyTree'; import {useLocation} from 'sentry/utils/useLocation'; import OrganizationContainer from 'sentry/views/organizationContainer'; import RouteAnalyticsContextProvider from 'sentry/views/routeAnalyticsContextProvider'; @@ -16,13 +19,23 @@ import {StoryHeader} from './storyHeader'; import {useStoryDarkModeTheme} from './useStoriesDarkMode'; import {useStoriesLoader} from './useStoriesLoader'; -export default function Stories() { +export function useStoryParams(): {storyCategory?: StoryCategory; storySlug?: string} { const location = useLocation(); - return isLandingPage(location) ? : ; + // Match: /stories/:category/(one/optional/or/more/path/segments) + const match = location.pathname.match(/^\/stories\/([^/]+)\/(.+)/); + return { + storyCategory: match?.[1] as StoryCategory | undefined, + storySlug: match?.[2] ?? undefined, + }; } -function isLandingPage(location: ReturnType) { - return /\/stories\/?$/.test(location.pathname) && !location.query.name; +export default function Stories() { + const location = useLocation(); + return isLandingPage(location) && !location.query.name ? ( + + ) : ( + + ); } function StoriesLanding() { @@ -36,13 +49,37 @@ function StoriesLanding() { } function StoryDetail() { - useStoryRedirect(); + const location = useLocation(); + const {storyCategory, storySlug} = useStoryParams(); + const stories = useStoryBookFilesByCategory(); + + let storyNode = getStoryFromParams(stories, { + category: storyCategory, + slug: storySlug, + }); - const location = useLocation<{name: string; query?: string}>(); + // If we don't have a story node, try to find it by the filesystem path + if (!storyNode && location.query.name) { + const nodes = Object.values(stories).flat(); + const queue = [...nodes]; + + while (queue.length > 0) { + const node = queue.pop(); + if (!node) break; + + if (node.filesystemPath === location.query.name) { + storyNode = node; + break; + } + + for (const key in node.children) { + queue.push(node.children[key]!); + } + } + } - const file = location.state?.storyPath ?? location.query.name; const story = useStoriesLoader({ - files: file ? [file] : [], + files: storyNode ? [storyNode.filesystemPath] : [], }); return ( @@ -93,6 +130,38 @@ function StoriesLayout(props: PropsWithChildren) { ); } +function isLandingPage(location: ReturnType) { + return /^\/stories\/?$/.test(location.pathname); +} + +function getStoryFromParams( + stories: ReturnType, + context: {category?: StoryCategory; slug?: string} +): StoryTreeNode | undefined { + const nodes = stories[context.category as keyof typeof stories] ?? []; + + if (!nodes || nodes.length === 0) { + return undefined; + } + + const queue = [...nodes]; + + while (queue.length > 0) { + const node = queue.pop(); + if (!node) break; + + if (node.slug === context.slug) { + return node; + } + + for (const key in node.children) { + queue.push(node.children[key]!); + } + } + + return undefined; +} + function GlobalStoryStyles() { const theme = useTheme(); const darkTheme = useStoryDarkModeTheme(); diff --git a/static/app/stories/view/landing/index.tsx b/static/app/stories/view/landing/index.tsx index 49cb82375ce337..498f986f4add46 100644 --- a/static/app/stories/view/landing/index.tsx +++ b/static/app/stories/view/landing/index.tsx @@ -32,7 +32,7 @@ const frontmatter = { actions: [ { children: 'Get Started', - to: '/stories?name=app/styles/colors.mdx', + to: '/stories/foundations/colors', priority: 'primary', }, { @@ -93,8 +93,9 @@ export function StoryLanding() { @@ -104,8 +105,9 @@ export function StoryLanding() { @@ -115,8 +117,9 @@ export function StoryLanding() { @@ -126,8 +129,9 @@ export function StoryLanding() { diff --git a/static/app/stories/view/storyExports.tsx b/static/app/stories/view/storyExports.tsx index 020cb9abbcc889..029fd51ee29a17 100644 --- a/static/app/stories/view/storyExports.tsx +++ b/static/app/stories/view/storyExports.tsx @@ -40,11 +40,11 @@ function StoryLayout() { const {story} = useStory(); const [tab, setTab] = useQueryState( 'tab', - parseAsString.withOptions({history: 'push'}) + parseAsString.withOptions({history: 'push'}).withDefault('usage') ); return ( - + {isMDXStory(story) ? : null} diff --git a/static/app/stories/view/storyFooter.tsx b/static/app/stories/view/storyFooter.tsx index f74484bc5378af..f06f799c2963ed 100644 --- a/static/app/stories/view/storyFooter.tsx +++ b/static/app/stories/view/storyFooter.tsx @@ -18,20 +18,15 @@ export function StoryFooter() { const pagination = findPreviousAndNextStory(story, stories); const organization = useOrganization(); - const {state: prevState, ...prevTo} = pagination?.prev?.location ?? {}; - const {state: nextState, ...nextTo} = pagination?.next?.location ?? {}; - return ( {pagination?.prev && ( } > @@ -46,12 +41,10 @@ export function StoryFooter() { } > @@ -74,16 +67,25 @@ function findPreviousAndNextStory( prev?: StoryTreeNode; } | null { const stories = Object.values(categories).flat(); - const currentIndex = stories.findIndex(s => s.filesystemPath === story.filename); + const queue = [...stories]; + + while (queue.length > 0) { + const node = queue.pop(); + if (!node) break; + + if (node.filesystemPath === story.filename) { + return { + prev: queue[queue.length - 1] ?? undefined, + next: queue[queue.length + 1] ?? undefined, + }; + } - if (currentIndex === -1) { - return null; + for (const key in node.children) { + queue.push(node.children[key]!); + } } - return { - prev: stories[currentIndex - 1] ?? undefined, - next: stories[currentIndex + 1] ?? undefined, - }; + return null; } const Card = styled(LinkButton)` diff --git a/static/app/stories/view/storySearch.tsx b/static/app/stories/view/storySearch.tsx index bc4ef41a3a53de..13267022cec393 100644 --- a/static/app/stories/view/storySearch.tsx +++ b/static/app/stories/view/storySearch.tsx @@ -220,14 +220,11 @@ function SearchComboBox(props: SearchComboBoxProps) { if (!node) { return; } - const {state, ...to} = node.location; - navigate( - { - ...to, - pathname: normalizeUrl(`/organizations/${organization.slug}${to.pathname}`), - }, - {replace: true, state} - ); + navigate({ + pathname: normalizeUrl( + `/organizations/${organization.slug}/stories/${node.category}/${node.slug}` + ), + }); }; const state = useComboBoxState({ diff --git a/static/app/stories/view/storySidebar.tsx b/static/app/stories/view/storySidebar.tsx index 6b60df3b3217c2..266d6de7d5fa5c 100644 --- a/static/app/stories/view/storySidebar.tsx +++ b/static/app/stories/view/storySidebar.tsx @@ -1,6 +1,8 @@ import {useMemo} from 'react'; import styled from '@emotion/styled'; +import {unreachable} from 'sentry/utils/unreachable'; + import type {StoryTreeNode} from './storyTree'; import {inferFileCategory, StoryTree, useStoryTree} from './storyTree'; import {useStoryBookFiles} from './useStoriesLoader'; @@ -86,7 +88,8 @@ export function useStoryBookFilesByCategory(): Record< shared: [], }; for (const file of files) { - switch (inferFileCategory(file)) { + const category = inferFileCategory(file); + switch (category) { case 'foundations': map.foundations.push(file); break; @@ -105,11 +108,14 @@ export function useStoryBookFilesByCategory(): Record< case 'core': map.core.push(file); break; + case 'product': + map.product.push(file); + break; case 'shared': map.shared.push(file); break; default: - map.product.push(file); + unreachable(category); } } return map; @@ -148,11 +154,12 @@ export function useStoryBookFilesByCategory(): Record< const product = useStoryTree(filesByOwner.product, { query: '', representation: 'category', + type: 'nested', }); - const shared = useStoryTree(filesByOwner.shared, { query: '', representation: 'category', + type: 'nested', }); return { @@ -160,9 +167,9 @@ export function useStoryBookFilesByCategory(): Record< principles, patterns, typography, + layout, core, product, - layout, shared, }; } diff --git a/static/app/stories/view/storyTree.tsx b/static/app/stories/view/storyTree.tsx index 5156941168e730..9442f54ed29e3c 100644 --- a/static/app/stories/view/storyTree.tsx +++ b/static/app/stories/view/storyTree.tsx @@ -1,14 +1,12 @@ import {useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; -import type {LocationDescriptorObject} from 'history'; -import kebabCase from 'lodash/kebabCase'; import {Flex} from 'sentry/components/core/layout'; import {Link} from 'sentry/components/core/link'; import {IconChevron} from 'sentry/icons'; +import {useStoryParams} from 'sentry/stories/view'; import {fzf} from 'sentry/utils/profiling/fzf/fzf'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; -import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; export class StoryTreeNode { @@ -17,7 +15,7 @@ export class StoryTreeNode { public path: string; public filesystemPath: string; public category: StoryCategory; - public location: LocationDescriptorObject; + public slug: string | undefined = undefined; public visible = true; public expanded = false; @@ -27,22 +25,19 @@ export class StoryTreeNode { constructor(name: string, path: string, filesystemPath: string) { this.name = name; - this.label = normalizeFilename(name); this.path = path; this.filesystemPath = filesystemPath; + this.label = normalizeFilename(name); 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}; + const [_app, ...segments] = this.filesystemPath.split('/'); + // Remove the filename from the path + segments.pop()!; + this.slug = `${segments.map(segment => segment.toLowerCase()).join('/')}/${this.label.replaceAll(' ', '-').toLowerCase()}`; + } else { + this.slug = `${this.label.replaceAll(' ', '-').toLowerCase()}`; } - return { - pathname: `/stories/${this.category}/${kebabCase(this.label)}`, - state, - }; } find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined { @@ -287,11 +282,11 @@ export function useStoryTree( options: { query: string; representation: 'filesystem' | 'category'; - type?: 'flat' | 'nested'; + type: 'flat' | 'nested'; } ) { - const location = useLocation(); - const initialName = useRef(location.state?.storyPath ?? location.query.name); + const {storySlug} = useStoryParams(); + const initialSlug = useRef(storySlug ?? null); const tree = useMemo(() => { const root = new StoryTreeNode('root', '', ''); @@ -380,9 +375,9 @@ export function useStoryTree( } // If the user navigates to a story, expand to its location in the tree - if (initialName.current) { + if (initialSlug.current) { for (const {node, path} of root) { - if (node.filesystemPath === initialName.current) { + if (node.slug === initialSlug.current) { for (const p of path) { p.expanded = true; } @@ -399,8 +394,8 @@ export function useStoryTree( const root = tree.find(node => node.name === 'app') ?? tree; if (!options.query) { - if (initialName.current) { - initialName.current = null; + if (initialSlug.current) { + initialSlug.current = null; } // If there is no initial query and no story is selected, the sidebar @@ -519,13 +514,11 @@ export function StoryTree({nodes, ...htmlProps}: Props) { function Folder(props: {node: StoryTreeNode}) { const [expanded, setExpanded] = useState(props.node.expanded); - const location = useLocation(); + const {storySlug} = useStoryParams(); + const hasActiveChild = useMemo(() => { - const child = props.node.find( - n => n.filesystemPath === (location.state?.storyPath ?? location.query.name) - ); - return !!child; - }, [location, props.node]); + return !!props.node.find(n => n.slug === storySlug); + }, [storySlug, props.node]); if (hasActiveChild && !props.node.expanded) { props.node.expanded = true; @@ -563,9 +556,9 @@ function Folder(props: {node: StoryTreeNode}) { return null; } return Object.keys(child.children).length === 0 ? ( - + ) : ( - + ); })} @@ -575,20 +568,18 @@ function Folder(props: {node: StoryTreeNode}) { } function File(props: {node: StoryTreeNode}) { - const location = useLocation(); const organization = useOrganization(); - const {state, ...to} = props.node.location; - const active = - props.node.filesystemPath === (location.state?.storyPath ?? location.query.name); + const {storySlug} = useStoryParams(); + const active = storySlug === props.node.slug; return (
  • diff --git a/static/app/stories/view/useStoryRedirect.tsx b/static/app/stories/view/useStoryRedirect.tsx deleted file mode 100644 index 7034bb39151404..00000000000000 --- a/static/app/stories/view/useStoryRedirect.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import {useLayoutEffect} from 'react'; -import kebabCase from 'lodash/kebabCase'; - -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import {useParams} from 'sentry/utils/useParams'; - -import {useStoryBookFilesByCategory} from './storySidebar'; -import type {StoryCategory, StoryTreeNode} from './storyTree'; - -type LegacyStoryQuery = { - name: string; -}; -interface StoryParams { - storySlug: string; - storyType: StoryCategory; -} - -export function useStoryRedirect() { - const location = useLocation(); - const params = useParams(); - const navigate = useNavigate(); - const stories = useStoryBookFilesByCategory(); - - useLayoutEffect(() => { - // If we already have a `storyPath` in state, bail out - if (location.state?.storyPath) { - return; - } - if (!location.pathname.startsWith('/stories')) { - return; - } - const story = getStory(stories, {query: location.query, params}); - if (!story) { - return; - } - const {state, ...to} = story.location; - navigate( - {pathname: location.pathname, hash: location.hash, ...to}, - {replace: true, state: {...location.state, ...state}} - ); - }, [location, params, navigate, stories]); -} - -interface StoryRouteContext { - params: StoryParams; - query: LegacyStoryQuery; -} - -function getStory( - stories: ReturnType, - context: StoryRouteContext -) { - if (context.params.storyType && context.params.storySlug) { - return getStoryFromParams(stories, context); - } - if (context.query.name) { - return legacyGetStoryFromQuery(stories, context); - } - return undefined; -} - -function legacyGetStoryFromQuery( - stories: ReturnType, - context: StoryRouteContext -): StoryTreeNode | undefined { - for (const category of Object.keys(stories) as StoryCategory[]) { - const nodes = stories[category as keyof typeof stories]; - for (const node of nodes) { - const match = node.find(n => n.filesystemPath === context.query.name); - if (match) { - return match; - } - } - } - return undefined; -} - -function getStoryFromParams( - stories: ReturnType, - context: StoryRouteContext -): StoryTreeNode | undefined { - const {storyType: category, storySlug} = context.params; - const nodes = - category && category in stories ? stories[category as keyof typeof stories] : []; - for (const node of nodes) { - const match = node.find(n => kebabCase(n.label) === storySlug); - if (match) { - return match; - } - } - return undefined; -} diff --git a/static/app/utils/replays/generatePlatformIconName.tsx b/static/app/utils/replays/generatePlatformIconName.tsx index 2504c1d92af3f1..d49a9251d40ad7 100644 --- a/static/app/utils/replays/generatePlatformIconName.tsx +++ b/static/app/utils/replays/generatePlatformIconName.tsx @@ -21,7 +21,7 @@ const PLATFORM_ALIASES = { }; /** - * Generates names used for PlatformIcon. Translates ContextIcon names (https://sentry.sentry.io/stories/?name=app/components/events/contexts/contextIcon.stories.tsx) to PlatformIcon (https://www.npmjs.com/package/platformicons) names + * Generates names used for PlatformIcon. Translates ContextIcon names (https://sentry.sentry.io/stories/stories/shared/components/events/contexts/contexticon) to PlatformIcon (https://www.npmjs.com/package/platformicons) names */ export function generatePlatformIconName( name: string, diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx index f118e6e0e58f83..892038a3910fcc 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -45,8 +45,6 @@ type ParamKeys = | 'shareId' | 'spanSlug' | 'step' - | 'storySlug' - | 'storyType' | 'tagKey' | 'teamId' | 'tokenId'