123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- import {useMemo, useRef, useState} from 'react';
- import styled from '@emotion/styled';
- import * as qs from 'query-string';
- import Link from 'sentry/components/links/link';
- import {
- IconChevron,
- IconCircle,
- IconCode,
- IconExpand,
- IconFile,
- IconGrid,
- IconNumber,
- } from 'sentry/icons';
- import type {SVGIconProps} from 'sentry/icons/svgIcon';
- import {space} from 'sentry/styles/space';
- import {fzf} from 'sentry/utils/profiling/fzf/fzf';
- import {useLocation} from 'sentry/utils/useLocation';
- class StoryTreeNode {
- public name: string;
- public path: string;
- public visible = true;
- public expanded = false;
- public children: Record<string, StoryTreeNode> = {};
- public result: ReturnType<typeof fzf> | null = null;
- constructor(name: string, path: string) {
- this.name = name;
- this.path = path;
- }
- find(predicate: (node: StoryTreeNode) => boolean): StoryTreeNode | undefined {
- for (const {node} of this) {
- if (predicate(node)) {
- return node;
- }
- }
- return undefined;
- }
- sort(predicate: (a: [string, StoryTreeNode], b: [string, StoryTreeNode]) => number) {
- this.children = Object.fromEntries(Object.entries(this.children).sort(predicate));
- for (const {node} of this) {
- node.children = Object.fromEntries(Object.entries(node.children).sort(predicate));
- }
- }
- // Iterator that yields all files in the tree, excluding folders
- *[Symbol.iterator]() {
- function* recurse(
- node: StoryTreeNode,
- path: StoryTreeNode[]
- ): Generator<{node: StoryTreeNode; path: StoryTreeNode[]}> {
- yield {node, path};
- for (const child of Object.values(node.children)) {
- yield* recurse(child, path.concat(node));
- }
- }
- yield* recurse(this, []);
- }
- }
- function folderOrSearchScoreFirst(
- a: [string, StoryTreeNode],
- b: [string, StoryTreeNode]
- ) {
- if (a[1].visible && !b[1].visible) {
- return -1;
- }
- if (!a[1].visible && b[1].visible) {
- return 1;
- }
- if (a[1].result && b[1].result) {
- if (a[1].result.score === b[1].result.score) {
- return a[0].localeCompare(b[0]);
- }
- return b[1].result.score - a[1].result.score;
- }
- const aIsFolder = Object.keys(a[1].children).length > 0;
- const bIsFolder = Object.keys(b[1].children).length > 0;
- if (aIsFolder && !bIsFolder) {
- return -1;
- }
- if (!aIsFolder && bIsFolder) {
- return 1;
- }
- return a[0].localeCompare(b[0]);
- }
- const order: FileCategory[] = ['components', 'hooks', 'views', 'assets', 'styles'];
- function rootCategorySort(
- a: [FileCategory | string, StoryTreeNode],
- b: [FileCategory | string, StoryTreeNode]
- ) {
- return order.indexOf(a[0] as FileCategory) - order.indexOf(b[0] as FileCategory);
- }
- function normalizeFilename(filename: string) {
- // Do not uppercase the first three characters of the filename
- if (filename.startsWith('use')) {
- return filename.replace('.stories.tsx', '');
- }
- // capitalizes the filename
- return filename.charAt(0).toUpperCase() + filename.slice(1).replace('.stories.tsx', '');
- }
- type FileCategory = 'hooks' | 'components' | 'views' | 'styles' | 'assets';
- function inferFileCategory(path: string): FileCategory {
- const parts = path.split('/');
- const filename = parts.at(-1);
- if (filename?.startsWith('use')) {
- return 'hooks';
- }
- if (parts[1]?.startsWith('icons') || path.endsWith('images.stories.tsx')) {
- return 'assets';
- }
- if (parts[1]?.startsWith('views')) {
- return 'views';
- }
- if (parts[1]?.startsWith('styles')) {
- return 'styles';
- }
- return 'components';
- }
- function inferComponentName(path: string): string {
- const parts = path.split('/');
- let part = parts.pop();
- while (part?.startsWith('index.')) {
- part = parts.pop();
- }
- return part ?? '';
- }
- export function useStoryTree(
- files: string[],
- options: {query: string; representation: 'filesystem' | 'category'}
- ) {
- const location = useLocation();
- const initialName = useRef(location.query.name);
- const tree = useMemo(() => {
- const root = new StoryTreeNode('root', '');
- if (options.representation === 'filesystem') {
- for (const file of files) {
- const parts = file.split('/');
- let parent = root;
- for (let i = 0; i < parts.length; i++) {
- const part = parts[i];
- if (!part) {
- continue;
- }
- if (!(part in parent.children)) {
- parent.children[part] = new StoryTreeNode(
- part,
- parts.slice(0, i + 1).join('/')
- );
- }
- parent = parent.children[part]!;
- }
- }
- // Sort the root by file/folder name when using the filesystem representation
- root.sort(folderOrSearchScoreFirst);
- } else if (options.representation === 'category') {
- for (const file of files) {
- const type = inferFileCategory(file);
- const name = inferComponentName(file);
- if (!root.children[type]) {
- root.children[type] = new StoryTreeNode(type, type);
- }
- if (!root.children[type].children[name]) {
- root.children[type].children[name] = new StoryTreeNode(name, file);
- } else {
- throw new Error(
- `Naming conflict found between ${file} and ${root.children[type].children[name].path}`
- );
- }
- }
- // Sort the root by category order when using the category representation
- root.children = Object.fromEntries(
- Object.entries(root.children).sort(rootCategorySort)
- );
- // Sort the children of each category by file, folder or alphabetically
- Object.values(root.children).forEach(child => {
- child.sort(folderOrSearchScoreFirst);
- });
- }
- // If the user navigates to a story, expand to its location in the tree
- if (initialName.current) {
- for (const {node, path} of root) {
- if (node.path === initialName.current) {
- for (const p of path) {
- p.expanded = true;
- }
- break;
- }
- }
- }
- return root;
- }, [files, options.representation]);
- const nodes = useMemo(() => {
- // Skip the top level app folder as it's where the entire project is at
- const root = tree.find(node => node.name === 'app') ?? tree;
- if (!options.query) {
- if (initialName.current) {
- initialName.current = null;
- }
- // If there is no initial query and no story is selected, the sidebar
- // tree is collapsed to the root node.
- for (const {node} of root) {
- node.visible = true;
- node.expanded = false;
- node.result = null;
- }
- // sort alphabetically or by category
- if (options.representation === 'filesystem') {
- root.sort(folderOrSearchScoreFirst);
- } else {
- root.children = Object.fromEntries(
- Object.entries(root.children).sort(rootCategorySort)
- );
- }
- return Object.values(root.children);
- }
- for (const {node} of root) {
- node.visible = false;
- node.expanded = false;
- node.result = null;
- }
- // Fzf requires the input to be lowercase as it normalizes the search candidates to lowercase
- const lowerCaseQuery = options.query.toLowerCase();
- for (const {node, path} of root) {
- // index files are useless when trying to match by name, so we'll special
- // case them and match by their full path as it'll contain a more
- // relevant path that we can match against.
- const name = node.name.startsWith('index.')
- ? [node.name, ...path.map(p => p.name)].join('.')
- : node.name;
- const match = fzf(name, lowerCaseQuery, false);
- node.result = match;
- if (match.score > 0) {
- node.visible = true;
- if (Object.keys(node.children).length > 0) {
- node.expanded = true;
- for (const child of Object.values(node.children)) {
- child.visible = true;
- }
- }
- // @TODO (JonasBadalic): We can trip this when we find a visible node if we reverse iterate
- for (const p of path) {
- p.visible = true;
- p.expanded = true;
- // The entire path needs to contain max score of its child results so that
- // the entire path to it can be sorted by this score. The side effect of this is that results from the same
- // tree path with a lower score will be placed higher in the tree if that same path has a higher score anywhere
- // in the tree. This isn't ideal, but given that it favors the most relevant results, it makes it a good starting point.
- p.result = match.score > (p.result?.score ?? 0) ? match : p.result;
- }
- }
- }
- root.sort(folderOrSearchScoreFirst);
- return Object.values(root.children);
- }, [tree, options.query, options.representation]);
- return nodes;
- }
- interface Props extends React.HTMLAttributes<HTMLDivElement> {
- nodes: StoryTreeNode[];
- }
- // @TODO (JonasBadalic): Implement treeview pattern navigation
- // https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
- export function StoryTree({nodes, ...htmlProps}: Props) {
- return (
- <nav {...htmlProps}>
- <StoryList>
- {nodes.map(node => {
- if (!node.visible) {
- return null;
- }
- return Object.keys(node.children).length === 0 ? (
- <File node={node} key={node.name} />
- ) : (
- <Folder node={node} key={node.name} />
- );
- })}
- </StoryList>
- </nav>
- );
- }
- function Folder(props: {node: StoryTreeNode}) {
- const [expanded, setExpanded] = useState(props.node.expanded);
- if (props.node.expanded !== expanded) {
- setExpanded(props.node.expanded);
- }
- if (!props.node.visible) {
- return null;
- }
- return (
- <li>
- <FolderName
- onClick={() => {
- props.node.expanded = !props.node.expanded;
- if (props.node.expanded) {
- for (const child of Object.values(props.node.children)) {
- child.visible = true;
- }
- }
- setExpanded(props.node.expanded);
- }}
- >
- <IconChevron size="xs" direction={expanded ? 'down' : 'right'} />
- {normalizeFilename(props.node.name)}
- </FolderName>
- {expanded && Object.keys(props.node.children).length > 0 && (
- <StoryList>
- {Object.values(props.node.children).map(child => {
- if (!child.visible) {
- return null;
- }
- return Object.keys(child.children).length === 0 ? (
- <File key={child.path} node={child} />
- ) : (
- <Folder key={child.path} node={child} />
- );
- })}
- </StoryList>
- )}
- </li>
- );
- }
- function File(props: {node: StoryTreeNode}) {
- const location = useLocation();
- const query = qs.stringify({...location.query, name: props.node.path});
- const category = props.node.path.split('/').at(1) ?? 'default';
- return (
- <li>
- <FolderLink
- to={`/stories/?${query}`}
- active={location.query.name === props.node.path}
- >
- <StoryIcon category={category} />
- {/* @TODO (JonasBadalic): Do we need to show the file extension? */}
- {normalizeFilename(props.node.name)}
- </FolderLink>
- </li>
- );
- }
- function StoryIcon(props: {
- category: 'components' | 'icons' | 'styles' | 'utils' | 'views' | (string & {});
- }) {
- const iconProps: SVGIconProps = {size: 'xs'};
- switch (props.category) {
- case 'components':
- return <IconGrid {...iconProps} />;
- case 'icons':
- return <IconExpand {...iconProps} />;
- case 'styles':
- return <IconCircle {...iconProps} />;
- case 'utils':
- return <IconCode {...iconProps} />;
- case 'views':
- return <IconNumber {...iconProps} />;
- default:
- return <IconFile {...iconProps} />;
- }
- }
- const StoryList = styled('ul')`
- list-style-type: none;
- padding-left: 10px;
- &:first-child {
- padding-left: 0;
- }
- `;
- const FolderName = styled('div')`
- display: flex;
- align-items: center;
- gap: ${space(0.75)};
- padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
- cursor: pointer;
- position: relative;
- &:before {
- background: ${p => p.theme.surface100};
- content: '';
- inset: 0px 0px 0px -100%;
- position: absolute;
- z-index: -1;
- opacity: 0;
- }
- &:hover {
- &:before {
- opacity: 1;
- }
- }
- `;
- const FolderLink = styled(Link, {
- shouldForwardProp: prop => prop !== 'active',
- })<{active: boolean}>`
- display: flex;
- align-items: center;
- margin-left: ${space(0.5)};
- gap: ${space(0.75)};
- color: ${p => p.theme.textColor};
- padding: ${space(0.25)} 0 ${space(0.25)} ${space(0.5)};
- position: relative;
- &:before {
- background: ${p => p.theme.surface100};
- content: '';
- inset: 0px 0px 0px -100%;
- position: absolute;
- z-index: -1;
- opacity: ${p => (p.active ? 1 : 0)};
- }
- &:hover {
- color: ${p => p.theme.textColor};
- &:before {
- opacity: 1;
- }
- }
- svg {
- flex-shrink: 0;
- }
- `;
|