feat: so many stuffs

This commit is contained in:
Jordan Knott 2020-11-22 14:11:13 -06:00
parent 451581e934
commit eff2044a6b
50 changed files with 3144 additions and 28 deletions

View File

@ -12,7 +12,7 @@ services:
volumes:
- taskcafe-postgres:/var/lib/postgresql/data
ports:
- 5432:5432
- 8855:5432
mailhog:
image: mailhog/mailhog:latest
restart: always

View File

@ -31,7 +31,9 @@
"@typescript-eslint/no-unused-vars": "off",
"react/jsx-filename-extension": [2, { "extensions": [".js", ".jsx", ".ts", ".tsx"] }],
"no-case-declarations": "off",
"no-plusplus": "off",
"react/prop-types": 0,
"no-continue": "off",
"react/jsx-props-no-spreading": "off",
"no-param-reassign": "off",
"import/extensions": [

View File

@ -39,7 +39,7 @@
"history": "^4.10.1",
"immer": "^6.0.3",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.15",
"lodash": "^4.17.20",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-autosize-textarea": "^7.0.0",

View File

@ -5,6 +5,7 @@ import GlobalTopNavbar from 'App/TopNavbar';
import {
useUsersQuery,
useDeleteUserAccountMutation,
useDeleteInvitedUserAccountMutation,
useCreateUserAccountMutation,
UsersDocument,
UsersQuery,
@ -176,6 +177,17 @@ const AdminRoute = () => {
const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
produce(cache, draftCache => {
draftCache.invitedUsers = cache.invitedUsers.filter(
u => u.id !== response.data.deleteInvitedUserAccount.invitedUser.id,
);
}),
);
},
});
const [deleteUser] = useDeleteUserAccountMutation({
update: (client, response) => {
updateApolloCache<UsersQuery>(client, UsersDocument, cache =>
@ -215,11 +227,16 @@ const AdminRoute = () => {
<Admin
initialTab={0}
users={data.users}
invitedUsers={data.invitedUsers}
canInviteUser={user.roles.org === 'admin'}
onInviteUser={NOOP}
onUpdateUserPassword={() => {
hidePopup();
}}
onDeleteInvitedUser={invitedUserID => {
deleteInvitedUser({ variables: { invitedUserID } });
hidePopup();
}}
onDeleteUser={(userID, newOwnerID) => {
deleteUser({ variables: { userID, newOwnerID } });
hidePopup();

View File

@ -5,6 +5,7 @@ import * as H from 'history';
import Dashboard from 'Dashboard';
import Admin from 'Admin';
import Projects from 'Projects';
import Outline from 'Outline';
import Project from 'Projects/Project';
import Teams from 'Teams';
import Login from 'Auth';
@ -36,6 +37,7 @@ const Routes: React.FC<RoutesProps> = () => (
<Route path="/teams/:teamID" component={Teams} />
<Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} />
<Route path="/outline" component={Outline} />
</MainContent>
</Switch>
);

View File

@ -230,10 +230,12 @@ type GlobalTopNavbarProps = {
menuType?: Array<MenuItem>;
onChangeRole?: (userID: string, roleCode: RoleCode) => void;
projectMembers?: null | Array<TaskUser>;
projectInvitedMembers?: null | Array<InvitedUser>;
onSaveProjectName?: (projectName: string) => void;
onInviteUser?: ($target: React.RefObject<HTMLElement>) => void;
onSetTab?: (tab: number) => void;
onRemoveFromBoard?: (userID: string) => void;
onRemoveInvitedFromBoard?: (email: string) => void;
};
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
@ -246,8 +248,10 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
name,
popupContent,
projectMembers,
projectInvitedMembers,
onInviteUser,
onSaveProjectName,
onRemoveInvitedFromBoard,
onRemoveFromBoard,
}) => {
const { user, setUserRoles, setUser } = useCurrentUser();
@ -333,6 +337,34 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
return null;
}
const userIsTeamOrProjectAdmin = user.isAdmin(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID);
const onInvitedMemberProfile = ($targetRef: React.RefObject<HTMLElement>, email: string) => {
const member = projectInvitedMembers ? projectInvitedMembers.find(u => u.email === email) : null;
if (member) {
showPopup(
$targetRef,
<MiniProfile
onRemoveFromBoard={() => {
if (onRemoveInvitedFromBoard) {
onRemoveInvitedFromBoard(member.email);
}
}}
invited
user={{
id: member.email,
fullName: member.email,
bio: 'Invited',
profileIcon: {
bgColor: '#000',
url: null,
initials: member.email.charAt(0),
},
}}
bio=""
/>,
);
}
};
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
const member = projectMembers ? projectMembers.find(u => u.id === memberID) : null;
const warning =
@ -382,6 +414,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
canEditProjectName={userIsTeamOrProjectAdmin}
canInviteUser={userIsTeamOrProjectAdmin}
onMemberProfile={onMemberProfile}
onInvitedMemberProfile={onInvitedMemberProfile}
onInviteUser={onInviteUser}
onChangeRole={onChangeRole}
onChangeProjectOwner={onChangeProjectOwner}
@ -392,6 +425,7 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({
history.push('/');
}}
projectMembers={projectMembers}
projectInvitedMembers={projectInvitedMembers}
onProfileClick={onProfileClick}
onSaveName={onSaveProjectName}
onOpenSettings={onOpenSettings}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { DragDebugWrapper } from './Styles';
type DragDebugProps = {
zone: ImpactZone | null;
depthTarget: number;
draggingID: string | null;
};
const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggingID }) => {
let aboveID = null;
let belowID = null;
if (zone) {
aboveID = zone.above ? zone.above.node.id : null;
belowID = zone.below ? zone.below.node.id : null;
}
return (
<DragDebugWrapper>{`aboveID=${aboveID} / belowID=${belowID} / depthTarget=${depthTarget} draggingID=${draggingID}`}</DragDebugWrapper>
);
};
export default DragDebug;

View File

@ -0,0 +1,40 @@
import React from 'react';
import { getDimensions } from './utils';
import { DragIndicatorBar } from './Styles';
type DragIndicatorProps = {
container: React.RefObject<HTMLDivElement>;
zone: ImpactZone;
depthTarget: number;
};
const DragIndicator: React.FC<DragIndicatorProps> = ({ container, zone, depthTarget }) => {
let top = 0;
let width = 0;
if (zone.below === null) {
if (zone.above) {
const entry = getDimensions(zone.above.dimensions.entry);
const children = getDimensions(zone.above.dimensions.children);
if (children) {
top = children.top;
width = children.width - depthTarget * 35;
} else if (entry) {
top = entry.bottom;
width = entry.width - depthTarget * 35;
}
}
} else if (zone.below) {
const entry = getDimensions(zone.below.dimensions.entry);
if (entry) {
top = entry.top;
width = entry.width - depthTarget * 35;
}
}
let left = 0;
if (container && container.current) {
left = container.current.getBoundingClientRect().left + 25 + depthTarget * 35;
}
return <DragIndicatorBar top={top} left={left} width={width} />;
};
export default DragIndicator;

View File

@ -0,0 +1,216 @@
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
import { DotCircle } from 'shared/icons';
import { findNextDraggable, getDimensions, getTargetDepth, getNodeAbove, getBelowParent, findNodeAbove } from './utils';
import { useDrag } from './useDrag';
type DraggerProps = {
container: React.RefObject<HTMLDivElement>;
draggingID: string;
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number };
};
const Dragger: React.FC<DraggerProps> = ({ draggingID, container, onDragEnd, isDragging, initialPos }) => {
const [pos, setPos] = useState<{ x: number; y: number }>(initialPos);
const { outline, impact, setImpact } = useDrag();
const $handle = useRef<HTMLDivElement>(null);
const handleMouseUp = useCallback(() => {
onDragEnd(impact ? impact.zone : { below: null, above: null });
}, [impact]);
const handleMouseMove = useCallback(
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;
}
}
}
let aboveNode: null | OutlineNode = null;
let belowNode: null | OutlineNode = null;
if (curPosition === 'before') {
belowNode = curDraggable;
} else {
aboveNode = curDraggable;
}
// if belowNode has the depth of 1, then the above element will be a part of a different branch
const { relationships, nodes } = outline.current;
if (belowNode) {
aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
} else if (aboveNode) {
let targetBelowNode: RelationshipChild | null = null;
const parent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0) {
const abr = relationships.get(aboveNode.id);
if (abr) {
const newTarget = abr.children[0];
if (newTarget) {
targetBelowNode = newTarget;
}
}
} else if (parent) {
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex !== -1) {
if (aboveNodeIndex === parent.children.length - 1) {
targetBelowNode = getBelowParent(aboveNode, outline.current);
} else {
const nextChild = parent.children[aboveNodeIndex + 1];
targetBelowNode = nextChild ?? null;
}
}
}
if (targetBelowNode) {
const depthNodes = nodes.get(targetBelowNode.depth);
if (depthNodes) {
belowNode = depthNodes.get(targetBelowNode.id) ?? null;
}
}
}
// if outside outline, get either first or last item in list based on mouse Y
if (!aboveNode && !belowNode) {
if (container && container.current) {
const bounds = container.current.getBoundingClientRect();
if (clientY < bounds.top + bounds.height / 2) {
const rootChildren = outline.current.relationships.get('root');
const rootDepth = outline.current.nodes.get(1);
if (rootChildren && rootDepth) {
const firstChild = rootChildren.children[0];
belowNode = rootDepth.get(firstChild.id) ?? null;
aboveNode = null;
}
} else {
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;
}
}
}
}
if (aboveNode && aboveNode.id === draggingID) {
belowNode = aboveNode;
aboveNode = findNodeAbove(outline.current, aboveNode.depth, aboveNode);
}
// calculate available depths
let minDepth = 1;
let maxDepth = 2;
if (aboveNode) {
const aboveParent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0) {
minDepth = aboveNode.depth + 1;
maxDepth = aboveNode.depth + 1;
} else if (aboveParent) {
minDepth = aboveNode.depth;
maxDepth = aboveNode.depth + 1;
const aboveNodeIndex = aboveParent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex === aboveParent.children.length - 1) {
minDepth = belowNode ? belowNode.depth : minDepth;
}
}
}
if (aboveNode) {
const dimensions = outline.current.dimensions.get(aboveNode.id);
const entry = getDimensions(dimensions?.entry);
if (entry) {
depthTarget = getTargetDepth(clientX, entry.left, { min: minDepth, max: maxDepth });
}
}
let aboveImpact: null | ImpactZoneData = null;
let belowImpact: null | ImpactZoneData = null;
if (aboveNode) {
const aboveDim = outline.current.dimensions.get(aboveNode.id);
if (aboveDim) {
aboveImpact = {
node: aboveNode,
dimensions: aboveDim,
};
}
}
if (belowNode) {
const belowDim = outline.current.dimensions.get(belowNode.id);
if (belowDim) {
belowImpact = {
node: belowNode,
dimensions: belowDim,
};
}
}
setImpact({
zone: {
above: aboveImpact,
below: belowImpact,
},
depth: depthTarget,
});
},
[outline.current.nodes],
);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, []);
const styles = useMemo(() => {
const position: 'absolute' | 'relative' = isDragging ? 'absolute' : 'relative';
return {
cursor: isDragging ? '-webkit-grabbing' : '-webkit-grab',
transform: `translate(${pos.x - 10}px, ${pos.y - 4}px)`,
transition: isDragging ? 'none' : 'transform 500ms',
zIndex: isDragging ? 2 : 1,
position,
};
}, [isDragging, pos]);
return (
<>
{pos && (
<div ref={$handle} style={styles}>
<DotCircle width={18} height={18} />
</div>
)}
</>
);
};
export default Dragger;

