import * as React from 'react' import styled, { css, FlattenSimpleInterpolation } from 'styled-components' import * as Arr from 'fp-ts/lib/Array' import { pipe, tuple } from 'fp-ts/lib/function' import * as NEA from 'fp-ts/lib/NonEmptyArray' import * as O from 'fp-ts/lib/Option' import * as R from 'fp-ts/lib/Record' import * as Sg from 'fp-ts/lib/Semigroup' import * as Task from 'fp-ts/lib/Task' import * as T from 'fp-ts/lib/Tree' import { Icon, IconProps } from '@monorail/exports' import { baseChromelessStyles, Colors, flexFlow, FontSizes, getColor, typography, } from '@monorail/helpers/exports' import { PopOverToggleProps } from '@monorail/metaComponents/popOver/PopOver' import * as TF from '@monorail/sharedHelpers/fp-ts-ext/Tree' import { matchI } from '@monorail/sharedHelpers/matchers' import { name, the } from '@monorail/sharedHelpers/names' import { isNotNil } from '@monorail/sharedHelpers/typeGuards' import { ActionsMenu, MenuAction, } from '@monorail/visualComponents/actionsMenu/ActionsMenu' import { Button } from '@monorail/visualComponents/buttons/Button' import { ButtonDisplay } from '@monorail/visualComponents/buttons/buttonTypes' import { IconButton } from '@monorail/visualComponents/buttons/IconButton' import { ScrollAnimation } from '@monorail/visualComponents/layout/ScrollAnimation' import { FlattenedNode, outlineNumbering, TreeRowToggleAndDepthLine, useTreeList, } from '@monorail/visualComponents/treeList/TreeList' type Key = string export enum TreeViewRowDisplayType { ArbitraryContent = 'ArbitraryContent', Default = 'Default', } export type TreeViewRowDisplay = | { tag: typeof TreeViewRowDisplayType.Default text: string iconLeft?: IconProps } | { tag: typeof TreeViewRowDisplayType.ArbitraryContent content: JSX.Element } export type ActionReturn = void | Task.Task<{ tag: 'nodeOpen' nodeOpen: boolean key: K }> interface Props { header?: { main: string sub?: string } forest: T.Forest getDisplay: (args: { value: A isLeaf: boolean isChildSelected: boolean }) => TreeViewRowDisplay getTreeViewTextProps?: (args: { value: A }) => Partial<{ css: FlattenSimpleInterpolation }> getKey: (node: A) => Key numbered?: boolean selected: O.Option editable?: boolean getActions?: (nodeKey: Key) => Array>> onAddSection: () => void onSelect?: (key: Key, numbering: string, value: A) => void } const TreeViewWrapper = styled.section` ${flexFlow('column')} height: 100%; ` const TreeViewScrollContainer = styled(ScrollAnimation)` ${flexFlow('row')} height: 100%; width: 100%; overflow: auto; ` const TreeViewList = styled.ul` ${flexFlow('column')} flex-grow: 1; ` const TreeViewText = styled.div.attrs({ role: 'button', tabIndex: 0, })<{ depth: number }>` ${flexFlow('row', 'nowrap')}; ${props => typography(props.depth === 0 ? 500 : 400, FontSizes.Title5)} margin-left: 8px; &:focus { outline: none; text-decoration: underline; } ` interface TreeViewRowProps extends React.HTMLProps { active?: boolean } const ActionsMenuToggle = styled(IconButton)`` export const TreeViewRow = styled.li.attrs((props: TreeViewRowProps) => ({ className: props.active ? 'is-active' : '', role: 'button', tabindex: '0', }))` ${baseChromelessStyles()}; ${flexFlow('row')}; align-items: center; min-height: 32px; padding: 0 8px; position: relative; white-space: nowrap; color: ${props => getColor(props.active ? Colors.AccentBlue500 : Colors.Gray89)}; & ${ActionsMenuToggle} { opacity: 0; } &:hover ${/* sc-selector */ ActionsMenuToggle}, &:focus ${/* sc-selector */ ActionsMenuToggle}, &:focus-within ${/* sc-selector */ ActionsMenuToggle} { opacity: 1; } ` const RowScrollIntoViewAnchor = styled.div` width: 40px; ` const ActionsMenuWrapper = styled.div` height: 100%; ${flexFlow('row')}; justify-content: space-between; align-items: center; flex-grow: 1; & ${ActionsMenuToggle} { margin: 0 8px; position: sticky; right: 8px; } ` const RowIconLeftContainer = styled.div` ${flexFlow('row', 'nowrap')}; margin: 0 8px 0 4px; ` const Header = styled.header` padding: 16px 32px; border-bottom: 1px solid ${getColor(Colors.Gray06)}; h1 { ${typography(500, FontSizes.Title3)}; padding-bottom: 8px; } h2 { ${typography(400, FontSizes.Title5)} color: ${getColor(Colors.Gray54)}; padding-bottom: 8px; } ` export const TreeView = (props: Props) => { const { editable, numbered, forest, getKey, header, getActions, onAddSection, } = props const treeListState = useTreeList({ forest, getTreeNodeKey: getKey, startExpanded: true, }) const selectedRow = pipe( props.selected, O.chain(selected => pipe( treeListState.rows, Arr.findFirst( (a: FlattenedNode) => props.getKey(a.value) === selected, ), ), ), ) const selectedRowAncestorKeys = pipe( selectedRow, O.map(x => x.ancestors.map(a => a.key)), ) const handleActionComplete = React.useCallback( async (r: ActionReturn) => { if (r) { matchI(await r())({ nodeOpen: ({ key, nodeOpen }) => { if (treeListState.openRows.includes(key) !== nodeOpen) { treeListState.toggleNode(key) } }, }) } }, [treeListState], ) const selectedItemScrollAnchor = React.useRef(null) const selectedKey = O.toUndefined(props.selected) React.useEffect(() => { selectedItemScrollAnchor.current?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'end', }) }, [selectedKey]) React.useEffect(() => { /** * If the selected node somehow ends up in a collapsed row, (e.g. during a * "move into" movement or when the user gets the latest checklist that has * undergone edits from another user moving their selected item into a collapsed * row), we need to make sure all the ancestors of the selected node are opened. */ const openRowKeyDict = pipe( treeListState.openRows, Arr.map(key => tuple(key, key)), R.fromFoldable(Sg.getLastSemigroup(), Arr.array), ) const collapsedParentsOfSelected = pipe( name(forest)(TF.getPath(node => getKey(node.value) === selectedKey)), O.map(pathToSelected => { return Arr.unfold(the(pathToSelected), path => { const init = NEA.init(path) return Arr.isNonEmpty(init) ? O.some([init, init]) : O.none }) }), O.getOrElse>>(() => Arr.empty), Arr.map(ancestor => TF.getTreeOptionalFromPath(ancestor)), Arr.filterMap(optional => optional.getOption(forest)), Arr.filter( parent => !R.hasOwnProperty(getKey(parent.value), openRowKeyDict), ), Arr.map(unopenedParent => getKey(unopenedParent.value)), ) collapsedParentsOfSelected.forEach(parent => treeListState.toggleNode(parent), ) }, [forest, getKey, selectedKey, treeListState]) return ( {header && (
{header &&

{header.main}

} {header?.sub &&

{header.sub}

} {editable && ( )}
)} {treeListState.rows.map(row => { const isChildSelected = pipe( selectedRowAncestorKeys, O.fold( () => false, xs => xs.includes(key), ), ) const key = getKey(row.value) const isSelected = pipe( props.selected, O.fold( () => false, selected => key === selected, ), ) // `rowDisplayUnmodified` shown in the `title` on hover const rowDisplayUnmodified = props.getDisplay({ value: row.value, isLeaf: row.isLeaf, isChildSelected, }) const rowNumber = outlineNumbering(row) const formattedRowNumber = rowNumber.split('.').length === 1 ? rowNumber.concat('.') : rowNumber return ( { switch (rowDisplayUnmodified.tag) { case TreeViewRowDisplayType.Default: return numbered ? `${formattedRowNumber} ${rowDisplayUnmodified.text}` : rowDisplayUnmodified.text case TreeViewRowDisplayType.ArbitraryContent: return '' } })()} onClick={() => props.onSelect?.(key, outlineNumbering(row), row.value) } onKeyDown={e => (e.key === ' ' || e.key === 'Enter') && props.onSelect?.(key, outlineNumbering(row), row.value) } active={isSelected} > { const childIsSelected = name(props.forest)(nf => { return pipe( nf, TF.getPath(node => getKey(node.value) === selectedKey), O.bindTo('selected'), O.bind('this', () => pipe( nf, TF.getPath( node => getKey(node.value) === getKey(row.value), ), ), ), O.exists(b => TF.isNodePathDescendantOf(b.this)(b.selected), ), ) }) if (childIsSelected) { props.onSelect?.(key, formattedRowNumber, row.value) } evt.stopPropagation() treeListState.toggleNode(key) }} isOpen={treeListState.openRows.includes(key)} isLeaf={row.isLeaf} /> {(() => { switch (rowDisplayUnmodified.tag) { case TreeViewRowDisplayType.Default: return isNotNil(rowDisplayUnmodified.iconLeft) ? ( ) : ( <> ) default: return null } })()} {numbered && formattedRowNumber.concat(' ')} {(() => { switch (rowDisplayUnmodified.tag) { case TreeViewRowDisplayType.Default: return rowDisplayUnmodified.text.length > 249 ? `${rowDisplayUnmodified.text.slice(0, 246)}...` : rowDisplayUnmodified.text case TreeViewRowDisplayType.ArbitraryContent: return rowDisplayUnmodified.content } })()} {(() => { switch (rowDisplayUnmodified.tag) { case TreeViewRowDisplayType.Default: return ( ( { e.stopPropagation() toggleProps.onClick(e) }} /> )} actions={getActions?.(key) ?? Arr.empty} onActionComplete={handleActionComplete} /> ) default: return ( ) } })()} ) })}
) }