index.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import {useCallback, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import {CompactSelect} from 'sentry/components/compactSelect';
  5. import {Alert} from 'sentry/components/core/alert';
  6. import {InputGroup} from 'sentry/components/core/input/inputGroup';
  7. import LoadingIndicator from 'sentry/components/loadingIndicator';
  8. import {IconSettings} from 'sentry/icons';
  9. import {IconSearch} from 'sentry/icons/iconSearch';
  10. import {space} from 'sentry/styles/space';
  11. import {useHotkeys} from 'sentry/utils/useHotkeys';
  12. import {useLocation} from 'sentry/utils/useLocation';
  13. import {useNavigate} from 'sentry/utils/useNavigate';
  14. import OrganizationContainer from 'sentry/views/organizationContainer';
  15. import RouteAnalyticsContextProvider from 'sentry/views/routeAnalyticsContextProvider';
  16. import {StoryExports} from 'sentry/views/stories/storyExports';
  17. import {StoryHeader} from 'sentry/views/stories/storyHeader';
  18. import {StoryTableOfContents} from 'sentry/views/stories/storyTableOfContents';
  19. import {StoryTree, useStoryTree} from 'sentry/views/stories/storyTree';
  20. import {useStoriesLoader, useStoryBookFiles} from 'sentry/views/stories/useStoriesLoader';
  21. import {useLocalStorageState} from '../../utils/useLocalStorageState';
  22. export default function Stories() {
  23. const searchInput = useRef<HTMLInputElement>(null);
  24. const location = useLocation<{name: string; query?: string}>();
  25. const files = useStoryBookFiles();
  26. // If no story is selected, show the landing page stories
  27. const storyFiles = useMemo(() => {
  28. if (!location.query.name) {
  29. return files.filter(
  30. file =>
  31. file.endsWith('styles/colors.stories.tsx') ||
  32. file.endsWith('styles/typography.stories.tsx')
  33. );
  34. }
  35. return [location.query.name];
  36. }, [files, location.query.name]);
  37. const story = useStoriesLoader({files: storyFiles});
  38. const [storyRepresentation, setStoryRepresentation] = useLocalStorageState<
  39. 'category' | 'filesystem'
  40. >('story-representation', 'category');
  41. const nodes = useStoryTree(files, {
  42. query: location.query.query ?? '',
  43. representation: storyRepresentation,
  44. });
  45. const navigate = useNavigate();
  46. const onSearchInputChange = useCallback(
  47. (e: React.ChangeEvent<HTMLInputElement>) => {
  48. navigate({
  49. query: {...location.query, query: e.target.value, name: location.query.name},
  50. });
  51. },
  52. [location.query, navigate]
  53. );
  54. const storiesSearchHotkeys = useMemo(() => {
  55. return [{match: '/', callback: () => searchInput.current?.focus()}];
  56. }, []);
  57. useHotkeys(storiesSearchHotkeys);
  58. return (
  59. <RouteAnalyticsContextProvider>
  60. <OrganizationContainer>
  61. <Layout>
  62. <HeaderContainer>
  63. <StoryHeader />
  64. </HeaderContainer>
  65. <SidebarContainer>
  66. <InputGroup>
  67. <InputGroup.LeadingItems disablePointerEvents>
  68. <IconSearch />
  69. </InputGroup.LeadingItems>
  70. <InputGroup.Input
  71. ref={searchInput}
  72. placeholder="Search stories"
  73. defaultValue={location.query.query ?? ''}
  74. onChange={onSearchInputChange}
  75. />
  76. <InputGroup.TrailingItems>
  77. <StoryRepresentationToggle
  78. storyRepresentation={storyRepresentation}
  79. setStoryRepresentation={setStoryRepresentation}
  80. />
  81. </InputGroup.TrailingItems>
  82. {/* @TODO (JonasBadalic): Implement clear button when there is an active query */}
  83. </InputGroup>
  84. <StoryTreeContainer>
  85. <StoryTree nodes={nodes} />
  86. </StoryTreeContainer>
  87. </SidebarContainer>
  88. {story.isLoading ? (
  89. <VerticalScroll style={{gridArea: 'body'}}>
  90. <LoadingIndicator />
  91. </VerticalScroll>
  92. ) : story.isError ? (
  93. <VerticalScroll style={{gridArea: 'body'}}>
  94. <Alert.Container>
  95. <Alert type="error" showIcon>
  96. <strong>{story.error.name}:</strong> {story.error.message}
  97. </Alert>
  98. </Alert.Container>
  99. </VerticalScroll>
  100. ) : story.isSuccess ? (
  101. <StoryMainContainer>
  102. {story.data.map(s => {
  103. return <StoryExports key={s.filename} story={s} />;
  104. })}
  105. </StoryMainContainer>
  106. ) : (
  107. <VerticalScroll style={{gridArea: 'body'}}>
  108. <strong>The file you selected does not export a story.</strong>
  109. </VerticalScroll>
  110. )}
  111. <StoryIndexContainer>
  112. <StoryTableOfContents />
  113. </StoryIndexContainer>
  114. </Layout>
  115. </OrganizationContainer>
  116. </RouteAnalyticsContextProvider>
  117. );
  118. }
  119. function StoryRepresentationToggle(props: {
  120. setStoryRepresentation: (value: 'category' | 'filesystem') => void;
  121. storyRepresentation: 'category' | 'filesystem';
  122. }) {
  123. return (
  124. <CompactSelect
  125. trigger={triggerProps => (
  126. <Button
  127. borderless
  128. icon={<IconSettings />}
  129. size="xs"
  130. aria-label="Toggle story representation"
  131. {...triggerProps}
  132. />
  133. )}
  134. defaultValue={props.storyRepresentation}
  135. options={[
  136. {label: 'Filesystem', value: 'filesystem'},
  137. {label: 'Category', value: 'category'},
  138. ]}
  139. onChange={option => props.setStoryRepresentation(option.value)}
  140. />
  141. );
  142. }
  143. const Layout = styled('div')`
  144. --stories-grid-space: ${space(2)};
  145. display: grid;
  146. grid-template:
  147. 'head head head' max-content
  148. 'aside body index' auto / 200px 1fr;
  149. gap: var(--stories-grid-space);
  150. place-items: stretch;
  151. height: 100vh;
  152. padding: var(--stories-grid-space);
  153. `;
  154. const HeaderContainer = styled('div')`
  155. grid-area: head;
  156. `;
  157. const SidebarContainer = styled('div')`
  158. grid-area: aside;
  159. display: flex;
  160. flex-direction: column;
  161. gap: ${space(2)};
  162. min-height: 0;
  163. position: relative;
  164. z-index: 10;
  165. `;
  166. const StoryTreeContainer = styled('div')`
  167. overflow-y: scroll;
  168. flex-grow: 1;
  169. `;
  170. const StoryIndexContainer = styled('div')`
  171. grid-area: index;
  172. `;
  173. const VerticalScroll = styled('main')`
  174. overflow-x: hidden;
  175. overflow-y: scroll;
  176. grid-area: body;
  177. `;
  178. /**
  179. * Avoid <Panel> here because nested panels will have a modified theme.
  180. * Therefore stories will look different in prod.
  181. */
  182. const StoryMainContainer = styled(VerticalScroll)`
  183. background: ${p => p.theme.background};
  184. border-radius: ${p => p.theme.borderRadius};
  185. border: 1px solid ${p => p.theme.border};
  186. grid-area: body;
  187. padding: var(--stories-grid-space);
  188. padding-top: 0;
  189. overflow-x: hidden;
  190. overflow-y: auto;
  191. h1,
  192. h2,
  193. h3,
  194. h4,
  195. h5,
  196. h6 {
  197. scroll-margin-top: ${space(3)};
  198. }
  199. `;