feat: more changes

This commit is contained in:
Jordan Knott 2020-12-08 19:44:48 -06:00
parent eff2044a6b
commit 33f06c1035
24 changed files with 1000 additions and 394 deletions

View File

@ -4,10 +4,10 @@ import { DragDebugWrapper } from './Styles';
type DragDebugProps = { type DragDebugProps = {
zone: ImpactZone | null; zone: ImpactZone | null;
depthTarget: number; depthTarget: number;
draggingID: string | null; draggedNodes: Array<string> | null;
}; };
const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggingID }) => { const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggedNodes }) => {
let aboveID = null; let aboveID = null;
let belowID = null; let belowID = null;
if (zone) { if (zone) {
@ -15,7 +15,9 @@ const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggingID })
belowID = zone.below ? zone.below.node.id : null; belowID = zone.below ? zone.below.node.id : null;
} }
return ( return (
<DragDebugWrapper>{`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggingID=${draggingID}`}</DragDebugWrapper> <DragDebugWrapper>{`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggedNodes=${
draggedNodes ? draggedNodes.toString() : null
}`}</DragDebugWrapper>
); );
}; };

View File

@ -32,7 +32,8 @@ const DragIndicator: React.FC<DragIndicatorProps> = ({ container, zone, depthTar
} }
let left = 0; let left = 0;
if (container && container.current) { 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 <DragIndicatorBar top={top} left={left} width={width} />; return <DragIndicatorBar top={top} left={left} width={width} />;
}; };

View File