View File

@ -0,0 +1,81 @@
import React, { useRef, useEffect } from 'react';
import { DotCircle } from 'shared/icons';
import { EntryChildren, EntryWrapper, EntryContent, EntryInnerContent, EntryHandle } from './Styles';
import { useDrag } from './useDrag';
type EntryProps = {
id: string;
parentID: string;
onStartDrag: (e: { id: string; clientX: number; clientY: number }) => void;
isRoot?: boolean;
draggingID: null | string;
entries: Array<ItemElement>;
position: number;
chain?: Array<string>;
depth?: number;
};
const Entry: React.FC<EntryProps> = ({
id,
parentID,
isRoot = false,
position,
onStartDrag,
draggingID,
entries,
chain = [],
depth = 0,
}) => {
const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(null);
const { setNodeDimensions } = useDrag();
useEffect(() => {
if (isRoot) return;
if ($entry && $entry.current) {
setNodeDimensions(id, {
entry: $entry,
children: entries.length !== 0 ? $children : null,
});
}
}, [position, depth, entries]);
let showHandle = true;
if (draggingID && draggingID === id) {
showHandle = false;
}
return (
<EntryWrapper isDragging={!showHandle}>
{!isRoot && (
<EntryContent>
{showHandle && (
<EntryHandle onMouseDown={e => onStartDrag({ id, clientX: e.clientX, clientY: e.clientY })}>
<DotCircle width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent ref={$entry}>{id.toString()}</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
parentID={id}
key={entry.id}
position={entry.position}
depth={depth + 1}
draggingID={draggingID}
id={entry.id}
onStartDrag={onStartDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
);
};
export default Entry;

View File

@ -0,0 +1,116 @@
import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const EntryWrapper = styled.div<{ isDragging: boolean }>`
position: relative;
${props =>
props.isDragging &&
css`
&:before {
border-radius: 3px;
content: '';
position: absolute;
top: 2px;
right: -2px;
left: -2px;
bottom: -2px;
background-color: #eceef0;
}
`}
`;
export const EntryChildren = styled.div<{ isRoot: boolean }>`
position: relative;
${props =>
!props.isRoot &&
css`
margin-left: 10px;
padding-left: 25px;
border-left: 1px solid rgb(236, 238, 240);
`}
`;
export const PageContent = styled.div`
min-height: calc(100vh - 146px);
width: 100%;
position: relative;
display: flex;
flex-direction: column;
box-shadow: none;
user-select: none;
margin-left: auto;
margin-right: auto;
max-width: 700px;
padding-left: 56px;
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 }>`
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
transform: translate3d(${props => props.left}px, ${props => props.top}px, 0);
transition: transform 0.2s cubic-bezier(0.2, 0, 0, 1);
width: 18px;
height: 18px;
color: rgb(75, 81, 85);
border-radius: 9px;
`;
export const EntryHandle = styled.div`
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 501px;
top: 7px;
width: 18px;
height: 18px;
color: rgb(75, 81, 85);
border-radius: 9px;
`;
export const EntryInnerContent = styled.div`
padding-top: 4px;
font-size: 15px;
white-space: pre-wrap;
line-height: 24px;
min-height: 24px;
overflow-wrap: break-word;
position: relative;
user-select: text;
&:focus {
outline: 0;
}
`;
export const DragDebugWrapper = styled.div`
position: absolute;
left: 42px;
bottom: 24px;
color: #fff;
`;
export const DragIndicatorBar = styled.div<{ left: number; top: number; width: number }>`
position: absolute;
width: ${props => props.width}px;
top: ${props => props.top}px;
left: ${props => props.left}px;
height: 4px;
border-radius: 3px;
background: rgb(204, 204, 204);
`;

View File

@ -0,0 +1,301 @@
import React, { useState, useRef, useEffect, useMemo, useCallback, useContext, memo, createRef } from 'react';
import { DotCircle } from 'shared/icons';
import _ from 'lodash';
import produce from 'immer';
import Entry from './Entry';
import DragIndicator from './DragIndicator';
import Dragger from './Dragger';
import DragDebug from './DragDebug';
import { DragContext } from './useDrag';
import {
DragDebugWrapper,
DragIndicatorBar,
PageContent,
EntryChildren,
EntryInnerContent,
EntryWrapper,
EntryContent,
EntryHandle,
} from './Styles';
import { transformToTree, findNode, findNodeDepth, getNumberOfChildren, validateDepth } from './utils';
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' },
];
const Outline: React.FC = () => {
const [items, setItems] = useState(listItems);
const [dragging, setDragging] = useState<{
show: boolean;
draggableID: null | string;
initialPos: { x: number; y: number };
}>({ show: false, draggableID: null, initialPos: { x: 0, y: 0 } });
const [impact, setImpact] = useState<null | {
listPosition: number;
zone: ImpactZone;
depthTarget: number;
}>(null);
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]);
const $content = useRef<HTMLDivElement>(null);
const outline = useRef<OutlineData>({
published: new Map<string, string>(),
dimensions: new Map<string, NodeDimensions>(),
nodes: new Map<number, Map<string, OutlineNode>>(),
relationships: new Map<string, NodeRelationships>(),
});
const tree = transformToTree(_.cloneDeep(items));
let root: any = null;
if (tree.length === 1) {
root = tree[0];
}
useEffect(() => {
outline.current.relationships = new Map<string, NodeRelationships>();
outline.current.published = new Map<string, string>();
outline.current.nodes = new Map<number, Map<string, OutlineNode>>();
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];
if (id === 'root') {
continue;
}
const parent = curParent ?? 'root';
outline.current.published.set(id, parent ?? 'root');
const { depth, ancestors } = findNodeDepth(outline.current.published, id);
const children = getNumberOfChildren(root, ancestors);
if (!outline.current.nodes.has(depth)) {
outline.current.nodes.set(depth, new Map<string, OutlineNode>());
}
const targetDepthNodes = outline.current.nodes.get(depth);
if (targetDepthNodes) {
targetDepthNodes.set(id, {
id,
children,
position,
depth,
ancestors,
parent,
});
}
if (!outline.current.relationships.has(parent)) {
outline.current.relationships.set(parent, {
self: {
depth: depth - 1,
id: parent,
},
children: [],
numberOfSubChildren: 0,
});
}
const nodeRelations = outline.current.relationships.get(parent);
if (nodeRelations) {
outline.current.relationships.set(parent, {
self: nodeRelations.self,
numberOfSubChildren: nodeRelations.numberOfSubChildren + children,
children: [...nodeRelations.children, { id, position, depth, children }].sort(
(a, b) => a.position - b.position,
),
});
}
}
}, [items]);
if (!root) {
return null;
}
return (
<>
<DragContext.Provider
value={{
outline,
impact,
setImpact: data => {
if (data) {
const { zone, depth } = data;
let listPosition = 65535;
const listAbove = validateDepth(zone.above ? zone.above.node : null, depth);
const listBelow = validateDepth(zone.below ? zone.below.node : null, depth);
if (listAbove && listBelow) {
listPosition = (listAbove.position + listBelow.position) / 2.0;
} else if (listAbove && !listBelow) {
listPosition = listAbove.position * 2.0;
} else if (!listAbove && listBelow) {
listPosition = listBelow.position / 2.0;
}
if (!zone.above && zone.below) {
const newPosition = zone.below.node.position / 2.0;
setImpact(() => ({
zone,
listPosition: newPosition,
depthTarget: depth,
}));
}
if (zone.above) {
// console.log(`prev=${prev} next=${next} targetPosition=${targetPosition}`);
// let targetID = depthTarget === 1 ? 'root' : node.ancestors[depthTarget - 1];
// targetID = targetID ?? node.id;
setImpact(() => ({
zone,
listPosition,
depthTarget: depth,
}));
}
} else {
setImpact(null);
}
},
setNodeDimensions: (nodeID, ref) => {
outline.current.dimensions.set(nodeID, ref);
},
}}
>
<>
<PageContent ref={$content}>
<Entry
id="root"
parentID="root"
isRoot
draggingID={dragging.draggableID}
position={root.position}
entries={root.children}
onStartDrag={e => {
if (e.id !== 'root') {
setImpact(null);
setDragging({ show: true, draggableID: e.id, initialPos: { x: e.clientX, y: e.clientY } });
}
}}
/>
</PageContent>
{dragging.show && dragging.draggableID && (
<Dragger
container={$content}
draggingID={dragging.draggableID}
initialPos={dragging.initialPos}
isDragging={dragging.show}
onDragEnd={() => {
const draggingID = dragging.draggableID;
if (draggingID && impactRef.current) {
const { zone, depth, listPosition } = impactRef.current;
const noZone = !zone.above && !zone.below;
const curParentID = outline.current.published.get(draggingID);
if (!noZone && curParentID) {
let parentID = 'root';
if (zone.above) {
parentID = zone.above.node.ancestors[depth - 1];
}
const node = findNode(curParentID, draggingID, outline.current);
console.log(`${node ? node.parent : null} => ${parentID}`);
// UPDATE OUTLINE DATA AFTER NODE MOVE
if (node) {
if (node.depth !== depth) {
const oldParentDepth = outline.current.nodes.get(node.depth - 1);
if (oldParentDepth) {
const oldParentNode = oldParentDepth.get(node.parent);
if (oldParentNode) {
oldParentNode.children -= 1;
}
}
const oldDepth = outline.current.nodes.get(node.depth);
if (oldDepth) {
oldDepth.delete(node.id);
}
if (!outline.current.nodes.has(depth)) {
outline.current.nodes.set(depth, new Map<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}`);
draftItems[curDragging].parent = parentID;
draftItems[curDragging].position = listPosition;
}
}),
);
}
}
setImpact(null);
setDragging({ show: false, draggableID: null, initialPos: { x: 0, y: 0 } });
}}
/>
)}
</>
</DragContext.Provider>
{impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />}
{impact && (
<DragDebug zone={impact.zone ?? null} draggingID={dragging.draggableID} depthTarget={impact.depthTarget} />
)}
</>
);
};
export default Outline;

