diff --git a/src/client/components/file_tree/controller.ts b/src/client/components/file_tree/controller.ts new file mode 100644 index 00000000..224dc10f --- /dev/null +++ b/src/client/components/file_tree/controller.ts @@ -0,0 +1,37 @@ +import { action } from 'mobx' + +import { FileTreeModel, TreeNode } from './model' + +export class FileTreeController { + constructor(private model: FileTreeModel) {} + + static of(model: FileTreeModel) { + return new FileTreeController(model) + } + + @action + toggleNode(node: TreeNode) { + node.expanded = !node.expanded + } + + @action + selectNode(node: TreeNode) { + this.model.selectedNode = node + } + + sortNodes(a: TreeNode, b: TreeNode) { + if (a.leaf) { + if (b.leaf) { + return a.label.localeCompare(b.label) + } else { + return 1 + } + } else { + if (b.leaf) { + return -1 + } else { + return a.label.localeCompare(b.label) + } + } + } +} diff --git a/src/client/components/file_tree/model.ts b/src/client/components/file_tree/model.ts new file mode 100644 index 00000000..1794139f --- /dev/null +++ b/src/client/components/file_tree/model.ts @@ -0,0 +1,82 @@ +import { observable } from 'mobx' +import { createTransformer } from 'mobx-utils' + +export interface TreeNode { + label: string + children: TreeNode[] + expanded: boolean + leaf: boolean + file?: File +} + +export interface File { + path: string + data?: any +} + +export class FileTreeModel { + @observable rootNode: TreeNode + @observable selectedNode?: TreeNode + + constructor(files: File[]) { + this.rootNode = createTreeFromFiles(files) + } + + static of = createTransformer((files: File[]): FileTreeModel => { + return new FileTreeModel(files) + }) +} + +function createTreeFromFiles(files: File[]): TreeNode { + const root = { + label: 'root', + children: [], + leaf: false, + expanded: true, + } + + files.forEach(file => { + const path = normalizePath(file.path) + createTreeNode(path.split('/'), file, root) + }) + + return root +} + +export function createTreeNode(pathSegments: string[], file: File, parent: TreeNode): void { + if (pathSegments.length === 0) { + throw Error('[createTreeNode]: pathSegments cannot be empty') + } else if (pathSegments.length === 1) { + const node = getOrCreateChild(parent, pathSegments[0]) + node.leaf = true + node.file = file + } else { + const intermediateParent = getOrCreateChild(parent, pathSegments[0]) + intermediateParent.leaf = false + + createTreeNode(pathSegments.slice(1), file, intermediateParent) + } +} + +function getOrCreateChild(parent: TreeNode, childLabel: string): TreeNode { + const existingChild = parent.children.find(parent => parent.label === childLabel) + + if (existingChild) { + return existingChild + } + + const newChild = { + label: childLabel, + children: [], + leaf: false, + expanded: false, + } + + parent.children.push(newChild) + + return newChild +} + +function normalizePath(path: string) { + return path.replace(/\/\//g, '/') // Collapse consecutive slashes +} diff --git a/src/client/components/file_tree/stories/file_tree.stories.tsx b/src/client/components/file_tree/stories/file_tree.stories.tsx new file mode 100644 index 00000000..e5024b20 --- /dev/null +++ b/src/client/components/file_tree/stories/file_tree.stories.tsx @@ -0,0 +1,90 @@ +import { action } from '@storybook/addon-actions' +import { storiesOf } from '@storybook/react' +import { action as mobxAction, observable } from 'mobx' +import { observer } from 'mobx-react' +import * as React from 'react' + +import { File } from '../model' +import { FileBrowser } from '../view' + +const actions = { + onSelect: action('onSelect'), +} + +storiesOf('components.file_tree', module) + .addDecorator(story => { + return
{story()}
+ }) + .add('empty', () => { + return + }) + .add('interactive', () => { + const files = getFiles() + return + }) + .add('no animation', () => { + const files = getFiles() + return + }) + +function getFiles(): File[] { + return [ + { + path: 'TopLevelFile.yaml', + }, + { + path: 'scripts/igus1/Stand.yaml', + }, + { + path: 'scripts/igus1/GetupFront.yaml', + }, + { + path: 'scripts/igus1/GetupBack.yaml', + }, + { + path: 'scripts/igus2/Stand.yaml', + }, + { + path: 'scripts/igus2/GetupFront.yaml', + }, + { + path: 'scripts/igus2/GetupBack.yaml', + }, + { + path: 'config/igus1/NUsight.yaml', + }, + { + path: 'config/igus1/DataLogger.yaml', + }, + { + path: 'config/igus1/WalkEngine.yaml', + }, + { + path: 'config/igus2/NUsight.yaml', + }, + { + path: 'config/igus2/DataLogger.yaml', + }, + { + path: 'config/igus2/WalkEngine.yaml', + }, + { + path: 'AnotherTopLevelFile.yaml', + }, + ] +} diff --git a/src/client/components/file_tree/style.css b/src/client/components/file_tree/style.css new file mode 100644 index 00000000..42738269 --- /dev/null +++ b/src/client/components/file_tree/style.css @@ -0,0 +1,7 @@ +.placeholder { + box-sizing: border-box; + width: 100%; + padding: 8px; + text-align: center; + color: #757575; +} diff --git a/src/client/components/file_tree/tree_node/dropdown.svg b/src/client/components/file_tree/tree_node/dropdown.svg new file mode 100644 index 00000000..c0638497 --- /dev/null +++ b/src/client/components/file_tree/tree_node/dropdown.svg @@ -0,0 +1 @@ + diff --git a/src/client/components/file_tree/tree_node/file.svg b/src/client/components/file_tree/tree_node/file.svg new file mode 100644 index 00000000..ffb611b5 --- /dev/null +++ b/src/client/components/file_tree/tree_node/file.svg @@ -0,0 +1 @@ + diff --git a/src/client/components/file_tree/tree_node/folder.svg b/src/client/components/file_tree/tree_node/folder.svg new file mode 100644 index 00000000..e4b37277 --- /dev/null +++ b/src/client/components/file_tree/tree_node/folder.svg @@ -0,0 +1 @@ + diff --git a/src/client/components/file_tree/tree_node/folder_open.svg b/src/client/components/file_tree/tree_node/folder_open.svg new file mode 100644 index 00000000..ff43ec94 --- /dev/null +++ b/src/client/components/file_tree/tree_node/folder_open.svg @@ -0,0 +1 @@ + diff --git a/src/client/components/file_tree/tree_node/style.css b/src/client/components/file_tree/tree_node/style.css new file mode 100644 index 00000000..f0eeb2d4 --- /dev/null +++ b/src/client/components/file_tree/tree_node/style.css @@ -0,0 +1,84 @@ +.node { + display: flex; + flex-direction: column; + list-style-type: none; + margin: 0; + padding: 0; +} + +.nodeHeader { + display: flex; + align-items: center; + height: 24px; + padding: 4px 8px; + cursor: pointer; + user-select: none; + font-weight: normal; +} + +.nodeHeader:hover { + background-color: #EEE; + color: #1197d3; +} + +.nodeHeader:hover .icon { + color: #1197d3; +} + +.nodeHeaderSelected, +.nodeHeaderSelected:hover { + background-color: #1197d3; + color: white; +} + +.nodeHeaderSelected .icon, +.nodeHeaderSelected:hover .icon { + color: white; +} + +.icon { + color: #757575; +} + +.dropdownIcon { + width: 18px; + height: 18px; + margin-right: 4px; +} + +.dropdownIcon svg { + display: block; + width: 18px; + height: 18px; +} + +.dropdownIconExpanded { + transform: rotate(90deg); +} + +.dropdownIconAnimate { + transition: transform 0.2s ease; +} + +.nodeIcon { + width: 16px; + height: 16px; + margin-right: 6px; +} + +.nodeIcon svg { + display: block; + width: 16px; + height: 16px; +} + +.nodeLabel { + flex-grow: 1; + line-height: 1; +} + +.nodeChildren { + padding: 0; + margin: 0; + border: none; +} diff --git a/src/client/components/file_tree/tree_node/view.tsx b/src/client/components/file_tree/tree_node/view.tsx new file mode 100644 index 00000000..eeca87e4 --- /dev/null +++ b/src/client/components/file_tree/tree_node/view.tsx @@ -0,0 +1,94 @@ +import classNames from 'classnames' +import { observer } from 'mobx-react' +import * as React from 'react' + +import { Collapsible } from '../../collapsible/view' +import { FileTreeController } from '../controller' +import { File, FileTreeModel, TreeNode } from '../model' + +import DropdownIcon from './dropdown.svg' +import FileIcon from './file.svg' +import FolderIcon from './folder.svg' +import FolderOpenIcon from './folder_open.svg' +import * as style from './style.css' + +export interface FileTreeNodeProps { + controller: FileTreeController + model: FileTreeModel + node: TreeNode + level: number + animate?: boolean + onSelect?(file: File): void +} + +export const FileTreeNode = observer((props: FileTreeNodeProps) => { + const { model, controller, node, level, animate, onSelect } = props + + const children = props.node.children + const hasChildren = children.length > 0 + + const onClick = () => { + if (node.leaf) { + controller.selectNode(node) + onSelect && onSelect(node.file!) // The leaf node will always have a file + } else { + controller.toggleNode(node) + } + } + + // Using inline padding-left to indent so that the hover and selected background indicators + // are full width. Padding is the default left padding of 8px plus each level's indent of 22px. + const headerInlineStyle = { + paddingLeft: 8 + (level * 22) + 'px', + } + + const nodeHeaderClassNames = classNames(style.nodeHeader, { + [style.nodeHeaderSelected]: model.selectedNode === node, + }) + + const dropdownIconClassNames = classNames(style.dropdownIcon, { + [style.dropdownIconExpanded]: node.expanded, + [style.dropdownIconAnimate]: animate, + }) + + return ( +
    +
  • +
    +
    + { hasChildren ? : null } +
    + +
    + { + node.leaf + ? + : ( + node.expanded + ? + : + ) + } +
    + +
    { node.label }
    +
    + + + { children.sort(controller.sortNodes).map((node, i) => + , + ) + } + +
  • +
+ ) +}) diff --git a/src/client/components/file_tree/view.tsx b/src/client/components/file_tree/view.tsx new file mode 100644 index 00000000..6a8513ae --- /dev/null +++ b/src/client/components/file_tree/view.tsx @@ -0,0 +1,37 @@ +import { observer } from 'mobx-react' +import * as React from 'react' + +import { FileTreeController } from './controller' +import { FileTreeModel } from './model' +import { File } from './model' +import * as style from './style.css' +import { FileTreeNode } from './tree_node/view' + +export type FileBrowserProps = { + files: File[] + animate?: boolean + onSelect?(file: File): void +} + +export const FileBrowser = observer((props: FileBrowserProps) => { + const model = FileTreeModel.of(props.files) + const controller = FileTreeController.of(model) + + return
+ { model.rootNode.children.length === 0 &&
No files
} + { model.rootNode.children + .sort(controller.sortNodes) + .map((node, i) => ( + + )) + } +
+})