diff --git a/eslint.config.mjs b/eslint.config.mjs index 42e907b767c2d9..ddbc0d99c863d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,17 +129,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/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/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/shared/views/dashboards/widgets/timeserieswidget/timeserieswidgetvisualization#deeplinking for more information', }, ]; diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index dfc52ad8345519..f2657fc6550278 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -318,9 +318,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/type-loader.ts b/static/app/stories/type-loader.ts index 073ffc1f3ce643..e821d198d8912f 100644 --- a/static/app/stories/type-loader.ts +++ b/static/app/stories/type-loader.ts @@ -120,7 +120,7 @@ function prodTypeloader(this: LoaderContext, _source: string) { function noopTypeLoader(this: LoaderContext, _source: string) { const callback = this.async(); - return callback(null, 'export default {}'); + return callback(null, 'export default {props: {},exports: {}}'); } export default function typeLoader(this: LoaderContext, _source: string) { diff --git a/static/app/stories/view/index.tsx b/static/app/stories/view/index.tsx index 5bac53203aa09f..0870783ecde84b 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,24 @@ 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) + // Handles both /stories/... and /organizations/{org}/stories/... + 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,11 +50,37 @@ function StoriesLanding() { } function StoryDetail() { - useStoryRedirect(); + const location = useLocation(); + const {storyCategory, storySlug} = useStoryParams(); + const stories = useStoryBookFilesByCategory(); + + let storyNode = getStoryFromParams(stories, { + category: storyCategory, + slug: storySlug, + }); + + // 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 location = useLocation<{name: string; query?: string}>(); const story = useStoriesLoader({ - files: [location.state?.storyPath ?? location.query.name], + files: storyNode ? [storyNode.filesystemPath] : [], }); return ( @@ -91,6 +131,39 @@ function StoriesLayout(props: PropsWithChildren) { ); } +function isLandingPage(location: ReturnType) { + // Handles both /stories and /organizations/{org}/stories + 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 4c722611580445..d80bc2034b0c51 100644 --- a/static/app/stories/view/landing/index.tsx +++ b/static/app/stories/view/landing/index.tsx @@ -2,6 +2,7 @@ import type {PropsWithChildren} from 'react'; import {Fragment} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import type {LocationDescriptor} from 'history'; import performanceWaitingForSpan from 'sentry-images/spot/performance-waiting-for-span.svg'; import heroImg from 'sentry-images/stories/landing/robopigeon.png'; @@ -13,6 +14,8 @@ import {Link} from 'sentry/components/core/link'; import {IconOpen} from 'sentry/icons'; import {Acronym} from 'sentry/stories/view/landing/acronym'; import {StoryDarkModeProvider} from 'sentry/stories/view/useStoriesDarkMode'; +import normalizeUrl from 'sentry/utils/url/normalizeUrl'; +import useOrganization from 'sentry/utils/useOrganization'; import {Colors, Icons, Typography} from './figures'; @@ -29,7 +32,7 @@ const frontmatter = { actions: [ { children: 'Get Started', - to: '/stories?name=app/styles/colors.mdx', + to: '/stories/foundations/colors', priority: 'primary', }, { @@ -43,6 +46,8 @@ const frontmatter = { }; export function StoryLanding() { + const organization = useOrganization(); + return ( @@ -57,9 +62,14 @@ export function StoryLanding() {

{frontmatter.hero.tagline}