View File

@ -0,0 +1,21 @@
import React, { useContext } from 'react';
type DragContextData = {
impact: null | { zone: ImpactZone; depthTarget: number };
outline: React.MutableRefObject<OutlineData>;
setNodeDimensions: (
nodeID: string,
ref: { entry: React.RefObject<HTMLElement>; children: React.RefObject<HTMLElement> | null },
) => void;
setImpact: (data: ImpactData | null) => void;
};
export const DragContext = React.createContext<DragContextData | null>(null);
export const useDrag = () => {
const ctx = useContext(DragContext);
if (ctx) {
return ctx;
}
throw new Error('context is null');
};

View File

@ -0,0 +1,262 @@
import _ from 'lodash';
export function validateDepth(node: OutlineNode | null, depth: number) {
if (node) {
return node.depth === depth ? node : null;
}
return null;
}
export function getNodeAbove(node: OutlineNode, startingParent: RelationshipChild, outline: OutlineData) {
let hasChildren = true;
let nodeAbove: null | RelationshipChild = null;
let aboveTargetID = startingParent.id;
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 (parentNode) {
nodeAbove = {
id: parentNode.id,
depth: parentNode.depth,
position: parentNode.position,
children: parentNode.children,
};
}
continue;
}
nodeAbove = targetParent.children[targetParent.children.length - 1];
if (targetParent.numberOfSubChildren === 0) {
hasChildren = false;
} else {
aboveTargetID = nodeAbove.id;
}
} else {
const target = outline.relationships.get(node.ancestors[0]);
if (target) {
const targetChild = target.children.find(i => i.id === aboveTargetID);
if (targetChild) {
nodeAbove = targetChild;
}
hasChildren = false;
}
}
}
return nodeAbove;
}
export function getBelowParent(node: OutlineNode, outline: OutlineData) {
const { relationships, nodes } = outline;
const parentDepth = nodes.get(node.depth - 1);
const parent = parentDepth ? parentDepth.get(node.parent) : null;
if (parent) {
const grandfather = relationships.get(parent.parent);
if (grandfather) {
const parentIndex = grandfather.children.findIndex(c => c.id === parent.id);
if (parentIndex !== -1) {
if (parentIndex === grandfather.children.length - 1) {
const root = relationships.get(node.ancestors[0]);
if (root) {
const ancestorIndex = root.children.findIndex(c => c.id === node.ancestors[1]);
if (ancestorIndex !== -1) {
const nextAncestor = root.children[ancestorIndex + 1];
if (nextAncestor) {
return nextAncestor;
}
}
}
} else {
const nextChild = grandfather.children[parentIndex + 1];
if (nextChild) {
return nextChild;
}
}
}
}
}
return null;
}
export function getDimensions(ref: React.RefObject<HTMLElement> | null | undefined) {
if (ref && ref.current) {
return ref.current.getBoundingClientRect();
}
return null;
}
export function getTargetDepth(mouseX: number, handleLeft: number, availableDepths: { min: number; max: number }) {
if (mouseX > handleLeft) {
return availableDepths.max;
}
let curDepth = availableDepths.max - 1;
for (let x = availableDepths.min; x < availableDepths.max; x++) {
const breakpoint = handleLeft - x * 35;
// console.log(`mouseX=${mouseX} breakpoint=${breakpoint} x=${x} curDepth=${curDepth}`);
if (mouseX > breakpoint) {
return curDepth;
}
curDepth -= 1;
}
return availableDepths.min;
}
export function findNextDraggable(
pos: { x: number; y: number },
outline: OutlineData,
curDepth: number,
draggingID: string,
) {
let index = 0;
const currentDepthNodes = outline.nodes.get(curDepth);
let nodeAbove: null | RelationshipChild = null;
if (!currentDepthNodes) {
return null;
}
for (const [id, node] of currentDepthNodes) {
const dimensions = outline.dimensions.get(id);
const target = dimensions ? getDimensions(dimensions.entry) : null;
const children = dimensions ? getDimensions(dimensions.children) : null;
if (target) {
console.log(
`[${id}] ${pos.y} <= ${target.bottom} = ${pos.y <= target.bottom} / ${pos.y} >= ${target.top} = ${pos.y >=
target.top}`,
);
if (pos.y <= target.bottom && pos.y >= target.top) {
const middlePoint = target.top + target.height / 2;
const position: ImpactPosition = pos.y > middlePoint ? 'after' : 'before';
return {
found: true,
node,
position,
};
}
}
if (children) {
console.log(
`[${id}] ${pos.y} <= ${children.bottom} = ${pos.y <= children.bottom} / ${pos.y} >= ${children.top} = ${pos.y >=
children.top}`,
);
if (pos.y <= children.bottom && pos.y >= children.top) {
const position: ImpactPosition = 'after';
return { found: false, node, position };
}
}
index += 1;
}
return null;
}
export function transformToTree(arr: any) {
const nodes: any = {};
return arr.filter(function(obj: any) {
var id = obj['id'],
parentId = obj['parent'];
nodes[id] = _.defaults(obj, nodes[id], { children: [] });
parentId && (nodes[parentId] = nodes[parentId] || { children: [] })['children'].push(obj);
return !parentId;
});
}
export function findNode(parentID: string, nodeID: string, data: OutlineData) {
const nodeRelations = data.relationships.get(parentID);
if (nodeRelations) {
const nodeDepth = data.nodes.get(nodeRelations.self.depth + 1);
if (nodeDepth) {
const node = nodeDepth.get(nodeID);
return node ?? null;
}
}
return null;
}
export function findNodeDepth(published: Map<string, string>, id: string) {
let currentID = id;
let breaker = 0;
let depth = 0;
let ancestors = [id];
while (currentID !== 'root') {
const nextID = published.get(currentID);
if (nextID) {
ancestors = [nextID, ...ancestors];
currentID = nextID;
depth += 1;
breaker += 1;
if (breaker > 100) {
throw new Error('node depth breaker was thrown');
}
} else {
throw new Error('unable to find nextID');
}
}
return { depth, ancestors };
}
export function getNumberOfChildren(root: ItemElement, ancestors: Array<string>) {
let currentBranch = root;
for (let i = 1; i < ancestors.length; i++) {
const nextBranch = currentBranch.children ? currentBranch.children.find(c => c.id === ancestors[i]) : null;
if (nextBranch) {
currentBranch = nextBranch;
} else {
throw new Error('unable to find next branch');
}
}
return currentBranch.children ? currentBranch.children.length : 0;
}
export function findNodeAbove(outline: OutlineData, curDepth: number, belowNode: OutlineNode) {
let targetAboveNode: null | RelationshipChild = null;
if (curDepth === 1) {
const relations = outline.relationships.get(belowNode.ancestors[0]);
if (relations) {
const parentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.ancestors[1]);
if (parentIndex !== -1) {
const aboveParent = relations.children[parentIndex - 1];
if (parentIndex === 0) {
targetAboveNode = null;
} else {
targetAboveNode = getNodeAbove(belowNode, aboveParent, outline);
}
}
}
} else {
const relations = outline.relationships.get(belowNode.parent);
if (relations) {
const currentIndex = relations.children.findIndex(n => belowNode && n.id === belowNode.id);
// is first child, so use parent
if (currentIndex === 0) {
const parentNodes = outline.nodes.get(belowNode.depth - 1);
const parentNode = parentNodes ? parentNodes.get(belowNode.parent) : null;
if (parentNode) {
targetAboveNode = {
id: belowNode.parent,
depth: belowNode.depth - 1,
position: parentNode.position,
children: parentNode.children,
};
}
} else if (currentIndex !== -1) {
// is not first child, so first prev sibling
const aboveParentNode = relations.children[currentIndex - 1];
if (aboveParentNode) {
targetAboveNode = getNodeAbove(belowNode, aboveParentNode, outline);
if (targetAboveNode === null) {
targetAboveNode = aboveParentNode;
}
}
}
}
}
if (targetAboveNode) {
const depthNodes = outline.nodes.get(targetAboveNode.depth);
if (depthNodes) {
return depthNodes.get(targetAboveNode.id) ?? null;
}
}
return null;
}

View File

