storyTree.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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 (predicate(node)) {
  32. return node;
  33. }
  34. }
  35. return undefined;
  36. }
  37. sort(predicate: (a: [string, StoryTreeNode], b: [string, StoryTreeNode]) => number) {
  38. this.children = Object.fromEntries(Object.entries(this.children).sort(predicate));
  39. for (const {node} of this) {
  40. node.children = Object.fromEntries(Object.entries(node.children).sort(predicate));
  41. }
  42. }
  43. // Iterator that yields all files in the tree, excluding folders
  44. *[Symbol.iterator]() {
  45. function* recurse(
  46. node: StoryTreeNode,
  47. path: StoryTreeNode[]
  48. ): Generator<{node: StoryTreeNode; path: StoryTreeNode[]}> {
  49. yield {node, path};
  50. for (const child of Object.values(node.children)) {
  51. yield* recurse(child, path.concat(node));
  52. }
  53. }
  54. yield* recurse(this, []);
  55. }
  56. }
  57. function folderOrSearchScoreFirst(
  58. a: [string, StoryTreeNode],
  59. b: [string, StoryTreeNode]
  60. ) {
  61. if (a[1].visible && !b[1].visible) {
  62. return -1;
  63. }
  64. if (!a[1].visible && b[1].visible) {
  65. return 1;
  66. }
  67. if (a[1].result && b[1].result) {
  68. if (a[1].result.score === b[1].result.score) {
  69. return a[0].localeCompare(b[0]);
  70. }
  71. return b[1].result.score - a[1].result.score;
  72. }
  73. const aIsFolder = Object.keys(a[1].children).length > 0;
  74. const bIsFolder = Object.keys(b[1].children).length > 0;
  75. if (aIsFolder && !bIsFolder) {
  76. return -1;
  77. }
  78. if (!aIsFolder && bIsFolder) {
  79. return 1;
  80. }
  81. return a[0].localeCompare(b[0]);
  82. }
  83. const order: FileCategory[] = ['components', 'hooks', 'views', 'assets', 'styles'];
  84. function rootCategorySort(
  85. a: [FileCategory | string, StoryTreeNode],
  86. b: [FileCategory | string, StoryTreeNode]
  87. ) {
  88. return order.indexOf(a[0] as FileCategory) - order.indexOf(b[0] as FileCategory);
  89. }
  90. function normalizeFilename(filename: string) {
  91. // Do not uppercase the first three characters of the filename
  92. if (filename.startsWith('use')) {
  93. return filename.replace('.stories.tsx', '');
  94. }
  95. // capitalizes the filename
  96. return filename.charAt(0).toUpperCase() + filename.slice(1).replace('.stories.tsx', '');
  97. }
  98. type FileCategory = 'hooks' | 'components' | 'views' | 'styles' | 'assets';
  99. function inferFileCategory(path: string): FileCategory {
  100. const parts = path.split('/');
  101. const filename = parts.at(-1);
  102. if (filename?.startsWith('use')) {
  103. return 'hooks';
  104. }
  105. if (parts[1]?.startsWith('icons') || path.endsWith('images.stories.tsx')) {
  106. return 'assets';
  107. }
  108. if (parts[1]?.startsWith('views')) {
  109. return 'views';
  110. }
  111. if (parts[1]?.startsWith('styles')) {
  112. return 'styles';
  113. }
  114. return 'components';
  115. }
  116. function inferComponentName(path: string): string {
  117. const parts = path.split('/');
  118. let part = parts.pop();
  119. while (part?.startsWith('index.')) {
  120. part = parts.pop();
  121. }
  122. return part ?? '';
  123. }
  124. export function useStoryTree(
  125. files: string[],
  126. options: {query: string; representation: 'filesystem' | 'category'}
  127. ) {
  128. const location = useLocation();
  129. const initialName = useRef(location.query.name);
  130. const tree = useMemo(() => {
  131. const root = new StoryTreeNode('root', '');
  132. if (options.representation === 'filesystem') {
  133. for (const file of files) {
  134. const parts = file.split('/');
  135. let parent = root;
  136. for (let i = 0; i < parts.length; i++) {
  137. const part = parts[i];
  138. if (!part) {
  139. continue;
  140. }
  141. if (!(part in parent.children)) {
  142. parent.children[part] = new StoryTreeNode(
  143. part,
  144. parts.slice(0, i + 1).join('/')
  145. );
  146. }
  147. parent = parent.children[part]!;
  148. }
  149. }
  150. // Sort the root by file/folder name when using the filesystem representation
  151. root.sort(folderOrSearchScoreFirst);
  152. } else if (options.representation === 'category') {
  153. for (const file of files) {
  154. const type = inferFileCategory(file);
  155. const name = inferComponentName(file);
  156. if (!root.children[type]) {
  157. root.children[type] = new StoryTreeNode(type, type);
  158. }
  159. if (!root.children[type].children[name]) {
  160. root.children[type].children[name] = new StoryTreeNode(name, file);
  161. } else {
  162. throw new Error(
  163. `Naming conflict found between ${file} and ${root.children[type].children[name].path}`
  164. );
  165. }
  166. }
  167. // Sort the root by category order when using the category representation
  168. root.children = Object.fromEntries(
  169. Object.entries(root.children).sort(rootCategorySort)
  170. );
  171. // Sort the children of each category by file, folder or alphabetically
  172. Object.values(root.children).forEach(child => {
  173. child.sort(folderOrSearchScoreFirst);
  174. });
  175. }
  176. // If the user navigates to a story, expand to its location in the tree
  177. if (initialName.current) {
  178. for (const {node, path} of root) {
  179. if (node.path === initialName.current) {
  180. for (const p of path) {
  181. p.expanded = true;
  182. }
  183. break;
  184. }
  185. }
  186. }
  187. return root;
  188. }, [files, options.representation]);
  189. const nodes = useMemo(() => {
  190. // Skip the top level app folder as it's where the entire project is at
  191. const root = tree.find(node => node.name === 'app') ?? tree;
  192. if (!options.query) {
  193. if (initialName.current) {
  194. initialName.current = null;
  195. }
  196. // If there is no initial query and no story is selected, the sidebar
  197. // tree is collapsed to the root node.
  198. for (const {node} of root) {
  199. node.visible = true;
  200. node.expanded = false;
  201. node.result = null;
  202. }
  203. // sort alphabetically or by category
  204. if (options.representation === 'filesystem') {
  205. root.sort(folderOrSearchScoreFirst);
  206. } else {
  207. root.children = Object.fromEntries(
  208. Object.entries(root.children).sort(rootCategorySort)
  209. );
  210. }
  211. return Object.values(root.children);
  212. }
  213. for (const {node} of root) {
  214. node.visible = false;
  215. node.expanded = false;
  216. node.result = null;
  217. }
  218. // Fzf requires the input to be lowercase as it normalizes the search candidates to lowercase
  219. const lowerCaseQuery = options.query.toLowerCase();
  220. for (const {node, path} of root) {
  221. // index files are useless when trying to match by name, so we'll special
  222. // case them and match by their full path as it'll contain a more
  223. // relevant path that we can match against.
  224. const name = node.name.startsWith('index.')
  225. ? [node.name, ...path.map(p => p.name)].join('.')
  226. : node.name;
  227. const match = fzf(name, lowerCaseQuery, false);
  228. node.result = match;
  229. if (match.score > 0) {
  230. node.visible = true;
  231. if (Object.keys(node.children).length > 0) {
  232. node.expanded = true;
  233. for (const child of Object.values(node.children)) {
  234. child.visible = true;
  235. }
  236. }
  237. // @TODO (JonasBadalic): We can trip this when we find a visible node if we reverse iterate
  238. for (const p of path) {
  239. p.visible = true;
  240. p.expanded = true;
  241. // The entire path needs to contain max score of its child results so that
  242. // the entire path to it can be sorted by this score. The side effect of this is that results from the same
  243. // tree path with a lower score will be placed higher in the tree if that same path has a higher score anywhere
  244. // in the tree. This isn't ideal, but given that it favors the most relevant results, it makes it a good starting point.
  245. p.result = match.score > (p.result?.score ?? 0) ? match : p.result;
  246. }
  247. }
  248. }
  249. root.sort(folderOrSearchScoreFirst);
  250. return Object.values(root.children);
  251. }, [tree, options.query, options.representation]);
  252. return nodes;
  253. }
  254. interface Props extends React.HTMLAttributes<HTMLDivElement> {
  255. nodes: StoryTreeNode[];
  256. }
  257. // @TODO (JonasBadalic): Implement treeview pattern navigation
  258. // https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
  259. export function StoryTree({nodes, ...htmlProps}: Props) {
  260. return (
  261. <nav {...htmlProps}>
  262. <StoryList>
  263. {nodes.map(node => {
  264. if (!node.visible) {
  265. return null;
  266. }
  267. return Object.keys(node.children).length === 0 ? (
  268. <File node={node} key={node.name} />
  269. ) : (
  270. <Folder node={node} key={node.name} />
  271. );
  272. })}
  273. </StoryList>
  274. </nav>
  275. );
  276. }
  277. function Folder(props: {node: StoryTreeNode}) {
  278. const [expanded, setExpanded] = useState(props.node.expanded);
  279. if (props.node.expanded !== expanded) {
  280. setExpanded(props.node.expanded);
  281. }
  282. if (!props.node.visible) {
  283. return null;
  284. }
  285. return (
  286. <li>
  287. <FolderName
  288. onClick={() => {
  289. props.node.expanded = !props.node.expanded;
  290. if (props.node.expanded) {
  291. for (const child of Object.values(props.node.children)) {
  292. child.visible = true;
  293. }
  294. }
  295. setExpanded(props.node.expanded);
  296. }}
  297. >
  298. <IconChevron size="xs" direction={expanded ? 'down' : 'right'} />
  299. {normalizeFilename(props.node.name)}
  300. </FolderName>
  301. {expanded && Object.keys(props.node.children).length > 0 && (
  302. <StoryList>
  303. {Object.values(props.node.children).map(child => {
  304. if (!child.visible) {
  305. return null;
  306. }
  307. return Object.keys(child.children).length === 0 ? (
  308. <File key={child.path} node={child} />
  309. ) : (
  310. <Folder key={child.path} node={child} />
  311. );
  312. })}
  313. </StoryList>
  314. )}
  315. </li>
  316. );
  317. }
  318. function File(props: {node: StoryTreeNode}) {
  319. const location = useLocation();
  320. const query = qs.stringify({...location.query, name: props.node.path});
  321. const category = props.node.path.split('/').at(1) ?? 'default';
  322. return (
  323. <li>
  324. <FolderLink
  325. to={`/stories/?${query}`}
  326. active={location.query.name === props.node.path}
  327. >
  328. <StoryIcon category={category} />
  329. {/* @TODO (JonasBadalic): Do we need to show the file extension? */}
  330. {normalizeFilename(props.node.name)}
  331. </FolderLink>
  332. </li>
  333. );
  334. }
  335. function StoryIcon(props: {
  336. category: 'components' | 'icons' | 'styles' | 'utils' | 'views' | (string & {});
  337. }) {
  338. const iconProps: SVGIconProps = {size: 'xs'};
  339. switch (props.category) {
  340. case 'components':
  341. return <IconGrid {...iconProps} />;
  342. case 'icons':
  343. return <IconExpand {...iconProps} />;
  344. case 'styles':
  345. return <IconCircle {...iconProps} />;
  346. case 'utils':
  347. return <IconCode {...iconProps} />;
  348. case 'views':
  349. return <IconNumber {...iconProps} />;
  350. default:
  351. return <IconFile {...iconProps} />;
  352. }
  353. }
  354. const StoryList = styled('ul')`
  355. list-style-type: none;
  356. padding-left: 10px;
  357. &:first-child {
  358. padding-left: 0;
  359. }
  360. `;
  361. const FolderName = styled('div')`
  362. display: flex;
  363. align-items: center;
  364. gap: ${space(0.75)};
  365. padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
  366. cursor: pointer;
  367. position: relative;
  368. &:before {
  369. background: ${p => p.theme.surface100};
  370. content: '';
  371. inset: 0px 0px 0px -100%;
  372. position: absolute;
  373. z-index: -1;
  374. opacity: 0;
  375. }
  376. &:hover {
  377. &:before {
  378. opacity: 1;
  379. }
  380. }
  381. `;
  382. const FolderLink = styled(Link, {
  383. shouldForwardProp: prop => prop !== 'active',
  384. })<{active: boolean}>`
  385. display: flex;
  386. align-items: center;
  387. margin-left: ${space(0.5)};
  388. gap: ${space(0.75)};
  389. color: ${p => p.theme.textColor};
  390. padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
  391. position: relative;
  392. &:before {
  393. background: ${p => p.theme.surface100};
  394. content: '';
  395. inset: 0px 0px 0px -100%;
  396. position: absolute;
  397. z-index: -1;
  398. opacity: ${p => (p.active ? 1 : 0)};
  399. }
  400. &:hover {
  401. color: ${p => p.theme.textColor};
  402. &:before {
  403. opacity: 1;
  404. }
  405. }
  406. svg {
  407. flex-shrink: 0;
  408. }
  409. `;