index.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import {useCallback, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import Alert from 'sentry/components/alert';
  4. import {InputGroup} from 'sentry/components/inputGroup';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import {IconSearch} from 'sentry/icons/iconSearch';
  7. import {space} from 'sentry/styles/space';
  8. import {fzf} from 'sentry/utils/profiling/fzf/fzf';
  9. import {useHotkeys} from 'sentry/utils/useHotkeys';
  10. import {useLocation} from 'sentry/utils/useLocation';
  11. import {useNavigate} from 'sentry/utils/useNavigate';
  12. import OrganizationContainer from 'sentry/views/organizationContainer';
  13. import RouteAnalyticsContextProvider from 'sentry/views/routeAnalyticsContextProvider';
  14. import StoryFile, {StoryExports} from 'sentry/views/stories/storyFile';
  15. import StoryHeader from 'sentry/views/stories/storyHeader';
  16. import StoryTree from 'sentry/views/stories/storyTree';
  17. import useStoriesLoader, {useStoryBookFiles} from 'sentry/views/stories/useStoriesLoader';
  18. export default function Stories() {
  19. const searchInput = useRef<HTMLInputElement>(null);
  20. const location = useLocation<{name: string; query?: string}>();
  21. const files = useStoryBookFiles();
  22. const story = useStoriesLoader({filename: location.query.name});
  23. const nodes = useStoryTree(location.query.query ?? '', files);
  24. const navigate = useNavigate();
  25. const onSearchInputChange = useCallback(
  26. (e: React.ChangeEvent<HTMLInputElement>) => {
  27. navigate({query: {query: e.target.value, name: location.query.name}});
  28. },
  29. [location.query.name, navigate]
  30. );
  31. useHotkeys([{match: '/', callback: () => searchInput.current?.focus()}], []);
  32. return (
  33. <RouteAnalyticsContextProvider>
  34. <OrganizationContainer>
  35. <Layout>
  36. <StoryHeader style={{gridArea: 'head'}} />
  37. <SidebarContainer style={{gridArea: 'aside'}}>
  38. <InputGroup>
  39. <InputGroup.LeadingItems disablePointerEvents>
  40. <IconSearch />
  41. </InputGroup.LeadingItems>
  42. <InputGroup.Input
  43. ref={searchInput}
  44. placeholder="Search stories"
  45. defaultValue={location.query.query ?? ''}
  46. onChange={onSearchInputChange}
  47. />
  48. {/* @TODO (JonasBadalic): Implement clear button when there is an active query */}
  49. </InputGroup>
  50. <StoryTreeContainer>
  51. <StoryTree nodes={nodes} />
  52. </StoryTreeContainer>
  53. </SidebarContainer>
  54. {story.isLoading ? (
  55. <VerticalScroll style={{gridArea: 'body'}}>
  56. <LoadingIndicator />
  57. </VerticalScroll>
  58. ) : story.isError ? (
  59. <VerticalScroll style={{gridArea: 'body'}}>
  60. <Alert type="error" showIcon>
  61. <strong>{story.error.name}:</strong> {story.error.message}
  62. </Alert>
  63. </VerticalScroll>
  64. ) : story.isSuccess ? (
  65. Object.keys(story.data.exports).length > 0 ? (
  66. <StoryMainContainer style={{gridArea: 'body'}}>
  67. <StoryFile story={story.data} />
  68. </StoryMainContainer>
  69. ) : (
  70. <VerticalScroll style={{gridArea: 'body'}}>
  71. <strong>The file you selected does not export a story.</strong>
  72. </VerticalScroll>
  73. )
  74. ) : (
  75. <StoryMainContainer style={{gridArea: 'body'}}>
  76. <StoriesLandingPage />
  77. </StoryMainContainer>
  78. )}
  79. </Layout>
  80. </OrganizationContainer>
  81. </RouteAnalyticsContextProvider>
  82. );
  83. }
  84. function StoriesLandingPage() {
  85. const files = useStoryBookFiles();
  86. const landingPageStories = useMemo(() => {
  87. const stories: string[] = [];
  88. for (const file of files) {
  89. if (
  90. file.endsWith('styles/colors.stories.tsx') ||
  91. file.endsWith('styles/typography.stories.tsx')
  92. ) {
  93. stories.push(file);
  94. }
  95. }
  96. return stories;
  97. }, [files]);
  98. const stories = useStoriesLoader({filename: landingPageStories});
  99. return stories.isFetching ? (
  100. <LoadingIndicator />
  101. ) : stories.isError ? (
  102. <Alert type="error" showIcon>
  103. <strong>{stories.error.name}:</strong> {stories.error.message}
  104. </Alert>
  105. ) : (
  106. stories.data?.map(story => <StoryExports key={story.filename} story={story} />)
  107. );
  108. }
  109. function useStoryTree(query: string, files: string[]) {
  110. const location = useLocation();
  111. const initialName = useRef(location.query.name);
  112. const tree = useMemo(() => {
  113. const root = new StoryTreeNode('root', '');
  114. for (const file of files) {
  115. const parts = file.split('/');
  116. let parent = root;
  117. for (const part of parts) {
  118. if (!(part in parent.children)) {
  119. parent.children[part] = new StoryTreeNode(part, file);
  120. }
  121. parent = parent.children[part]!;
  122. }
  123. }
  124. // If the user navigates to a story, expand to its location in the tree
  125. if (initialName.current) {
  126. for (const {node, path} of root) {
  127. if (node.path === initialName.current) {
  128. for (const p of path) {
  129. p.expanded = true;
  130. }
  131. initialName.current = null;
  132. break;
  133. }
  134. }
  135. }
  136. return root;
  137. }, [files]);
  138. const nodes = useMemo(() => {
  139. // Skip the top level app folder as it's where the entire project is at
  140. const root = tree.find(node => node.name === 'app') ?? tree;
  141. if (!query) {
  142. if (initialName.current) {
  143. return Object.values(root.children);
  144. }
  145. // If there is no initial query and no story is selected, the sidebar
  146. // tree is collapsed to the root node.
  147. for (const {node} of root) {
  148. node.visible = true;
  149. node.expanded = false;
  150. node.result = null;
  151. }
  152. return Object.values(root.children);
  153. }
  154. for (const {node} of root) {
  155. node.visible = false;
  156. node.expanded = false;
  157. node.result = null;
  158. }
  159. // Fzf requires the input to be lowercase as it normalizes the search candidates to lowercase
  160. const lowerCaseQuery = query.toLowerCase();
  161. for (const {node, path} of root) {
  162. // index files are useless when trying to match by name, so we'll special
  163. // case them and match by their full path as it'll contain a more
  164. // relevant path that we can match against.
  165. const name = node.name.startsWith('index.')
  166. ? [node.name, ...path.map(p => p.name)].join('.')
  167. : node.name;
  168. const match = fzf(name, lowerCaseQuery, false);
  169. node.result = match;
  170. if (match.score > 0) {
  171. node.visible = true;
  172. if (Object.keys(node.children).length > 0) {
  173. node.expanded = true;
  174. for (const child of Object.values(node.children)) {
  175. child.visible = true;
  176. }
  177. }
  178. // @TODO (JonasBadalic): We can trip this when we find a visible node if we reverse iterate
  179. for (const p of path) {
  180. p.visible = true;
  181. p.expanded = true;
  182. // The entire path needs to contain max score of its child results so that
  183. // the entire path to it can be sorted by this score. The side effect of this is that results from the same
  184. // tree path with a lower score will be placed higher in the tree if that same path has a higher score anywhere
  185. // in the tree. This isn't ideal, but given that it favors the most relevant results, it makes it a good starting point.
  186. p.result = match.score > (p.result?.score ?? 0) ? match : p.result;
  187. }
  188. }
  189. }
  190. return Object.values(root.children);
  191. }, [tree, query]);
  192. return nodes;
  193. }
  194. export class StoryTreeNode {
  195. public name: string;
  196. public path: string;
  197. public visible = true;
  198. public expanded = false;
  199. public children: Record<string, StoryTreeNode> = {};
  200. public result: ReturnType<typeof fzf> | null = null;
  201. constructor(name: string, path: string) {
  202. this.name = name;
  203. this.path = path;
  204. }
  205. find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined {
  206. for (const {node} of this) {
  207. if (node && predicate(node)) {
  208. return node;
  209. }
  210. }
  211. return undefined;
  212. }
  213. // Iterator that yields all files in the tree, excluding folders
  214. *[Symbol.iterator]() {
  215. function* recurse(
  216. node: StoryTreeNode,
  217. path: StoryTreeNode[]
  218. ): Generator<{node: StoryTreeNode; path: StoryTreeNode[]}> {
  219. yield {node, path};
  220. for (const child of Object.values(node.children)) {
  221. yield* recurse(child, [...path, node]);
  222. }
  223. }
  224. yield* recurse(this, []);
  225. }
  226. }
  227. const Layout = styled('div')`
  228. --stories-grid-space: ${space(2)};
  229. display: grid;
  230. grid-template:
  231. 'head head' max-content
  232. 'aside body' auto/ ${p => p.theme.settings.sidebarWidth} 1fr;
  233. gap: var(--stories-grid-space);
  234. place-items: stretch;
  235. height: 100vh;
  236. padding: var(--stories-grid-space);
  237. `;
  238. const SidebarContainer = styled('div')`
  239. display: flex;
  240. flex-direction: column;
  241. gap: ${space(2)};
  242. min-height: 0;
  243. `;
  244. const StoryTreeContainer = styled('div')`
  245. overflow-y: scroll;
  246. flex-grow: 1;
  247. `;
  248. const VerticalScroll = styled('main')`
  249. overflow-x: hidden;
  250. overflow-y: scroll;
  251. grid-area: body;
  252. `;
  253. /**
  254. * Avoid <Panel> here because nested panels will have a modified theme.
  255. * Therefore stories will look different in prod.
  256. */
  257. const StoryMainContainer = styled(VerticalScroll)`
  258. background: ${p => p.theme.background};
  259. border-radius: ${p => p.theme.panelBorderRadius};
  260. border: 1px solid ${p => p.theme.border};
  261. padding: var(--stories-grid-space);
  262. padding-top: 0;
  263. overflow-x: hidden;
  264. overflow-y: auto;
  265. position: relative;
  266. `;