@ -21,6 +21,7 @@ import {
useToggleTaskLabelMutation,
useUpdateProjectNameMutation,
useFindProjectQuery,
useDeleteInvitedProjectMemberMutation,
useUpdateTaskNameMutation,
useCreateTaskMutation,
useDeleteTaskMutation,
@ -431,6 +432,25 @@ const Project = () => {
...cache.findProject.members,
...response.data.inviteProjectMembers.members,
];
draftCache.findProject.invitedMembers = [
...cache.findProject.invitedMembers,
...response.data.inviteProjectMembers.invitedMembers,
];
}),
{ projectID },
);
},
});
const [deleteInvitedProjectMember] = useDeleteInvitedProjectMemberMutation({
update: (client, response) => {
updateApolloCache<FindProjectQuery>(
client,
FindProjectDocument,
cache =>
produce(cache, draftCache => {
draftCache.findProject.invitedMembers = cache.findProject.invitedMembers.filter(
m => m.email !== response.data.deleteInvitedProjectMember.invitedMember.email,
);
}),
{ projectID },
);
@ -490,6 +510,10 @@ const Project = () => {
deleteProjectMember({ variables: { userID, projectID } });
hidePopup();
}}
onRemoveInvitedFromBoard={email => {
deleteInvitedProjectMember({ variables: { projectID, email } });
hidePopup();
}}
onSaveProjectName={projectName => {
updateProjectName({ variables: { projectID, name: projectName } });
}}
@ -511,6 +535,7 @@ const Project = () => {
menuType={[{ name: 'Board', link: location.pathname }]}
currentTab={0}
projectMembers={data.findProject.members}
projectInvitedMembers={data.findProject.invitedMembers}
projectID={projectID}
teamID={data.findProject.team ? data.findProject.team.id : null}
name={data.findProject.name}

0
frontend/src/outline.d.ts vendored Normal file
View File

View File

@ -51,7 +51,9 @@ export const Default = () => {
},
},
]}
invitedUsers={[]}
onAddUser={action('add user')}
onDeleteInvitedUser={action('delete invited user')}
/>
</ThemeProvider>
</>

View File

