storyTree.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import {useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as qs from 'query-string';
  4. import Link from 'sentry/components/links/link';
  5. import {
  6. IconChevron,
  7. IconCircle,
  8. IconCode,
  9. IconExpand,
  10. IconFile,
  11. IconGrid,
  12. IconNumber,
  13. } from 'sentry/icons';
  14. import type {SVGIconProps} from 'sentry/icons/svgIcon';
  15. import {space} from 'sentry/styles/space';
  16. import {fzf} from 'sentry/utils/profiling/fzf/fzf';
  17. import {useLocation} from 'sentry/utils/useLocation';
  18. class StoryTreeNode {
  19. public name: string;
  20. public path: string;
  21. public visible = true;
  22. public expanded = false;
  23. public children: Record<string, StoryTreeNode> = {};
  24. public result: ReturnType<typeof fzf> | null = null;
  25. constructor(name: string, path: string) {
  26. this.name = name;
  27. this.path = path;
  28. }
  29. find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined {
  30. for (const {node} of this) {
  31. if (node && predicate(node)) {
  32. return node;
  33. }
  34. }
  35. return undefined;
  36. }
  37. // Iterator that yields all files in the tree, excluding folders
  38. *[Symbol.iterator]() {
  39. function* recurse(
  40. node: StoryTreeNode,
  41. path: StoryTreeNode[]
  42. ): Generator<{node: StoryTreeNode; path: StoryTreeNode[]}> {
  43. yield {node, path};
  44. for (const child of Object.values(node.children)) {
  45. yield* recurse(child, path.concat(node));
  46. }
  47. }
  48. yield* recurse(this, []);
  49. }
  50. }
  51. function folderOrSearchScoreFirst(a: StoryTreeNode, b: StoryTreeNode) {
  52. if (a.visible && !b.visible) {
  53. return -1;
  54. }
  55. if (!a.visible && b.visible) {
  56. return 1;
  57. }
  58. if (a.result && b.result) {
  59. if (a.result.score === b.result.score) {
  60. return a.name.localeCompare(b.name);
  61. }
  62. return b.result.score - a.result.score;
  63. }
  64. const aIsFolder = Object.keys(a.children).length > 0;
  65. const bIsFolder = Object.keys(b.children).length > 0;
  66. if (aIsFolder && !bIsFolder) {
  67. return -1;
  68. }
  69. if (!aIsFolder && bIsFolder) {
  70. return 1;
  71. }
  72. return a.name.localeCompare(b.name);
  73. }
  74. function normalizeFilename(filename: string) {
  75. // Do not uppercase the first three characters of the filename
  76. if (filename.startsWith('use')) {
  77. return filename.replace('.stories.tsx', '');
  78. }
  79. // capitalizes the filename
  80. return filename.charAt(0).toUpperCase() + filename.slice(1).replace('.stories.tsx', '');
  81. }
  82. export function useStoryTree(files: string[], query: string) {
  83. const location = useLocation();
  84. const initialName = useRef(location.query.name);
  85. const tree = useMemo(() => {
  86. const root = new StoryTreeNode('root', '');
  87. for (const file of files) {
  88. const parts = file.split('/');
  89. let parent = root;
  90. for (const part of parts) {
  91. if (!(part in parent.children)) {
  92. parent.children[part] = new StoryTreeNode(part, file);
  93. }
  94. parent = parent.children[part]!;
  95. }
  96. }
  97. // If the user navigates to a story, expand to its location in the tree
  98. if (initialName.current) {
  99. for (const {node, path} of root) {
  100. if (node.path === initialName.current) {
  101. for (const p of path) {
  102. p.expanded = true;
  103. }
  104. initialName.current = null;
  105. break;
  106. }
  107. }
  108. }
  109. return root;
  110. }, [files]);
  111. const nodes = useMemo(() => {
  112. // Skip the top level app folder as it's where the entire project is at
  113. const root = tree.find(node => node.name === 'app') ?? tree;
  114. if (!query) {
  115. if (initialName.current) {
  116. return Object.values(root.children);
  117. }
  118. // If there is no initial query and no story is selected, the sidebar
  119. // tree is collapsed to the root node.
  120. for (const {node} of root) {
  121. node.visible = true;
  122. node.expanded = false;
  123. node.result = null;
  124. }
  125. return Object.values(root.children);
  126. }
  127. for (const {node} of root) {
  128. node.visible = false;
  129. node.expanded = false;
  130. node.result = null;
  131. }
  132. // Fzf requires the input to be lowercase as it normalizes the search candidates to lowercase
  133. const lowerCaseQuery = query.toLowerCase();
  134. for (const {node, path} of root) {
  135. // index files are useless when trying to match by name, so we'll special
  136. // case them and match by their full path as it'll contain a more
  137. // relevant path that we can match against.
  138. const name = node.name.startsWith('index.')
  139. ? [node.name, ...path.map(p => p.name)].join('.')
  140. : node.name;
  141. const match = fzf(name, lowerCaseQuery, false);
  142. node.result = match;
  143. if (match.score > 0) {
  144. node.visible = true;
  145. if (Object.keys(node.children).length > 0) {
  146. node.expanded = true;
  147. for (const child of Object.values(node.children)) {
  148. child.visible = true;
  149. }
  150. }
  151. // @TODO (JonasBadalic): We can trip this when we find a visible node if we reverse iterate
  152. for (const p of path) {
  153. p.visible = true;
  154. p.expanded = true;
  155. // The entire path needs to contain max score of its child results so that
  156. // the entire path to it can be sorted by this score. The side effect of this is that results from the same
  157. // tree path with a lower score will be placed higher in the tree if that same path has a higher score anywhere
  158. // in the tree. This isn't ideal, but given that it favors the most relevant results, it makes it a good starting point.
  159. p.result = match.score > (p.result?.score ?? 0) ? match : p.result;
  160. }
  161. }
  162. }
  163. return Object.values(root.children);
  164. }, [tree, query]);
  165. return nodes;
  166. }
  167. interface Props extends React.HTMLAttributes<HTMLDivElement> {
  168. nodes: StoryTreeNode[];
  169. }
  170. // @TODO (JonasBadalic): Implement treeview pattern navigation
  171. // https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
  172. export function StoryTree({nodes, ...htmlProps}: Props) {
  173. return (
  174. <nav {...htmlProps}>
  175. <StoryList>
  176. {nodes.sort(folderOrSearchScoreFirst).map(node => {
  177. if (!node.visible) {
  178. return null;
  179. }
  180. return Object.keys(node.children).length === 0 ? (
  181. <File node={node} key={node.name} />
  182. ) : (
  183. <Folder node={node} key={node.name} />
  184. );
  185. })}
  186. </StoryList>
  187. </nav>
  188. );
  189. }
  190. function Folder(props: {node: StoryTreeNode}) {
  191. const [expanded, setExpanded] = useState(props.node.expanded);
  192. if (props.node.expanded !== expanded) {
  193. setExpanded(props.node.expanded);
  194. }
  195. if (!props.node.visible) {
  196. return null;
  197. }
  198. return (
  199. <li>
  200. <FolderName
  201. onClick={() => {
  202. props.node.expanded = !props.node.expanded;
  203. if (props.node.expanded) {
  204. for (const child of Object.values(props.node.children)) {
  205. child.visible = true;
  206. }
  207. }
  208. setExpanded(props.node.expanded);
  209. }}
  210. >
  211. <IconChevron size="xs" direction={expanded ? 'down' : 'right'} />
  212. {normalizeFilename(props.node.name)}
  213. </FolderName>
  214. {expanded && Object.keys(props.node.children).length > 0 && (
  215. <StoryList>
  216. {Object.values(props.node.children)
  217. .sort(folderOrSearchScoreFirst)
  218. .map(child => {
  219. if (!child.visible) {
  220. return null;
  221. }
  222. return Object.keys(child.children).length === 0 ? (
  223. <File key={child.path} node={child} />
  224. ) : (
  225. <Folder key={child.path} node={child} />
  226. );
  227. })}
  228. </StoryList>
  229. )}
  230. </li>
  231. );
  232. }
  233. function File(props: {node: StoryTreeNode}) {
  234. const location = useLocation();
  235. const query = qs.stringify({...location.query, name: props.node.path});
  236. const category = props.node.path.split('/').at(1) ?? 'default';
  237. return (
  238. <li>
  239. <FolderLink
  240. to={`/stories/?${query}`}
  241. active={location.query.name === props.node.path}
  242. >
  243. <StoryIcon category={category} />
  244. {/* @TODO (JonasBadalic): Do we need to show the file extension? */}
  245. {normalizeFilename(props.node.name)}
  246. </FolderLink>
  247. </li>
  248. );
  249. }
  250. function StoryIcon(props: {
  251. category: 'components' | 'icons' | 'styles' | 'utils' | 'views' | string | {};
  252. }) {
  253. const iconProps: SVGIconProps = {size: 'xs'};
  254. switch (props.category) {
  255. case 'components':
  256. return <IconGrid {...iconProps} />;
  257. case 'icons':
  258. return <IconExpand {...iconProps} />;
  259. case 'styles':
  260. return <IconCircle {...iconProps} />;
  261. case 'utils':
  262. return <IconCode {...iconProps} />;
  263. case 'views':
  264. return <IconNumber {...iconProps} />;
  265. default:
  266. return <IconFile {...iconProps} />;
  267. }
  268. }
  269. const StoryList = styled('ul')`
  270. list-style-type: none;
  271. padding-left: 10px;
  272. &:first-child {
  273. padding-left: 0;
  274. }
  275. `;
  276. const FolderName = styled('div')`
  277. display: flex;
  278. align-items: center;
  279. gap: ${space(0.75)};
  280. padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
  281. cursor: pointer;
  282. position: relative;
  283. &:before {
  284. background: ${p => p.theme.surface100};
  285. content: '';
  286. inset: 0px 0px 0px -100%;
  287. position: absolute;
  288. z-index: -1;
  289. opacity: 0;
  290. }
  291. &:hover {
  292. &:before {
  293. opacity: 1;
  294. }
  295. }
  296. `;
  297. const FolderLink = styled(Link, {
  298. shouldForwardProp: prop => prop !== 'active',
  299. })<{active: boolean}>`
  300. display: flex;
  301. align-items: center;
  302. margin-left: ${space(0.5)};
  303. gap: ${space(0.75)};
  304. color: ${p => p.theme.textColor};
  305. padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
  306. position: relative;
  307. &:before {
  308. background: ${p => p.theme.surface100};
  309. content: '';
  310. inset: 0px 0px 0px -100%;
  311. position: absolute;
  312. z-index: -1;
  313. opacity: ${p => (p.active ? 1 : 0)};
  314. }
  315. &:hover {
  316. color: ${p => p.theme.textColor};
  317. &:before {
  318. opacity: 1;
  319. }
  320. }
  321. svg {
  322. flex-shrink: 0;
  323. }
  324. `;