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 = {
zone: ImpactZone | null;
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 belowID = null;
if (zone) {
@ -15,7 +15,9 @@ const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggingID })
belowID = zone.below ? zone.below.node.id : null;
}
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;
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} />;
};

View File

@ -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<HTMLDivElement>;
draggingID: string;
draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
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 { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null);
@ -22,38 +47,9 @@ const Dragger: React.FC<DraggerProps> = ({ 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,12 +62,13 @@ 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
const { relationships, nodes } = outline.current;
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) {
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
const abr = relationships.get(aboveNode.id);
if (abr) {
const newTarget = abr.children[0];
@ -97,6 +94,7 @@ const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isD
}
}
}
}
// if outside outline, get either first or last item in list based on mouse Y
if (!aboveNode && !belowNode) {
@ -111,19 +109,47 @@ const Dragger: React.FC<DraggerProps> = ({ 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<DraggerProps> = ({ 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<DraggerProps> = ({ draggingID, container, onDragEnd, isD
return (
<>
{pos && (
<div ref={$handle} style={styles}>
<DotCircle width={18} height={18} />
</div>
<Container ref={$handle} style={styles}>
<Dot width={18} height={18} />
</Container>
)}
</>
);

View File

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

View File

@ -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;
`;

View File

@ -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<ItemElement> = [
{ 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<null | { nodes: Array<{ id: string }>; first?: OutlineNode | null }>(null);
const [dragging, setDragging] = useState<{
show: boolean;
draggableID: null | string;
draggedNodes: null | Array<string>;
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 | {
listPosition: number;
zone: ImpactZone;
depthTarget: number;
}>(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);
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<HTMLDivElement>(null);
const outline = useRef<OutlineData>({
@ -67,20 +112,32 @@ const Outline: React.FC = () => {
if (tree.length === 1) {
root = tree[0];
}
const outlineHistory = useRef<{ commands: Array<OutlineCommand>; current: number }>({ current: -1, commands: [] });
useEffect(() => {
outline.current.relationships = new Map<string, NodeRelationships>();
outline.current.published = new Map<string, string>();
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'));
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<string, OutlineNode>());
@ -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 (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<DragContext.Provider
value={{
outline,
@ -132,8 +322,22 @@ const Outline: React.FC = () => {
if (data) {
const { zone, depth } = data;
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 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) {
@ -141,6 +345,7 @@ const Outline: React.FC = () => {
} else if (!listAbove && listBelow) {
listPosition = listBelow.position / 2.0;
}
}
if (!zone.above && zone.below) {
const newPosition = zone.below.node.position / 2.0;
@ -167,124 +372,122 @@ const Outline: React.FC = () => {
setNodeDimensions: (nodeID, ref) => {
outline.current.dimensions.set(nodeID, ref);
},
clearNodeDimensions: nodeID => {
outline.current.dimensions.delete(nodeID);
},
}}
>
<>
<PageContent ref={$content}>
<PageContainer>
<PageContent>
<RootWrapper ref={$content}>
<Entry
onStartSelect={({ id, depth }) => {
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
draggingID={dragging.draggableID}
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, draggableID: e.id, initialPos: { x: e.clientX, y: e.clientY } });
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>
{dragging.show && dragging.draggableID && (
</PageContainer>
{dragging.show && dragging.draggedNodes && (
<Dragger
container={$content}
draggingID={dragging.draggableID}
initialPos={dragging.initialPos}
draggedNodes={{ nodes: dragging.draggedNodes, first: selection ? selection.first : null }}
isDragging={dragging.show}
onDragEnd={() => {
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}`);
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;
}
}
// TODO: set reparent if list position changed but parent did not
//
if (reparent) {
// 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<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);
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}`);
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 = () => {
</DragContext.Provider>
{impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />}
{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,
ref: { entry: React.RefObject<HTMLElement>; children: React.RefObject<HTMLElement> | null },
) => void;
clearNodeDimensions: (nodeID: string) => void;
setImpact: (data: ImpactData | null) => void;
};

View File

@ -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) {
if (targetParent.children.length === 0) {
const parentNodes = outline.nodes.get(targetParent.self.depth);
const parentNode = parentNodes ? parentNodes.get(targetParent.self.id) : null;
if (targetParent.children.length === 0) {
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;
}

View File

@ -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<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) {
results = [
...res.data.searchMembers.map((m: any) => {
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<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? (
@ -223,11 +249,20 @@ const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerPro
size={32}
member={{
id: '',
fullName: 'Jordan Knott',
fullName: data.value.label,
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>
) : null;
};

View File

@ -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;

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 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,
};

View File

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

View File

@ -229,13 +229,14 @@ 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 {
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
}
@ -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)

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;
-- 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;

View File

@ -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

View File

@ -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()))
}

View File

@ -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 {

View File

@ -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 {
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")
userID := masterList[rank.Target]
user, err := r.Repository.GetUserAccountByID(ctx, userID)
entry := masterList[rank.Target]
user, err := r.Repository.GetUserAccountByID(ctx, entry.ID)
if err != nil {
if err == sql.ErrNoRows {
continue
}
return []MemberSearchResult{}, err
}
results = append(results, MemberSearchResult{User: &user, Joined: false, Confirmed: false, Similarity: rank.Distance})
memberList[masterList[rank.Target]] = true
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})
}
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
}

View File

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

View File

@ -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 {