@ -104,8 +104,8 @@ type TeamRoleManagerPopupProps = {
user: User;
users: Array<User>;
warning?: string | null;
canChangeRole: boolean;
onChangeRole: (roleCode: RoleCode) => void;
canChangeRole?: boolean;
onChangeRole?: (roleCode: RoleCode) => void;
updateUserPassword?: (user: TaskUser, password: string) => void;
onDeleteUser?: (userID: string, newOwnerID: string | null) => void;
};
@ -530,8 +530,10 @@ type AdminProps = {
onDeleteUser: (userID: string, newOwnerID: string | null) => void;
onInviteUser: ($target: React.RefObject<HTMLElement>) => void;
users: Array<User>;
invitedUsers: Array<InvitedUserAccount>;
canInviteUser: boolean;
onUpdateUserPassword: (user: TaskUser, password: string) => void;
onDeleteInvitedUser: (invitedUserID: string) => void;
};
const Admin: React.FC<AdminProps> = ({
@ -540,7 +542,9 @@ const Admin: React.FC<AdminProps> = ({
onUpdateUserPassword,
canInviteUser,
onDeleteUser,
onDeleteInvitedUser,
onInviteUser,
invitedUsers,
users,
}) => {
const warning =
@ -577,7 +581,7 @@ const Admin: React.FC<AdminProps> = ({
<TabContent>
<MemberListWrapper>
<MemberListHeader>
<ListTitle>{`Members (${users.length})`}</ListTitle>
<ListTitle>{`Members (${users.length + invitedUsers.length})`}</ListTitle>
<ListDesc>
Organization admins can create / manage / delete all projects & teams. Members only have access to teams
or projects they have been added to.
@ -635,6 +639,65 @@ const Admin: React.FC<AdminProps> = ({
</MemberListItem>
);
})}
{invitedUsers.map(member => {
return (
<MemberListItem>
<MemberProfile
showRoleIcons
size={32}
onMemberProfile={NOOP}
member={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
}}
/>
<MemberListItemDetails>
<MemberItemName>{member.email}</MemberItemName>
<MemberItemUsername>Invited</MemberItemUsername>
</MemberListItemDetails>
<MemberItemOptions>
<MemberItemOption
variant="outline"
onClick={$target => {
showPopup(
$target,
<TeamRoleManagerPopup
user={{
id: member.id,
fullName: member.email,
profileIcon: {
bgColor: '#fff',
url: null,
initials: member.email.charAt(0),
},
member: {
teams: [],
projects: [],
},
owned: {
teams: [],
projects: [],
},
}}
users={users}
onDeleteUser={() => {
onDeleteInvitedUser(member.id);
}}
/>,
);
}}
>
Manage
</MemberItemOption>
</MemberItemOptions>
</MemberListItem>
);
})}
</MemberList>
</MemberListWrapper>
</TabContent>

View File

@ -47,6 +47,7 @@ const permissions = [
type MiniProfileProps = {
bio: string;
user: TaskUser;
invited?: boolean;
onRemoveFromTask?: () => void;
onChangeRole?: (roleCode: RoleCode) => void;
onRemoveFromBoard?: () => void;
@ -56,6 +57,7 @@ type MiniProfileProps = {
const MiniProfile: React.FC<MiniProfileProps> = ({
user,
bio,
invited,
canChangeRole,
onRemoveFromTask,
onChangeRole,
@ -74,7 +76,7 @@ const MiniProfile: React.FC<MiniProfileProps> = ({
)}
<ProfileInfo>
<InfoTitle>{user.fullName}</InfoTitle>
<InfoUsername>{`@${user.username}`}</InfoUsername>
{invited ? <InfoUsername>Invited</InfoUsername> : <InfoUsername>{`@${user.username}`}</InfoUsername>}
<InfoBio>{bio}</InfoBio>
</ProfileInfo>
</Profile>

View File

@ -55,11 +55,19 @@ type TaskAssigneeProps = {
size: number | string;
showRoleIcons?: boolean;
member: TaskUser;
invited?: boolean;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
className?: string;
};
const TaskAssignee: React.FC<TaskAssigneeProps> = ({ showRoleIcons, member, onMemberProfile, size, className }) => {
const TaskAssignee: React.FC<TaskAssigneeProps> = ({
showRoleIcons,
member,
invited,
onMemberProfile,
size,
className,
}) => {
const $memberRef = useRef<HTMLDivElement>(null);
let profileIcon: ProfileIcon = {
url: null,

View File

@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect } from 'react';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle } from 'shared/icons';
import { Home, Star, Bell, AngleDown, BarChart, CheckCircle, ListUnordered } from 'shared/icons';
import styled from 'styled-components';
import ProfileIcon from 'shared/components/ProfileIcon';
import { usePopup } from 'shared/components/PopupMenu';
@ -30,6 +30,7 @@ import {
ProjectMember,
ProjectMembers,
} from './Styles';
import { useHistory } from 'react-router';
type IconContainerProps = {
disabled?: boolean;
@ -173,8 +174,11 @@ type NavBarProps = {
user: TaskUser | null;
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
projectMembers?: Array<TaskUser> | null;
projectInvitedMembers?: Array<InvitedUser> | null;
onRemoveFromBoard?: (userID: string) => void;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onInvitedMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, email: string) => void;
};
const NavBar: React.FC<NavBarProps> = ({
@ -184,10 +188,12 @@ const NavBar: React.FC<NavBarProps> = ({
onChangeProjectOwner,
currentTab,
onMemberProfile,
onInvitedMemberProfile,
canEditProjectName = false,
onOpenProjectFinder,
onFavorite,
onSetTab,
projectInvitedMembers,
onChangeRole,
name,
onRemoveFromBoard,
@ -204,6 +210,7 @@ const NavBar: React.FC<NavBarProps> = ({
onProfileClick($target);
}
};
const history = useHistory();
const { showPopup } = usePopup();
return (
<NavbarWrapper>
@ -245,19 +252,38 @@ const NavBar: React.FC<NavBarProps> = ({
<TaskcafeTitle>Taskcafé</TaskcafeTitle>
</LogoContainer>
<GlobalActions>
{projectMembers && onMemberProfile && (
{projectMembers && projectInvitedMembers && onMemberProfile && onInvitedMemberProfile && (
<>
<ProjectMembers>
{projectMembers.map((member, idx) => (
<ProjectMember
showRoleIcons
zIndex={projectMembers.length - idx}
zIndex={projectMembers.length - idx + projectInvitedMembers.length}
key={member.id}
size={28}
member={member}
onMemberProfile={onMemberProfile}
/>
))}
{projectInvitedMembers.map((member, idx) => (
<ProjectMember
showRoleIcons
zIndex={projectInvitedMembers.length - idx}
key={member.email}
size={28}
invited
member={{
id: member.email,
fullName: member.email,
profileIcon: {
url: null,
initials: member.email.charAt(0),
bgColor: '#fff',
},
}}
onMemberProfile={onInvitedMemberProfile}
/>
))}
{canInviteUser && (
<InviteButton
onClick={$target => {
@ -283,6 +309,9 @@ const NavBar: React.FC<NavBarProps> = ({
<IconContainer disabled onClick={NOOP}>
<CheckCircle width={20} height={20} />
</IconContainer>
<IconContainer onClick={() => history.push('/outline')}>
<ListUnordered width={20} height={20} />
</IconContainer>
<IconContainer disabled onClick={onNotificationClick}>
<Bell color="#c2c6dc" size={20} />
</IconContainer>

View File

@ -113,6 +113,14 @@ export type UserAccount = {
member: MemberList;
};
export type InvitedUserAccount = {
__typename?: 'InvitedUserAccount';
id: Scalars['ID'];
email: Scalars['String'];
invitedOn: Scalars['Time'];
member: MemberList;
};
export type Team = {
__typename?: 'Team';
id: Scalars['ID'];
@ -121,6 +129,12 @@ export type Team = {
members: Array<Member>;
};
export type InvitedMember = {
__typename?: 'InvitedMember';
email: Scalars['String'];
invitedOn: Scalars['Time'];
};
export type Project = {
__typename?: 'Project';
id: Scalars['ID'];
@ -129,6 +143,7 @@ export type Project = {
team?: Maybe<Team>;
taskGroups: Array<TaskGroup>;
members: Array<Member>;
invitedMembers: Array<InvitedMember>;
labels: Array<ProjectLabel>;
};
@ -221,6 +236,7 @@ export type Query = {
findTask: Task;
findTeam: Team;
findUser: UserAccount;
invitedUsers: Array<InvitedUserAccount>;
labelColors: Array<LabelColor>;
me: MePayload;
notifications: Array<Notification>;
@ -277,6 +293,8 @@ export type Mutation = {
createTeam: Team;
createTeamMember: CreateTeamMemberPayload;
createUserAccount: UserAccount;
deleteInvitedProjectMember: DeleteInvitedProjectMemberPayload;
deleteInvitedUserAccount: DeleteInvitedUserAccountPayload;
deleteProject: DeleteProjectPayload;
deleteProjectLabel: ProjectLabel;
deleteProjectMember: DeleteProjectMemberPayload;
@ -379,6 +397,16 @@ export type MutationCreateUserAccountArgs = {
};
export type MutationDeleteInvitedProjectMemberArgs = {
input: DeleteInvitedProjectMember;
};
export type MutationDeleteInvitedUserAccountArgs = {
input: DeleteInvitedUserAccount;
};
export type MutationDeleteProjectArgs = {
input: DeleteProject;
};
@ -694,6 +722,16 @@ export type UpdateProjectLabelColor = {
labelColorID: Scalars['UUID'];
};
export type DeleteInvitedProjectMember = {
projectID: Scalars['UUID'];
email: Scalars['String'];
};
export type DeleteInvitedProjectMemberPayload = {
__typename?: 'DeleteInvitedProjectMemberPayload';
invitedMember: InvitedMember;
};
export type MemberInvite = {
userID?: Maybe<Scalars['UUID']>;
email?: Maybe<Scalars['String']>;
@ -709,6 +747,7 @@ export type InviteProjectMembersPayload = {
ok: Scalars['Boolean'];
projectID: Scalars['UUID'];
members: Array<Member>;
invitedMembers: Array<InvitedMember>;
};
export type DeleteProjectMember = {
@ -1001,6 +1040,15 @@ export type UpdateTeamMemberRolePayload = {
member: Member;
};
export type DeleteInvitedUserAccount = {
invitedUserID: Scalars['UUID'];
};
export type DeleteInvitedUserAccountPayload = {
__typename?: 'DeleteInvitedUserAccountPayload';
invitedUser: InvitedUserAccount;
};
export type MemberSearchFilter = {
SearchFilter: Scalars['String'];
projectID?: Maybe<Scalars['UUID']>;
@ -1231,6 +1279,9 @@ export type FindProjectQuery = (
{ __typename?: 'ProfileIcon' }
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
) }
)>, invitedMembers: Array<(
{ __typename?: 'InvitedMember' }
& Pick<InvitedMember, 'email' | 'invitedOn'>
)>, labels: Array<(
{ __typename?: 'ProjectLabel' }
& Pick<ProjectLabel, 'id' | 'createdDate' | 'name'>
@ -1433,6 +1484,23 @@ export type DeleteProjectMutation = (
) }
);
export type DeleteInvitedProjectMemberMutationVariables = {
projectID: Scalars['UUID'];
email: Scalars['String'];
};
export type DeleteInvitedProjectMemberMutation = (
{ __typename?: 'Mutation' }
& { deleteInvitedProjectMember: (
{ __typename?: 'DeleteInvitedProjectMemberPayload' }
& { invitedMember: (
{ __typename?: 'InvitedMember' }
& Pick<InvitedMember, 'email'>
) }
) }
);
export type DeleteProjectMemberMutationVariables = {
projectID: Scalars['UUID'];
userID: Scalars['UUID'];
@ -1462,7 +1530,10 @@ export type InviteProjectMembersMutation = (
& { inviteProjectMembers: (
{ __typename?: 'InviteProjectMembersPayload' }
& Pick<InviteProjectMembersPayload, 'ok'>
& { members: Array<(
& { invitedMembers: Array<(
{ __typename?: 'InvitedMember' }
& Pick<InvitedMember, 'email' | 'invitedOn'>
)>, members: Array<(
{ __typename?: 'Member' }
& Pick<Member, 'id' | 'fullName' | 'username'>
& { profileIcon: (
@ -2156,6 +2227,22 @@ export type CreateUserAccountMutation = (
) }
);
export type DeleteInvitedUserAccountMutationVariables = {
invitedUserID: Scalars['UUID'];
};
export type DeleteInvitedUserAccountMutation = (
{ __typename?: 'Mutation' }
& { deleteInvitedUserAccount: (
{ __typename?: 'DeleteInvitedUserAccountPayload' }
& { invitedUser: (
{ __typename?: 'InvitedUserAccount' }
& Pick<InvitedUserAccount, 'id'>
) }
) }
);
export type DeleteUserAccountMutationVariables = {
userID: Scalars['UUID'];
newOwnerID?: Maybe<Scalars['UUID']>;
@ -2237,7 +2324,10 @@ export type UsersQueryVariables = {};
export type UsersQuery = (
{ __typename?: 'Query' }
& { users: Array<(
& { invitedUsers: Array<(
{ __typename?: 'InvitedUserAccount' }
& Pick<InvitedUserAccount, 'id' | 'email' | 'invitedOn'>
)>, users: Array<(
{ __typename?: 'UserAccount' }
& Pick<UserAccount, 'id' | 'email' | 'fullName' | 'username'>
& { role: (
@ -2629,6 +2719,10 @@ export const FindProjectDocument = gql`
bgColor
}
}
invitedMembers {
email
invitedOn
}
labels {
id
createdDate
@ -2945,6 +3039,41 @@ export function useDeleteProjectMutation(baseOptions?: ApolloReactHooks.Mutation
export type DeleteProjectMutationHookResult = ReturnType<typeof useDeleteProjectMutation>;
export type DeleteProjectMutationResult = ApolloReactCommon.MutationResult<DeleteProjectMutation>;
export type DeleteProjectMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteProjectMutation, DeleteProjectMutationVariables>;
export const DeleteInvitedProjectMemberDocument = gql`
mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) {
deleteInvitedProjectMember(input: {projectID: $projectID, email: $email}) {
invitedMember {
email
}
}
}
`;
export type DeleteInvitedProjectMemberMutationFn = ApolloReactCommon.MutationFunction<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>;
/**
* __useDeleteInvitedProjectMemberMutation__
*
* To run a mutation, you first call `useDeleteInvitedProjectMemberMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteInvitedProjectMemberMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteInvitedProjectMemberMutation, { data, loading, error }] = useDeleteInvitedProjectMemberMutation({
* variables: {
* projectID: // value for 'projectID'
* email: // value for 'email'
* },
* });
*/
export function useDeleteInvitedProjectMemberMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>(DeleteInvitedProjectMemberDocument, baseOptions);
}
export type DeleteInvitedProjectMemberMutationHookResult = ReturnType<typeof useDeleteInvitedProjectMemberMutation>;
export type DeleteInvitedProjectMemberMutationResult = ApolloReactCommon.MutationResult<DeleteInvitedProjectMemberMutation>;
export type DeleteInvitedProjectMemberMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteInvitedProjectMemberMutation, DeleteInvitedProjectMemberMutationVariables>;
export const DeleteProjectMemberDocument = gql`
mutation deleteProjectMember($projectID: UUID!, $userID: UUID!) {
deleteProjectMember(input: {projectID: $projectID, userID: $userID}) {
@ -2986,6 +3115,10 @@ export const InviteProjectMembersDocument = gql`
mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) {
inviteProjectMembers(input: {projectID: $projectID, members: $members}) {
ok
invitedMembers {
email
invitedOn
}
members {
id
fullName
@ -4407,6 +4540,40 @@ export function useCreateUserAccountMutation(baseOptions?: ApolloReactHooks.Muta
export type CreateUserAccountMutationHookResult = ReturnType<typeof useCreateUserAccountMutation>;
export type CreateUserAccountMutationResult = ApolloReactCommon.MutationResult<CreateUserAccountMutation>;
export type CreateUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<CreateUserAccountMutation, CreateUserAccountMutationVariables>;
export const DeleteInvitedUserAccountDocument = gql`
mutation deleteInvitedUserAccount($invitedUserID: UUID!) {
deleteInvitedUserAccount(input: {invitedUserID: $invitedUserID}) {
invitedUser {
id
}
}
}
`;
export type DeleteInvitedUserAccountMutationFn = ApolloReactCommon.MutationFunction<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>;
/**
* __useDeleteInvitedUserAccountMutation__
*
* To run a mutation, you first call `useDeleteInvitedUserAccountMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteInvitedUserAccountMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteInvitedUserAccountMutation, { data, loading, error }] = useDeleteInvitedUserAccountMutation({
* variables: {
* invitedUserID: // value for 'invitedUserID'
* },
* });
*/
export function useDeleteInvitedUserAccountMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>) {
return ApolloReactHooks.useMutation<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>(DeleteInvitedUserAccountDocument, baseOptions);
}
export type DeleteInvitedUserAccountMutationHookResult = ReturnType<typeof useDeleteInvitedUserAccountMutation>;
export type DeleteInvitedUserAccountMutationResult = ApolloReactCommon.MutationResult<DeleteInvitedUserAccountMutation>;
export type DeleteInvitedUserAccountMutationOptions = ApolloReactCommon.BaseMutationOptions<DeleteInvitedUserAccountMutation, DeleteInvitedUserAccountMutationVariables>;
export const DeleteUserAccountDocument = gql`
mutation deleteUserAccount($userID: UUID!, $newOwnerID: UUID) {
deleteUserAccount(input: {userID: $userID, newOwnerID: $newOwnerID}) {
@ -4560,6 +4727,11 @@ export type UpdateUserRoleMutationResult = ApolloReactCommon.MutationResult<Upda
export type UpdateUserRoleMutationOptions = ApolloReactCommon.BaseMutationOptions<UpdateUserRoleMutation, UpdateUserRoleMutationVariables>;
export const UsersDocument = gql`
query users {
invitedUsers {
id
email
invitedOn
}
users {
id
email
@ -4621,4 +4793,4 @@ export function useUsersLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOp
}
export type UsersQueryHookResult = ReturnType<typeof useUsersQuery>;
export type UsersLazyQueryHookResult = ReturnType<typeof useUsersLazyQuery>;
export type UsersQueryResult = ApolloReactCommon.QueryResult<UsersQuery, UsersQueryVariables>;
export type UsersQueryResult = ApolloReactCommon.QueryResult<UsersQuery, UsersQueryVariables>;

View File

@ -22,6 +22,10 @@ query findProject($projectID: UUID!) {
bgColor
}
}
invitedMembers {
email
invitedOn
}
labels {
id
createdDate

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
export const DELETE_PROJECT_INVITED_MEMBER_MUTATION = gql`
mutation deleteInvitedProjectMember($projectID: UUID!, $email: String!) {
deleteInvitedProjectMember(input: { projectID: $projectID, email: $email }) {
invitedMember {
email
}
}
}
`;
export default DELETE_PROJECT_INVITED_MEMBER_MUTATION;

View File

@ -4,6 +4,10 @@ export const INVITE_PROJECT_MEMBERS_MUTATION = gql`
mutation inviteProjectMembers($projectID: UUID!, $members: [MemberInvite!]!) {
inviteProjectMembers(input: { projectID: $projectID, members: $members }) {
ok
invitedMembers {
email
invitedOn
}
members {
id
fullName

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag';
export const DELETE_INVITED_USER_MUTATION = gql`
mutation deleteInvitedUserAccount($invitedUserID: UUID!) {
deleteInvitedUserAccount(input: { invitedUserID: $invitedUserID }) {
invitedUser {
id
}
}
}
`;
export default DELETE_INVITED_USER_MUTATION;

View File

@ -1,4 +1,9 @@
query users {
invitedUsers {
id
email
invitedOn
}
users {
id
email

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const DotCircle: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm80 248c0 44.112-35.888 80-80 80s-80-35.888-80-80 35.888-80 80-80 80 35.888 80 80z" />
</Icon>
);
};
export default DotCircle;

View File

@ -0,0 +1,12 @@
import React from 'react';
import Icon, { IconProps } from './Icon';
const ListUnordered: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M48 48a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0 160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0 160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 16H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm0-320H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z" />
</Icon>
);
};
export default ListUnordered;

View File

@ -1,10 +1,12 @@
import Cross from './Cross';
import Cog from './Cog';
import ListUnordered from './ListUnordered';
import Eye from './Eye';
import EyeSlash from './EyeSlash';
import List from './List';
import At from './At';
import Task from './Task';
import DotCircle from './DotCircle';
import Smile from './Smile';
import Paperclip from './Paperclip';
import Calendar from './Calendar';
@ -89,6 +91,8 @@ export {
Paperclip,
Share,
Eye,
ListUnordered,
EyeSlash,
List,
DotCircle,
};

View File

@ -127,3 +127,139 @@ type ElementBounds = {
};
type CardLabelVariant = 'large' | 'small';
type InvitedUser = {
email: string;
invitedOn: string;
};
type InvitedUserAccount = {
id: string;
email: string;
invitedOn: string;
};
type NodeDimensions = {
entry: React.RefObject<HTMLElement>;
children: React.RefObject<HTMLElement> | null;
};
type OutlineNode = {
id: string;
parent: string;
depth: number;
position: number;
ancestors: Array<string>;
children: number;
};
type RelationshipChild = {
position: number;
id: string;
depth: number;
children: number;
};
type NodeRelationships = {
self: { id: string; depth: number };
children: Array<RelationshipChild>;
numberOfSubChildren: number;
};
type OutlineData = {
published: Map<string, string>;
nodes: Map<number, Map<string, OutlineNode>>;
relationships: Map<string, NodeRelationships>;
dimensions: Map<string, NodeDimensions>;
};
type ImpactZoneData = {
node: OutlineNode;
dimensions: NodeDimensions;
};
type ImpactZone = {
above: ImpactZoneData | null;
below: ImpactZoneData | null;
};
type ImpactData = {
zone: ImpactZone;
depth: number;
};
type ImpactPosition = 'before' | 'after' | 'beforeChildren' | 'afterChildren';
type ImpactAction = {
on: 'children' | 'entry';
position: ImpactPosition;
};
type ItemElement = {
id: string;
parent: null | string;
position: number;
children?: Array<ItemElement>;
};
type NodeDimensions = {
entry: React.RefObject<HTMLElement>;
children: React.RefObject<HTMLElement> | null;
};
type OutlineNode = {
id: string;
parent: string;
depth: number;
position: number;
ancestors: Array<string>;
children: number;
};
type RelationshipChild = {
position: number;
id: string;
depth: number;
children: number;
};
type NodeRelationships = {
self: { id: string; depth: number };
children: Array<RelationshipChild>;
numberOfSubChildren: number;
};
type OutlineData = {
published: Map<string, string>;
nodes: Map<number, Map<string, OutlineNode>>;
relationships: Map<string, NodeRelationships>;
dimensions: Map<string, NodeDimensions>;
};
type ImpactZoneData = {
node: OutlineNode;
dimensions: NodeDimensions;
};
type ImpactZone = {
above: ImpactZoneData | null;
below: ImpactZoneData | null;
};
type ImpactData = {
zone: ImpactZone;
depth: number;
};
type ImpactPosition = 'before' | 'after' | 'beforeChildren' | 'afterChildren';
type ImpactAction = {
on: 'children' | 'entry';
position: ImpactPosition;
};
type ItemElement = {
id: string;
parent: null | string;
position: number;
children?: Array<ItemElement>;
};

View File

@ -10600,6 +10600,11 @@ lodash@4.17.15, "lodash@>=3.5 <5", lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
lodash@^4.17.20:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
log-symbols@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"

View File

@ -81,6 +81,7 @@ func Execute() {
viper.SetDefault("database.name", "taskcafe")
viper.SetDefault("database.user", "taskcafe")
viper.SetDefault("database.password", "taskcafe_test")
viper.SetDefault("database.port", "5432")
viper.SetDefault("queue.broker", "amqp://guest:guest@localhost:5672/")
viper.SetDefault("queue.store", "memcache://localhost:11211")

View File

@ -32,11 +32,12 @@ func newWebCmd() *cobra.Command {
log.SetFormatter(Formatter)
log.SetLevel(log.InfoLevel)
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s sslmode=disable",
connection := fmt.Sprintf("user=%s password=%s host=%s dbname=%s port=%s sslmode=disable",
viper.GetString("database.user"),
viper.GetString("database.password"),
viper.GetString("database.host"),
viper.GetString("database.name"),
viper.GetString("database.port"),
)
var db *sqlx.DB
var err error

View File

@ -67,6 +67,12 @@ type ProjectMember struct {
RoleCode string `json:"role_code"`
}
type ProjectMemberInvited struct {
ProjectMemberInvitedID uuid.UUID `json:"project_member_invited_id"`
ProjectID uuid.UUID `json:"project_id"`
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
}
type RefreshToken struct {
TokenID uuid.UUID `json:"token_id"`
UserID uuid.UUID `json:"user_id"`
@ -165,3 +171,10 @@ type UserAccount struct {
RoleCode string `json:"role_code"`
Bio string `json:"bio"`
}
type UserAccountInvited struct {
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
HasJoined bool `json:"has_joined"`
}

View File

@ -99,6 +99,15 @@ func (q *Queries) CreateTeamProject(ctx context.Context, arg CreateTeamProjectPa
return i, err
}
const deleteInvitedProjectMemberByID = `-- name: DeleteInvitedProjectMemberByID :exec
DELETE FROM project_member_invited WHERE project_member_invited_id = $1
`
func (q *Queries) DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteInvitedProjectMemberByID, projectMemberInvitedID)
return err
}
const deleteProjectByID = `-- name: DeleteProjectByID :exec
DELETE FROM project WHERE project_id = $1
`
@ -219,6 +228,41 @@ func (q *Queries) GetAllVisibleProjectsForUserID(ctx context.Context, userID uui
return items, nil
}
const getInvitedMembersForProjectID = `-- name: GetInvitedMembersForProjectID :many
SELECT email, invited_on FROM project_member_invited AS pmi
INNER JOIN user_account_invited AS uai
ON uai.user_account_invited_id = pmi.user_account_invited_id
WHERE project_id = $1
`
type GetInvitedMembersForProjectIDRow struct {
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
}
func (q *Queries) GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error) {
rows, err := q.db.QueryContext(ctx, getInvitedMembersForProjectID, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetInvitedMembersForProjectIDRow
for rows.Next() {
var i GetInvitedMembersForProjectIDRow
if err := rows.Scan(&i.Email, &i.InvitedOn); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getMemberProjectIDsForUserID = `-- name: GetMemberProjectIDsForUserID :many
SELECT project_id FROM project_member WHERE user_id = $1
`
@ -296,6 +340,26 @@ func (q *Queries) GetProjectByID(ctx context.Context, projectID uuid.UUID) (Proj
return i, err
}
const getProjectMemberInvitedIDByEmail = `-- name: GetProjectMemberInvitedIDByEmail :one
SELECT email, invited_on, project_member_invited_id FROM user_account_invited AS uai
inner join project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE email = $1
`
type GetProjectMemberInvitedIDByEmailRow struct {
Email string `json:"email"`
InvitedOn time.Time `json:"invited_on"`
ProjectMemberInvitedID uuid.UUID `json:"project_member_invited_id"`
}
func (q *Queries) GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error) {
row := q.db.QueryRowContext(ctx, getProjectMemberInvitedIDByEmail, email)
var i GetProjectMemberInvitedIDByEmailRow
err := row.Scan(&i.Email, &i.InvitedOn, &i.ProjectMemberInvitedID)
return i, err
}
const getProjectMembersForProjectID = `-- name: GetProjectMembersForProjectID :many
SELECT project_member_id, project_id, user_id, added_at, role_code FROM project_member WHERE project_id = $1
`

View File

@ -9,6 +9,8 @@ import (
)
type Querier interface {
CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error)
CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error)
CreateLabelColor(ctx context.Context, arg CreateLabelColorParams) (LabelColor, error)
CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error)
CreateNotificationObject(ctx context.Context, arg CreateNotificationObjectParams) (NotificationObject, error)
@ -31,6 +33,8 @@ type Querier interface {
CreateTeamProject(ctx context.Context, arg CreateTeamProjectParams) (Project, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
DeleteExpiredTokens(ctx context.Context) error
DeleteInvitedProjectMemberByID(ctx context.Context, projectMemberInvitedID uuid.UUID) error
DeleteInvitedUserAccount(ctx context.Context, userAccountInvitedID uuid.UUID) (UserAccountInvited, error)
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) error
DeleteProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) error
DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) error
@ -59,6 +63,9 @@ type Querier interface {
GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error)
GetEntityForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetEntityForNotificationIDRow, error)
GetEntityIDForNotificationID(ctx context.Context, notificationID uuid.UUID) (uuid.UUID, error)
GetInvitedMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]GetInvitedMembersForProjectIDRow, error)
GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error)
GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error)
GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error)
GetLabelColors(ctx context.Context) ([]LabelColor, error)
GetMemberData(ctx context.Context, projectID uuid.UUID) ([]UserAccount, error)
@ -73,6 +80,7 @@ type Querier interface {
GetProjectIDForTaskGroup(ctx context.Context, taskGroupID uuid.UUID) (uuid.UUID, error)
GetProjectLabelByID(ctx context.Context, projectLabelID uuid.UUID) (ProjectLabel, error)
GetProjectLabelsForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectLabel, error)
GetProjectMemberInvitedIDByEmail(ctx context.Context, email string) (GetProjectMemberInvitedIDByEmailRow, error)
GetProjectMembersForProjectID(ctx context.Context, projectID uuid.UUID) ([]ProjectMember, error)
GetProjectRolesForUserID(ctx context.Context, userID uuid.UUID) ([]GetProjectRolesForUserIDRow, error)
GetRefreshTokenByID(ctx context.Context, tokenID uuid.UUID) (RefreshToken, error)

View File

@ -43,6 +43,21 @@ SELECT project_id, role_code FROM project_member WHERE user_id = $1;
-- name: GetMemberProjectIDsForUserID :many
SELECT project_id FROM project_member WHERE user_id = $1;
-- name: GetInvitedMembersForProjectID :many
SELECT 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;
-- name: GetProjectMemberInvitedIDByEmail :one
SELECT email, invited_on, project_member_invited_id FROM user_account_invited AS uai
inner join project_member_invited AS pmi
ON pmi.user_account_invited_id = uai.user_account_invited_id
WHERE email = $1;
-- name: DeleteInvitedProjectMemberByID :exec
DELETE FROM project_member_invited WHERE project_member_invited_id = $1;
-- name: GetAllVisibleProjectsForUserID :many
SELECT project.* FROM project LEFT JOIN
project_member ON project_member.project_id = project.project_id WHERE project_member.user_id = $1;

View File

@ -37,3 +37,19 @@ UPDATE user_account SET role_code = $2 WHERE user_id = $1 RETURNING *;
-- name: SetUserPassword :one
UPDATE user_account SET password_hash = $2 WHERE user_id = $1 RETURNING *;
-- name: CreateInvitedUser :one
INSERT INTO user_account_invited (email) VALUES ($1) RETURNING *;
-- name: GetInvitedUserByEmail :one
SELECT * FROM user_account_invited WHERE email = $1;
-- name: CreateInvitedProjectMember :one
INSERT INTO project_member_invited (project_id, user_account_invited_id) VALUES ($1, $2)
RETURNING *;
-- name: GetInvitedUserAccounts :many
SELECT * FROM user_account_invited;
-- name: DeleteInvitedUserAccount :one
DELETE FROM user_account_invited WHERE user_account_invited_id = $1 RETURNING *;

View File

@ -11,6 +11,39 @@ import (
"github.com/google/uuid"
)
const createInvitedProjectMember = `-- name: CreateInvitedProjectMember :one
INSERT INTO project_member_invited (project_id, user_account_invited_id) VALUES ($1, $2)
RETURNING project_member_invited_id, project_id, user_account_invited_id
`
type CreateInvitedProjectMemberParams struct {
ProjectID uuid.UUID `json:"project_id"`
UserAccountInvitedID uuid.UUID `json:"user_account_invited_id"`
}
func (q *Queries) CreateInvitedProjectMember(ctx context.Context, arg CreateInvitedProjectMemberParams) (ProjectMemberInvited, error) {
row := q.db.QueryRowContext(ctx, createInvitedProjectMember, arg.ProjectID, arg.UserAccountInvitedID)
var i ProjectMemberInvited
err := row.Scan(&i.ProjectMemberInvitedID, &i.ProjectID, &i.UserAccountInvitedID)
return i, err
}
const createInvitedUser = `-- name: CreateInvitedUser :one
INSERT INTO user_account_invited (email) VALUES ($1) RETURNING user_account_invited_id, email, invited_on, has_joined
`
func (q *Queries) CreateInvitedUser(ctx context.Context, email string) (UserAccountInvited, error) {
row := q.db.QueryRowContext(ctx, createInvitedUser, email)
var i UserAccountInvited
err := row.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
)
return i, err
}
const createUserAccount = `-- name: CreateUserAccount :one
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash, role_code)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio
@ -53,6 +86,22 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa
return i, err
}
const deleteInvitedUserAccount = `-- name: DeleteInvitedUserAccount :one
DELETE FROM user_account_invited WHERE user_account_invited_id = $1 RETURNING user_account_invited_id, email, invited_on, has_joined
`
func (q *Queries) DeleteInvitedUserAccount(ctx context.Context, userAccountInvitedID uuid.UUID) (UserAccountInvited, error) {
row := q.db.QueryRowContext(ctx, deleteInvitedUserAccount, userAccountInvitedID)
var i UserAccountInvited
err := row.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
)
return i, err
}
const deleteUserAccountByID = `-- name: DeleteUserAccountByID :exec
DELETE FROM user_account WHERE user_id = $1
`
@ -101,6 +150,54 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
return items, nil
}
const getInvitedUserAccounts = `-- name: GetInvitedUserAccounts :many
SELECT user_account_invited_id, email, invited_on, has_joined FROM user_account_invited
`
func (q *Queries) GetInvitedUserAccounts(ctx context.Context) ([]UserAccountInvited, error) {
rows, err := q.db.QueryContext(ctx, getInvitedUserAccounts)
if err != nil {
return nil, err
}
defer rows.Close()
var items []UserAccountInvited
for rows.Next() {
var i UserAccountInvited
if err := rows.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getInvitedUserByEmail = `-- name: GetInvitedUserByEmail :one
SELECT user_account_invited_id, email, invited_on, has_joined FROM user_account_invited WHERE email = $1
`
func (q *Queries) GetInvitedUserByEmail(ctx context.Context, email string) (UserAccountInvited, error) {
row := q.db.QueryRowContext(ctx, getInvitedUserByEmail, email)
var i UserAccountInvited
err := row.Scan(
&i.UserAccountInvitedID,
&i.Email,
&i.InvitedOn,
&i.HasJoined,
)
return i, err
}
const getMemberData = `-- name: GetMemberData :many
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url, role_code, bio FROM user_account
WHERE username != 'system'

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,23 @@ type CreateTeamMemberPayload struct {
TeamMember *Member `json:"teamMember"`
}
type DeleteInvitedProjectMember struct {
ProjectID uuid.UUID `json:"projectID"`
Email string `json:"email"`
}
type DeleteInvitedProjectMemberPayload struct {
InvitedMember *InvitedMember `json:"invitedMember"`
}
type DeleteInvitedUserAccount struct {
InvitedUserID uuid.UUID `json:"invitedUserID"`
}
type DeleteInvitedUserAccountPayload struct {
InvitedUser *InvitedUserAccount `json:"invitedUser"`
}
type DeleteProject struct {
ProjectID uuid.UUID `json:"projectID"`
}
@ -183,9 +200,22 @@ type InviteProjectMembers struct {
}
type InviteProjectMembersPayload struct {
Ok bool `json:"ok"`
ProjectID uuid.UUID `json:"projectID"`
Members []Member `json:"members"`
Ok bool `json:"ok"`
ProjectID uuid.UUID `json:"projectID"`
Members []Member `json:"members"`
InvitedMembers []InvitedMember `json:"invitedMembers"`
}
type InvitedMember struct {
Email string `json:"email"`
InvitedOn time.Time `json:"invitedOn"`
}
type InvitedUserAccount struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
InvitedOn time.Time `json:"invitedOn"`
Member *MemberList `json:"member"`
}
type LogoutUser struct {

View File

@ -86,6 +86,13 @@ type UserAccount {
member: MemberList!
}
type InvitedUserAccount {
id: ID!
email: String!
invitedOn: Time!
member: MemberList!
}
type Team {
id: ID!
createdAt: Time!
@ -93,6 +100,12 @@ type Team {
members: [Member!]!
}
type InvitedMember {
email: String!
invitedOn: Time!
}
type Project {
id: ID!
createdAt: Time!
@ -100,6 +113,7 @@ type Project {
team: Team
taskGroups: [TaskGroup!]!
members: [Member!]!
invitedMembers: [InvitedMember!]!
labels: [ProjectLabel!]!
}
@ -184,6 +198,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!)
type Query {
organizations: [Organization!]!
users: [UserAccount!]!
invitedUsers: [InvitedUserAccount!]!
findUser(input: FindUser!): UserAccount!
findProject(input: FindProject!):
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
@ -344,6 +359,18 @@ extend type Mutation {
DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!):
UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteInvitedProjectMember(input: DeleteInvitedProjectMember!):
DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input DeleteInvitedProjectMember {
projectID: UUID!
email: String!
}
type DeleteInvitedProjectMemberPayload {
invitedMember: InvitedMember!
}
input MemberInvite {
@ -360,6 +387,7 @@ type InviteProjectMembersPayload {
ok: Boolean!
projectID: UUID!
members: [Member!]!
invitedMembers: [InvitedMember!]!
}
input DeleteProjectMember {
@ -729,6 +757,8 @@ extend type Mutation {
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!):
DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteInvitedUserAccount(input: DeleteInvitedUserAccount!):
DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
logoutUser(input: LogoutUser!): Boolean!
clearProfileAvatar: UserAccount!
@ -744,6 +774,14 @@ extend type Query {
searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]!
}
input DeleteInvitedUserAccount {
invitedUserID: UUID!
}
type DeleteInvitedUserAccountPayload {
invitedUser: InvitedUserAccount!
}
input MemberSearchFilter {
SearchFilter: String!
projectID: UUID
@ -813,3 +851,4 @@ type DeleteUserAccountPayload {
ok: Boolean!
userAccount: UserAccount!
}

View File

@ -127,6 +127,7 @@ func (r *mutationResolver) UpdateProjectLabelColor(ctx context.Context, input Up
func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input InviteProjectMembers) (*InviteProjectMembersPayload, error) {
members := []Member{}
invitedMembers := []InvitedMember{}
for _, invitedMember := range input.Members {
if invitedMember.Email != nil && invitedMember.UserID != nil {
return &InviteProjectMembersPayload{Ok: false}, &gqlerror.Error{
@ -144,6 +145,7 @@ func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input Invit
}
}
if invitedMember.UserID != nil {
// Invite by user ID
addedAt := time.Now().UTC()
_, err := r.Repository.CreateProjectMember(ctx, db.CreateProjectMemberParams{ProjectID: input.ProjectID, UserID: *invitedMember.UserID, AddedAt: addedAt, RoleCode: "member"})
if err != nil {
@ -171,9 +173,39 @@ func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input Invit
ProfileIcon: profileIcon,
Role: &db.Role{Code: role.Code, Name: role.Name},
})
} else {
// Invite by email
// if invited user does not exist, create entry
invitedUser, err := r.Repository.GetInvitedUserByEmail(ctx, *invitedMember.Email)
now := time.Now().UTC()
if err != nil {
if err == sql.ErrNoRows {
invitedUser, err = r.Repository.CreateInvitedUser(ctx, *invitedMember.Email)
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
} else {
return &InviteProjectMembersPayload{Ok: false}, err
}
}
_, err = r.Repository.CreateInvitedProjectMember(ctx, db.CreateInvitedProjectMemberParams{
ProjectID: input.ProjectID,
UserAccountInvitedID: invitedUser.UserAccountInvitedID,
})
if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err
}
logger.New(ctx).Info("adding invited member")
invitedMembers = append(invitedMembers, InvitedMember{Email: *invitedMember.Email, InvitedOn: now})
// send out invitation
// add project invite entry
// send out notification?
}
}
return &InviteProjectMembersPayload{Ok: false, ProjectID: input.ProjectID, Members: members}, nil
return &InviteProjectMembersPayload{Ok: false, ProjectID: input.ProjectID, Members: members, InvitedMembers: invitedMembers}, nil
}
func (r *mutationResolver) DeleteProjectMember(ctx context.Context, input DeleteProjectMember) (*DeleteProjectMemberPayload, error) {
@ -233,6 +265,20 @@ func (r *mutationResolver) UpdateProjectMemberRole(ctx context.Context, input Up
return &UpdateProjectMemberRolePayload{Ok: true, Member: &member}, err
}
func (r *mutationResolver) DeleteInvitedProjectMember(ctx context.Context, input DeleteInvitedProjectMember) (*DeleteInvitedProjectMemberPayload, error) {
member, err := r.Repository.GetProjectMemberInvitedIDByEmail(ctx, input.Email)
if err != nil {
return &DeleteInvitedProjectMemberPayload{}, err
}
err = r.Repository.DeleteInvitedProjectMemberByID(ctx, member.ProjectMemberInvitedID)
if err != nil {
return &DeleteInvitedProjectMemberPayload{}, err
}
return &DeleteInvitedProjectMemberPayload{
InvitedMember: &InvitedMember{Email: member.Email, InvitedOn: member.InvitedOn},
}, nil
}
func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.Task, error) {
createdAt := time.Now().UTC()
logger.New(ctx).WithFields(log.Fields{"positon": input.Position, "taskGroupID": input.TaskGroupID}).Info("creating task")
@ -809,6 +855,20 @@ func (r *mutationResolver) DeleteUserAccount(ctx context.Context, input DeleteUs
return &DeleteUserAccountPayload{UserAccount: &user, Ok: true}, nil
}
func (r *mutationResolver) DeleteInvitedUserAccount(ctx context.Context, input DeleteInvitedUserAccount) (*DeleteInvitedUserAccountPayload, error) {
user, err := r.Repository.DeleteInvitedUserAccount(ctx, input.InvitedUserID)
if err != nil {
return &DeleteInvitedUserAccountPayload{}, err
}
return &DeleteInvitedUserAccountPayload{
InvitedUser: &InvitedUserAccount{
Email: user.Email,
ID: user.UserAccountInvitedID,
InvitedOn: user.InvitedOn,
},
}, err
}
func (r *mutationResolver) LogoutUser(ctx context.Context, input LogoutUser) (bool, error) {
err := r.Repository.DeleteRefreshTokenByUserID(ctx, input.UserID)
return true, err
@ -979,6 +1039,18 @@ func (r *projectResolver) Members(ctx context.Context, obj *db.Project) ([]Membe
return members, nil
}
func (r *projectResolver) InvitedMembers(ctx context.Context, obj *db.Project) ([]InvitedMember, error) {
members, err := r.Repository.GetInvitedMembersForProjectID(ctx, obj.ProjectID)
if err != nil && err == sql.ErrNoRows {
return []InvitedMember{}, nil
}
invited := []InvitedMember{}
for _, member := range members {
invited = append(invited, InvitedMember{Email: member.Email, InvitedOn: member.InvitedOn})
}
return invited, err
}
func (r *projectResolver) Labels(ctx context.Context, obj *db.Project) ([]db.ProjectLabel, error) {
labels, err := r.Repository.GetProjectLabelsForProject(ctx, obj.ProjectID)
return labels, err
@ -1012,6 +1084,25 @@ func (r *queryResolver) Users(ctx context.Context) ([]db.UserAccount, error) {
return r.Repository.GetAllUserAccounts(ctx)
}
func (r *queryResolver) InvitedUsers(ctx context.Context) ([]InvitedUserAccount, error) {
invitedMembers, err := r.Repository.GetInvitedUserAccounts(ctx)
if err != nil {
if err == sql.ErrNoRows {
return []InvitedUserAccount{}, nil
}
return []InvitedUserAccount{}, err
}
members := []InvitedUserAccount{}
for _, invitedMember := range invitedMembers {
members = append(members, InvitedUserAccount{
ID: invitedMember.UserAccountInvitedID,
Email: invitedMember.Email,
InvitedOn: invitedMember.InvitedOn,
})
}
return members, nil
}
func (r *queryResolver) FindUser(ctx context.Context, input FindUser) (*db.UserAccount, error) {
account, err := r.Repository.GetUserAccountByID(ctx, input.UserID)
if err == sql.ErrNoRows {

View File

@ -86,6 +86,13 @@ type UserAccount {
member: MemberList!
}
type InvitedUserAccount {
id: ID!
email: String!
invitedOn: Time!
member: MemberList!
}
type Team {
id: ID!
createdAt: Time!
@ -93,6 +100,12 @@ type Team {
members: [Member!]!
}
type InvitedMember {
email: String!
invitedOn: Time!
}
type Project {
id: ID!
createdAt: Time!
@ -100,6 +113,7 @@ type Project {
team: Team
taskGroups: [TaskGroup!]!
members: [Member!]!
invitedMembers: [InvitedMember!]!
labels: [ProjectLabel!]!
}

View File

@ -24,6 +24,7 @@ directive @hasRole(roles: [RoleLevel!]!, level: ActionLevel!, type: ObjectType!)
type Query {
organizations: [Organization!]!
users: [UserAccount!]!
invitedUsers: [InvitedUserAccount!]!
findUser(input: FindUser!): UserAccount!
findProject(input: FindProject!):
Project! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)

View File

@ -5,6 +5,18 @@ extend type Mutation {
DeleteProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
updateProjectMemberRole(input: UpdateProjectMemberRole!):
UpdateProjectMemberRolePayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
deleteInvitedProjectMember(input: DeleteInvitedProjectMember!):
DeleteInvitedProjectMemberPayload! @hasRole(roles: [ADMIN], level: PROJECT, type: PROJECT)
}
input DeleteInvitedProjectMember {
projectID: UUID!
email: String!
}
type DeleteInvitedProjectMemberPayload {
invitedMember: InvitedMember!
}
input MemberInvite {
@ -21,6 +33,7 @@ type InviteProjectMembersPayload {
ok: Boolean!
projectID: UUID!
members: [Member!]!
invitedMembers: [InvitedMember!]!
}
input DeleteProjectMember {

View File

@ -4,6 +4,8 @@ extend type Mutation {
UserAccount! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteUserAccount(input: DeleteUserAccount!):
DeleteUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
deleteInvitedUserAccount(input: DeleteInvitedUserAccount!):
DeleteInvitedUserAccountPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG)
logoutUser(input: LogoutUser!): Boolean!
clearProfileAvatar: UserAccount!
@ -19,6 +21,14 @@ extend type Query {
searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]!
}
input DeleteInvitedUserAccount {
invitedUserID: UUID!
}
type DeleteInvitedUserAccountPayload {
invitedUser: InvitedUserAccount!
}
input MemberSearchFilter {
SearchFilter: String!
projectID: UUID

View File

@ -0,0 +1,6 @@
CREATE TABLE user_account_invited (
user_account_invited_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
email text NOT NULL UNIQUE,
invited_on timestamptz NOT NULL DEFAULT NOW(),
has_joined boolean NOT NULL DEFAULT false
);

View File

@ -0,0 +1,7 @@
CREATE TABLE project_member_invited (
project_member_invited_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
project_id uuid NOT NULL
REFERENCES project(project_id) ON DELETE CASCADE,
user_account_invited_id uuid NOT NULL
REFERENCES user_account_invited(user_account_invited_id) ON DELETE CASCADE
);