- {frontmatter.hero.actions.map(props => ( - - ))} + {frontmatter.hero.actions.map(props => { + // Normalize internal paths with organization context + const to = + typeof props.to === 'string' && !props.external + ? normalizeUrl(`/organizations/${organization.slug}${props.to}`) + : props.to; + return ; + })} - + - + - + @@ -192,12 +227,13 @@ const CardGrid = styled('div')` interface CardProps { children: React.ReactNode; - href: string; title: string; + to: LocationDescriptor; } + function Card(props: CardProps) { return ( - + {props.children} {props.title} diff --git a/static/app/stories/view/storyExports.tsx b/static/app/stories/view/storyExports.tsx index 299baf51e67d3c..b2e27f58356995 100644 --- a/static/app/stories/view/storyExports.tsx +++ b/static/app/stories/view/storyExports.tsx @@ -2,6 +2,7 @@ import React, {Fragment, useEffect} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {ErrorBoundary} from '@sentry/react'; +import {parseAsString, useQueryState} from 'nuqs'; import {Alert} from 'sentry/components/core/alert'; import {Tag} from 'sentry/components/core/badge/tag'; @@ -37,8 +38,13 @@ export function StoryExports(props: {story: StoryDescriptor}) { function StoryLayout() { const {story} = useStory(); + const [tab, setTab] = useQueryState( + 'tab', + parseAsString.withOptions({history: 'push'}).withDefault('usage') + ); + return ( - + {isMDXStory(story) ? : null} @@ -123,6 +129,7 @@ function MDXStoryTitle(props: {story: MDXStoryDescriptor}) { function StoryTabList() { const {story} = useStory(); + if (!isMDXStory(story)) return null; if (story.exports.frontmatter?.layout === 'document') return null; diff --git a/static/app/stories/view/storyFooter.tsx b/static/app/stories/view/storyFooter.tsx index 3e167eb0acb929..3d222ff5f7d300 100644 --- a/static/app/stories/view/storyFooter.tsx +++ b/static/app/stories/view/storyFooter.tsx @@ -4,6 +4,8 @@ import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Flex} from 'sentry/components/core/layout'; import {Text} from 'sentry/components/core/text'; import {IconArrow} from 'sentry/icons'; +import normalizeUrl from 'sentry/utils/url/normalizeUrl'; +import useOrganization from 'sentry/utils/useOrganization'; import {useStoryBookFilesByCategory} from './storySidebar'; import type {StoryTreeNode} from './storyTree'; @@ -14,11 +16,19 @@ export function StoryFooter() { const {story} = useStory(); const stories = useStoryBookFilesByCategory(); const pagination = findPreviousAndNextStory(story, stories); + const organization = useOrganization(); return ( {pagination?.prev && ( - }> + } + > Previous @@ -30,7 +40,11 @@ export function StoryFooter() { {pagination?.next && ( } > @@ -53,16 +67,34 @@ function findPreviousAndNextStory( prev?: StoryTreeNode; } | null { const stories = Object.values(categories).flat(); - const currentIndex = stories.findIndex(s => s.filesystemPath === story.filename); + const queue: StoryTreeNode[] = []; + + function processNode(node: StoryTreeNode) { + for (const key in node.children) { + processNode(node.children[key]!); + } + if (!Object.keys(node.children).length) { + queue.push(node); + } + } + + for (const node of stories) { + processNode(node); + } + + for (let i = 0; i < queue.length; i++) { + const node = queue[i]; + if (!node) break; - if (currentIndex === -1) { - return null; + if (node.filesystemPath === story.filename) { + return { + prev: queue[i - 1], + next: queue[i + 1], + }; + } } - return { - prev: stories[currentIndex - 1] ?? undefined, - next: stories[currentIndex + 1] ?? undefined, - }; + return null; } const Card = styled(LinkButton)` diff --git a/static/app/stories/view/storyHeader.tsx b/static/app/stories/view/storyHeader.tsx index 19377992fc793a..64a83335d87d8f 100644 --- a/static/app/stories/view/storyHeader.tsx +++ b/static/app/stories/view/storyHeader.tsx @@ -6,6 +6,8 @@ import {Link} from 'sentry/components/core/link'; import {Heading} from 'sentry/components/core/text'; import {IconGithub, IconLink} from 'sentry/icons'; import * as Storybook from 'sentry/stories'; +import normalizeUrl from 'sentry/utils/url/normalizeUrl'; +import useOrganization from 'sentry/utils/useOrganization'; import {StorySearch} from './storySearch'; @@ -29,9 +31,10 @@ function ScrapsLogo(props: React.SVGProps) { } export function StoryHeader() { + const organization = useOrganization(); return ( - + diff --git a/static/app/stories/view/storySearch.tsx b/static/app/stories/view/storySearch.tsx index 6232838b8842dc..13267022cec393 100644 --- a/static/app/stories/view/storySearch.tsx +++ b/static/app/stories/view/storySearch.tsx @@ -15,8 +15,10 @@ import {IconSearch} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {StoryTreeNode} from 'sentry/stories/view/storyTree'; import {fzf} from 'sentry/utils/profiling/fzf/fzf'; +import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import {useHotkeys} from 'sentry/utils/useHotkeys'; import {useNavigate} from 'sentry/utils/useNavigate'; +import useOrganization from 'sentry/utils/useOrganization'; import {useStoryBookFilesByCategory} from './storySidebar'; @@ -39,6 +41,8 @@ export function StorySearch() { typography: typographyTree, layout: layoutTree, shared: sharedTree, + principles: principlesTree, + patterns: patternsTree, } = useStoryBookFilesByCategory(); const foundations = useMemo( () => foundationsTree.flatMap(tree => tree.flat()), @@ -50,8 +54,17 @@ export function StorySearch() { () => typographyTree.flatMap(tree => tree.flat()), [typographyTree] ); + const principles = useMemo( + () => principlesTree.flatMap(tree => tree.flat()), + [principlesTree] + ); const layout = useMemo(() => layoutTree.flatMap(tree => tree.flat()), [layoutTree]); const shared = useMemo(() => sharedTree.flatMap(tree => tree.flat()), [sharedTree]); + const patterns = useMemo( + () => patternsTree.flatMap(tree => tree.flat()), + [patternsTree] + ); + useHotkeys([{match: '/', callback: () => inputRef.current?.focus()}]); const sectionedItems = useMemo(() => { @@ -64,6 +77,21 @@ export function StorySearch() { options: foundations, }); } + if (principles.length > 0) { + sections.push({ + key: 'principles', + label: 'Principles', + options: principles, + }); + } + + if (patterns.length > 0) { + sections.push({ + key: 'patterns', + label: 'Patterns', + options: patterns, + }); + } if (typography.length > 0) { sections.push({ @@ -106,7 +134,7 @@ export function StorySearch() { } return sections; - }, [foundations, core, product, layout, typography, shared]); + }, [foundations, core, product, layout, typography, shared, patterns, principles]); return ( (null); const popoverRef = useRef(null); const navigate = useNavigate(); + + const organization = useOrganization(); const handleSelectionChange = (key: Key | null) => { if (!key) { return; @@ -190,8 +220,11 @@ function SearchComboBox(props: SearchComboBoxProps) { if (!node) { return; } - const {state, ...to} = node.location; - navigate(to, {replace: true, state}); + navigate({ + pathname: normalizeUrl( + `/organizations/${organization.slug}/stories/${node.category}/${node.slug}` + ), + }); }; const state = useComboBoxState({ @@ -227,7 +260,6 @@ function SearchComboBox(props: SearchComboBoxProps) { 0 + ? `${segments.map(segment => segment.toLowerCase()).join('/')}/` + : ''; + this.slug = `${pathPrefix}${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 { @@ -285,11 +286,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', '', ''); @@ -378,9 +379,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; } @@ -397,8 +398,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 @@ -517,13 +518,12 @@ 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]); + // eslint-disable-next-line unicorn/prefer-array-some + return !!props.node.find(n => n.slug === storySlug); + }, [storySlug, props.node]); if (hasActiveChild && !props.node.expanded) { props.node.expanded = true; @@ -561,9 +561,9 @@ function Folder(props: {node: StoryTreeNode}) { return null; } return Object.keys(child.children).length === 0 ? ( - + ) : ( - + ); })} @@ -573,16 +573,18 @@ function Folder(props: {node: StoryTreeNode}) { } function File(props: {node: StoryTreeNode}) { - const location = useLocation(); - const {state, ...to} = props.node.location; - const active = - props.node.filesystemPath === (location.state?.storyPath ?? location.query.name); + const organization = useOrganization(); + const {storySlug} = useStoryParams(); + const active = storySlug === props.node.slug; return (
  • diff --git a/static/app/stories/view/useStoriesLoader.tsx b/static/app/stories/view/useStoriesLoader.tsx index 5be1d28f25ef78..b26a43533ddf2a 100644 --- a/static/app/stories/view/useStoriesLoader.tsx +++ b/static/app/stories/view/useStoriesLoader.tsx @@ -51,6 +51,10 @@ export function useStoryBookFiles() { } async function importStory(filename: string): Promise { + if (!filename) { + throw new Error(`Filename is required, got ${filename}`); + } + if (filename.endsWith('.mdx')) { const story = await mdxContext(filename.replace(/^app\//, './')); 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 b61004c009bf40..ba2fe25b6e5019 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -49,8 +49,6 @@ type ParamKeys = | 'shareId' | 'spanSlug' | 'step' - | 'storySlug' - | 'storyType' | 'tagKey' | 'teamId' | 'tokenId'