storyTree.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import {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 {useLocation} from 'sentry/utils/useLocation';
  17. import type {StoryTreeNode} from './index';
  18. function folderOrSearchScoreFirst(a: StoryTreeNode, b: StoryTreeNode) {
  19. if (a.visible && !b.visible) {
  20. return -1;
  21. }
  22. if (!a.visible && b.visible) {
  23. return 1;
  24. }
  25. if (a.result && b.result) {
  26. if (a.result.score === b.result.score) {
  27. return a.name.localeCompare(b.name);
  28. }
  29. return b.result.score - a.result.score;
  30. }
  31. const aIsFolder = Object.keys(a.children).length > 0;
  32. const bIsFolder = Object.keys(b.children).length > 0;
  33. if (aIsFolder && !bIsFolder) {
  34. return -1;
  35. }
  36. if (!aIsFolder && bIsFolder) {
  37. return 1;
  38. }
  39. return a.name.localeCompare(b.name);
  40. }
  41. function normalizeFilename(filename: string) {
  42. // Do not uppercase the first three characters of the filename
  43. if (filename.startsWith('use')) {
  44. return filename.replace('.stories.tsx', '');
  45. }
  46. // capitalizes the filename
  47. return filename.charAt(0).toUpperCase() + filename.slice(1).replace('.stories.tsx', '');
  48. }
  49. interface Props extends React.HTMLAttributes<HTMLDivElement> {
  50. nodes: StoryTreeNode[];
  51. }
  52. // @TODO (JonasBadalic): Implement treeview pattern navigation
  53. // https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
  54. export default function StoryTree({nodes, ...htmlProps}: Props) {
  55. return (
  56. <nav {...htmlProps}>
  57. <StoryList>
  58. {nodes.sort(folderOrSearchScoreFirst).map(node => {
  59. if (!node.visible) {
  60. return null;
  61. }
  62. return Object.keys(node.children).length === 0 ? (
  63. <File node={node} key={node.name} />
  64. ) : (
  65. <Folder node={node} key={node.name} />
  66. );
  67. })}
  68. </StoryList>
  69. </nav>
  70. );
  71. }
  72. function Folder(props: {node: StoryTreeNode}) {
  73. const [expanded, setExpanded] = useState(props.node.expanded);
  74. if (props.node.expanded !== expanded) {
  75. setExpanded(props.node.expanded);
  76. }
  77. if (!props.node.visible) {
  78. return null;
  79. }
  80. return (
  81. <li>
  82. <FolderName
  83. onClick={() => {
  84. props.node.expanded = !props.node.expanded;
  85. if (props.node.expanded) {
  86. for (const child of Object.values(props.node.children)) {
  87. child.visible = true;
  88. }
  89. }
  90. setExpanded(props.node.expanded);
  91. }}
  92. >
  93. <IconChevron size="xs" direction={expanded ? 'down' : 'right'} />
  94. {normalizeFilename(props.node.name)}
  95. </FolderName>
  96. {expanded && Object.keys(props.node.children).length > 0 && (
  97. <StoryList>
  98. {Object.values(props.node.children)
  99. .sort(folderOrSearchScoreFirst)
  100. .map(child => {
  101. if (!child.visible) {
  102. return null;
  103. }
  104. return Object.keys(child.children).length === 0 ? (
  105. <File key={child.path} node={child} />
  106. ) : (
  107. <Folder key={child.path} node={child} />
  108. );
  109. })}
  110. </StoryList>
  111. )}
  112. </li>
  113. );
  114. }
  115. function File(props: {node: StoryTreeNode}) {
  116. const location = useLocation();
  117. const query = qs.stringify({...location.query, name: props.node.path});
  118. const category = props.node.path.split('/').at(1) ?? 'default';
  119. return (
  120. <li>
  121. <FolderLink
  122. to={`/stories/?${query}`}
  123. active={location.query.name === props.node.path}
  124. >
  125. <StoryIcon category={category} />
  126. {/* @TODO (JonasBadalic): Do we need to show the file extension? */}
  127. {normalizeFilename(props.node.name)}
  128. </FolderLink>
  129. </li>
  130. );
  131. }
  132. function StoryIcon(props: {
  133. category: 'components' | 'icons' | 'styles' | 'utils' | 'views' | string | {};
  134. }) {
  135. const iconProps: SVGIconProps = {size: 'xs'};
  136. switch (props.category) {
  137. case 'components':
  138. return <IconGrid {...iconProps} />;
  139. case 'icons':
  140. return <IconExpand {...iconProps} />;
  141. case 'styles':
  142. return <IconCircle {...iconProps} />;
  143. case 'utils':
  144. return <IconCode {...iconProps} />;
  145. case 'views':
  146. return <IconNumber {...iconProps} />;
  147. default:
  148. return <IconFile {...iconProps} />;
  149. }
  150. }
  151. const StoryList = styled('ul')`
  152. list-style-type: none;
  153. padding-left: 10px;
  154. &:first-child {
  155. padding-left: 0;
  156. }
  157. `;
  158. const FolderName = styled('div')`
  159. display: flex;
  160. align-items: center;
  161. gap: ${space(0.75)};
  162. padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
  163. cursor: pointer;
  164. position: relative;
  165. &:before {
  166. background: ${p => p.theme.surface100};
  167. content: '';
  168. inset: 0px 0px 0px -100%;
  169. position: absolute;
  170. z-index: -1;
  171. opacity: 0;
  172. }
  173. &:hover {
  174. &:before {
  175. opacity: 1;
  176. }
  177. }
  178. `;
  179. const FolderLink = styled(Link, {
  180. shouldForwardProp: prop => prop !== 'active',
  181. })<{active: boolean}>`
  182. display: flex;
  183. align-items: center;
  184. margin-left: ${space(0.5)};
  185. gap: ${space(0.75)};
  186. color: ${p => p.theme.textColor};
  187. padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
  188. position: relative;
  189. &:before {
  190. background: ${p => p.theme.surface100};
  191. content: '';
  192. inset: 0px 0px 0px -100%;
  193. position: absolute;
  194. z-index: -1;
  195. opacity: ${p => (p.active ? 1 : 0)};
  196. }
  197. &:hover {
  198. color: ${p => p.theme.textColor};
  199. &:before {
  200. opacity: 1;
  201. }
  202. }
  203. svg {
  204. flex-shrink: 0;
  205. }
  206. `;