@ -1,17 +1,42 @@
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react'; import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { DotCircle } from 'shared/icons'; import { Dot } from 'shared/icons';
import { findNextDraggable, getDimensions, getTargetDepth, getNodeAbove, getBelowParent, findNodeAbove } from './utils'; import styled from 'styled-components';
import {
findNextDraggable,
getDimensions,
getTargetDepth,
getNodeAbove,
getBelowParent,
findNodeAbove,
getNodeOver,
getLastChildInBranch,
findNodeDepth,
} from './utils';
import { useDrag } from './useDrag'; 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 = { type DraggerProps = {
container: React.RefObject<HTMLDivElement>; container: React.RefObject<HTMLDivElement>;
draggingID: string; draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
isDragging: boolean; isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void; onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number }; initialPos: { x: number; y: number };
}; };
const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isDragging, initialPos }) => { const Dragger: React.FC<DraggerProps> = ({ draggedNodes, container, onDragEnd, isDragging, initialPos }) => {
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos); const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
const { outline, impact, setImpact } = useDrag(); const { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null); const $handle = useRef<HTMLDivElement>(null);
@ -22,38 +47,9 @@ const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isD
e => { e => {
e.preventDefault(); e.preventDefault();
const { clientX, clientY, pageX, pageY } = e; const { clientX, clientY, pageX, pageY } = e;
console.log(clientX, clientY);
setPos({ x: clientX, y: clientY }); setPos({ x: clientX, y: clientY });
let curDepth = 1; const { curDepth, curPosition, curDraggable } = getNodeOver({ x: clientX, y: clientY }, outline.current);
let curDraggables: any; let depthTarget: number = 0;
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;
}
}
}
let aboveNode: null | OutlineNode = null; let aboveNode: null | OutlineNode = null;
let belowNode: null | OutlineNode = null; let belowNode: null | OutlineNode = null;
@ -66,36 +62,38 @@ const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isD
// if belowNode has the depth of 1, then the above element will be a part of a different branch // if belowNode has the depth of 1, then the above element will be a part of a different branch
const { relationships, nodes } = outline.current; const { relationships, nodes } = outline.current;
if (belowNode) { if (!belowNode || !aboveNode) {
aboveNode = findNodeAbove(outline.current, curDepth, belowNode); if (belowNode) {
} else if (aboveNode) { aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
let targetBelowNode: RelationshipChild | null = null; } else if (aboveNode) {
const parent = relationships.get(aboveNode.parent); let targetBelowNode: RelationshipChild | null = null;
if (aboveNode.children !== 0) { const parent = relationships.get(aboveNode.parent);
const abr = relationships.get(aboveNode.id); if (aboveNode.children !== 0 && !aboveNode.collapsed) {
if (abr) { const abr = relationships.get(aboveNode.id);
const newTarget = abr.children[0]; if (abr) {
if (newTarget) { const newTarget = abr.children[0];
targetBelowNode = newTarget; 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) { if (targetBelowNode) {
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id); const depthNodes = nodes.get(targetBelowNode.depth);
if (aboveNodeIndex !== -1) { if (depthNodes) {
if (aboveNodeIndex === parent.children.length - 1) { belowNode = depthNodes.get(targetBelowNode.id) ?? null;
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 outside outline, get either first or last item in list based on mouse Y // if outside outline, get either first or last item in list based on mouse Y
@ -111,19 +109,47 @@ const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isD
aboveNode = null; aboveNode = null;
} }
} else { } else {
// TODO: enhance to actually get last child item, not last top level branch
const rootChildren = outline.current.relationships.get('root'); const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1); const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) { if (rootChildren && rootDepth) {
const lastChild = rootChildren.children[rootChildren.children.length - 1]; 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) { if (aboveNode) {
belowNode = aboveNode; const { ancestors } = findNodeDepth(outline.current.published, aboveNode.id);
aboveNode = findNodeAbove(outline.current, aboveNode.depth, aboveNode); 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 // calculate available depths
@ -132,7 +158,7 @@ const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isD
let maxDepth = 2; let maxDepth = 2;
if (aboveNode) { if (aboveNode) {
const aboveParent = relationships.get(aboveNode.parent); const aboveParent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0) { if (aboveNode.children !== 0 && !aboveNode.collapsed) {
minDepth = aboveNode.depth + 1; minDepth = aboveNode.depth + 1;
maxDepth = aboveNode.depth + 1; maxDepth = aboveNode.depth + 1;
} else if (aboveParent) { } else if (aboveParent) {
@ -205,9 +231,9 @@ const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isD
return ( return (
<> <>
{pos && ( {pos && (
<div ref={$handle} style={styles}> <Container ref={$handle} style={styles}>
<DotCircle width={18} height={18} /> <Dot width={18} height={18} />
</div> </Container>
)} )}
</> </>
); );

View File

@ -1,16 +1,37 @@
import React, { useRef, useEffect } from 'react'; 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'; 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 = { type EntryProps = {
id: string; id: string;
collapsed?: boolean;
onToggleCollapse: (id: string, collapsed: boolean) => void;
parentID: string; parentID: string;
onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void; onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void;
onStartSelect: (e: { id: string; depth: number }) => void;
isRoot?: boolean; isRoot?: boolean;
draggingID: null | string; selection: null | Array<{ id: string }>;
draggedNodes: null | Array<string>;
entries: Array<ItemElement>; entries: Array<ItemElement>;
onCancelDrag: () => void;
position: number; position: number;
chain?: Array<string>; chain?: Array<string>;
depth?: number; depth?: number;
@ -20,16 +41,21 @@ const Entry: React.FC<EntryProps> = ({
id, id,
parentID, parentID,
isRoot = false, isRoot = false,
selection,
onToggleCollapse,
onStartSelect,
position, position,
onCancelDrag,
onStartDrag, onStartDrag,
draggingID, collapsed = false,
draggedNodes,
entries, entries,
chain = [], chain = [],
depth = 0, depth = 0,
}) => { }) => {
const $entry = useRef<HTMLDivElement>(null); const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(null); const $children = useRef<HTMLDivElement>(null);
const { setNodeDimensions } = useDrag(); const { setNodeDimensions, clearNodeDimensions } = useDrag();
useEffect(() => { useEffect(() => {
if (isRoot) return; if (isRoot) return;
if ($entry && $entry.current) { if ($entry && $entry.current) {
@ -38,24 +64,67 @@ const Entry: React.FC<EntryProps> = ({
children: entries.length !== 0 ? $children : null, children: entries.length !== 0 ? $children : null,
}); });
} }
return () => {
clearNodeDimensions(id);
};
}, [position, depth, entries]); }, [position, depth, entries]);
let showHandle = true; let showHandle = true;
if (draggingID && draggingID === id) { if (draggedNodes && draggedNodes.length === 1 && draggedNodes.find(c => c === id)) {
showHandle = false; showHandle = false;
} }
let isSelected = false;
if (selection && selection.find(c => c.id === id)) {
isSelected = true;
}
let onSaveTimer: any = null;
const onSaveTimeout = 300;
return ( return (
<EntryWrapper isDragging={!showHandle}> <EntryWrapper isSelected={isSelected} isDragging={!showHandle}>
{!isRoot && ( {!isRoot && (
<EntryContent> <EntryContent>
{entries.length !== 0 && (
<ExpandButton onClick={() => onToggleCollapse(id, !collapsed)}>
{collapsed ? <CaretRight width={20} height={20} /> : <CaretDown width={20} height={20} />}
</ExpandButton>
)}
{showHandle && ( {showHandle && (
<EntryHandle onMouseDown={e => onStartDrag({ id, clientX: e.clientX, clientY: e.clientY })}> <EntryHandle
<DotCircle width={18} height={18} /> onMouseUp={() => onCancelDrag()}
onMouseDown={e => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
}}
>
<Dot width={18} height={18} />
</EntryHandle> </EntryHandle>
)} )}
<EntryInnerContent ref={$entry}>{id.toString()}</EntryInnerContent> <EntryInnerContent
onMouseDown={() => {
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}`}
</EntryInnerContent>
</EntryContent> </EntryContent>
)} )}
{entries.length !== 0 && ( {entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}> <EntryChildren ref={$children} isRoot={isRoot}>
{entries {entries
.sort((a, b) => a.position - b.position) .sort((a, b) => a.position - b.position)
@ -65,11 +134,16 @@ const Entry: React.FC<EntryProps> = ({
key={entry.id} key={entry.id}
position={entry.position} position={entry.position}
depth={depth + 1} depth={depth + 1}
draggingID={draggingID} draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id} id={entry.id}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag} onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []} entries={entry.children ?? []}
chain={[...chain, id]} chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
/> />
))} ))}
</EntryChildren> </EntryChildren>

View File

@ -1,7 +1,7 @@
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles'; import { mixin } from 'shared/utils/styles';
export const EntryWrapper = styled.div<{ isDragging: boolean }>` export const EntryWrapper = styled.div<{ isDragging: boolean; isSelected: boolean }>`
position: relative; position: relative;
${props => ${props =>
props.isDragging && props.isDragging &&
@ -11,12 +11,26 @@ export const EntryWrapper = styled.div<{ isDragging: boolean }>`
content: ''; content: '';
position: absolute; position: absolute;
top: 2px; top: 2px;
right: -2px; right: -5px;
left: -2px; left: -5px;
bottom: -2px; bottom: -2px;
background-color: #eceef0; 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 }>` export const EntryChildren = styled.div<{ isRoot: boolean }>`
@ -26,7 +40,7 @@ export const EntryChildren = styled.div<{ isRoot: boolean }>`
css` css`
margin-left: 10px; margin-left: 10px;
padding-left: 25px; 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-right: 56px;
padding-top: 24px; padding-top: 24px;
padding-bottom: 24px; padding-bottom: 24px;
margin-top: 72px;
text-size-adjust: none; 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 }>` 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); color: rgb(75, 81, 85);
border-radius: 9px; border-radius: 9px;
`; `;
export const RootWrapper = styled.div``;
export const EntryHandle = styled.div` export const EntryHandle = styled.div`
display: flex; display: flex;
@ -80,8 +87,15 @@ export const EntryHandle = styled.div`
top: 7px; top: 7px;
width: 18px; width: 18px;
height: 18px; height: 18px;
color: rgb(75, 81, 85); color: rgba(${p => p.theme.colors.text.primary});
border-radius: 9px; 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` export const EntryInnerContent = styled.div`
@ -93,6 +107,10 @@ export const EntryInnerContent = styled.div`
overflow-wrap: break-word; overflow-wrap: break-word;
position: relative; position: relative;
user-select: text; user-select: text;
color: rgba(${p => p.theme.colors.text.primary});
&::selection {
background: #a49de8;
}
&:focus { &:focus {
outline: 0; outline: 0;
} }
@ -114,3 +132,33 @@ export const DragIndicatorBar = styled.div<{ left: number; top: number; width: n
border-radius: 3px; border-radius: 3px;
background: rgb(204, 204, 204); 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;
`;

View File

@ -1,5 +1,7 @@
import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react'; import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react';
import { DotCircle } from 'shared/icons'; import { DotCircle } from 'shared/icons';
import styled from 'styled-components/macro';
import GlobalTopNavbar from 'App/TopNavbar';
import _ from 'lodash'; import _ from 'lodash';
import produce from 'immer'; import produce from 'immer';
import Entry from './Entry'; import Entry from './Entry';
@ -9,6 +11,7 @@ import DragDebug from './DragDebug';
import { DragContext } from './useDrag'; import { DragContext } from './useDrag';
import { import {
PageContainer,
DragDebugWrapper, DragDebugWrapper,
DragIndicatorBar, DragIndicatorBar,
PageContent, PageContent,
@ -16,43 +19,85 @@ import {
EntryInnerContent, EntryInnerContent,
EntryWrapper, EntryWrapper,
EntryContent, EntryContent,
RootWrapper,
EntryHandle, EntryHandle,
} from './Styles'; } 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<ItemElement> = [ const listItems: Array<ItemElement> = [
{ id: 'root', position: 4096, parent: null }, { id: 'root', position: 4096, parent: null, collapsed: false },
{ id: 'entry-1', position: 4096, parent: 'root' }, { id: 'entry-1', position: 4096, parent: 'root', collapsed: false },
{ id: 'entry-1_1', position: 4096, parent: 'entry-1' }, { id: 'entry-1_3', position: 4096 * 3, parent: 'entry-1', collapsed: false },
{ id: 'entry-1_1_1', position: 4096, parent: 'entry-1_1' }, { id: 'entry-1_3_1', position: 4096, parent: 'entry-1_3', collapsed: false },
{ id: 'entry-1_2', position: 4096 * 2, parent: 'entry-1' }, { id: 'entry-1_3_2', position: 4096 * 2, parent: 'entry-1_3', collapsed: false },
{ id: 'entry-1_2_1', position: 4096, parent: 'entry-1_2' }, { id: 'entry-1_3_3', position: 4096 * 3, parent: 'entry-1_3', collapsed: false },
{ id: 'entry-1_2_2', position: 4096 * 2, parent: 'entry-1_2' }, { id: 'entry-1_3_3_1', position: 4096 * 1, parent: 'entry-1_3_3', collapsed: false },
{ id: 'entry-1_2_3', position: 4096 * 3, parent: 'entry-1_2' }, { 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' }, { id: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false },
{ id: 'entry-3', position: 4096 * 3, parent: 'root' }, { id: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false },
{ id: 'entry-4', position: 4096 * 4, parent: 'root' }, { id: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false },
{ id: 'entry-5', position: 4096 * 5, parent: 'root' }, { id: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false },
]; ];
const Outline: React.FC = () => { const Outline: React.FC = () => {
const [items, setItems] = useState(listItems); 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<null | { nodes: Array<{ id: string }>; first?: OutlineNode | null }>(null);
const [dragging, setDragging] = useState<{ const [dragging, setDragging] = useState<{
show: boolean; show: boolean;
draggableID: null | string; draggedNodes: null | Array<string>;
initialPos: { x: number; y: number }; 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 [impact, setImpact] = useState<null | {
listPosition: number; listPosition: number;
zone: ImpactZone; zone: ImpactZone;
depthTarget: number; depthTarget: number;
}>(null); }>(null);
const selectRef = useRef<{ isSelecting: boolean; hasSelection: boolean; node: { id: string; depth: number } | null }>(
{
isSelecting: false,
node: null,
hasSelection: false,
},
);
const impactRef = useRef<null | { listPosition: number; depth: number; zone: ImpactZone }>(null); const impactRef = useRef<null | { listPosition: number; depth: number; zone: ImpactZone }>(null);
useEffect(() => { useEffect(() => {
if (impact) { if (impact) {
impactRef.current = { zone: impact.zone, depth: impact.depthTarget, listPosition: impact.listPosition }; impactRef.current = { zone: impact.zone, depth: impact.depthTarget, listPosition: impact.listPosition };
} }
}, [impact]); }, [impact]);
useEffect(() => {
selectRef.current.isSelecting = selecting.isSelecting;
selectRef.current.node = selecting.node;
}, [selecting]);
const $content = useRef<HTMLDivElement>(null); const $content = useRef<HTMLDivElement>(null);
const outline = useRef<OutlineData>({ const outline = useRef<OutlineData>({
@ -67,20 +112,32 @@ const Outline: React.FC = () => {
if (tree.length === 1) { if (tree.length === 1) {
root = tree[0]; root = tree[0];
} }
const outlineHistory = useRef<{ commands: Array<OutlineCommand>; current: number }>({ current: -1, commands: [] });
useEffect(() => { useEffect(() => {
outline.current.relationships = new Map<string, NodeRelationships>(); outline.current.relationships = new Map<string, NodeRelationships>();
outline.current.published = new Map<string, string>(); outline.current.published = new Map<string, string>();
outline.current.nodes = new Map<number, Map<string, OutlineNode>>(); outline.current.nodes = new Map<number, Map<string, OutlineNode>>();
const collapsedMap = items.reduce((map, next) => {
if (next.collapsed) {
map.set(next.id, true);
}
return map;
}, new Map<string, boolean>());
items.forEach(item => outline.current.published.set(item.id, item.parent ?? 'root')); items.forEach(item => outline.current.published.set(item.id, item.parent ?? 'root'));
for (let i = 0; i < items.length; i++) { 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') { if (id === 'root') {
continue; continue;
} }
const parent = curParent ?? 'root'; const parent = curParent ?? 'root';
outline.current.published.set(id, parent ?? 'root'); outline.current.published.set(id, parent ?? 'root');
const { depth, ancestors } = findNodeDepth(outline.current.published, id); 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); const children = getNumberOfChildren(root, ancestors);
if (!outline.current.nodes.has(depth)) { if (!outline.current.nodes.has(depth)) {
outline.current.nodes.set(depth, new Map<string, OutlineNode>()); outline.current.nodes.set(depth, new Map<string, OutlineNode>());
@ -93,6 +150,7 @@ const Outline: React.FC = () => {
position, position,
depth, depth,
ancestors, ancestors,
collapsed,
parent, parent,
}); });
} }
@ -118,12 +176,144 @@ const Outline: React.FC = () => {
} }
} }
}, [items]); }, [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) { if (!root) {
return null; return null;
} }
return ( return (
<> <>
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<DragContext.Provider <DragContext.Provider
value={{ value={{
outline, outline,
@ -132,14 +322,29 @@ const Outline: React.FC = () => {
if (data) { if (data) {
const { zone, depth } = data; const { zone, depth } = data;
let listPosition = 65535; let listPosition = 65535;
const listAbove = validateDepth(zone.above ? zone.above.node : null, depth); if (zone.above && zone.above.node.depth + 1 <= depth && zone.above.node.collapsed) {
const listBelow = validateDepth(zone.below ? zone.below.node : null, depth); const aboveChildren = items
if (listAbove && listBelow) { .filter(i => (zone.above ? i.parent === zone.above.node.id : false))
listPosition = (listAbove.position + listBelow.position) / 2.0; .sort((a, b) => a.position - b.position);
} else if (listAbove && !listBelow) { const lastChild = aboveChildren[aboveChildren.length - 1];
listPosition = listAbove.position * 2.0; if (lastChild) {
} else if (!listAbove && listBelow) { listPosition = lastChild.position * 2.0;
listPosition = listBelow.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) { if (!zone.above && zone.below) {
@ -167,124 +372,122 @@ const Outline: React.FC = () => {
setNodeDimensions: (nodeID, ref) => { setNodeDimensions: (nodeID, ref) => {
outline.current.dimensions.set(nodeID, ref); outline.current.dimensions.set(nodeID, ref);
}, },
clearNodeDimensions: nodeID => {
outline.current.dimensions.delete(nodeID);
},
}} }}
> >
<> <>
<PageContent ref={$content}> <PageContainer>
<Entry <PageContent>
id="root" <RootWrapper ref={$content}>
parentID="root" <Entry
isRoot onStartSelect={({ id, depth }) => {
draggingID={dragging.draggableID} setSelection(null);
position={root.position} setSelecting({ isSelecting: true, node: { id, depth } });
entries={root.children} }}
onStartDrag={e => { onToggleCollapse={(id, collapsed) => {
if (e.id !== 'root') { setItems(prevItems =>
setImpact(null); produce(prevItems, draftItems => {
setDragging({ show: true, draggableID: e.id, initialPos: { x: e.clientX, y: e.clientY } }); const idx = prevItems.findIndex(c => c.id === id);
} if (idx !== -1) {
}} draftItems[idx].collapsed = collapsed;
/> }
</PageContent> }),
{dragging.show && dragging.draggableID && ( );
}}
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 } });
}
}
}}
/>
</RootWrapper>
</PageContent>
</PageContainer>
{dragging.show && dragging.draggedNodes && (
<Dragger <Dragger
container={$content} container={$content}
draggingID={dragging.draggableID}
initialPos={dragging.initialPos} initialPos={dragging.initialPos}
draggedNodes={{ nodes: dragging.draggedNodes, first: selection ? selection.first : null }}
isDragging={dragging.show} isDragging={dragging.show}
onDragEnd={() => { onDragEnd={() => {
const draggingID = dragging.draggableID; if (dragging.draggedNodes && impactRef.current) {
if (draggingID && impactRef.current) {
const { zone, depth, listPosition } = impactRef.current; const { zone, depth, listPosition } = impactRef.current;
const noZone = !zone.above && !zone.below; const noZone = !zone.above && !zone.below;
const curParentID = outline.current.published.get(draggingID); if (!noZone) {
if (!noZone && curParentID) {
let parentID = 'root'; let parentID = 'root';
if (zone.above) { if (zone.above) {
parentID = zone.above.node.ancestors[depth - 1]; parentID = zone.above.node.ancestors[depth - 1];
} }
const node = findNode(curParentID, draggingID, outline.current); let reparent = true;
console.log(`${node ? node.parent : null} => ${parentID}`); for (let i = 0; i < dragging.draggedNodes.length; i++) {
// UPDATE OUTLINE DATA AFTER NODE MOVE const draggedID = dragging.draggedNodes[i];
if (node) { const prevItem = items.find(i => i.id === draggedID);
if (node.depth !== depth) { if (prevItem && prevItem.position === listPosition && prevItem.parent === parentID) {
const oldParentDepth = outline.current.nodes.get(node.depth - 1); reparent = false;
if (oldParentDepth) { break;
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<string, OutlineNode>());
}
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);
});
} }
} }
outline.current.published.set(draggingID, parentID); // TODO: set reparent if list position changed but parent did not
setItems(itemsPrev => //
produce(itemsPrev, draftItems => {
const curDragging = itemsPrev.findIndex(i => i.id === draggingID); if (reparent) {
// console.log(`parent=${impactRef.current} target=${draggingID}`); // UPDATE OUTLINE DATA AFTER NODE MOVE
if (impactRef.current) { setItems(itemsPrev =>
// console.log(`updating position = ${impactRef.current.targetPosition}`); produce(itemsPrev, draftItems => {
draftItems[curDragging].parent = parentID; if (dragging.draggedNodes) {
draftItems[curDragging].position = listPosition; 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); 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 = () => {
</DragContext.Provider> </DragContext.Provider>
{impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />} {impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />}
{impact && ( {impact && (
<DragDebug zone={impact.zone ?? null} draggingID={dragging.draggableID} depthTarget={impact.depthTarget} /> <DragDebug zone={impact.zone ?? null} draggedNodes={dragging.draggedNodes} depthTarget={impact.depthTarget} />
)} )}
</> </>
); );

View File

@ -7,6 +7,7 @@ type DragContextData = {
nodeID: string, nodeID: string,
ref: { entry: React.RefObject<HTMLElement>; children: React.RefObject<HTMLElement> | null }, ref: { entry: React.RefObject<HTMLElement>; children: React.RefObject<HTMLElement> | null },
) => void; ) => void;
clearNodeDimensions: (nodeID: string) => void;
setImpact: (data: ImpactData | null) => void; setImpact: (data: ImpactData | null) => void;
}; };

View File

@ -1,6 +1,25 @@
import _ from 'lodash'; 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) { if (node) {
return node.depth === depth ? node : null; return node.depth === depth ? node : null;
} }
@ -14,9 +33,9 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil
while (hasChildren) { while (hasChildren) {
const targetParent = outline.relationships.get(aboveTargetID); const targetParent = outline.relationships.get(aboveTargetID);
if (targetParent) { if (targetParent) {
const parentNodes = outline.nodes.get(targetParent.self.depth);
const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null;
if (targetParent.children.length === 0) { if (targetParent.children.length === 0) {
const parentNodes = outline.nodes.get(targetParent.self.depth);
const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null;
if (parentNode) { if (parentNode) {
nodeAbove = { nodeAbove = {
id: parentNode.id, id: parentNode.id,
@ -24,7 +43,9 @@ export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChil
position: parentNode.position, position: parentNode.position,
children: parentNode.children, children: parentNode.children,
}; };
console.log('node above', nodeAbove);
} }
hasChildren = false;
continue; continue;
} }
nodeAbove = targetParent.children[targetParent.children.length - 1]; 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; return nodeAbove;
} }
@ -103,12 +125,7 @@ export function getTargetDepth(mouseX: number, handleLeft: number, availableDept
return availableDepths.min; return availableDepths.min;
} }
export function findNextDraggable( export function findNextDraggable(pos: { x: number; y: number }, outline: OutlineData, curDepth: number) {
pos: { x: number; y: number },
outline: OutlineData,
curDepth: number,
draggingID: string,
) {
let index = 0; let index = 0;
const currentDepthNodes = outline.nodes.get(curDepth); const currentDepthNodes = outline.nodes.get(curDepth);
let nodeAbove: null | RelationshipChild = null; let nodeAbove: null | RelationshipChild = null;
@ -260,3 +277,85 @@ export function findNodeAbove(outline: OutlineData, curDepth: number, belowNode:
} }
return null; 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;
}

View File

@ -134,16 +134,17 @@ type MemberFilterOptions = {
}; };
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => { 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) { if (input && input.trim().length < 3) {
return []; return [];
} }
const res = await client.query({ const res = await client.query({
query: gql` query: gql`
query { query {
searchMembers(input: {SearchFilter:"${input}", projectID:"${projectID}"}) { searchMembers(input: {searchFilter:"${input}", projectID:"${projectID}"}) {
id
similarity similarity
confirmed status
joined
user { user {
id id
fullName fullName
@ -161,16 +162,34 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
let results: any = []; let results: any = [];
const emails: Array<string> = []; const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) { if (res.data && res.data.searchMembers) {
results = [ results = [
...res.data.searchMembers.map((m: any) => { ...res.data.searchMembers.map((m: any) => {
emails.push(m.user.email); if (m.status === 'INVITED') {
return { console.log(`${m.id} is added`);
label: m.user.fullName, return {
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon }, 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)) { if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
@ -215,6 +234,13 @@ const OptionContent = styled.div`
margin-left: 12px; 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<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => { const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data); console.log(data);
return !isDisabled ? ( return !isDisabled ? (
@ -223,11 +249,20 @@ const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerPro
size={32} size={32}
member={{ member={{
id: '', id: '',
fullName: 'Jordan Knott', fullName: data.value.label,
profileIcon: data.value.profileIcon, profileIcon: data.value.profileIcon,
}} }}
/> />
<OptionContent>{label}</OptionContent> <OptionContent>
<OptionLabel fontSize={16} quiet={false}>
{label}
</OptionLabel>
{data.value.type === 2 && (
<OptionLabel fontSize={14} quiet>
Joined
</OptionLabel>
)}
</OptionContent>
</OptionWrapper> </OptionWrapper>
) : null; ) : null;
}; };

View File

@ -73,7 +73,6 @@ export const HeaderName = styled(TextareaAutosize)`
box-shadow: none; box-shadow: none;
font-weight: 600; font-weight: 600;
margin: -4px 0; margin: -4px 0;
padding: 4px 8px;
letter-spacing: normal; letter-spacing: normal;
word-spacing: normal; word-spacing: normal;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const ArrowDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 448 512">
<path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" />
</Icon>
);
};
export default ArrowDown;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CaretDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 320 512">
<path d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z" />
</Icon>
);
};
export default CaretDown;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const CaretRight: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 192 512">
<path d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z" />
</Icon>
);
};
export default CaretRight;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const Dot: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 18 18">
<circle cx="9" cy="9" r="3.5" />
</Icon>
);
};
export default Dot;

View File

@ -1,8 +1,12 @@
import Cross from './Cross'; import Cross from './Cross';
import Cog from './Cog'; import Cog from './Cog';
import ArrowDown from './ArrowDown';
import ListUnordered from './ListUnordered'; import ListUnordered from './ListUnordered';
import Dot from './Dot';
import CaretDown from './CaretDown';
import Eye from './Eye'; import Eye from './Eye';
import EyeSlash from './EyeSlash'; import EyeSlash from './EyeSlash';
import CaretRight from './CaretRight';
import List from './List'; import List from './List';
import At from './At'; import At from './At';
import Task from './Task'; import Task from './Task';
@ -94,5 +98,9 @@ export {
ListUnordered, ListUnordered,
EyeSlash, EyeSlash,
List, List,
CaretDown,
Dot,
ArrowDown,
CaretRight,
DotCircle, DotCircle,
}; };

View File

@ -150,6 +150,7 @@ type OutlineNode = {
depth: number; depth: number;
position: number; position: number;
ancestors: Array<string>; ancestors: Array<string>;
collapsed: boolean;
children: number; children: number;
}; };
@ -199,6 +200,7 @@ type ItemElement = {
id: string; id: string;
parent: null | string; parent: null | string;
position: number; position: number;
collapsed: boolean;
children?: Array<ItemElement>; children?: Array<ItemElement>;
}; };
type NodeDimensions = { type NodeDimensions = {

View File

@ -229,15 +229,16 @@ func (q *Queries) GetAllVisibleProjectsForUserID(ctx context.Context, userID uui
} }
const getInvitedMembersForProjectID = `-- name: GetInvitedMembersForProjectID :many 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 INNER JOIN user_account_invited AS uai
ON uai.user_account_invited_id = pmi.user_account_invited_id ON uai.user_account_invited_id = pmi.user_account_invited_id
WHERE project_id = $1 WHERE project_id = $1
` `
type GetInvitedMembersForProjectIDRow struct { type GetInvitedMembersForProjectIDRow struct {
Email string `json:"email"` UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
InvitedOn time.Time `json:"invited_on"` Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
} }
func (q *Queries) GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) { 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 var items []GetInvitedMembersForProjectIDRow
for rows.Next() { for rows.Next() {
var i GetInvitedMembersForProjectIDRow 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 return nil, err
} }
items = append(items, i) items = append(items, i)

View File

@ -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; SELECT project_id FROM project_member WHERE user_id = $1;
-- name: GetInvitedMembersForProjectID :many -- 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 INNER JOIN user_account_invited AS uai
ON uai.user_account_invited_id = pmi.user_account_invited_id ON uai.user_account_invited_id = pmi.user_account_invited_id
WHERE project_id = $1; WHERE project_id = $1;

View File

@ -183,10 +183,9 @@ type ComplexityRoot struct {
} }
MemberSearchResult struct { MemberSearchResult struct {
Confirmed func(childComplexity int) int ID func(childComplexity int) int
Invited func(childComplexity int) int
Joined func(childComplexity int) int
Similarity func(childComplexity int) int Similarity func(childComplexity int) int
Status func(childComplexity int) int
User 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 return e.complexity.MemberList.Teams(childComplexity), true
case "MemberSearchResult.confirmed": case "MemberSearchResult.id":
if e.complexity.MemberSearchResult.Confirmed == nil { if e.complexity.MemberSearchResult.ID == nil {
break break
} }
return e.complexity.MemberSearchResult.Confirmed(childComplexity), true return e.complexity.MemberSearchResult.ID(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
case "MemberSearchResult.similarity": case "MemberSearchResult.similarity":
if e.complexity.MemberSearchResult.Similarity == nil { 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 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": case "MemberSearchResult.user":
if e.complexity.MemberSearchResult.User == nil { if e.complexity.MemberSearchResult.User == nil {
break break
@ -2841,6 +2833,11 @@ type TaskChecklist {
items: [TaskChecklistItem!]! items: [TaskChecklistItem!]!
} }
enum ShareStatus {
INVITED
JOINED
}
enum RoleLevel { enum RoleLevel {
ADMIN ADMIN
MEMBER MEMBER
@ -3452,16 +3449,16 @@ type DeleteInvitedUserAccountPayload {
} }
input MemberSearchFilter { input MemberSearchFilter {
SearchFilter: String! searchFilter: String!
projectID: UUID projectID: UUID
} }
type MemberSearchResult { type MemberSearchResult {
similarity: Int! similarity: Int!
user: UserAccount! id: String!
confirmed: Boolean! user: UserAccount
invited: Boolean! status: ShareStatus!
joined: Boolean!
} }
type UpdateUserInfoPayload { type UpdateUserInfoPayload {
@ -6373,6 +6370,40 @@ func (ec *executionContext) _MemberSearchResult_similarity(ctx context.Context,
return ec.marshalNInt2int(ctx, field.Selections, res) 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) { func (ec *executionContext) _MemberSearchResult_user(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -6397,17 +6428,14 @@ func (ec *executionContext) _MemberSearchResult_user(ctx context.Context, field
return graphql.Null return graphql.Null
} }
if resTmp == nil { if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null return graphql.Null
} }
res := resTmp.(*db.UserAccount) res := resTmp.(*db.UserAccount)
fc.Result = res 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() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r)) 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) ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children ctx = rctx // use context from middleware stack in children
return obj.Confirmed, nil return obj.Status, nil
}) })
if err != nil { if err != nil {
ec.Error(ctx, err) ec.Error(ctx, err)
@ -6436,77 +6464,9 @@ func (ec *executionContext) _MemberSearchResult_confirmed(ctx context.Context, f
} }
return graphql.Null return graphql.Null
} }
res := resTmp.(bool) res := resTmp.(ShareStatus)
fc.Result = res 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) _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)
} }
func (ec *executionContext) _Mutation_createProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { 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 { for k, v := range asMap {
switch k { switch k {
case "SearchFilter": case "searchFilter":
var err error var err error
it.SearchFilter, err = ec.unmarshalNString2string(ctx, v) it.SearchFilter, err = ec.unmarshalNString2string(ctx, v)
if err != nil { if err != nil {
@ -17982,23 +17942,15 @@ func (ec *executionContext) _MemberSearchResult(ctx context.Context, sel ast.Sel
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
invalids++ invalids++
} }
case "id":
out.Values[i] = ec._MemberSearchResult_id(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "user": case "user":
out.Values[i] = ec._MemberSearchResult_user(ctx, field, obj) out.Values[i] = ec._MemberSearchResult_user(ctx, field, obj)
if out.Values[i] == graphql.Null { case "status":
invalids++ out.Values[i] = ec._MemberSearchResult_status(ctx, field, obj)
}
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)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
invalids++ invalids++
} }
@ -21486,6 +21438,15 @@ func (ec *executionContext) unmarshalNSetTaskComplete2githubᚗcomᚋjordanknott
return ec.unmarshalInputSetTaskComplete(ctx, v) 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) { func (ec *executionContext) unmarshalNSortTaskGroup2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐSortTaskGroup(ctx context.Context, v interface{}) (SortTaskGroup, error) {
return ec.unmarshalInputSortTaskGroup(ctx, v) return ec.unmarshalInputSortTaskGroup(ctx, v)
} }
@ -22625,6 +22586,17 @@ func (ec *executionContext) unmarshalOUpdateProjectName2ᚖgithubᚗcomᚋjordan
return &res, err 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 { 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 { if v == nil {
return graphql.Null return graphql.Null

View File

@ -249,16 +249,15 @@ type MemberList struct {
} }
type MemberSearchFilter struct { type MemberSearchFilter struct {
SearchFilter string `json:"SearchFilter"` SearchFilter string `json:"searchFilter"`
ProjectID *uuid.UUID `json:"projectID"` ProjectID *uuid.UUID `json:"projectID"`
} }
type MemberSearchResult struct { type MemberSearchResult struct {
Similarity int `json:"similarity"` Similarity int `json:"similarity"`
ID string `json:"id"`
User *db.UserAccount `json:"user"` User *db.UserAccount `json:"user"`
Confirmed bool `json:"confirmed"` Status ShareStatus `json:"status"`
Invited bool `json:"invited"`
Joined bool `json:"joined"`
} }
type NewProject struct { type NewProject struct {
@ -830,3 +829,44 @@ func (e *RoleLevel) UnmarshalGQL(v interface{}) error {
func (e RoleLevel) MarshalGQL(w io.Writer) { func (e RoleLevel) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String())) 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()))
}

View File

@ -172,6 +172,11 @@ type TaskChecklist {
items: [TaskChecklistItem!]! items: [TaskChecklistItem!]!
} }
enum ShareStatus {
INVITED
JOINED
}
enum RoleLevel { enum RoleLevel {
ADMIN ADMIN
MEMBER MEMBER
@ -783,16 +788,16 @@ type DeleteInvitedUserAccountPayload {
} }
input MemberSearchFilter { input MemberSearchFilter {
SearchFilter: String! searchFilter: String!
projectID: UUID projectID: UUID
} }
type MemberSearchResult { type MemberSearchResult {
similarity: Int! similarity: Int!
user: UserAccount! id: String!
confirmed: Boolean! user: UserAccount
invited: Boolean! status: ShareStatus!
joined: Boolean!
} }
type UpdateUserInfoPayload { type UpdateUserInfoPayload {

View File

@ -1310,31 +1310,52 @@ func (r *queryResolver) SearchMembers(ctx context.Context, input MemberSearchFil
return []MemberSearchResult{}, err 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{} sortList := []string{}
masterList := map[string]uuid.UUID{} masterList := map[string]MasterEntry{}
for _, member := range availableMembers { for _, member := range availableMembers {
sortList = append(sortList, member.Username) sortList = append(sortList, member.Username)
sortList = append(sortList, member.Email) sortList = append(sortList, member.Email)
masterList[member.Username] = member.UserID masterList[member.Username] = MasterEntry{ID: member.UserID, MemberType: MemberTypeJoined}
masterList[member.Email] = member.UserID 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) rankedList := fuzzy.RankFind(input.SearchFilter, sortList)
logger.New(ctx).Info(rankedList)
results := []MemberSearchResult{} results := []MemberSearchResult{}
memberList := map[uuid.UUID]bool{} memberList := map[uuid.UUID]bool{}
for _, rank := range rankedList { for _, rank := range rankedList {
if _, ok := memberList[masterList[rank.Target]]; !ok { entry, _ := masterList[rank.Target]
logger.New(ctx).WithFields(log.Fields{"source": rank.Source, "target": rank.Target}).Info("searching") _, ok := memberList[entry.ID]
userID := masterList[rank.Target] logger.New(ctx).WithField("ok", ok).WithField("target", rank.Target).Info("checking rank")
user, err := r.Repository.GetUserAccountByID(ctx, userID) if !ok {
if err != nil { if entry.MemberType == MemberTypeJoined {
if err == sql.ErrNoRows { logger.New(ctx).WithFields(log.Fields{"source": rank.Source, "target": rank.Target}).Info("searching")
continue 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[entry.ID] = true
memberList[masterList[rank.Target]] = true
} }
} }
return results, nil return results, nil
@ -1621,9 +1642,7 @@ func (r *Resolver) Task() TaskResolver { return &taskResolver{r} }
func (r *Resolver) TaskChecklist() TaskChecklistResolver { return &taskChecklistResolver{r} } func (r *Resolver) TaskChecklist() TaskChecklistResolver { return &taskChecklistResolver{r} }
// TaskChecklistItem returns TaskChecklistItemResolver implementation. // TaskChecklistItem returns TaskChecklistItemResolver implementation.
func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver { func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver { return &taskChecklistItemResolver{r} }
return &taskChecklistItemResolver{r}
}
// TaskGroup returns TaskGroupResolver implementation. // TaskGroup returns TaskGroupResolver implementation.
func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} } func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} }
@ -1652,3 +1671,21 @@ type taskGroupResolver struct{ *Resolver }
type taskLabelResolver struct{ *Resolver } type taskLabelResolver struct{ *Resolver }
type teamResolver struct{ *Resolver } type teamResolver struct{ *Resolver }
type userAccountResolver 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
}

View File

@ -1,3 +1,8 @@
enum ShareStatus {
INVITED
JOINED
}
enum RoleLevel { enum RoleLevel {
ADMIN ADMIN
MEMBER MEMBER

View File

@ -30,16 +30,16 @@ type DeleteInvitedUserAccountPayload {
} }
input MemberSearchFilter { input MemberSearchFilter {
SearchFilter: String! searchFilter: String!
projectID: UUID projectID: UUID
} }
type MemberSearchResult { type MemberSearchResult {
similarity: Int! similarity: Int!
user: UserAccount! id: String!
confirmed: Boolean! user: UserAccount
invited: Boolean! status: ShareStatus!
joined: Boolean!
} }
type UpdateUserInfoPayload { type UpdateUserInfoPayload {