From 33f06c10353d056cb6872e015e5f73c7310f56a3 Mon Sep 17 00:00:00 2001 From: Jordan Knott Date: Tue, 8 Dec 2020 19:44:48 -0600 Subject: [PATCH] feat: more changes --- frontend/src/Outline/DragDebug.tsx | 8 +- frontend/src/Outline/DragIndicator.tsx | 3 +- frontend/src/Outline/Dragger.tsx | 162 ++++--- frontend/src/Outline/Entry.tsx | 98 +++- frontend/src/Outline/Styles.ts | 74 ++- frontend/src/Outline/index.tsx | 451 +++++++++++++----- frontend/src/Outline/useDrag.ts | 1 + frontend/src/Outline/utils.ts | 117 ++++- frontend/src/Projects/Project/index.tsx | 55 ++- frontend/src/shared/components/List/Styles.ts | 1 - frontend/src/shared/icons/ArrowDown.tsx | 12 + frontend/src/shared/icons/CaretDown.tsx | 12 + frontend/src/shared/icons/CaretRight.tsx | 12 + frontend/src/shared/icons/Dot.tsx | 12 + frontend/src/shared/icons/index.ts | 8 + frontend/src/taskcafe.d.ts | 2 + internal/db/project.sql.go | 9 +- internal/db/query/project.sql | 2 +- internal/graph/generated.go | 206 ++++---- internal/graph/models_gen.go | 48 +- internal/graph/schema.graphqls | 15 +- internal/graph/schema.resolvers.go | 71 ++- internal/graph/schema/_root.gql | 5 + internal/graph/schema/user.gql | 10 +- 24 files changed, 1000 insertions(+), 394 deletions(-) create mode 100644 frontend/src/shared/icons/ArrowDown.tsx create mode 100644 frontend/src/shared/icons/CaretDown.tsx create mode 100644 frontend/src/shared/icons/CaretRight.tsx create mode 100644 frontend/src/shared/icons/Dot.tsx diff --git a/frontend/src/Outline/DragDebug.tsx b/frontend/src/Outline/DragDebug.tsx index 1fed4e4..c73d134 100644 --- a/frontend/src/Outline/DragDebug.tsx +++ b/frontend/src/Outline/DragDebug.tsx @@ -4,10 +4,10 @@ import { DragDebugWrapper } from './Styles'; type DragDebugProps = { zone: ImpactZone | null; depthTarget: number; - draggingID: string | null; + draggedNodes: Array | null; }; -const DragDebug: React.FC = ({ zone, depthTarget, draggingID }) => { +const DragDebug: React.FC = ({ zone, depthTarget, draggedNodes }) => { let aboveID = null; let belowID = null; if (zone) { @@ -15,7 +15,9 @@ const DragDebug: React.FC = ({ zone, depthTarget, draggingID }) belowID = zone.below ? zone.below.node.id : null; } return ( - {`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggingID=${draggingID}`} + {`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggedNodes=${ + draggedNodes ? draggedNodes.toString() : null + }`} ); }; diff --git a/frontend/src/Outline/DragIndicator.tsx b/frontend/src/Outline/DragIndicator.tsx index a66bc02..a067c94 100644 --- a/frontend/src/Outline/DragIndicator.tsx +++ b/frontend/src/Outline/DragIndicator.tsx @@ -32,7 +32,8 @@ const DragIndicator: React.FC = ({ container, zone, depthTar } let left = 0; if (container && container.current) { - left = container.current.getBoundingClientRect().left + 25 + depthTarget * 35; + left = container.current.getBoundingClientRect().left + (depthTarget - 1) * 35; + width = container.current.getBoundingClientRect().width - depthTarget * 35; } return ; }; diff --git a/frontend/src/Outline/Dragger.tsx b/frontend/src/Outline/Dragger.tsx index e46ac0b..0eb516c 100644 --- a/frontend/src/Outline/Dragger.tsx +++ b/frontend/src/Outline/Dragger.tsx @@ -1,17 +1,42 @@ import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react'; -import { DotCircle } from 'shared/icons'; -import { findNextDraggable, getDimensions, getTargetDepth, getNodeAbove, getBelowParent, findNodeAbove } from './utils'; +import { Dot } from 'shared/icons'; +import styled from 'styled-components'; +import { + findNextDraggable, + getDimensions, + getTargetDepth, + getNodeAbove, + getBelowParent, + findNodeAbove, + getNodeOver, + getLastChildInBranch, + findNodeDepth, +} from './utils'; import { useDrag } from './useDrag'; +const Container = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 9px; + background: rgba(${p => p.theme.colors.primary}); + svg { + fill: rgba(${p => p.theme.colors.text.primary}); + stroke: rgba(${p => p.theme.colors.text.primary}); + } +`; + type DraggerProps = { container: React.RefObject; - draggingID: string; + draggedNodes: { nodes: Array; first?: OutlineNode | null }; isDragging: boolean; onDragEnd: (zone: ImpactZone) => void; initialPos: { x: number; y: number }; }; -const Dragger: React.FC = ({ draggingID, container, onDragEnd, isDragging, initialPos }) => { +const Dragger: React.FC = ({ draggedNodes, container, onDragEnd, isDragging, initialPos }) => { const [pos, setPos] = useState<{ x: number; y: number }>(initialPos); const { outline, impact, setImpact } = useDrag(); const $handle = useRef(null); @@ -22,38 +47,9 @@ const Dragger: React.FC = ({ draggingID, container, onDragEnd, isD e => { e.preventDefault(); const { clientX, clientY, pageX, pageY } = e; - console.log(clientX, clientY); setPos({ x: clientX, y: clientY }); - let curDepth = 1; - let curDraggables: any; - let prevDraggable: any; - let curDraggable: any; - let depthTarget = 1; - let curPosition: ImpactPosition = 'after'; - - // get hovered over node - // decide if node is bottom or top - // calculate the missing node, if it exists - // calculate available depth - // calulcate current selected depth - - while (outline.current.nodes.size + 1 > curDepth) { - curDraggables = outline.current.nodes.get(curDepth); - if (curDraggables) { - const nextDraggable = findNextDraggable({ x: clientX, y: clientY }, outline.current, curDepth, draggingID); - if (nextDraggable) { - prevDraggable = curDraggable; - curDraggable = nextDraggable.node; - curPosition = nextDraggable.position; - if (nextDraggable.found) { - break; - } - curDepth += 1; - } else { - break; - } - } - } + const { curDepth, curPosition, curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current); + let depthTarget: number = 0; let aboveNode: null | OutlineNode = null; let belowNode: null | OutlineNode = null; @@ -66,36 +62,38 @@ const Dragger: React.FC = ({ draggingID, container, onDragEnd, isD // if belowNode has the depth of 1, then the above element will be a part of a different branch const { relationships, nodes } = outline.current; - if (belowNode) { - aboveNode = findNodeAbove(outline.current, curDepth, belowNode); - } else if (aboveNode) { - let targetBelowNode: RelationshipChild | null = null; - const parent = relationships.get(aboveNode.parent); - if (aboveNode.children !== 0) { - const abr = relationships.get(aboveNode.id); - if (abr) { - const newTarget = abr.children[0]; - if (newTarget) { - targetBelowNode = newTarget; + if (!belowNode || !aboveNode) { + if (belowNode) { + aboveNode = findNodeAbove(outline.current, curDepth, belowNode); + } else if (aboveNode) { + let targetBelowNode: RelationshipChild | null = null; + const parent = relationships.get(aboveNode.parent); + if (aboveNode.children !== 0 && !aboveNode.collapsed) { + const abr = relationships.get(aboveNode.id); + if (abr) { + const newTarget = abr.children[0]; + if (newTarget) { + targetBelowNode = newTarget; + } + } + } else if (parent) { + const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id); + if (aboveNodeIndex !== -1) { + if (aboveNodeIndex === parent.children.length - 1) { + targetBelowNode = getBelowParent(aboveNode, outline.current); + } else { + const nextChild = parent.children[aboveNodeIndex + 1]; + targetBelowNode = nextChild ?? null; + } } } - } else if (parent) { - const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id); - if (aboveNodeIndex !== -1) { - if (aboveNodeIndex === parent.children.length - 1) { - targetBelowNode = getBelowParent(aboveNode, outline.current); - } else { - const nextChild = parent.children[aboveNodeIndex + 1]; - targetBelowNode = nextChild ?? null; + if (targetBelowNode) { + const depthNodes = nodes.get(targetBelowNode.depth); + if (depthNodes) { + belowNode = depthNodes.get(targetBelowNode.id) ?? null; } } } - if (targetBelowNode) { - const depthNodes = nodes.get(targetBelowNode.depth); - if (depthNodes) { - belowNode = depthNodes.get(targetBelowNode.id) ?? null; - } - } } // if outside outline, get either first or last item in list based on mouse Y @@ -111,19 +109,47 @@ const Dragger: React.FC = ({ draggingID, container, onDragEnd, isD aboveNode = null; } } else { + // TODO: enhance to actually get last child item, not last top level branch const rootChildren = outline.current.relationships.get('root'); const rootDepth = outline.current.nodes.get(1); if (rootChildren && rootDepth) { const lastChild = rootChildren.children[rootChildren.children.length - 1]; - aboveNode = rootDepth.get(lastChild.id) ?? null; + const lastParentNode = rootDepth.get(lastChild.id) ?? null; + + if (lastParentNode) { + const lastBranchChild = getLastChildInBranch(outline.current, lastParentNode); + if (lastBranchChild) { + const lastChildDepth = outline.current.nodes.get(lastBranchChild.depth); + if (lastChildDepth) { + aboveNode = lastChildDepth.get(lastBranchChild.id) ?? null; + } + } + } } } } } - if (aboveNode && aboveNode.id === draggingID) { - belowNode = aboveNode; - aboveNode = findNodeAbove(outline.current, aboveNode.depth, aboveNode); + if (aboveNode) { + const { ancestors } = findNodeDepth(outline.current.published, aboveNode.id); + for (let i = 0; i < draggedNodes.nodes.length; i++) { + const nodeID = draggedNodes.nodes[i]; + if (ancestors.find(c => c === nodeID)) { + if (draggedNodes.first) { + belowNode = draggedNodes.first; + aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first); + } else { + const { depth } = findNodeDepth(outline.current.published, nodeID); + const nodeDepth = outline.current.nodes.get(depth); + const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null; + if (targetNode) { + belowNode = targetNode; + + aboveNode = findNodeAbove(outline.current, depth, targetNode); + } + } + } + } } // calculate available depths @@ -132,7 +158,7 @@ const Dragger: React.FC = ({ draggingID, container, onDragEnd, isD let maxDepth = 2; if (aboveNode) { const aboveParent = relationships.get(aboveNode.parent); - if (aboveNode.children !== 0) { + if (aboveNode.children !== 0 && !aboveNode.collapsed) { minDepth = aboveNode.depth + 1; maxDepth = aboveNode.depth + 1; } else if (aboveParent) { @@ -205,9 +231,9 @@ const Dragger: React.FC = ({ draggingID, container, onDragEnd, isD return ( <> {pos && ( -
- -
+ + + )} ); diff --git a/frontend/src/Outline/Entry.tsx b/frontend/src/Outline/Entry.tsx index ace4190..68fb65a 100644 --- a/frontend/src/Outline/Entry.tsx +++ b/frontend/src/Outline/Entry.tsx @@ -1,16 +1,37 @@ import React, { useRef, useEffect } from 'react'; -import { DotCircle } from 'shared/icons'; +import { Dot, CaretDown, CaretRight } from 'shared/icons'; -import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle } from './Styles'; +import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle, ExpandButton } from './Styles'; import { useDrag } from './useDrag'; +function getCaretPosition(editableDiv: any) { + let caretPos = 0; + let sel: any = null; + let range: any = null; + if (window.getSelection) { + sel = window.getSelection(); + if (sel && sel.rangeCount) { + range = sel.getRangeAt(0); + if (range.commonAncestorContainer.parentNode === editableDiv) { + caretPos = range.endOffset; + } + } + } + return caretPos; +} + type EntryProps = { id: string; + collapsed?: boolean; + onToggleCollapse: (id: string, collapsed: boolean) => void; parentID: string; onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void; + onStartSelect: (e: { id: string; depth: number }) => void; isRoot?: boolean; - draggingID: null | string; + selection: null | Array<{ id: string }>; + draggedNodes: null | Array; entries: Array; + onCancelDrag: () => void; position: number; chain?: Array; depth?: number; @@ -20,16 +41,21 @@ const Entry: React.FC = ({ id, parentID, isRoot = false, + selection, + onToggleCollapse, + onStartSelect, position, + onCancelDrag, onStartDrag, - draggingID, + collapsed = false, + draggedNodes, entries, chain = [], depth = 0, }) => { const $entry = useRef(null); const $children = useRef(null); - const { setNodeDimensions } = useDrag(); + const { setNodeDimensions, clearNodeDimensions } = useDrag(); useEffect(() => { if (isRoot) return; if ($entry && $entry.current) { @@ -38,24 +64,67 @@ const Entry: React.FC = ({ children: entries.length !== 0 ? $children : null, }); } + return () => { + clearNodeDimensions(id); + }; }, [position, depth, entries]); let showHandle = true; - if (draggingID && draggingID === id) { + if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) { showHandle = false; } + let isSelected = false; + if (selection && selection.find(c => c.id === id)) { + isSelected = true; + } + let onSaveTimer: any = null; + const onSaveTimeout = 300; return ( - + {!isRoot && ( + {entries.length !== 0 && ( + onToggleCollapse(id, !collapsed)}> + {collapsed ? : } + + )} {showHandle && ( - onStartDrag({ id, clientX: e.clientX, clientY: e.clientY })}> - + onCancelDrag()} + onMouseDown={e => { + onStartDrag({ id, clientX: e.clientX, clientY: e.clientY }); + }} + > + )} - {id.toString()} + { + onStartSelect({ id, depth }); + }} + onKeyDown={e => { + if (e.key === 'z' && e.ctrlKey) { + if ($entry && $entry.current) { + console.log(getCaretPosition($entry.current)); + } + } else { + clearTimeout(onSaveTimer); + if ($entry && $entry.current) { + onSaveTimer = setTimeout(() => { + if ($entry && $entry.current) { + console.log($entry.current.textContent); + } + }, onSaveTimeout); + } + } + }} + contentEditable + ref={$entry} + > + {`${id.toString()} - ${position}`} + )} - {entries.length !== 0 && ( + {entries.length !== 0 && !collapsed && ( {entries .sort((a, b) => a.position - b.position) @@ -65,11 +134,16 @@ const Entry: React.FC = ({ key={entry.id} position={entry.position} depth={depth + 1} - draggingID={draggingID} + draggedNodes={draggedNodes} + collapsed={entry.collapsed} id={entry.id} + onStartSelect={onStartSelect} onStartDrag={onStartDrag} + onCancelDrag={onCancelDrag} entries={entry.children ?? []} chain={[...chain, id]} + selection={selection} + onToggleCollapse={onToggleCollapse} /> ))} diff --git a/frontend/src/Outline/Styles.ts b/frontend/src/Outline/Styles.ts index f478073..0fc9d17 100644 --- a/frontend/src/Outline/Styles.ts +++ b/frontend/src/Outline/Styles.ts @@ -1,7 +1,7 @@ import styled, { css } from 'styled-components'; import { mixin } from 'shared/utils/styles'; -export const EntryWrapper = styled.div<{ isDragging: boolean }>` +export const EntryWrapper = styled.div<{ isDragging: boolean; isSelected: boolean }>` position: relative; ${props => props.isDragging && @@ -11,12 +11,26 @@ export const EntryWrapper = styled.div<{ isDragging: boolean }>` content: ''; position: absolute; top: 2px; - right: -2px; - left: -2px; + right: -5px; + left: -5px; bottom: -2px; background-color: #eceef0; } `} + ${props => + props.isSelected && + css` + &:before { + border-radius: 3px; + content: ''; + position: absolute; + top: 2px; + right: -5px; + bottom: -2px; + left: -5px; + background-color: rgba(${props.theme.colors.primary}, 0.75); + } + `} `; export const EntryChildren = styled.div<{ isRoot: boolean }>` @@ -26,7 +40,7 @@ export const EntryChildren = styled.div<{ isRoot: boolean }>` css` margin-left: 10px; padding-left: 25px; - border-left: 1px solid rgb(236, 238, 240); + border-left: 1px solid rgba(${props.theme.colors.text.primary}, 0.6); `} `; @@ -45,15 +59,7 @@ export const PageContent = styled.div` padding-right: 56px; padding-top: 24px; padding-bottom: 24px; - margin-top: 72px; text-size-adjust: none; - background: rgb(255, 255, 255); - border-radius: 6px; -`; -export const EntryContent = styled.div` - position: relative; - margin-left: -500px; - padding-left: 524px; `; export const DragHandle = styled.div<{ top: number; left: number }>` @@ -70,6 +76,7 @@ export const DragHandle = styled.div<{ top: number; left: number }>` color: rgb(75, 81, 85); border-radius: 9px; `; +export const RootWrapper = styled.div``; export const EntryHandle = styled.div` display: flex; @@ -80,8 +87,15 @@ export const EntryHandle = styled.div` top: 7px; width: 18px; height: 18px; - color: rgb(75, 81, 85); + color: rgba(${p => p.theme.colors.text.primary}); border-radius: 9px; + &:hover { + background: rgba(${p => p.theme.colors.primary}); + } + svg { + fill: rgba(${p => p.theme.colors.text.primary}); + stroke: rgba(${p => p.theme.colors.text.primary}); + } `; export const EntryInnerContent = styled.div` @@ -93,6 +107,10 @@ export const EntryInnerContent = styled.div` overflow-wrap: break-word; position: relative; user-select: text; + color: rgba(${p => p.theme.colors.text.primary}); + &::selection { + background: #a49de8; + } &:focus { outline: 0; } @@ -114,3 +132,33 @@ export const DragIndicatorBar = styled.div<{ left: number; top: number; width: n border-radius: 3px; background: rgb(204, 204, 204); `; + +export const ExpandButton = styled.div` + top: 6px; + cursor: default; + color: transparent; + position: absolute; + top: 6px; + display: flex; + align-items: center; + justify-content: center; + left: 478px; + width: 20px; + height: 20px; + svg { + fill: transparent; + } +`; +export const EntryContent = styled.div` + position: relative; + margin-left: -500px; + padding-left: 524px; + + &:hover ${ExpandButton} svg { + fill: rgb(${props => props.theme.colors.text.primary}); + } +`; + +export const PageContainer = styled.div` + overflow: scroll; +`; diff --git a/frontend/src/Outline/index.tsx b/frontend/src/Outline/index.tsx index 5c9f69e..a0c649e 100644 --- a/frontend/src/Outline/index.tsx +++ b/frontend/src/Outline/index.tsx @@ -1,5 +1,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react'; import { DotCircle } from 'shared/icons'; +import styled from 'styled-components/macro'; +import GlobalTopNavbar from 'App/TopNavbar'; import _ from 'lodash'; import produce from 'immer'; import Entry from './Entry'; @@ -9,6 +11,7 @@ import DragDebug from './DragDebug'; import { DragContext } from './useDrag'; import { + PageContainer, DragDebugWrapper, DragIndicatorBar, PageContent, @@ -16,43 +19,85 @@ import { EntryInnerContent, EntryWrapper, EntryContent, + RootWrapper, EntryHandle, } from './Styles'; -import { transformToTree, findNode, findNodeDepth, getNumberOfChildren, validateDepth } from './utils'; +import { + transformToTree, + findNode, + findNodeDepth, + getNumberOfChildren, + validateDepth, + getDimensions, + findNextDraggable, + getNodeOver, + getCorrectNode, + findCommonParent, +} from './utils'; +import NOOP from 'shared/utils/noop'; + +type OutlineCommand = { + nodes: Array<{ + id: string; + prev: { position: number; parent: string | null }; + next: { position: number; parent: string | null }; + }>; +}; + +type ItemCollapsed = { + id: string; + collapsed: boolean; +}; const listItems: Array = [ - { id: 'root', position: 4096, parent: null }, - { id: 'entry-1', position: 4096, parent: 'root' }, - { id: 'entry-1_1', position: 4096, parent: 'entry-1' }, - { id: 'entry-1_1_1', position: 4096, parent: 'entry-1_1' }, - { id: 'entry-1_2', position: 4096 * 2, parent: 'entry-1' }, - { id: 'entry-1_2_1', position: 4096, parent: 'entry-1_2' }, - { id: 'entry-1_2_2', position: 4096 * 2, parent: 'entry-1_2' }, - { id: 'entry-1_2_3', position: 4096 * 3, parent: 'entry-1_2' }, - { id: 'entry-2', position: 4096 * 2, parent: 'root' }, - { id: 'entry-3', position: 4096 * 3, parent: 'root' }, - { id: 'entry-4', position: 4096 * 4, parent: 'root' }, - { id: 'entry-5', position: 4096 * 5, parent: 'root' }, + { id: 'root', position: 4096, parent: null, collapsed: false }, + { id: 'entry-1', position: 4096, parent: 'root', collapsed: false }, + { id: 'entry-1_3', position: 4096 * 3, parent: 'entry-1', collapsed: false }, + { id: 'entry-1_3_1', position: 4096, parent: 'entry-1_3', collapsed: false }, + { id: 'entry-1_3_2', position: 4096 * 2, parent: 'entry-1_3', collapsed: false }, + { id: 'entry-1_3_3', position: 4096 * 3, parent: 'entry-1_3', collapsed: false }, + { id: 'entry-1_3_3_1', position: 4096 * 1, parent: 'entry-1_3_3', collapsed: false }, + { id: 'entry-1_3_3_1_1', position: 4096 * 1, parent: 'entry-1_3_3_1', collapsed: false }, + { id: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false }, + { id: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false }, + { id: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false }, + { id: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false }, ]; const Outline: React.FC = () => { const [items, setItems] = useState(listItems); + const [selecting, setSelecting] = useState<{ + isSelecting: boolean; + node: { id: string; depth: number } | null; + }>({ isSelecting: false, node: null }); + const [selection, setSelection] = useState; first?: OutlineNode | null }>(null); const [dragging, setDragging] = useState<{ show: boolean; - draggableID: null | string; + draggedNodes: null | Array; initialPos: { x: number; y: number }; - }>({ show: false, draggableID: null, initialPos: { x: 0, y: 0 } }); + }>({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } }); const [impact, setImpact] = useState(null); + const selectRef = useRef<{ isSelecting: boolean; hasSelection: boolean; node: { id: string; depth: number } | null }>( + { + isSelecting: false, + node: null, + hasSelection: false, + }, + ); const impactRef = useRef(null); useEffect(() => { if (impact) { impactRef.current = { zone: impact.zone, depth: impact.depthTarget, listPosition: impact.listPosition }; } }, [impact]); + useEffect(() => { + selectRef.current.isSelecting = selecting.isSelecting; + selectRef.current.node = selecting.node; + }, [selecting]); const $content = useRef(null); const outline = useRef({ @@ -67,20 +112,32 @@ const Outline: React.FC = () => { if (tree.length === 1) { root = tree[0]; } + const outlineHistory = useRef<{ commands: Array; current: number }>({ current: -1, commands: [] }); + useEffect(() => { outline.current.relationships = new Map(); outline.current.published = new Map(); outline.current.nodes = new Map>(); + const collapsedMap = items.reduce((map, next) => { + if (next.collapsed) { + map.set(next.id, true); + } + return map; + }, new Map()); items.forEach(item => outline.current.published.set(item.id, item.parent ?? 'root')); for (let i = 0; i < items.length; i++) { - const { position, id, parent: curParent } = items[i]; + const { collapsed, position, id, parent: curParent } = items[i]; if (id === 'root') { continue; } const parent = curParent ?? 'root'; outline.current.published.set(id, parent ?? 'root'); const { depth, ancestors } = findNodeDepth(outline.current.published, id); + const collapsedParent = ancestors.slice(0, -1).find(a => collapsedMap.get(a)); + if (collapsedParent) { + continue; + } const children = getNumberOfChildren(root, ancestors); if (!outline.current.nodes.has(depth)) { outline.current.nodes.set(depth, new Map()); @@ -93,6 +150,7 @@ const Outline: React.FC = () => { position, depth, ancestors, + collapsed, parent, }); } @@ -118,12 +176,144 @@ const Outline: React.FC = () => { } } }, [items]); + const handleKeyDown = useCallback(e => { + if (e.code === 'KeyZ' && e.ctrlKey) { + const currentCommand = outlineHistory.current.commands[outlineHistory.current.current]; + if (currentCommand) { + setItems(prevItems => + produce(prevItems, draftItems => { + currentCommand.nodes.forEach(node => { + const idx = prevItems.findIndex(c => c.id === node.id); + if (idx !== -1) { + draftItems[idx].parent = node.prev.parent; + draftItems[idx].position = node.prev.position; + } + }); + outlineHistory.current.current--; + }), + ); + } + } else if (e.code === 'KeyY' && e.ctrlKey) { + const currentCommand = outlineHistory.current.commands[outlineHistory.current.current + 1]; + if (currentCommand) { + setItems(prevItems => + produce(prevItems, draftItems => { + currentCommand.nodes.forEach(node => { + const idx = prevItems.findIndex(c => c.id === node.id); + if (idx !== -1) { + draftItems[idx].parent = node.next.parent; + draftItems[idx].position = node.next.position; + } + }); + outlineHistory.current.current++; + }), + ); + } + } + }, []); + + const handleMouseUp = useCallback( + e => { + if (selectRef.current.hasSelection && !selectRef.current.isSelecting) { + setSelection(null); + } + if (selectRef.current.isSelecting) { + setSelecting({ isSelecting: false, node: null }); + } + }, + [dragging, selecting], + ); + const handleMouseMove = useCallback(e => { + if (selectRef.current.isSelecting && selectRef.current.node) { + const { clientX, clientY } = e; + const dimensions = outline.current.dimensions.get(selectRef.current.node.id); + if (dimensions) { + const entry = getDimensions(dimensions.entry); + if (entry) { + const isAbove = clientY < entry.top; + const isBelow = clientY > entry.bottom; + if (!isAbove && !isBelow && selectRef.current.hasSelection) { + const nodeDepth = outline.current.nodes.get(selectRef.current.node.depth); + const aboveNode = nodeDepth ? nodeDepth.get(selectRef.current.node.id) : null; + if (aboveNode) { + setSelection({ nodes: [{ id: selectRef.current.node.id }], first: aboveNode }); + selectRef.current.hasSelection = false; + } + } + if (isAbove || isBelow) { + e.preventDefault(); + const { curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current); + const nodeDepth = outline.current.nodes.get(selectRef.current.node.depth); + const selectedNode = nodeDepth ? nodeDepth.get(selectRef.current.node.id) : null; + let aboveNode: OutlineNode | undefined | null = null; + let belowNode: OutlineNode | undefined | null = null; + if (isBelow) { + aboveNode = selectedNode; + belowNode = curDraggable; + } else { + aboveNode = curDraggable; + belowNode = selectedNode; + } + if (aboveNode && belowNode) { + const aboveDim = outline.current.dimensions.get(aboveNode.id); + const belowDim = outline.current.dimensions.get(belowNode.id); + if (aboveDim && belowDim) { + const aboveDimBounds = getDimensions(aboveDim.entry); + const belowDimBounds = getDimensions(belowDim.children ? belowDim.children : belowDim.entry); + const aboveDimY = aboveDimBounds ? aboveDimBounds.bottom : 0; + const belowDimY = belowDimBounds ? belowDimBounds.top : 0; + const inbetweenNodes: Array<{ id: string }> = []; + for (const [id, dimension] of outline.current.dimensions.entries()) { + if (id === aboveNode.id || id === belowNode.id) { + inbetweenNodes.push({ id }); + continue; + } + const targetNodeBounds = getDimensions(dimension.entry); + if (targetNodeBounds) { + if ( + Math.round(aboveDimY) <= Math.round(targetNodeBounds.top) && + Math.round(belowDimY) >= Math.round(targetNodeBounds.bottom) + ) { + inbetweenNodes.push({ id }); + } + } + } + const filteredNodes = inbetweenNodes.filter(n => { + const parent = outline.current.published.get(n.id); + if (parent) { + const foundParent = inbetweenNodes.find(c => c.id === parent); + if (foundParent) { + return false; + } + } + return true; + }); + selectRef.current.hasSelection = true; + setSelection({ nodes: filteredNodes, first: aboveNode }); + } + } + } + } + } + } + }, []); + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mousemove', handleMouseMove); + document.addEventListener('keydown', handleKeyDown); + }; + }, []); if (!root) { return null; } return ( <> + { if (data) { const { zone, depth } = data; let listPosition = 65535; - const listAbove = validateDepth(zone.above ? zone.above.node : null, depth); - const listBelow = validateDepth(zone.below ? zone.below.node : null, depth); - if (listAbove && listBelow) { - listPosition = (listAbove.position + listBelow.position) / 2.0; - } else if (listAbove && !listBelow) { - listPosition = listAbove.position * 2.0; - } else if (!listAbove && listBelow) { - listPosition = listBelow.position / 2.0; + if (zone.above && zone.above.node.depth + 1 <= depth && zone.above.node.collapsed) { + const aboveChildren = items + .filter(i => (zone.above ? i.parent === zone.above.node.id : false)) + .sort((a, b) => a.position - b.position); + const lastChild = aboveChildren[aboveChildren.length - 1]; + if (lastChild) { + listPosition = lastChild.position * 2.0; + } + } else { + console.log(zone.above); + console.log(zone.below); + const correctNode = getCorrectNode(outline.current, zone.above ? zone.above.node : null, depth); + console.log(correctNode); + const listAbove = validateDepth(correctNode, depth); + const listBelow = validateDepth(zone.below ? zone.below.node : null, depth); + console.log(listAbove, listBelow); + if (listAbove && listBelow) { + listPosition = (listAbove.position + listBelow.position) / 2.0; + } else if (listAbove && !listBelow) { + listPosition = listAbove.position * 2.0; + } else if (!listAbove && listBelow) { + listPosition = listBelow.position / 2.0; + } } if (!zone.above && zone.below) { @@ -167,124 +372,122 @@ const Outline: React.FC = () => { setNodeDimensions: (nodeID, ref) => { outline.current.dimensions.set(nodeID, ref); }, + clearNodeDimensions: nodeID => { + outline.current.dimensions.delete(nodeID); + }, }} > <> - - { - if (e.id !== 'root') { - setImpact(null); - setDragging({ show: true, draggableID: e.id, initialPos: { x: e.clientX, y: e.clientY } }); - } - }} - /> - - {dragging.show && dragging.draggableID && ( + + + + { + setSelection(null); + setSelecting({ isSelecting: true, node: { id, depth } }); + }} + onToggleCollapse={(id, collapsed) => { + setItems(prevItems => + produce(prevItems, draftItems => { + const idx = prevItems.findIndex(c => c.id === id); + if (idx !== -1) { + draftItems[idx].collapsed = collapsed; + } + }), + ); + }} + id="root" + parentID="root" + isRoot + selection={selection ? selection.nodes : null} + draggedNodes={dragging.draggedNodes} + position={root.position} + entries={root.children} + onCancelDrag={() => { + setImpact(null); + setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } }); + }} + onStartDrag={e => { + if (e.id !== 'root') { + if (selectRef.current.hasSelection && selection && selection.nodes.find(c => c.id === e.id)) { + setImpact(null); + setDragging({ + show: true, + draggedNodes: [...selection.nodes.map(c => c.id)], + initialPos: { x: e.clientX, y: e.clientY }, + }); + } else { + setImpact(null); + setDragging({ show: true, draggedNodes: [e.id], initialPos: { x: e.clientX, y: e.clientY } }); + } + } + }} + /> + + + + {dragging.show && dragging.draggedNodes && ( { - const draggingID = dragging.draggableID; - if (draggingID && impactRef.current) { + if (dragging.draggedNodes && impactRef.current) { const { zone, depth, listPosition } = impactRef.current; const noZone = !zone.above && !zone.below; - const curParentID = outline.current.published.get(draggingID); - if (!noZone && curParentID) { + if (!noZone) { let parentID = 'root'; if (zone.above) { parentID = zone.above.node.ancestors[depth - 1]; } - const node = findNode(curParentID, draggingID, outline.current); - console.log(`${node ? node.parent : null} => ${parentID}`); - // UPDATE OUTLINE DATA AFTER NODE MOVE - if (node) { - if (node.depth !== depth) { - const oldParentDepth = outline.current.nodes.get(node.depth - 1); - if (oldParentDepth) { - const oldParentNode = oldParentDepth.get(node.parent); - if (oldParentNode) { - oldParentNode.children -= 1; - } - } - const oldDepth = outline.current.nodes.get(node.depth); - if (oldDepth) { - oldDepth.delete(node.id); - } - if (!outline.current.nodes.has(depth)) { - outline.current.nodes.set(depth, new Map()); - } - const newParentDepth = outline.current.nodes.get(depth - 1); - if (newParentDepth) { - const newParentNode = newParentDepth.get(parentID); - if (newParentNode) { - newParentNode.children += 1; - } - } - const newDepth = outline.current.nodes.get(depth); - if (newDepth) { - // TODO: rebuild ancestors - newDepth.set(node.id, { - ...node, - depth, - position: listPosition, - parent: parentID, - }); - } - } - if (!outline.current.relationships.has(parentID)) { - outline.current.relationships.set(parentID, { - self: { - depth: depth - 1, - id: parentID, - }, - children: [{ id: draggingID, position: listPosition, depth, children: node.children }], - numberOfSubChildren: 0, - }); - } - const nodeRelations = outline.current.relationships.get(parentID); - if (parentID !== node.parent) { - // ?? - } - if (nodeRelations) { - nodeRelations.children = produce(nodeRelations.children, draftChildren => { - const nodeIdx = draftChildren.findIndex(c => c.id === node.id); - if (nodeIdx !== -1) { - draftChildren[nodeIdx] = { - children: node.children, - depth, - position: listPosition, - id: node.id, - }; - } - draftChildren.sort((a, b) => a.position - b.position); - }); + let reparent = true; + for (let i = 0; i < dragging.draggedNodes.length; i++) { + const draggedID = dragging.draggedNodes[i]; + const prevItem = items.find(i => i.id === draggedID); + if (prevItem && prevItem.position === listPosition && prevItem.parent === parentID) { + reparent = false; + break; } } - outline.current.published.set(draggingID, parentID); - setItems(itemsPrev => - produce(itemsPrev, draftItems => { - const curDragging = itemsPrev.findIndex(i => i.id === draggingID); - // console.log(`parent=${impactRef.current} target=${draggingID}`); - if (impactRef.current) { - // console.log(`updating position = ${impactRef.current.targetPosition}`); - draftItems[curDragging].parent = parentID; - draftItems[curDragging].position = listPosition; - } - }), - ); + // TODO: set reparent if list position changed but parent did not + // + + if (reparent) { + // UPDATE OUTLINE DATA AFTER NODE MOVE + setItems(itemsPrev => + produce(itemsPrev, draftItems => { + if (dragging.draggedNodes) { + const command: OutlineCommand = { nodes: [] }; + outlineHistory.current.current += 1; + dragging.draggedNodes.forEach(n => { + const curDragging = itemsPrev.findIndex(i => i.id === n); + command.nodes.push({ + id: n, + prev: { + parent: draftItems[curDragging].parent, + position: draftItems[curDragging].position, + }, + next: { + parent: parentID, + position: listPosition, + }, + }); + draftItems[curDragging].parent = parentID; + draftItems[curDragging].position = listPosition; + }); + outlineHistory.current.commands[outlineHistory.current.current] = command; + if (outlineHistory.current.commands[outlineHistory.current.current + 1]) { + outlineHistory.current.commands.splice(outlineHistory.current.current + 1); + } + } + }), + ); + } } } setImpact(null); - setDragging({ show: false, draggableID: null, initialPos: { x: 0, y: 0 } }); + setDragging({ show: false, draggedNodes: null, initialPos: { x: 0, y: 0 } }); }} /> )} @@ -292,7 +495,7 @@ const Outline: React.FC = () => { {impact && } {impact && ( - + )} ); diff --git a/frontend/src/Outline/useDrag.ts b/frontend/src/Outline/useDrag.ts index 81a3fd0..d94653c 100644 --- a/frontend/src/Outline/useDrag.ts +++ b/frontend/src/Outline/useDrag.ts @@ -7,6 +7,7 @@ type DragContextData = { nodeID: string, ref: { entry: React.RefObject; children: React.RefObject | null }, ) => void; + clearNodeDimensions: (nodeID: string) => void; setImpact: (data: ImpactData | null) => void; }; diff --git a/frontend/src/Outline/utils.ts b/frontend/src/Outline/utils.ts index 8efa624..1220334 100644 --- a/frontend/src/Outline/utils.ts +++ b/frontend/src/Outline/utils.ts @@ -1,6 +1,25 @@ import _ from 'lodash'; -export function validateDepth(node: OutlineNode | null, depth: number) { +export function getCorrectNode(data: OutlineData, node: OutlineNode | null, depth: number) { + if (node) { + console.log(depth, node); + if (depth === node.depth) { + return node; + } + const parent = node.ancestors[depth]; + console.log('parent', parent); + if (parent) { + const parentNode = data.relationships.get(parent); + if (parentNode) { + const parentDepth = parentNode.self.depth; + const nodeDepth = data.nodes.get(parentDepth); + return nodeDepth ? nodeDepth.get(parent) : null; + } + } + } + return null; +} +export function validateDepth(node: OutlineNode | null | undefined, depth: number) { if (node) { return node.depth === depth ? node : null; } @@ -14,9 +33,9 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil while (hasChildren) { const targetParent = outline.relationships.get(aboveTargetID); if (targetParent) { + const parentNodes = outline.nodes.get(targetParent.self.depth); + const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null; if (targetParent.children.length === 0) { - const parentNodes = outline.nodes.get(targetParent.self.depth); - const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null; if (parentNode) { nodeAbove = { id: parentNode.id, @@ -24,7 +43,9 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil position: parentNode.position, children: parentNode.children, }; + console.log('node above', nodeAbove); } + hasChildren = false; continue; } nodeAbove = targetParent.children[targetParent.children.length - 1]; @@ -44,6 +65,7 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil } } } + console.log('final node above', nodeAbove); return nodeAbove; } @@ -103,12 +125,7 @@ export function getTargetDepth(mouseX: number, handleLeft: number, availableDept return availableDepths.min; } -export function findNextDraggable( - pos: { x: number; y: number }, - outline: OutlineData, - curDepth: number, - draggingID: string, -) { +export function findNextDraggable(pos: { x: number; y: number }, outline: OutlineData, curDepth: number) { let index = 0; const currentDepthNodes = outline.nodes.get(curDepth); let nodeAbove: null | RelationshipChild = null; @@ -260,3 +277,85 @@ export function findNodeAbove(outline: OutlineData, curDepth: number, belowNode: } return null; } + +export function getNodeOver(mouse: { x: number; y: number }, outline: OutlineData) { + let curDepth = 1; + let curDraggables: any; + let curDraggable: any; + let curPosition: ImpactPosition = 'after'; + while (outline.nodes.size + 1 > curDepth) { + curDraggables = outline.nodes.get(curDepth); + if (curDraggables) { + const nextDraggable = findNextDraggable(mouse, outline, curDepth); + if (nextDraggable) { + curDraggable = nextDraggable.node; + curPosition = nextDraggable.position; + if (nextDraggable.found) { + break; + } + curDepth += 1; + } else { + break; + } + } + } + return { + curDepth, + curDraggable, + curPosition, + }; +} + +export function findCommonParent(outline: OutlineData, aboveNode: OutlineNode, belowNode: OutlineNode) { + let aboveParentID = null; + let depth = 0; + for (let aIdx = aboveNode.ancestors.length - 1; aIdx !== 0; aIdx--) { + depth = aIdx; + const aboveNodeParent = aboveNode.ancestors[aIdx]; + for (let bIdx = belowNode.ancestors.length - 1; bIdx !== 0; bIdx--) { + if (belowNode.ancestors[bIdx] === aboveNodeParent) { + aboveParentID = aboveNodeParent; + } + } + } + if (aboveParentID) { + const parent = outline.relationships.get(aboveParentID) ?? null; + if (parent) { + return { + parent, + depth, + }; + } + return null; + } + return null; +} + +export function getLastChildInBranch(outline: OutlineData, lastParentNode: OutlineNode) { + let curParentRelation = outline.relationships.get(lastParentNode.id); + if (!curParentRelation) { + return { id: lastParentNode.id, depth: 1 }; + } + let hasChildren = lastParentNode.children !== 0; + let depth = 1; + let finalID: null | string = null; + while (hasChildren) { + if (curParentRelation) { + const lastChild = curParentRelation.children.sort((a, b) => a.position - b.position)[ + curParentRelation.children.length - 1 + ]; + depth += 1; + if (lastChild.children === 0) { + finalID = lastChild.id; + break; + } + curParentRelation = outline.relationships.get(lastChild.id); + } else { + hasChildren = false; + } + } + if (finalID !== null) { + return { id: finalID, depth }; + } + return null; +} diff --git a/frontend/src/Projects/Project/index.tsx b/frontend/src/Projects/Project/index.tsx index 07da9b0..cbf7a8d 100644 --- a/frontend/src/Projects/Project/index.tsx +++ b/frontend/src/Projects/Project/index.tsx @@ -134,16 +134,17 @@ type MemberFilterOptions = { }; const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => { + console.log(input.trim().length < 3); if (input && input.trim().length < 3) { return []; } const res = await client.query({ query: gql` query { - searchMembers(input: {SearchFilter:"${input}", projectID:"${projectID}"}) { + searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) { + id similarity - confirmed - joined + status user { id fullName @@ -161,16 +162,34 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte let results: any = []; const emails: Array = []; + console.log(res.data && res.data.searchMembers); if (res.data && res.data.searchMembers) { results = [ ...res.data.searchMembers.map((m: any) => { - emails.push(m.user.email); - return { - label: m.user.fullName, - value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon }, - }; + if (m.status === 'INVITED') { + console.log(`${m.id} is added`); + return { + label: m.id, + value: { + id: m.id, + type: 2, + profileIcon: { + bgColor: '#ccc', + initials: m.id.charAt(0), + }, + }, + }; + } else { + console.log(`${m.user.email} is added`); + emails.push(m.user.email); + return { + label: m.user.fullName, + value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon }, + }; + } }), ]; + console.log(results); } if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) { @@ -215,6 +234,13 @@ const OptionContent = styled.div` margin-left: 12px; `; +const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>` + display: flex; + align-items: center; + font-size: ${p => p.fontSize}px; + color: rgba(${p => (p.quiet ? p.theme.colors.text.primary : p.theme.colors.text.primary)}); +`; + const UserOption: React.FC = ({ isDisabled, isFocused, innerProps, label, data }) => { console.log(data); return !isDisabled ? ( @@ -223,11 +249,20 @@ const UserOption: React.FC = ({ isDisabled, isFocused, innerPro size={32} member={{ id: '', - fullName: 'Jordan Knott', + fullName: data.value.label, profileIcon: data.value.profileIcon, }} /> - {label} + + + {label} + + {data.value.type === 2 && ( + + Joined + + )} + ) : null; }; diff --git a/frontend/src/shared/components/List/Styles.ts b/frontend/src/shared/components/List/Styles.ts index 3af39d2..f3876ce 100644 --- a/frontend/src/shared/components/List/Styles.ts +++ b/frontend/src/shared/components/List/Styles.ts @@ -73,7 +73,6 @@ export const HeaderName = styled(TextareaAutosize)` box-shadow: none; font-weight: 600; margin: -4px 0; - padding: 4px 8px; letter-spacing: normal; word-spacing: normal; diff --git a/frontend/src/shared/icons/ArrowDown.tsx b/frontend/src/shared/icons/ArrowDown.tsx new file mode 100644 index 0000000..4c09257 --- /dev/null +++ b/frontend/src/shared/icons/ArrowDown.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const ArrowDown: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default ArrowDown; diff --git a/frontend/src/shared/icons/CaretDown.tsx b/frontend/src/shared/icons/CaretDown.tsx new file mode 100644 index 0000000..d0f2676 --- /dev/null +++ b/frontend/src/shared/icons/CaretDown.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const CaretDown: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default CaretDown; diff --git a/frontend/src/shared/icons/CaretRight.tsx b/frontend/src/shared/icons/CaretRight.tsx new file mode 100644 index 0000000..2fe7464 --- /dev/null +++ b/frontend/src/shared/icons/CaretRight.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const CaretRight: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default CaretRight; diff --git a/frontend/src/shared/icons/Dot.tsx b/frontend/src/shared/icons/Dot.tsx new file mode 100644 index 0000000..37ef1e1 --- /dev/null +++ b/frontend/src/shared/icons/Dot.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const Dot: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default Dot; diff --git a/frontend/src/shared/icons/index.ts b/frontend/src/shared/icons/index.ts index b05d8bd..9ecad17 100644 --- a/frontend/src/shared/icons/index.ts +++ b/frontend/src/shared/icons/index.ts @@ -1,8 +1,12 @@ import Cross from './Cross'; import Cog from './Cog'; +import ArrowDown from './ArrowDown'; import ListUnordered from './ListUnordered'; +import Dot from './Dot'; +import CaretDown from './CaretDown'; import Eye from './Eye'; import EyeSlash from './EyeSlash'; +import CaretRight from './CaretRight'; import List from './List'; import At from './At'; import Task from './Task'; @@ -94,5 +98,9 @@ export { ListUnordered, EyeSlash, List, + CaretDown, + Dot, + ArrowDown, + CaretRight, DotCircle, }; diff --git a/frontend/src/taskcafe.d.ts b/frontend/src/taskcafe.d.ts index 529eaf6..20d965b 100644 --- a/frontend/src/taskcafe.d.ts +++ b/frontend/src/taskcafe.d.ts @@ -150,6 +150,7 @@ type OutlineNode = { depth: number; position: number; ancestors: Array; + collapsed: boolean; children: number; }; @@ -199,6 +200,7 @@ type ItemElement = { id: string; parent: null | string; position: number; + collapsed: boolean; children?: Array; }; type NodeDimensions = { diff --git a/internal/db/project.sql.go b/internal/db/project.sql.go index f47d831..1b9e839 100644 --- a/internal/db/project.sql.go +++ b/internal/db/project.sql.go @@ -229,15 +229,16 @@ func (q *Queries) GetAllVisibleProjectsForUserID(ctx context.Context, userID uui } const getInvitedMembersForProjectID = `-- name: GetInvitedMembersForProjectID :many -SELECT email, invited_on FROM project_member_invited AS pmi +SELECT uai.user_account_invited_id, email, invited_on FROM project_member_invited AS pmi INNER JOIN user_account_invited AS uai ON uai.user_account_invited_id = pmi.user_account_invited_id WHERE project_id = $1 ` type GetInvitedMembersForProjectIDRow struct { - Email string `json:"email"` - InvitedOn time.Time `json:"invited_on"` + UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"` + Email string `json:"email"` + InvitedOn time.Time `json:"invited_on"` } func (q *Queries) GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) { @@ -249,7 +250,7 @@ func (q *Queries) GetInvitedMembersForProjectID(ctx context.Context, projectID u var items []GetInvitedMembersForProjectIDRow for rows.Next() { var i GetInvitedMembersForProjectIDRow - if err := rows.Scan(&i.Email, &i.InvitedOn); err != nil { + if err := rows.Scan(&i.UserAccountInvitedID, &i.Email, &i.InvitedOn); err != nil { return nil, err } items = append(items, i) diff --git a/internal/db/query/project.sql b/internal/db/query/project.sql index ac8b4b5..0f4f34e 100644 --- a/internal/db/query/project.sql +++ b/internal/db/query/project.sql @@ -44,7 +44,7 @@ SELECT project_id, role_code FROM project_member WHERE user_id = $1; SELECT project_id FROM project_member WHERE user_id = $1; -- name: GetInvitedMembersForProjectID :many -SELECT email, invited_on FROM project_member_invited AS pmi +SELECT uai.user_account_invited_id, email, invited_on FROM project_member_invited AS pmi INNER JOIN user_account_invited AS uai ON uai.user_account_invited_id = pmi.user_account_invited_id WHERE project_id = $1; diff --git a/internal/graph/generated.go b/internal/graph/generated.go index 31ee2f1..e827d49 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -183,10 +183,9 @@ type ComplexityRoot struct { } MemberSearchResult struct { - Confirmed func(childComplexity int) int - Invited func(childComplexity int) int - Joined func(childComplexity int) int + ID func(childComplexity int) int Similarity func(childComplexity int) int + Status func(childComplexity int) int User func(childComplexity int) int } @@ -1027,26 +1026,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.MemberList.Teams(childComplexity), true - case "MemberSearchResult.confirmed": - if e.complexity.MemberSearchResult.Confirmed == nil { + case "MemberSearchResult.id": + if e.complexity.MemberSearchResult.ID == nil { break } - return e.complexity.MemberSearchResult.Confirmed(childComplexity), true - - case "MemberSearchResult.invited": - if e.complexity.MemberSearchResult.Invited == nil { - break - } - - return e.complexity.MemberSearchResult.Invited(childComplexity), true - - case "MemberSearchResult.joined": - if e.complexity.MemberSearchResult.Joined == nil { - break - } - - return e.complexity.MemberSearchResult.Joined(childComplexity), true + return e.complexity.MemberSearchResult.ID(childComplexity), true case "MemberSearchResult.similarity": if e.complexity.MemberSearchResult.Similarity == nil { @@ -1055,6 +1040,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.MemberSearchResult.Similarity(childComplexity), true + case "MemberSearchResult.status": + if e.complexity.MemberSearchResult.Status == nil { + break + } + + return e.complexity.MemberSearchResult.Status(childComplexity), true + case "MemberSearchResult.user": if e.complexity.MemberSearchResult.User == nil { break @@ -2841,6 +2833,11 @@ type TaskChecklist { items: [TaskChecklistItem!]! } +enum ShareStatus { + INVITED + JOINED +} + enum RoleLevel { ADMIN MEMBER @@ -3452,16 +3449,16 @@ type DeleteInvitedUserAccountPayload { } input MemberSearchFilter { - SearchFilter: String! + searchFilter: String! projectID: UUID } + type MemberSearchResult { similarity: Int! - user: UserAccount! - confirmed: Boolean! - invited: Boolean! - joined: Boolean! + id: String! + user: UserAccount + status: ShareStatus! } type UpdateUserInfoPayload { @@ -6373,6 +6370,40 @@ func (ec *executionContext) _MemberSearchResult_similarity(ctx context.Context, return ec.marshalNInt2int(ctx, field.Selections, res) } +func (ec *executionContext) _MemberSearchResult_id(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + func (ec *executionContext) _MemberSearchResult_user(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6397,17 +6428,14 @@ func (ec *executionContext) _MemberSearchResult_user(ctx context.Context, field return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } res := resTmp.(*db.UserAccount) fc.Result = res - return ec.marshalNUserAccount2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐUserAccount(ctx, field.Selections, res) + return ec.marshalOUserAccount2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐUserAccount(ctx, field.Selections, res) } -func (ec *executionContext) _MemberSearchResult_confirmed(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { +func (ec *executionContext) _MemberSearchResult_status(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) @@ -6424,7 +6452,7 @@ func (ec *executionContext) _MemberSearchResult_confirmed(ctx context.Context, f ctx = graphql.WithFieldContext(ctx, fc) resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Confirmed, nil + return obj.Status, nil }) if err != nil { ec.Error(ctx, err) @@ -6436,77 +6464,9 @@ func (ec *executionContext) _MemberSearchResult_confirmed(ctx context.Context, f } return graphql.Null } - res := resTmp.(bool) + res := resTmp.(ShareStatus) fc.Result = res - return ec.marshalNBoolean2bool(ctx, field.Selections, res) -} - -func (ec *executionContext) _MemberSearchResult_invited(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - fc := &graphql.FieldContext{ - Object: "MemberSearchResult", - Field: field, - Args: nil, - IsMethod: false, - } - - ctx = graphql.WithFieldContext(ctx, fc) - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return obj.Invited, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(bool) - fc.Result = res - return ec.marshalNBoolean2bool(ctx, field.Selections, res) -} - -func (ec *executionContext) _MemberSearchResult_joined(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - fc := &graphql.FieldContext{ - Object: "MemberSearchResult", - Field: field, - Args: nil, - IsMethod: false, - } - - ctx = graphql.WithFieldContext(ctx, fc) - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return obj.Joined, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(bool) - fc.Result = res - return ec.marshalNBoolean2bool(ctx, field.Selections, res) + return ec.marshalNShareStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐShareStatus(ctx, field.Selections, res) } func (ec *executionContext) _Mutation_createProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { @@ -16279,7 +16239,7 @@ func (ec *executionContext) unmarshalInputMemberSearchFilter(ctx context.Context for k, v := range asMap { switch k { - case "SearchFilter": + case "searchFilter": var err error it.SearchFilter, err = ec.unmarshalNString2string(ctx, v) if err != nil { @@ -17982,23 +17942,15 @@ func (ec *executionContext) _MemberSearchResult(ctx context.Context, sel ast.Sel if out.Values[i] == graphql.Null { invalids++ } + case "id": + out.Values[i] = ec._MemberSearchResult_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } case "user": out.Values[i] = ec._MemberSearchResult_user(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ - } - case "confirmed": - out.Values[i] = ec._MemberSearchResult_confirmed(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ - } - case "invited": - out.Values[i] = ec._MemberSearchResult_invited(ctx, field, obj) - if out.Values[i] == graphql.Null { - invalids++ - } - case "joined": - out.Values[i] = ec._MemberSearchResult_joined(ctx, field, obj) + case "status": + out.Values[i] = ec._MemberSearchResult_status(ctx, field, obj) if out.Values[i] == graphql.Null { invalids++ } @@ -21486,6 +21438,15 @@ func (ec *executionContext) unmarshalNSetTaskComplete2githubᚗcomᚋjordanknott return ec.unmarshalInputSetTaskComplete(ctx, v) } +func (ec *executionContext) unmarshalNShareStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐShareStatus(ctx context.Context, v interface{}) (ShareStatus, error) { + var res ShareStatus + return res, res.UnmarshalGQL(v) +} + +func (ec *executionContext) marshalNShareStatus2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐShareStatus(ctx context.Context, sel ast.SelectionSet, v ShareStatus) graphql.Marshaler { + return v +} + func (ec *executionContext) unmarshalNSortTaskGroup2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐSortTaskGroup(ctx context.Context, v interface{}) (SortTaskGroup, error) { return ec.unmarshalInputSortTaskGroup(ctx, v) } @@ -22625,6 +22586,17 @@ func (ec *executionContext) unmarshalOUpdateProjectName2ᚖgithubᚗcomᚋjordan return &res, err } +func (ec *executionContext) marshalOUserAccount2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐUserAccount(ctx context.Context, sel ast.SelectionSet, v db.UserAccount) graphql.Marshaler { + return ec._UserAccount(ctx, sel, &v) +} + +func (ec *executionContext) marshalOUserAccount2ᚖgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐUserAccount(ctx context.Context, sel ast.SelectionSet, v *db.UserAccount) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._UserAccount(ctx, sel, v) +} + func (ec *executionContext) marshalO__EnumValue2ᚕgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐEnumValueᚄ(ctx context.Context, sel ast.SelectionSet, v []introspection.EnumValue) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/graph/models_gen.go b/internal/graph/models_gen.go index 502a445..3505f08 100644 --- a/internal/graph/models_gen.go +++ b/internal/graph/models_gen.go @@ -249,16 +249,15 @@ type MemberList struct { } type MemberSearchFilter struct { - SearchFilter string `json:"SearchFilter"` + SearchFilter string `json:"searchFilter"` ProjectID *uuid.UUID `json:"projectID"` } type MemberSearchResult struct { Similarity int `json:"similarity"` + ID string `json:"id"` User *db.UserAccount `json:"user"` - Confirmed bool `json:"confirmed"` - Invited bool `json:"invited"` - Joined bool `json:"joined"` + Status ShareStatus `json:"status"` } type NewProject struct { @@ -830,3 +829,44 @@ func (e *RoleLevel) UnmarshalGQL(v interface{}) error { func (e RoleLevel) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } + +type ShareStatus string + +const ( + ShareStatusInvited ShareStatus = "INVITED" + ShareStatusJoined ShareStatus = "JOINED" +) + +var AllShareStatus = []ShareStatus{ + ShareStatusInvited, + ShareStatusJoined, +} + +func (e ShareStatus) IsValid() bool { + switch e { + case ShareStatusInvited, ShareStatusJoined: + return true + } + return false +} + +func (e ShareStatus) String() string { + return string(e) +} + +func (e *ShareStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ShareStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ShareStatus", str) + } + return nil +} + +func (e ShareStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 359cc9a..1165298 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -172,6 +172,11 @@ type TaskChecklist { items: [TaskChecklistItem!]! } +enum ShareStatus { + INVITED + JOINED +} + enum RoleLevel { ADMIN MEMBER @@ -783,16 +788,16 @@ type DeleteInvitedUserAccountPayload { } input MemberSearchFilter { - SearchFilter: String! + searchFilter: String! projectID: UUID } + type MemberSearchResult { similarity: Int! - user: UserAccount! - confirmed: Boolean! - invited: Boolean! - joined: Boolean! + id: String! + user: UserAccount + status: ShareStatus! } type UpdateUserInfoPayload { diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index d55dc95..a653329 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -1310,31 +1310,52 @@ func (r *queryResolver) SearchMembers(ctx context.Context, input MemberSearchFil return []MemberSearchResult{}, err } + invitedMembers, err := r.Repository.GetInvitedMembersForProjectID(ctx, *input.ProjectID) + if err != nil { + logger.New(ctx).WithField("projectID", input.ProjectID).WithError(err).Error("error while getting member data") + return []MemberSearchResult{}, err + } + sortList := []string{} - masterList := map[string]uuid.UUID{} + masterList := map[string]MasterEntry{} for _, member := range availableMembers { sortList = append(sortList, member.Username) sortList = append(sortList, member.Email) - masterList[member.Username] = member.UserID - masterList[member.Email] = member.UserID + masterList[member.Username] = MasterEntry{ID: member.UserID, MemberType: MemberTypeJoined} + masterList[member.Email] = MasterEntry{ID: member.UserID, MemberType: MemberTypeJoined} } - logger.New(ctx).Info("fuzzy rank finder") + for _, member := range invitedMembers { + sortList = append(sortList, member.Email) + logger.New(ctx).WithField("Email", member.Email).Info("adding member") + masterList[member.Email] = MasterEntry{ID: member.UserAccountInvitedID, MemberType: MemberTypeInvited} + } + + logger.New(ctx).WithField("searchFilter", input.SearchFilter).Info(sortList) rankedList := fuzzy.RankFind(input.SearchFilter, sortList) + logger.New(ctx).Info(rankedList) results := []MemberSearchResult{} memberList := map[uuid.UUID]bool{} for _, rank := range rankedList { - if _, ok := memberList[masterList[rank.Target]]; !ok { - logger.New(ctx).WithFields(log.Fields{"source": rank.Source, "target": rank.Target}).Info("searching") - userID := masterList[rank.Target] - user, err := r.Repository.GetUserAccountByID(ctx, userID) - if err != nil { - if err == sql.ErrNoRows { - continue + entry, _ := masterList[rank.Target] + _, ok := memberList[entry.ID] + logger.New(ctx).WithField("ok", ok).WithField("target", rank.Target).Info("checking rank") + if !ok { + if entry.MemberType == MemberTypeJoined { + logger.New(ctx).WithFields(log.Fields{"source": rank.Source, "target": rank.Target}).Info("searching") + entry := masterList[rank.Target] + user, err := r.Repository.GetUserAccountByID(ctx, entry.ID) + if err != nil { + if err == sql.ErrNoRows { + continue + } + return []MemberSearchResult{}, err } - return []MemberSearchResult{}, err + results = append(results, MemberSearchResult{ID: user.UserID.String(), User: &user, Status: ShareStatusJoined, Similarity: rank.Distance}) + } else { + logger.New(ctx).WithField("id", rank.Target).Info("adding target") + results = append(results, MemberSearchResult{ID: rank.Target, Status: ShareStatusInvited, Similarity: rank.Distance}) } - results = append(results, MemberSearchResult{User: &user, Joined: false, Confirmed: false, Similarity: rank.Distance}) - memberList[masterList[rank.Target]] = true + memberList[entry.ID] = true } } return results, nil @@ -1621,9 +1642,7 @@ func (r *Resolver) Task() TaskResolver { return &taskResolver{r} } func (r *Resolver) TaskChecklist() TaskChecklistResolver { return &taskChecklistResolver{r} } // TaskChecklistItem returns TaskChecklistItemResolver implementation. -func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver { - return &taskChecklistItemResolver{r} -} +func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver { return &taskChecklistItemResolver{r} } // TaskGroup returns TaskGroupResolver implementation. func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} } @@ -1652,3 +1671,21 @@ type taskGroupResolver struct{ *Resolver } type taskLabelResolver struct{ *Resolver } type teamResolver struct{ *Resolver } type userAccountResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +type MemberType string + +const ( + MemberTypeInvited MemberType = "INVITED" + MemberTypeJoined MemberType = "JOINED" +) + +type MasterEntry struct { + MemberType MemberType + ID uuid.UUID +} diff --git a/internal/graph/schema/_root.gql b/internal/graph/schema/_root.gql index 1f20627..38e479b 100644 --- a/internal/graph/schema/_root.gql +++ b/internal/graph/schema/_root.gql @@ -1,3 +1,8 @@ +enum ShareStatus { + INVITED + JOINED +} + enum RoleLevel { ADMIN MEMBER diff --git a/internal/graph/schema/user.gql b/internal/graph/schema/user.gql index eabdc65..b9c86ab 100644 --- a/internal/graph/schema/user.gql +++ b/internal/graph/schema/user.gql @@ -30,16 +30,16 @@ type DeleteInvitedUserAccountPayload { } input MemberSearchFilter { - SearchFilter: String! + searchFilter: String! projectID: UUID } + type MemberSearchResult { similarity: Int! - user: UserAccount! - confirmed: Boolean! - invited: Boolean! - joined: Boolean! + id: String! + user: UserAccount + status: ShareStatus! } type UpdateUserInfoPayload {