3 Commits

56 changed files with 2886 additions and 3389 deletions

View File

@ -17,9 +17,8 @@ user = 'taskcafe'
password = 'taskcafe_test' password = 'taskcafe_test'
[smtp] [smtp]
username = 'taskcafe@example.com' username = 'admin@example.com'
password = '' password = 'example'
from = 'no-reply@taskcafe.com' server = 'mail.example.com'
host = 'localhost' port = 465
port = 11500 connection_security = 'STARTTLS'
skip_verify = false

View File

@ -9,11 +9,10 @@
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
"@types/date-fns": "^2.6.0", "@types/date-fns": "^2.6.0",
"@types/dompurify": "^2.0.4",
"@types/emoji-mart": "^3.0.4",
"@types/jest": "^24.0.0", "@types/jest": "^24.0.0",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"@types/lodash": "^4.14.149", "@types/lodash": "^4.14.149",
"@types/marked": "^1.2.2",
"@types/node": "^12.0.0", "@types/node": "^12.0.0",
"@types/query-string": "^6.3.0", "@types/query-string": "^6.3.0",
"@types/react": "^16.9.21", "@types/react": "^16.9.21",
@ -24,6 +23,7 @@
"@types/react-router-dom": "^5.1.3", "@types/react-router-dom": "^5.1.3",
"@types/react-select": "^3.0.13", "@types/react-select": "^3.0.13",
"@types/react-timeago": "^4.1.1", "@types/react-timeago": "^4.1.1",
"@types/react-window": "^1.8.2",
"@types/styled-components": "^5.0.0", "@types/styled-components": "^5.0.0",
"apollo-cache-inmemory": "^1.6.5", "apollo-cache-inmemory": "^1.6.5",
"apollo-client": "^2.6.8", "apollo-client": "^2.6.8",
@ -37,16 +37,13 @@
"color": "^3.1.2", "color": "^3.1.2",
"date-fns": "^2.14.0", "date-fns": "^2.14.0",
"dayjs": "^1.9.1", "dayjs": "^1.9.1",
"dompurify": "^2.2.6",
"emoji-mart": "^3.0.0",
"emoticon": "^3.2.0",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"graphql-tag": "^2.10.3", "graphql-tag": "^2.10.3",
"history": "^4.10.1", "history": "^4.10.1",
"immer": "^6.0.3", "immer": "^6.0.3",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"node-emoji": "^1.10.0", "marked": "^2.0.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.13.7", "query-string": "^6.13.7",
"react": "^16.12.0", "react": "^16.12.0",
@ -54,7 +51,6 @@
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.0.0",
"react-datepicker": "^2.14.1", "react-datepicker": "^2.14.1",
"react-dom": "^16.12.0", "react-dom": "^16.12.0",
"react-emoji-render": "^1.2.4",
"react-hook-form": "^6.0.6", "react-hook-form": "^6.0.6",
"react-markdown": "^4.3.1", "react-markdown": "^4.3.1",
"react-router": "^5.1.2", "react-router": "^5.1.2",
@ -63,6 +59,8 @@
"react-select": "^3.1.0", "react-select": "^3.1.0",
"react-timeago": "^4.4.0", "react-timeago": "^4.4.0",
"react-toastify": "^6.0.8", "react-toastify": "^6.0.8",
"react-visibility-sensor": "^5.1.1",
"react-window": "^1.8.6",
"rich-markdown-editor": "^10.6.5", "rich-markdown-editor": "^10.6.5",
"styled-components": "^5.0.1", "styled-components": "^5.0.1",
"typescript": "~3.7.2" "typescript": "~3.7.2"

View File

@ -174,7 +174,7 @@ const AdminRoute = () => {
useEffect(() => { useEffect(() => {
document.title = 'Admin | Taskcafé'; document.title = 'Admin | Taskcafé';
}, []); }, []);
const { loading, data } = useUsersQuery({ fetchPolicy: 'cache-and-network' }); const { loading, data } = useUsersQuery();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({ const [deleteInvitedUser] = useDeleteInvitedUserAccountMutation({
@ -214,6 +214,9 @@ const AdminRoute = () => {
}); });
}, },
}); });
if (loading) {
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />;
}
if (data && user) { if (data && user) {
if (user.roles.org !== 'admin') { if (user.roles.org !== 'admin') {
return <Redirect to="/" />; return <Redirect to="/" />;
@ -256,7 +259,7 @@ const AdminRoute = () => {
</> </>
); );
} }
return <GlobalTopNavbar projectID={null} onSaveProjectName={NOOP} name={null} />; return <span>error</span>;
}; };
export default AdminRoute; export default AdminRoute;

View File

@ -15,6 +15,7 @@ import styled from 'styled-components';
import JwtDecode from 'jwt-decode'; import JwtDecode from 'jwt-decode';
import { setAccessToken } from 'shared/utils/accessToken'; import { setAccessToken } from 'shared/utils/accessToken';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import Outline from 'Outline';
const MainContent = styled.div` const MainContent = styled.div`
padding: 0 0 0 0; padding: 0 0 0 0;
@ -35,7 +36,10 @@ const AuthorizedRoutes = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { setUser } = useCurrentUser(); const { setUser } = useCurrentUser();
useEffect(() => { useEffect(() => {
const abortController = new AbortController();
fetch('/auth/refresh_token', { fetch('/auth/refresh_token', {
signal: abortController.signal,
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}).then(async x => { }).then(async x => {
@ -59,6 +63,9 @@ const AuthorizedRoutes = () => {
} }
setLoading(false); setLoading(false);
}); });
return () => {
abortController.abort();
};
}, []); }, []);
return loading ? null : ( return loading ? null : (
<Switch> <Switch>
@ -67,6 +74,7 @@ const AuthorizedRoutes = () => {
<Route exact path="/projects" component={Projects} /> <Route exact path="/projects" component={Projects} />
<Route path="/projects/:projectID" component={Project} /> <Route path="/projects/:projectID" component={Project} />
<Route path="/teams/:teamID" component={Teams} /> <Route path="/teams/:teamID" component={Teams} />
<Route path="/outline" component={Outline} />
<Route path="/profile" component={Profile} /> <Route path="/profile" component={Profile} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />
</MainContent> </MainContent>

View File

@ -128,10 +128,12 @@ const TeamProjectContainer = styled.div`
const colors = [theme.colors.primary, theme.colors.secondary]; const colors = [theme.colors.primary, theme.colors.secondary];
const ProjectFinder = () => { const ProjectFinder = () => {
const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'cache-and-network' }); const { loading, data } = useGetProjectsQuery();
if (loading) {
return <span>loading</span>;
}
if (data) { if (data) {
const { projects, teams } = data; const { projects, teams } = data;
const personalProjects = data.projects.filter(p => p.team === null);
const projectTeams = teams.map(team => { const projectTeams = teams.map(team => {
return { return {
id: team.id, id: team.id,
@ -141,22 +143,6 @@ const ProjectFinder = () => {
}); });
return ( return (
<> <>
<TeamContainer>
<TeamTitle>Personal</TeamTitle>
<TeamProjects>
{personalProjects.map((project, idx) => (
<TeamProjectContainer key={project.id}>
<TeamProjectLink to={`/projects/${project.id}`}>
<TeamProjectBackground color={colors[idx % 5]} />
<TeamProjectAvatar color={colors[idx % 5]} />
<TeamProjectContent>
<TeamProjectTitle>{project.name}</TeamProjectTitle>
</TeamProjectContent>
</TeamProjectLink>
</TeamProjectContainer>
))}
</TeamProjects>
</TeamContainer>
{projectTeams.map(team => ( {projectTeams.map(team => (
<TeamContainer key={team.id}> <TeamContainer key={team.id}>
<TeamTitle>{team.name}</TeamTitle> <TeamTitle>{team.name}</TeamTitle>
@ -178,7 +164,7 @@ const ProjectFinder = () => {
</> </>
); );
} }
return <span>loading</span>; return <span>error</span>;
}; };
type ProjectPopupProps = { type ProjectPopupProps = {
history: any; history: any;

View File

@ -0,0 +1,24 @@
import React from 'react';
import { DragDebugWrapper } from './Styles';
type DragDebugProps = {
zone: ImpactZone | null;
depthTarget: number;
draggedNodes: Array<string> | null;
};
const DragDebug: React.FC<DragDebugProps> = ({ zone, depthTarget, draggedNodes }) => {
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} draggedNodes=${
draggedNodes ? draggedNodes.toString() : null
}`}</DragDebugWrapper>
);
};
export default DragDebug;

View File

@ -0,0 +1,41 @@
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 + (depthTarget - 1) * 35;
width = container.current.getBoundingClientRect().width - depthTarget * 35;
}
return <DragIndicatorBar top={top} left={left} width={width} />;
};
export default DragIndicator;

View File

@ -0,0 +1,385 @@
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react';
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>;
draggedNodes: { nodes: Array<string>; first?: OutlineNode | null };
isDragging: boolean;
onDragEnd: (zone: ImpactZone) => void;
initialPos: { x: number; y: number };
pageRef: React.RefObject<HTMLDivElement>;
};
let timer: any = null;
type windowScrollOptions = {
maxScrollX: number;
maxScrollY: number;
isInTopEdge: boolean;
isInBottomEdge: boolean;
edgeTop: number;
edgeBottom: number;
edgeSize: number;
viewportY: number;
$page: React.RefObject<HTMLDivElement>;
};
function adjustWindowScroll({
maxScrollY,
maxScrollX,
$page,
isInTopEdge,
isInBottomEdge,
edgeTop,
edgeBottom,
edgeSize,
viewportY,
}: windowScrollOptions) {
// Get the current scroll position of the document.
if ($page.current) {
var currentScrollX = $page.current.scrollLeft;
var currentScrollY = $page.current.scrollTop;
// Determine if the window can be scrolled in any particular direction.
var canScrollUp = currentScrollY > 0;
var canScrollDown = currentScrollY < maxScrollY;
// Since we can potentially scroll in two directions at the same time,
// let's keep track of the next scroll, starting with the current scroll.
// Each of these values can then be adjusted independently in the logic
// below.
var nextScrollX = currentScrollX;
var nextScrollY = currentScrollY;
// As we examine the mouse position within the edge, we want to make the
// incremental scroll changes more "intense" the closer that the user
// gets the viewport edge. As such, we'll calculate the percentage that
// the user has made it "through the edge" when calculating the delta.
// Then, that use that percentage to back-off from the "max" step value.
var maxStep = 50;
// Should we scroll up?
if (isInTopEdge && canScrollUp) {
var intensity = (edgeTop - viewportY) / edgeSize;
nextScrollY = nextScrollY - maxStep * intensity;
// Should we scroll down?
} else if (isInBottomEdge && canScrollDown) {
var intensity = (viewportY - edgeBottom) / edgeSize;
nextScrollY = nextScrollY + maxStep * intensity;
}
// Sanitize invalid maximums. An invalid scroll offset won't break the
// subsequent .scrollTo() call; however, it will make it harder to
// determine if the .scrollTo() method should have been called in the
// first place.
nextScrollX = Math.max(0, Math.min(maxScrollX, nextScrollX));
nextScrollY = Math.max(0, Math.min(maxScrollY, nextScrollY));
if (nextScrollX !== currentScrollX || nextScrollY !== currentScrollY) {
$page.current.scrollTo(nextScrollX, nextScrollY);
return true;
} else {
return false;
}
}
}
const Dragger: React.FC<DraggerProps> = ({
draggedNodes,
container,
onDragEnd,
isDragging,
initialPos,
pageRef: $page,
}) => {
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 => {
var t0 = performance.now();
e.preventDefault();
const { clientX, clientY, pageX, pageY } = e;
setPos({ x: clientX, y: clientY });
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;
const edgeSize = 50;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
var edgeTop = edgeSize + 80;
var edgeBottom = viewportHeight - edgeSize;
var isInTopEdge = clientY < edgeTop;
var isInBottomEdge = clientY > edgeBottom;
if ((isInBottomEdge || isInTopEdge) && $page.current) {
var documentWidth = Math.max(
$page.current.scrollWidth,
$page.current.offsetWidth,
$page.current.clientWidth,
$page.current.scrollWidth,
$page.current.offsetWidth,
$page.current.clientWidth,
);
var documentHeight = Math.max(
$page.current.scrollHeight,
$page.current.offsetHeight,
$page.current.clientHeight,
$page.current.scrollHeight,
$page.current.offsetHeight,
$page.current.clientHeight,
);
var maxScrollX = documentWidth - viewportWidth;
var maxScrollY = documentHeight - viewportHeight;
(function checkForWindowScroll() {
clearTimeout(timer);
if (
adjustWindowScroll({
maxScrollX,
maxScrollY,
edgeBottom,
$page,
edgeTop,
edgeSize,
isInBottomEdge,
isInTopEdge,
viewportY: clientY,
})
) {
timer = setTimeout(checkForWindowScroll, 30);
}
})();
} else {
clearTimeout(timer);
}
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) {
if (belowNode) {
aboveNode = findNodeAbove(outline.current, curDepth, belowNode);
} else if (aboveNode) {
let targetBelowNode: RelationshipChild | null = null;
const parent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
const abr = relationships.get(aboveNode.id);
if (abr) {
const newTarget = abr.children[0];
if (newTarget) {
targetBelowNode = newTarget;
}
}
} else if (parent) {
const aboveNodeIndex = parent.children.findIndex(c => aboveNode && c.id === aboveNode.id);
if (aboveNodeIndex !== -1) {
if (aboveNodeIndex === parent.children.length - 1) {
targetBelowNode = getBelowParent(aboveNode, outline.current);
} else {
const nextChild = parent.children[aboveNodeIndex + 1];
targetBelowNode = nextChild ?? null;
}
}
}
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 {
// 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];
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) {
const foundDepth = findNodeDepth(outline.current.published, aboveNode.id);
if (foundDepth === null) return;
for (let i = 0; i < draggedNodes.nodes.length; i++) {
const nodeID = draggedNodes.nodes[i];
if (foundDepth.ancestors.find(c => c === nodeID)) {
if (draggedNodes.first) {
belowNode = draggedNodes.first;
aboveNode = findNodeAbove(outline.current, aboveNode ? aboveNode.depth : 1, draggedNodes.first);
} else {
const foundDepth = findNodeDepth(outline.current.published, nodeID);
if (foundDepth === null) return;
const nodeDepth = outline.current.nodes.get(foundDepth.depth);
const targetNode = nodeDepth ? nodeDepth.get(nodeID) : null;
if (targetNode) {
belowNode = targetNode;
aboveNode = findNodeAbove(outline.current, foundDepth.depth, targetNode);
}
}
}
}
}
// calculate available depths
let minDepth = 1;
let maxDepth = 2;
if (aboveNode) {
const aboveParent = relationships.get(aboveNode.parent);
if (aboveNode.children !== 0 && !aboveNode.collapsed) {
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: 'fixed' | 'relative' = isDragging ? 'fixed' : '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 && (
<Container ref={$handle} style={styles}>
<Dot width={18} height={18} />
</Container>
)}
</>
);
};
export default Dragger;

View File

@ -0,0 +1,377 @@
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { Dot, CaretDown, CaretRight } from 'shared/icons';
import _ from 'lodash';
import marked from 'marked';
import VisibilitySensor from 'react-visibility-sensor';
import {
EntryChildren,
EntryWrapper,
EntryContent,
EntryInnerContent,
EntryHandle,
ExpandButton,
EntryContentEditor,
EntryContentDisplay,
} from './Styles';
import { useDrag } from './useDrag';
import { getCaretPosition, setCurrentCursorPosition } from './utils';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
type EditorProps = {
text: string;
initFocus: null | { caret: null | number };
autoFocus: number | null;
onChangeCurrentText: (text: string) => void;
onDeleteEntry: (caret: number) => void;
onBlur: () => void;
handleChangeText: (caret: number) => void;
onDepthChange: (delta: number) => void;
onCreateEntry: () => void;
onNodeFocused: () => void;
};
const Editor: React.FC<EditorProps> = ({
text,
onCreateEntry,
initFocus,
autoFocus,
onChangeCurrentText,
onDepthChange,
onDeleteEntry,
onNodeFocused,
handleChangeText,
onBlur,
}) => {
const $editor = useRef<HTMLInputElement>(null);
useOnOutsideClick($editor, true, () => onBlur(), null);
useEffect(() => {
if (autoFocus && $editor.current) {
$editor.current.focus();
$editor.current.setSelectionRange(autoFocus, autoFocus);
onNodeFocused();
}
}, [autoFocus]);
useEffect(() => {
if (initFocus && $editor.current) {
$editor.current.focus();
if (initFocus.caret) {
$editor.current.setSelectionRange(initFocus.caret ?? 0, initFocus.caret ?? 0);
}
onNodeFocused();
}
}, []);
return (
<EntryContentEditor
value={text}
ref={$editor}
onChange={e => {
onChangeCurrentText(e.currentTarget.value);
}}
onKeyDown={e => {
if (e.keyCode === 13) {
e.preventDefault();
// onCreateEntry(parentID, position * 2);
onCreateEntry();
return;
} else if (e.keyCode === 9) {
e.preventDefault();
onDepthChange(e.shiftKey ? -1 : 1);
} else if (e.keyCode === 8) {
const caretPos = e.currentTarget.selectionEnd;
if (caretPos === 0) {
// handleChangeText.flush();
// onDeleteEntry(depth, id, currentText, caretPos);
onDeleteEntry(caretPos);
e.preventDefault();
return;
}
} else if (e.key === 'z' && e.ctrlKey) {
e.preventDefault();
return;
}
handleChangeText(e.currentTarget.selectionEnd ?? 0);
// setCaretPos(e.currentTarget.selectionEnd ?? 0);
// handleChangeText();
}}
/>
);
};
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;
selection: null | Array<{ id: string }>;
draggedNodes: null | Array<string>;
onNodeFocused: (id: string) => void;
text: string;
entries: Array<ItemElement>;
onTextChange: (id: string, prex: string, next: string, caret: number) => void;
onCancelDrag: () => void;
autoFocus: null | { caret: null | number };
onCreateEntry: (parent: string, nextPositon: number) => void;
position: number;
chain?: Array<string>;
onHandleClick: (id: string) => void;
onDepthChange: (id: string, parent: string, position: number, depth: number, depthDelta: number) => void;
onDeleteEntry: (depth: number, id: string, text: string, caretPos: number) => void;
depth?: number;
};
const Entry: React.FC<EntryProps> = ({
id,
text,
parentID,
isRoot = false,
selection,
onToggleCollapse,
autoFocus,
onStartSelect,
onHandleClick,
onTextChange,
position,
onNodeFocused,
onDepthChange,
onCreateEntry,
onDeleteEntry,
onCancelDrag,
onStartDrag,
collapsed = false,
draggedNodes,
entries,
chain = [],
depth = 0,
}) => {
const $entry = useRef<HTMLDivElement>(null);
const $children = useRef<HTMLDivElement>(null);
const { setNodeDimensions, clearNodeDimensions } = useDrag();
if (autoFocus) {
}
const $snapshot = useRef<{ now: string; prev: string }>({ now: text, prev: text });
const [currentText, setCurrentText] = useState(text);
const [caretPos, setCaretPos] = useState(0);
const $firstRun = useRef<boolean>(true);
useEffect(() => {
if ($firstRun.current) {
$firstRun.current = false;
return;
}
console.log('updating text');
setCurrentText(text);
}, [text]);
const [editor, setEditor] = useState<{ open: boolean; caret: null | number }>({
open: false,
caret: null,
});
useEffect(() => {
if (autoFocus) setEditor({ open: true, caret: null });
}, [autoFocus]);
useEffect(() => {
$snapshot.current.now = currentText;
}, [currentText]);
const handleChangeText = useCallback(
_.debounce(() => {
onTextChange(id, $snapshot.current.prev, $snapshot.current.now, caretPos);
$snapshot.current.prev = $snapshot.current.now;
}, 500),
[],
);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (isRoot) return;
if (!visible) {
clearNodeDimensions(id);
return;
}
if ($entry && $entry.current) {
setNodeDimensions(id, {
entry: $entry,
children: entries.length !== 0 ? $children : null,
});
}
return () => {
clearNodeDimensions(id);
};
}, [position, depth, entries, visible]);
let showHandle = true;
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;
}
const renderMap: Array<number> = [];
const renderer = {
text(text: any) {
const localId = renderMap.length;
renderMap.push(text.length);
return `<span id="${id}_${localId}">${text}</span>`;
},
codespan(text: any) {
const localId = renderMap.length;
renderMap.push(text.length + 2);
return `<span class="markdown-code" id="${id}_${localId}">${text}</span>`;
},
strong(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 4;
return text.replace('<span', '<span class="markdown-strong"');
},
em(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 2;
return text.replace('<span', '<span class="markdown-em"');
},
del(text: string) {
const idx = parseInt(text.split('"')[1].split('_')[1]);
renderMap[idx] += 2;
return text.replace('<span', '<span class="markdown-del"');
},
};
// @ts-ignore
marked.use({ renderer });
const handleMouseDown = useCallback(
_.debounce((e: any) => {
onStartDrag({ id, clientX: e.clientX, clientY: e.clientY });
}, 100),
[],
);
return (
<VisibilitySensor
onChange={v => {
if (v) {
setVisible(v);
}
}}
>
<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
onMouseUp={() => {
handleMouseDown.cancel();
onHandleClick(id);
}}
onMouseDown={e => {
handleMouseDown(e);
}}
>
<Dot width={18} height={18} />
</EntryHandle>
)}
<EntryInnerContent
onMouseDown={() => {
onStartSelect({ id, depth });
}}
ref={$entry}
>
{editor.open ? (
<Editor
onDepthChange={delta => onDepthChange(id, parentID, depth, position, delta)}
onBlur={() => setEditor({ open: false, caret: null })}
onNodeFocused={() => onNodeFocused(id)}
autoFocus={autoFocus ? (autoFocus.caret ? autoFocus.caret : 0) : null}
initFocus={editor.open ? { caret: editor.caret } : null}
text={currentText}
onDeleteEntry={caret => {
handleChangeText.flush();
onDeleteEntry(depth, id, currentText, caret);
}}
onCreateEntry={() => {
onCreateEntry(parentID, position * 2);
}}
onChangeCurrentText={text => setCurrentText(text)}
handleChangeText={caret => {
setCaretPos(caret);
handleChangeText();
}}
/>
) : (
<EntryContentDisplay
onClick={e => {
let offset = 0;
let textNode: any;
if (document.caretPositionFromPoint) {
// standard
const range = document.caretPositionFromPoint(e.pageX, e.pageY);
console.dir(range);
if (range) {
textNode = range.offsetNode;
offset = range.offset;
}
} else if (document.caretRangeFromPoint) {
// WebKit
const range = document.caretRangeFromPoint(e.pageX, e.pageY);
if (range) {
textNode = range.startContainer;
offset = range.startOffset;
}
}
const id = textNode.parentNode.id.split('_');
const index = parseInt(id[1]);
let caret = offset;
for (let i = 0; i < index; i++) {
caret += renderMap[i];
}
setEditor({ open: true, caret });
}}
dangerouslySetInnerHTML={{ __html: marked.parseInline(text) }}
/>
)}
</EntryInnerContent>
</EntryContent>
)}
{entries.length !== 0 && !collapsed && (
<EntryChildren ref={$children} isRoot={isRoot}>
{entries
.sort((a, b) => a.position - b.position)
.map(entry => (
<Entry
onDeleteEntry={onDeleteEntry}
onHandleClick={onHandleClick}
onDepthChange={onDepthChange}
parentID={id}
key={entry.id}
onTextChange={onTextChange}
position={entry.position}
text={entry.text}
depth={depth + 1}
draggedNodes={draggedNodes}
collapsed={entry.collapsed}
id={entry.id}
autoFocus={entry.focus}
onNodeFocused={onNodeFocused}
onStartSelect={onStartSelect}
onStartDrag={onStartDrag}
onCancelDrag={onCancelDrag}
entries={entry.children ?? []}
chain={[...chain, id]}
selection={selection}
onToggleCollapse={onToggleCollapse}
onCreateEntry={onCreateEntry}
/>
))}
</EntryChildren>
)}
</EntryWrapper>
</VisibilitySensor>
);
};
export default Entry;

View File

@ -0,0 +1,260 @@
import styled, { css } from 'styled-components';
import { mixin } from 'shared/utils/styles';
export const EntryWrapper = styled.div<{ isDragging: boolean; isSelected: boolean }>`
position: relative;
${props =>
props.isDragging &&
css`
&:before {
border-radius: 3px;
content: '';
position: absolute;
top: 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: ${mixin.rgba(props.theme.colors.primary, 0.75)};
}
`}
`;
export const EntryChildren = styled.div<{ isRoot: boolean }>`
position: relative;
${props =>
!props.isRoot &&
css`
margin-left: 10px;
padding-left: 25px;
border-left: 1px solid ${mixin.rgba(props.theme.colors.text.primary, 0.6)};
`}
`;
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;
text-size-adjust: none;
`;
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 RootWrapper = styled.div``;
export const EntryHandle = styled.div`
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 501px;
top: 7px;
width: 18px;
height: 18px;
color: ${p => p.theme.colors.text.primary};
border-radius: 9px;
&:hover {
background: ${p => p.theme.colors.primary};
}
svg {
fill: ${p => p.theme.colors.text.primary};
stroke: ${p => p.theme.colors.text.primary};
}
`;
export const EntryContentDisplay = styled.div`
display: inline-flex;
align-items: center;
width: 100%;
font-size: 15px;
white-space: pre-wrap;
background: none;
outline: none;
border: none;
line-height: 24px;
min-height: 24px;
overflow-wrap: break-word;
position: relative;
padding: 0;
margin: 0;
color: ${p => p.theme.colors.text.primary};
user-select: none;
cursor: text;
.markdown-del {
text-decoration: line-through;
}
.markdown-code {
margin-top: -4px;
font-size: 16px;
line-height: 19px;
color: ${props => props.theme.colors.primary};
font-family: monospace;
padding: 4px 5px 0;
font-family: 'Consolas', Courier, monospace;
background: ${props => props.theme.colors.bg.primary};
display: inline-block;
vertical-align: middle;
border-radius: 4px;
}
.markdown-em {
margin-top: -4px;
font-style: italic;
}
.markdown-strong {
font-weight: 700;
color: #fff;
}
&:focus {
outline: 0;
}
`;
export const EntryContentEditor = styled.input`
width: 100%;
font-size: 15px;
padding: 0;
margin: 0;
white-space: pre-wrap;
background: none;
outline: none;
border: none;
line-height: 24px;
min-height: 24px;
overflow-wrap: break-word;
position: relative;
user-select: text;
color: ${p => p.theme.colors.text.primary};
&::selection {
background: #a49de8;
}
&:focus {
outline: 0;
}
`;
export const EntryInnerContent = styled.div`
padding-top: 4px;
font-size: 15px;
white-space: pre-wrap;
background: none;
outline: none;
border: none;
line-height: 24px;
min-height: 24px;
overflow-wrap: break-word;
position: relative;
user-select: text;
color: ${p => p.theme.colors.text.primary};
&::selection {
background: #a49de8;
}
&: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: fixed;
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);
`;
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: ${props => props.theme.colors.text.primary};
}
`;
export const PageContainer = styled.div`
overflow-y: auto;
overflow-x: hidden;
`;
export const PageName = styled.div`
position: relative;
margin-left: -100px;
padding-left: 100px;
margin-bottom: 10px;
border-color: rgb(170, 170, 170);
font-size: 26px;
font-weight: bold;
color: #fff;
`;
export const PageNameContent = styled.div`
white-space: pre-wrap;
line-height: 34px;
min-height: 34px;
overflow-wrap: break-word;
position: relative;
user-select: text;
`;
export const PageNameText = styled.span``;

View File

@ -0,0 +1,784 @@
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';
import DragIndicator from './DragIndicator';
import Dragger from './Dragger';
import DragDebug from './DragDebug';
import { DragContext } from './useDrag';
import {
PageContainer,
DragDebugWrapper,
DragIndicatorBar,
PageContent,
EntryChildren,
EntryInnerContent,
EntryWrapper,
EntryContent,
RootWrapper,
EntryHandle,
PageNameContent,
PageNameText,
PageName,
} from './Styles';
import {
transformToTree,
findNode,
findNodeDepth,
getNumberOfChildren,
validateDepth,
getDimensions,
findNextDraggable,
getNodeOver,
getCorrectNode,
findCommonParent,
getNodeAbove,
findNodeAbove,
} from './utils';
import NOOP from 'shared/utils/noop';
enum CommandType {
MOVE,
MERGE,
CHANGE_TEXT,
DELETE,
CREATE,
}
type MoveData = {
prev: { position: number; parent: string | null };
next: { position: number; parent: string | null };
};
type ChangeTextData = {
node: {
id: string;
parentID: string;
position: number;
};
caret: number;
prev: string;
next: string;
};
type DeleteData = {
node: {
id: string;
parentID: string;
position: number;
text: string;
};
};
type OutlineCommand = {
nodes: Array<{
id: string;
type: CommandType;
data: MoveData | DeleteData | ChangeTextData;
}>;
};
type ItemCollapsed = {
id: string;
collapsed: boolean;
};
function generateItems(c: number) {
const items: Array<ItemElement> = [];
for (let i = 0; i < c; i++) {
items.push({
collapsed: false,
focus: null,
id: `entry-gen-${i}`,
text: `entry-gen-${i}`,
parent: 'root',
position: 4096 * (6 + i),
});
}
return items;
}
const listItems: Array<ItemElement> = [
{ id: 'root', text: '', position: 4096, parent: null, collapsed: false, focus: null },
{ id: 'entry-1', text: 'entry-1', position: 4096, parent: 'root', collapsed: false, focus: null },
{ id: 'entry-1-3', text: 'entry-1-3', position: 4096 * 3, parent: 'entry-1', collapsed: false, focus: null },
{ id: 'entry-1-3-1', text: 'entry-1-3-1', position: 4096, parent: 'entry-1-3', collapsed: false, focus: null },
{ id: 'entry-1-3-2', text: 'entry-1-3-2', position: 4096 * 2, parent: 'entry-1-3', collapsed: false, focus: null },
{ id: 'entry-1-3-3', text: 'entry-1-3-3', position: 4096 * 3, parent: 'entry-1-3', collapsed: false, focus: null },
{
id: 'entry-1-3-3-1',
text: '*Hello!* I am `doing super` well ~how~ are **you**?',
position: 4096 * 1,
parent: 'entry-1-3-3',
collapsed: false,
focus: null,
},
{
id: 'entry-1-3-3-1-1',
text: 'entry-1-3-3-1-1',
position: 4096 * 1,
parent: 'entry-1-3-3-1',
collapsed: false,
focus: null,
},
{ id: 'entry-2', text: 'entry-2', position: 4096 * 2, parent: 'root', collapsed: false, focus: null },
{ id: 'entry-3', text: 'entry-3', position: 4096 * 3, parent: 'root', collapsed: false, focus: null },
{ id: 'entry-4', text: 'entry-4', position: 4096 * 4, parent: 'root', collapsed: false, focus: null },
{ id: 'entry-5', text: 'entry-5', position: 4096 * 5, parent: 'root', collapsed: false, focus: null },
...generateItems(100),
];
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;
draggedNodes: null | Array<string>;
initialPos: { x: number; y: number };
}>({ 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>({
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];
}
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 { collapsed, position, id, parent: curParent } = items[i];
if (id === 'root') {
continue;
}
const parent = curParent ?? 'root';
outline.current.published.set(id, parent ?? 'root');
const foundDepth = findNodeDepth(outline.current.published, id);
if (foundDepth === null) {
continue;
}
const { depth, ancestors } = foundDepth;
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>());
}
const targetDepthNodes = outline.current.nodes.get(depth);
if (targetDepthNodes) {
targetDepthNodes.set(id, {
id,
children,
position,
depth,
ancestors,
collapsed,
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]);
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 (node.type === CommandType.MOVE) {
if (idx === -1) return;
const data = node.data as MoveData;
draftItems[idx].parent = data.prev.parent;
draftItems[idx].position = data.prev.position;
} else if (node.type === CommandType.CHANGE_TEXT) {
if (idx === -1) return;
const data = node.data as ChangeTextData;
draftItems[idx] = produce(prevItems[idx], draftItem => {
draftItem.text = data.prev;
draftItem.focus = { caret: data.caret };
});
} else if (node.type === CommandType.DELETE) {
const data = node.data as DeleteData;
draftItems.push({
id: data.node.id,
position: data.node.position,
parent: data.node.parentID,
text: '',
focus: { caret: null },
children: [],
collapsed: false,
});
}
});
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) {
if (node.type === CommandType.MOVE) {
const data = node.data as MoveData;
draftItems[idx].parent = data.next.parent;
draftItems[idx].position = data.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);
};
}, []);
const $page = useRef<HTMLDivElement>(null);
const $pageName = useRef<HTMLDivElement>(null);
if (!root) {
return null;
}
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />
<DragContext.Provider
value={{
outline,
impact,
setImpact: data => {
if (data) {
const { zone, depth } = data;
let listPosition = 65535;
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 {
const correctNode = getCorrectNode(outline.current, zone.above ? zone.above.node : null, depth);
const listAbove = validateDepth(correctNode, 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);
},
clearNodeDimensions: nodeID => {
outline.current.dimensions.delete(nodeID);
},
}}
>
<>
<PageContainer ref={$page}>
<PageContent>
<RootWrapper ref={$content}>
<PageName>
<PageNameContent ref={$pageName}>
<PageNameText>entry-1-3-1</PageNameText>
</PageNameContent>
</PageName>
<Entry
onDepthChange={(id, parentID, position, depth, depthDelta) => {
if (depthDelta === -1) {
const parentRelation = outline.current.relationships.get(parentID);
if (parentRelation) {
const nodeIdx = parentRelation.children
.sort((a, b) => a.position - b.position)
.findIndex(c => c.id === id);
if (parentRelation.children.length !== 0) {
const grandparent = outline.current.published.get(parentID);
if (grandparent) {
const grandparentNode = outline.current.relationships.get(grandparent);
if (grandparentNode) {
const parents = grandparentNode.children.sort((a, b) => a.position - b.position);
const parentIdx = parents.findIndex(c => c.id === parentID);
if (parentIdx === -1) return;
let position = parents[parentIdx].position * 2;
const nextParent = parents[parentIdx + 1];
if (nextParent) {
position = (parents[parentIdx].position + nextParent.position) / 2.0;
}
setItems(prevItems =>
produce(prevItems, draftItems => {
const idx = prevItems.findIndex(c => c.id === id);
draftItems[idx] = produce(prevItems[idx], draftItem => {
draftItem.parent = grandparent;
draftItem.position = position;
draftItem.focus = { caret: 0 };
});
}),
);
}
}
}
}
} else {
const parent = outline.current.relationships.get(parentID);
if (parent) {
const nodeIdx = parent.children
.sort((a, b) => a.position - b.position)
.findIndex(c => c.id === id);
const aboveNode = parent.children[nodeIdx - 1];
if (aboveNode) {
const aboveNodeRelations = outline.current.relationships.get(aboveNode.id);
let position = 65535;
if (aboveNodeRelations) {
const children = aboveNodeRelations.children.sort((a, b) => a.position - b.position);
if (children.length !== 0) {
position = children[children.length - 1].position * 2;
}
}
setItems(prevItems =>
produce(prevItems, draftItems => {
const idx = prevItems.findIndex(c => c.id === id);
draftItems[idx] = produce(prevItems[idx], draftItem => {
draftItem.parent = aboveNode.id;
draftItem.position = position;
draftItem.focus = { caret: 0 };
});
}),
);
}
}
}
}}
onTextChange={(id, prev, next, caret) => {
outlineHistory.current.current += 1;
const data: ChangeTextData = {
node: {
id,
position: 0,
parentID: '',
},
caret,
prev,
next,
};
const command: OutlineCommand = {
nodes: [
{
id,
type: CommandType.CHANGE_TEXT,
data,
},
],
};
outlineHistory.current.commands[outlineHistory.current.current] = command;
if (outlineHistory.current.commands[outlineHistory.current.current + 1]) {
outlineHistory.current.commands.splice(outlineHistory.current.current + 1);
}
setItems(prevItems =>
produce(prevItems, draftItems => {
const idx = prevItems.findIndex(c => c.id === id);
if (idx !== -1) {
draftItems[idx] = produce(prevItems[idx], draftItem => {
draftItem.text = next;
});
}
}),
);
}}
text=""
autoFocus={null}
onDeleteEntry={(depth, id, text, caretPos) => {
const nodeDepth = outline.current.nodes.get(depth);
if (nodeDepth) {
const node = nodeDepth.get(id);
if (node) {
const nodeAbove = findNodeAbove(outline.current, depth, node);
setItems(prevItems => {
return produce(prevItems, draftItems => {
draftItems = prevItems.filter(c => c.id !== id);
const idx = prevItems.findIndex(c => c.id === nodeAbove?.id);
if (idx !== -1) {
draftItems[idx] = produce(prevItems[idx], draftItem => {
draftItem.focus = { caret: draftItem.text.length };
const cType = CommandType.DELETE;
const data: DeleteData = {
node: {
id,
position: node.position,
parentID: node.parent,
text: '',
},
};
if (text !== '') {
draftItem.text += text;
}
const command: OutlineCommand = {
nodes: [
{
id,
type: cType,
data,
},
],
};
outlineHistory.current.current += 1;
outlineHistory.current.commands[outlineHistory.current.current] = command;
if (outlineHistory.current.commands[outlineHistory.current.current + 1]) {
outlineHistory.current.commands.splice(outlineHistory.current.current + 1);
}
});
}
return draftItems;
});
});
}
}
}}
onCreateEntry={(parent, position) => {
setItems(prevItems =>
produce(prevItems, draftItems => {
draftItems.push({
id: '' + Math.random(),
collapsed: false,
position,
text: '',
focus: {
caret: null,
},
parent,
children: [],
});
}),
);
}}
onNodeFocused={id => {
setItems(prevItems =>
produce(prevItems, draftItems => {
const idx = draftItems.findIndex(c => c.id === id);
draftItems[idx] = produce(draftItems[idx], draftItem => {
draftItem.focus = null;
});
}),
);
}}
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
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 } });
}}
onHandleClick={id => {}}
onStartDrag={e => {
if (e.id !== 'root') {
if (selectRef.current.hasSelection && selection && selection.nodes.find(c => c.id === e.id)) {
setImpact(null);
setDragging({
show: true,
draggedNodes: [...selection.nodes.map(c => c.id)],
initialPos: { x: e.clientX, y: e.clientY },
});
} else {
setImpact(null);
setDragging({ show: true, draggedNodes: [e.id], initialPos: { x: e.clientX, y: e.clientY } });
}
}
}}
/>
</RootWrapper>
</PageContent>
</PageContainer>
{dragging.show && dragging.draggedNodes && (
<Dragger
container={$content}
initialPos={dragging.initialPos}
pageRef={$page}
draggedNodes={{ nodes: dragging.draggedNodes, first: selection ? selection.first : null }}
isDragging={dragging.show}
onDragEnd={() => {
if (dragging.draggedNodes && impactRef.current) {
const { zone, depth, listPosition } = impactRef.current;
const noZone = !zone.above && !zone.below;
if (!noZone) {
let parentID = 'root';
if (zone.above) {
parentID = zone.above.node.ancestors[depth - 1];
}
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
setItems(itemsPrev =>
produce(itemsPrev, draftItems => {
if (dragging.draggedNodes) {
const command: OutlineCommand = { nodes: [] };
outlineHistory.current.current += 1;
dragging.draggedNodes.forEach(n => {
const curDragging = itemsPrev.findIndex(i => i.id === n);
command.nodes.push({
id: n,
type: CommandType.MOVE,
data: {
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, draggedNodes: null, initialPos: { x: 0, y: 0 } });
}}
/>
)}
</>
</DragContext.Provider>
{impact && <DragIndicator depthTarget={impact.depthTarget} container={$content} zone={impact.zone} />}
{impact && (
<DragDebug zone={impact.zone ?? null} draggedNodes={dragging.draggedNodes} depthTarget={impact.depthTarget} />
)}
</>
);
};
export default Outline;

View File

@ -0,0 +1,22 @@
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;
clearNodeDimensions: (nodeID: string) => 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,409 @@
import _ from 'lodash';
export function getCorrectNode(data: OutlineData, node: OutlineNode | null, depth: number) {
if (node) {
if (depth === node.depth) {
return node;
}
const parent = node.ancestors[depth];
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;
}
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) {
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,
depth: parentNode.depth,
position: parentNode.position,
children: parentNode.children,
};
}
hasChildren = false;
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;
if (mouseX > breakpoint) {
return curDepth;
}
curDepth -= 1;
}
return availableDepths.min;
}
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;
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) {
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) {
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 {
return null;
}
}
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;
}
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;
}
export 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.current) {
caretPos = range.endOffset;
}
}
}
*/
return editableDiv.selectionEnd;
}
export function createRange(node: any, chars: any, range: any) {
if (!range) {
range = document.createRange();
range.selectNode(node);
range.setStart(node, 0);
}
if (chars.count === 0) {
range.setEnd(node, chars.count);
} else if (node && chars.count > 0) {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent.length < chars.count) {
chars.count -= node.textContent.length;
} else {
range.setEnd(node, chars.count);
chars.count = 0;
}
} else {
for (var lp = 0; lp < node.childNodes.length; lp++) {
range = createRange(node.childNodes[lp], chars, range);
if (chars.count === 0) {
break;
}
}
}
}
return range;
}
export function setCurrentCursorPosition(element: any, chars: any) {
if (chars >= 0) {
const selection = window.getSelection();
const range = createRange(element, { count: chars }, false);
if (range && selection) {
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}
}

View File

@ -459,6 +459,9 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
} }
}; };
if (loading) {
return <BoardLoading />;
}
const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => { const getTaskStatusFilterLabel = (filter: TaskStatusFilter) => {
if (filter.status === TaskStatus.COMPLETE) { if (filter.status === TaskStatus.COMPLETE) {
return 'Complete'; return 'Complete';
@ -815,7 +818,7 @@ const ProjectBoard: React.FC<ProjectBoardProps> = ({ projectID, onCardLabelClick
); );
} }
return <BoardLoading />; return <span>Error</span>;
}; };
export default ProjectBoard; export default ProjectBoard;

View File

@ -21,9 +21,6 @@ import {
useCreateTaskChecklistItemMutation, useCreateTaskChecklistItemMutation,
FindTaskDocument, FindTaskDocument,
FindTaskQuery, FindTaskQuery,
useCreateTaskCommentMutation,
useDeleteTaskCommentMutation,
useUpdateTaskCommentMutation,
} from 'shared/generated/graphql'; } from 'shared/generated/graphql';
import { useCurrentUser } from 'App/context'; import { useCurrentUser } from 'App/context';
import MiniProfile from 'shared/components/MiniProfile'; import MiniProfile from 'shared/components/MiniProfile';
@ -36,73 +33,6 @@ import { useForm } from 'react-hook-form';
import updateApolloCache from 'shared/utils/cache'; import updateApolloCache from 'shared/utils/cache';
import NOOP from 'shared/utils/noop'; import NOOP from 'shared/utils/noop';
export const ActionsList = styled.ul`
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
`;
export const ActionItem = styled.li`
position: relative;
padding-left: 4px;
padding-right: 4px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
font-size: 14px;
&:hover {
background: ${props => props.theme.colors.primary};
}
`;
export const ActionTitle = styled.span`
margin-left: 20px;
`;
const WarningLabel = styled.p`
font-size: 14px;
margin: 8px 12px;
`;
const DeleteConfirm = styled(Button)`
width: 100%;
padding: 8px 12px;
margin-bottom: 6px;
`;
type TaskCommentActionsProps = {
onDeleteComment: () => void;
onEditComment: () => void;
};
const TaskCommentActions: React.FC<TaskCommentActionsProps> = ({ onDeleteComment, onEditComment }) => {
const { setTab } = usePopup();
return (
<>
<Popup tab={0} title={null}>
<ActionsList>
<ActionItem>
<ActionTitle>Pin to top</ActionTitle>
</ActionItem>
<ActionItem onClick={() => onEditComment()}>
<ActionTitle>Edit comment</ActionTitle>
</ActionItem>
<ActionItem onClick={() => setTab(1)}>
<ActionTitle>Delete comment</ActionTitle>
</ActionItem>
</ActionsList>
</Popup>
<Popup tab={1} title="Delete comment?">
<WarningLabel>Deleting a comment can not be undone.</WarningLabel>
<DeleteConfirm onClick={() => onDeleteComment()} color="danger">
Delete comment
</DeleteConfirm>
</Popup>
</>
);
};
const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => { const calculateChecklistBadge = (checklists: Array<TaskChecklist>) => {
const total = checklists.reduce((prev: any, next: any) => { const total = checklists.reduce((prev: any, next: any) => {
return ( return (
@ -200,40 +130,6 @@ const Details: React.FC<DetailsProps> = ({
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const history = useHistory(); const history = useHistory();
const [deleteTaskComment] = useDeleteTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTask.comments = cache.findTask.comments.filter(
c => c.id !== response.data?.deleteTaskComment.commentID,
);
}
}),
{ taskID },
);
},
});
const [createTaskComment] = useCreateTaskCommentMutation({
update: (client, response) => {
updateApolloCache<FindTaskQuery>(
client,
FindTaskDocument,
cache =>
produce(cache, draftCache => {
if (response.data) {
draftCache.findTask.comments.push({
...response.data.createTaskComment.comment,
});
}
}),
{ taskID },
);
},
});
const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation(); const [updateTaskChecklistLocation] = useUpdateTaskChecklistLocationMutation();
const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({ const [updateTaskChecklistItemLocation] = useUpdateTaskChecklistItemLocationMutation({
update: (client, response) => { update: (client, response) => {
@ -256,7 +152,7 @@ const Details: React.FC<DetailsProps> = ({
draftCache.findTask.checklists[newIdx].items.push({ draftCache.findTask.checklists[newIdx].items.push({
...item, ...item,
position: checklistItem.position, position: checklistItem.position,
taskChecklistID, taskChecklistID: taskChecklistID,
}); });
} }
} }
@ -381,11 +277,7 @@ const Details: React.FC<DetailsProps> = ({
); );
}, },
}); });
const { loading, data, refetch } = useFindTaskQuery({ const { loading, data, refetch } = useFindTaskQuery({ variables: { taskID }, fetchPolicy: 'cache-and-network' });
variables: { taskID },
pollInterval: 3000,
fetchPolicy: 'cache-and-network',
});
const [setTaskComplete] = useSetTaskCompleteMutation(); const [setTaskComplete] = useSetTaskCompleteMutation();
const [updateTaskDueDate] = useUpdateTaskDueDateMutation({ const [updateTaskDueDate] = useUpdateTaskDueDateMutation({
onCompleted: () => { onCompleted: () => {
@ -405,8 +297,9 @@ const Details: React.FC<DetailsProps> = ({
refreshCache(); refreshCache();
}, },
}); });
const [updateTaskComment] = useUpdateTaskCommentMutation(); if (loading) {
const [editableComment, setEditableComment] = useState<null | string>(null); return null;
}
if (!data) { if (!data) {
return null; return null;
} }
@ -420,31 +313,8 @@ const Details: React.FC<DetailsProps> = ({
renderContent={() => { renderContent={() => {
return ( return (
<TaskDetails <TaskDetails
onCancelCommentEdit={() => setEditableComment(null)}
onUpdateComment={(commentID, message) => {
updateTaskComment({ variables: { commentID, message } });
}}
editableComment={editableComment}
me={data.me.user} me={data.me.user}
onCommentShowActions={(commentID, $targetRef) => {
showPopup(
$targetRef,
<TaskCommentActions
onDeleteComment={() => {
deleteTaskComment({ variables: { commentID } });
hidePopup();
}}
onEditComment={() => {
setEditableComment(commentID);
hidePopup();
}}
/>,
);
}}
task={data.findTask} task={data.findTask}
onCreateComment={(task, message) => {
createTaskComment({ variables: { taskID: task.id, message } });
}}
onChecklistDrop={checklist => { onChecklistDrop={checklist => {
updateTaskChecklistLocation({ updateTaskChecklistLocation({
variables: { taskChecklistID: checklist.id, position: checklist.position }, variables: { taskChecklistID: checklist.id, position: checklist.position },

View File

@ -134,6 +134,7 @@ type MemberFilterOptions = {
}; };
const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => { const fetchMembers = async (client: any, projectID: string, options: MemberFilterOptions, input: string, cb: any) => {
console.log(input.trim().length < 3);
if (input && input.trim().length < 3) { if (input && input.trim().length < 3) {
return []; return [];
} }
@ -161,10 +162,12 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
let results: any = []; let results: any = [];
const emails: Array<string> = []; const emails: Array<string> = [];
console.log(res.data && res.data.searchMembers);
if (res.data && res.data.searchMembers) { if (res.data && res.data.searchMembers) {
results = [ results = [
...res.data.searchMembers.map((m: any) => { ...res.data.searchMembers.map((m: any) => {
if (m.status === 'INVITED') { if (m.status === 'INVITED') {
console.log(`${m.id} is added`);
return { return {
label: m.id, label: m.id,
value: { value: {
@ -176,15 +179,17 @@ const fetchMembers = async (client: any, projectID: string, options: MemberFilte
}, },
}, },
}; };
} 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 },
};
} }
emails.push(m.user.email);
return {
label: m.user.fullName,
value: { id: m.user.id, type: 0, profileIcon: m.user.profileIcon },
};
}), }),
]; ];
console.log(results);
} }
if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) { if (RFC2822_EMAIL.test(input) && !emails.find(e => e === input)) {
@ -237,6 +242,7 @@ const OptionLabel = styled.span<{ fontSize: number; quiet: boolean }>`
`; `;
const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => { const UserOption: React.FC<UserOptionProps> = ({ isDisabled, isFocused, innerProps, label, data }) => {
console.log(data);
return !isDisabled ? ( return !isDisabled ? (
<OptionWrapper {...innerProps} isFocused={isFocused}> <OptionWrapper {...innerProps} isFocused={isFocused}>
<TaskAssignee <TaskAssignee
@ -436,7 +442,6 @@ const Project = () => {
const { loading, data, error } = useFindProjectQuery({ const { loading, data, error } = useFindProjectQuery({
variables: { projectID }, variables: { projectID },
pollInterval: 3000,
}); });
const [updateProjectName] = useUpdateProjectNameMutation({ const [updateProjectName] = useUpdateProjectNameMutation({
@ -517,6 +522,14 @@ const Project = () => {
document.title = `${data.findProject.name} | Taskcafé`; document.title = `${data.findProject.name} | Taskcafé`;
} }
}, [data]); }, [data]);
if (loading) {
return (
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
}
if (error) { if (error) {
history.push('/projects'); history.push('/projects');
} }
@ -629,12 +642,7 @@ const Project = () => {
</> </>
); );
} }
return ( return <div>Error</div>;
<>
<GlobalTopNavbar onSaveProjectName={NOOP} name="" projectID={null} />
<BoardLoading />
</>
);
}; };
export default Project; export default Project;

View File

@ -202,7 +202,7 @@ type ShowNewProject = {
const Projects = () => { const Projects = () => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetProjectsQuery({ pollInterval: 3000, fetchPolicy: 'cache-and-network' }); const { loading, data } = useGetProjectsQuery({ fetchPolicy: 'network-only' });
useEffect(() => { useEffect(() => {
document.title = 'Taskcafé'; document.title = 'Taskcafé';
}, []); }, []);
@ -231,6 +231,9 @@ const Projects = () => {
); );
}, },
}); });
if (loading) {
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />;
}
const colors = theme.colors.multiColors; const colors = theme.colors.multiColors;
if (data && user) { if (data && user) {
@ -392,7 +395,7 @@ const Projects = () => {
</> </>
); );
} }
return <GlobalTopNavbar onSaveProjectName={NOOP} projectID={null} name={null} />; return <div>Error!</div>;
}; };
export default Projects; export default Projects;

View File

@ -419,11 +419,7 @@ type MembersProps = {
const Members: React.FC<MembersProps> = ({ teamID }) => { const Members: React.FC<MembersProps> = ({ teamID }) => {
const { showPopup, hidePopup } = usePopup(); const { showPopup, hidePopup } = usePopup();
const { loading, data } = useGetTeamQuery({ const { loading, data } = useGetTeamQuery({ variables: { teamID } });
variables: { teamID },
fetchPolicy: 'cache-and-network',
pollInterval: 3000,
});
const { user, setUserRoles } = useCurrentUser(); const { user, setUserRoles } = useCurrentUser();
const warning = const warning =
'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.'; 'You cant leave because you are the only admin. To make another user an admin, click their avatar, select “Change permissions…”, and select “Admin”.';
@ -472,6 +468,9 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
); );
}, },
}); });
if (loading) {
return <span>loading</span>;
}
if (data && user) { if (data && user) {
return ( return (
@ -559,7 +558,7 @@ const Members: React.FC<MembersProps> = ({ teamID }) => {
); );
} }
return <div>loading</div>; return <div>error</div>;
}; };
export default Members; export default Members;

View File

@ -155,11 +155,10 @@ type TeamProjectsProps = {
}; };
const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => { const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
const { loading, data } = useGetTeamQuery({ const { loading, data } = useGetTeamQuery({ variables: { teamID } });
variables: { teamID }, if (loading) {
fetchPolicy: 'cache-and-network', return <span>loading</span>;
pollInterval: 3000, }
});
if (data) { if (data) {
return ( return (
<ProjectsContainer> <ProjectsContainer>
@ -190,7 +189,7 @@ const TeamProjects: React.FC<TeamProjectsProps> = ({ teamID }) => {
</ProjectsContainer> </ProjectsContainer>
); );
} }
return <span>loading</span>; return <span>error</span>;
}; };
export default TeamProjects; export default TeamProjects;

View File

@ -94,6 +94,23 @@ const Teams = () => {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [currentTab, setCurrentTab] = useState(0); const [currentTab, setCurrentTab] = useState(0);
const match = useRouteMatch(); const match = useRouteMatch();
if (loading) {
return (
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
}
if (data && user) { if (data && user) {
if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) { if (!user.isVisible(PermissionLevel.TEAM, PermissionObjectType.TEAM, teamID)) {
return <Redirect to="/" />; return <Redirect to="/" />;
@ -129,21 +146,7 @@ const Teams = () => {
</> </>
); );
} }
return ( return <div>Error!</div>;
<GlobalTopNavbar
menuType={[
{ name: 'Projects', link: `${match.url}` },
{ name: 'Members', link: `${match.url}/members` },
]}
currentTab={currentTab}
onSetTab={tab => {
setCurrentTab(tab);
}}
onSaveProjectName={NOOP}
projectID={null}
name={null}
/>
);
}; };
export default Teams; export default Teams;

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { TaskActivityData, ActivityType } from 'shared/generated/graphql'; import { TaskActivityData, ActivityType } from 'shared/generated/graphql';
import dayjs from 'dayjs';
type ActivityMessageProps = { type ActivityMessageProps = {
type: ActivityType; type: ActivityType;
@ -13,41 +12,12 @@ function getVariable(data: Array<TaskActivityData>, name: string) {
return target ? target.value : null; return target ? target.value : null;
} }
function renderDate(timestamp: string | null) {
if (timestamp) {
return dayjs(timestamp).format('MMM D [at] h:mm A');
}
return null;
}
const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => { const ActivityMessage: React.FC<ActivityMessageProps> = ({ type, data }) => {
let message = '';
switch (type) { switch (type) {
case ActivityType.TaskAdded: case ActivityType.TaskAdded:
message = `added this task to ${getVariable(data, 'TaskGroup')}`; return <>`added this task to ${getVariable(data, 'TaskGroup')}`</>;
break;
case ActivityType.TaskMoved:
message = `moved this task from ${getVariable(data, 'PrevTaskGroup')} to ${getVariable(data, 'CurTaskGroup')}`;
break;
case ActivityType.TaskDueDateAdded:
message = `set this task to be due ${renderDate(getVariable(data, 'DueDate'))}`;
break;
case ActivityType.TaskDueDateRemoved:
message = `removed the due date from this task`;
break;
case ActivityType.TaskDueDateChanged:
message = `changed the due date of this task to ${renderDate(getVariable(data, 'CurDueDate'))}`;
break;
case ActivityType.TaskMarkedComplete:
message = `marked this task complete`;
break;
case ActivityType.TaskMarkedIncomplete:
message = `marked this task incomplete`;
break;
default:
message = '<unknown type>';
} }
return <>{message}</>; return <h1>hello</h1>;
}; };
export default ActivityMessage; export default ActivityMessage;

View File

@ -1,133 +0,0 @@
import React, { useRef, useState, useEffect } from 'react';
import {
CommentTextArea,
CommentEditorContainer,
CommentEditorActions,
CommentEditorActionIcon,
CommentEditorSaveButton,
CommentProfile,
CommentInnerWrapper,
} from './Styles';
import { usePopup } from 'shared/components/PopupMenu';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { At, Paperclip, Smile } from 'shared/icons';
import { Picker, Emoji } from 'emoji-mart';
import Task from 'shared/icons/Task';
type CommentCreatorProps = {
me?: TaskUser;
autoFocus?: boolean;
onMemberProfile?: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
message?: string | null;
onCreateComment: (message: string) => void;
onCancelEdit?: () => void;
};
const CommentCreator: React.FC<CommentCreatorProps> = ({
me,
message,
onMemberProfile,
onCreateComment,
onCancelEdit,
autoFocus = false,
}) => {
const $commentWrapper = useRef<HTMLDivElement>(null);
const $comment = useRef<HTMLTextAreaElement>(null);
const $emoji = useRef<HTMLDivElement>(null);
const $emojiCart = useRef<HTMLDivElement>(null);
const [comment, setComment] = useState(message ?? '');
const [showCommentActions, setShowCommentActions] = useState(autoFocus);
const { showPopup, hidePopup } = usePopup();
useEffect(() => {
if (autoFocus && $comment && $comment.current) {
$comment.current.select();
}
}, []);
useOnOutsideClick(
[$commentWrapper, $emojiCart],
showCommentActions,
() => {
if (onCancelEdit) {
onCancelEdit();
}
setShowCommentActions(false);
},
null,
);
return (
<CommentInnerWrapper ref={$commentWrapper}>
{me && onMemberProfile && (
<CommentProfile
member={me}
size={32}
onMemberProfile={$target => {
onMemberProfile($target, me.id);
}}
/>
)}
<CommentEditorContainer>
<CommentTextArea
showCommentActions={showCommentActions}
placeholder="Write a comment..."
ref={$comment}
value={comment}
onChange={e => setComment(e.currentTarget.value)}
onFocus={() => {
setShowCommentActions(true);
}}
/>
<CommentEditorActions visible={showCommentActions}>
<CommentEditorActionIcon>
<Paperclip width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<At width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon
ref={$emoji}
onClick={() => {
showPopup(
$emoji,
<div ref={$emojiCart}>
<Picker
onClick={emoji => {
console.log(emoji);
if ($comment && $comment.current) {
let textToInsert = `${emoji.colons} `;
let cursorPosition = $comment.current.selectionStart;
let textBeforeCursorPosition = $comment.current.value.substring(0, cursorPosition);
let textAfterCursorPosition = $comment.current.value.substring(
cursorPosition,
$comment.current.value.length,
);
setComment(textBeforeCursorPosition + textToInsert + textAfterCursorPosition);
}
hidePopup();
}}
set="google"
/>
</div>,
);
}}
>
<Smile width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Task width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorSaveButton
onClick={() => {
setShowCommentActions(false);
onCreateComment(comment);
setComment('');
}}
>
Save
</CommentEditorSaveButton>
</CommentEditorActions>
</CommentEditorContainer>
</CommentInnerWrapper>
);
};
export default CommentCreator;

View File

@ -475,7 +475,6 @@ export const CommentEditorContainer = styled.div`
border: 1px solid #414561; border: 1px solid #414561;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #1f243e;
`; `;
export const CommentProfile = styled(TaskAssignee)` export const CommentProfile = styled(TaskAssignee)`
margin-right: 8px; margin-right: 8px;
@ -485,7 +484,7 @@ export const CommentProfile = styled(TaskAssignee)`
align-items: normal; align-items: normal;
`; `;
export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: boolean }>` export const CommentTextArea = styled(TextareaAutosize)`
width: 100%; width: 100%;
line-height: 28px; line-height: 28px;
padding: 4px 6px; padding: 4px 6px;
@ -496,16 +495,14 @@ export const CommentTextArea = styled(TextareaAutosize)<{ showCommentActions: bo
transition: max-height 200ms, height 200ms, min-height 200ms; transition: max-height 200ms, height 200ms, min-height 200ms;
min-height: 36px; min-height: 36px;
max-height: 36px; max-height: 36px;
${props => &:not(:focus) {
props.showCommentActions height: 36px;
? css` }
min-height: 80px; &:focus {
max-height: none; min-height: 80px;
line-height: 20px; max-height: none;
` line-height: 20px;
: css` }
height: 36px;
`}
`; `;
export const CommentEditorActions = styled.div<{ visible: boolean }>` export const CommentEditorActions = styled.div<{ visible: boolean }>`
@ -532,18 +529,6 @@ export const ActivitySection = styled.div`
overflow-x: hidden; overflow-x: hidden;
padding: 8px 26px; padding: 8px 26px;
display: flex;
flex-direction: column-reverse;
`;
export const ActivityItemCommentAction = styled.div`
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
svg {
fill: ${props => props.theme.colors.text.primary} !important;
}
`; `;
export const ActivityItem = styled.div` export const ActivityItem = styled.div`
@ -553,20 +538,14 @@ export const ActivityItem = styled.div`
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
display: flex; display: flex;
&:hover ${ActivityItemCommentAction} {
display: flex;
}
`; `;
export const ActivityItemHeader = styled.div<{ editable?: boolean }>` export const ActivityItemHeader = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-left: 8px; padding-left: 8px;
${props => props.editable && 'width: 100%;'}
`;
export const ActivityItemHeaderUser = styled(TaskAssignee)`
align-items: start;
`; `;
export const ActivityItemHeaderUser = styled(TaskAssignee)``;
export const ActivityItemHeaderTitle = styled.div` export const ActivityItemHeaderTitle = styled.div`
display: flex; display: flex;
@ -577,7 +556,7 @@ export const ActivityItemHeaderTitle = styled.div`
export const ActivityItemHeaderTitleName = styled.span` export const ActivityItemHeaderTitleName = styled.span`
font-weight: 500; font-weight: 500;
padding-right: 3px; padding-right: 2px;
`; `;
export const ActivityItemTimestamp = styled.span<{ margin: number }>` export const ActivityItemTimestamp = styled.span<{ margin: number }>`
@ -590,10 +569,8 @@ export const ActivityItemDetails = styled.div`
margin-left: 32px; margin-left: 32px;
`; `;
export const ActivityItemCommentContainer = styled.div``; export const ActivityItemComment = styled.div`
export const ActivityItemComment = styled.div<{ editable: boolean }>`
display: inline-flex; display: inline-flex;
flex-direction: column;
border-radius: 3px; border-radius: 3px;
${mixin.boxShadowCard} ${mixin.boxShadowCard}
position: relative; position: relative;
@ -601,32 +578,6 @@ export const ActivityItemComment = styled.div<{ editable: boolean }>`
padding: 8px 12px; padding: 8px 12px;
margin: 4px 0; margin: 4px 0;
background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)}; background-color: ${props => mixin.darken(props.theme.colors.alternate, 0.1)};
${props => props.editable && 'width: 100%;'}
& span {
display: flex;
align-items: center;
}
& ul {
list-style-type: disc;
margin: 8px 0;
}
& ul > li {
margin: 8px 8px 8px 24px;
margin-inline-start: 24px;
margin-inline-end: 8px;
}
& ul > li ul > li {
list-style: circle;
}
`;
export const ActivityItemCommentActions = styled.div`
display: flex;
align-items: center;
position: absolute;
top: 8px;
right: 0;
`; `;
export const ActivityItemLog = styled.span` export const ActivityItemLog = styled.span`

View File

@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import NormalizeStyles from 'App/NormalizeStyles';
import BaseStyles from 'App/BaseStyles';
import Modal from 'shared/components/Modal';
import TaskDetails from '.';
export default {
component: TaskDetails,
title: 'TaskDetails',
parameters: {
backgrounds: [
{ name: 'white', value: '#ffffff' },
{ name: 'gray', value: '#cdd3e1', default: true },
],
},
};
export const Default = () => {
const [description, setDescription] = useState('');
return (
<>
<NormalizeStyles />
<BaseStyles />
<Modal
width={1040}
onClose={action('on close')}
renderContent={() => {
return (
<TaskDetails
onDeleteItem={action('delete item')}
onChangeItemName={action('change item name')}
task={{
id: '1',
taskGroup: { name: 'General', id: '1' },
name: 'Hello, world',
position: 1,
labels: [
{
id: 'soft-skills',
assignedDate: new Date().toString(),
projectLabel: {
createdDate: new Date().toString(),
id: 'label-soft-skills',
name: 'Soft Skills',
labelColor: {
id: '1',
name: 'white',
colorHex: '#fff',
position: 1,
},
},
},
],
description,
assigned: [
{
id: '1',
profileIcon: { bgColor: null, url: null, initials: null },
fullName: 'Jordan Knott',
},
],
}}
onTaskNameChange={action('task name change')}
onTaskDescriptionChange={(_task, desc) => setDescription(desc)}
onDeleteTask={action('delete task')}
onCloseModal={action('close modal')}
onMemberProfile={action('profile')}
onOpenAddMemberPopup={action('open add member popup')}
onAddItem={action('add item')}
onToggleTaskComplete={action('toggle task complete')}
onToggleChecklistItem={action('toggle checklist item')}
onOpenAddLabelPopup={action('open add label popup')}
onChangeChecklistName={action('change checklist name')}
onDeleteChecklist={action('delete checklist')}
onOpenAddChecklistPopup={action(' open checklist')}
onOpenDueDatePopop={action('open due date popup')}
onChecklistDrop={action('on checklist drop')}
onChecklistItemDrop={action('on checklist item drop')}
/>
);
}}
/>
</>
);
};

View File

@ -12,32 +12,21 @@ import {
At, At,
Smile, Smile,
} from 'shared/icons'; } from 'shared/icons';
import { toArray } from 'react-emoji-render';
import DOMPurify from 'dompurify';
import TaskAssignee from 'shared/components/TaskAssignee';
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
import { usePopup } from 'shared/components/PopupMenu';
import CommentCreator from 'shared/components/TaskDetails/CommentCreator';
import { AngleDown } from 'shared/icons/AngleDown';
import Editor from 'rich-markdown-editor'; import Editor from 'rich-markdown-editor';
import dark from 'shared/utils/editorTheme'; import dark from 'shared/utils/editorTheme';
import styled from 'styled-components'; import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { Picker, Emoji } from 'emoji-mart';
import 'emoji-mart/css/emoji-mart.css';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import ActivityMessage from './ActivityMessage'; import ActivityMessage from './ActivityMessage';
import Task from 'shared/icons/Task'; import Task from 'shared/icons/Task';
import { import {
ActivityItemHeader, ActivityItemHeader,
ActivityItemTimestamp, ActivityItemTimestamp,
ActivityItem, ActivityItem,
ActivityItemCommentAction,
ActivityItemCommentActions,
TaskDetailLabel, TaskDetailLabel,
CommentContainer, CommentContainer,
ActivityItemCommentContainer,
MetaDetailContent, MetaDetailContent,
TaskDetailsAddLabelIcon, TaskDetailsAddLabelIcon,
ActionButton, ActionButton,
@ -73,126 +62,22 @@ import {
TaskMember, TaskMember,
TabBarSection, TabBarSection,
TabBarItem, TabBarItem,
CommentTextArea,
CommentEditorContainer,
CommentEditorActions,
CommentEditorActionIcon,
CommentEditorSaveButton,
CommentProfile,
CommentInnerWrapper,
ActivitySection, ActivitySection,
TaskDetailsEditor, TaskDetailsEditor,
ActivityItemHeaderUser, ActivityItemHeaderUser,
ActivityItemHeaderTitle, ActivityItemHeaderTitle,
ActivityItemHeaderTitleName, ActivityItemHeaderTitleName,
ActivityItemComment,
} from './Styles'; } from './Styles';
import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist'; import Checklist, { ChecklistItem, ChecklistItems } from '../Checklist';
import onDragEnd from './onDragEnd'; import onDragEnd from './onDragEnd';
import { plugin as em } from './remark'; import TaskAssignee from 'shared/components/TaskAssignee';
const parseEmojis = (value: string) => {
const emojisArray = toArray(value);
// toArray outputs React elements for emojis and strings for other
const newValue = emojisArray.reduce((previous: any, current: any) => {
if (typeof current === 'string') {
return previous + current;
}
return previous + current.props.children;
}, '');
return newValue;
};
type StreamCommentProps = {
comment?: TaskComment | null;
onUpdateComment: (message: string) => void;
onExtraActions: (commentID: string, $target: React.RefObject<HTMLElement>) => void;
onCancelCommentEdit: () => void;
editable: boolean;
};
const StreamComment: React.FC<StreamCommentProps> = ({
comment,
onExtraActions,
editable,
onUpdateComment,
onCancelCommentEdit,
}) => {
const $actions = useRef<HTMLDivElement>(null);
if (comment) {
return (
<ActivityItem>
<ActivityItemHeaderUser size={32} member={comment.createdBy} />
<ActivityItemHeader editable={editable}>
<ActivityItemHeaderTitle>
<ActivityItemHeaderTitleName>{comment.createdBy.fullName}</ActivityItemHeaderTitleName>
<ActivityItemTimestamp margin={8}>
{dayjs(comment.createdAt).format('MMM D [at] h:mm A')}
{comment.updatedAt && ' (edited)'}
</ActivityItemTimestamp>
</ActivityItemHeaderTitle>
<ActivityItemCommentContainer>
<ActivityItemComment editable={editable}>
{editable ? (
<CommentCreator
message={comment.message}
autoFocus
onCancelEdit={onCancelCommentEdit}
onCreateComment={onUpdateComment}
/>
) : (
<ReactMarkdown escapeHtml={false} plugins={[em]}>
{DOMPurify.sanitize(comment.message, { FORBID_TAGS: ['style', 'img'] })}
</ReactMarkdown>
)}
</ActivityItemComment>
<ActivityItemCommentActions>
<ActivityItemCommentAction
ref={$actions}
onClick={() => {
onExtraActions(comment.id, $actions);
}}
>
<AngleDown width={18} height={18} />
</ActivityItemCommentAction>
</ActivityItemCommentActions>
</ActivityItemCommentContainer>
</ActivityItemHeader>
</ActivityItem>
);
}
return null;
};
type StreamActivityProps = {
activity?: TaskActivity | null;
};
const StreamActivity: React.FC<StreamActivityProps> = ({ activity }) => {
if (activity) {
return (
<ActivityItem>
<ActivityItemHeaderUser
size={32}
member={{
id: activity.causedBy.id,
fullName: activity.causedBy.fullName,
profileIcon: activity.causedBy.profileIcon
? activity.causedBy.profileIcon
: {
url: null,
initials: activity.causedBy.fullName.charAt(0),
bgColor: '#fff',
},
}}
/>
<ActivityItemHeader>
<ActivityItemHeaderTitle>
<ActivityItemHeaderTitleName>{activity.causedBy.fullName}</ActivityItemHeaderTitleName>
<ActivityMessage type={activity.type} data={activity.data} />
</ActivityItemHeaderTitle>
<ActivityItemTimestamp margin={0}>
{dayjs(activity.createdAt).format('MMM D [at] h:mm A')}
</ActivityItemTimestamp>
</ActivityItemHeader>
</ActivityItem>
);
}
return null;
};
const ChecklistContainer = styled.div``; const ChecklistContainer = styled.div``;
@ -237,13 +122,8 @@ type TaskDetailsProps = {
onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; onOpenAddLabelPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; onOpenDueDatePopop: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void; onOpenAddChecklistPopup: (task: Task, $targetRef: React.RefObject<HTMLElement>) => void;
onCreateComment: (task: Task, message: string) => void;
onCommentShowActions: (commentID: string, $targetRef: React.RefObject<HTMLElement>) => void;
onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void; onMemberProfile: ($targetRef: React.RefObject<HTMLElement>, memberID: string) => void;
onCancelCommentEdit: () => void;
onUpdateComment: (commentID: string, message: string) => void;
onChangeChecklistName: (checklistID: string, name: string) => void; onChangeChecklistName: (checklistID: string, name: string) => void;
editableComment?: string | null;
onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void; onDeleteChecklist: ($target: React.RefObject<HTMLElement>, checklistID: string) => void;
onCloseModal: () => void; onCloseModal: () => void;
onChecklistDrop: (checklist: TaskChecklist) => void; onChecklistDrop: (checklist: TaskChecklist) => void;
@ -252,15 +132,11 @@ type TaskDetailsProps = {
const TaskDetails: React.FC<TaskDetailsProps> = ({ const TaskDetails: React.FC<TaskDetailsProps> = ({
me, me,
onCancelCommentEdit,
task, task,
editableComment = null,
onDeleteChecklist, onDeleteChecklist,
onTaskNameChange, onTaskNameChange,
onCommentShowActions,
onOpenAddChecklistPopup, onOpenAddChecklistPopup,
onChangeChecklistName, onChangeChecklistName,
onCreateComment,
onChecklistDrop, onChecklistDrop,
onChecklistItemDrop, onChecklistItemDrop,
onToggleTaskComplete, onToggleTaskComplete,
@ -269,7 +145,6 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
onDeleteItem, onDeleteItem,
onDeleteTask, onDeleteTask,
onCloseModal, onCloseModal,
onUpdateComment,
onOpenAddMemberPopup, onOpenAddMemberPopup,
onOpenAddLabelPopup, onOpenAddLabelPopup,
onOpenDueDatePopop, onOpenDueDatePopop,
@ -289,38 +164,12 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
}); });
const [saveTimeout, setSaveTimeout] = useState<any>(null); const [saveTimeout, setSaveTimeout] = useState<any>(null);
const [showRaw, setShowRaw] = useState(false); const [showRaw, setShowRaw] = useState(false);
const [showCommentActions, setShowCommentActions] = useState(false);
const taskDescriptionRef = useRef(task.description ?? ''); const taskDescriptionRef = useRef(task.description ?? '');
const $noMemberBtn = useRef<HTMLDivElement>(null); const $noMemberBtn = useRef<HTMLDivElement>(null);
const $addMemberBtn = useRef<HTMLDivElement>(null); const $addMemberBtn = useRef<HTMLDivElement>(null);
const $dueDateBtn = useRef<HTMLDivElement>(null); const $dueDateBtn = useRef<HTMLDivElement>(null);
const activityStream: Array<{ id: string; data: { time: string; type: 'comment' | 'activity' } }> = [];
if (task.activity) {
task.activity.forEach(activity => {
activityStream.push({
id: activity.id,
data: {
time: activity.createdAt,
type: 'activity',
},
});
});
}
if (task.comments) {
task.comments.forEach(comment => {
activityStream.push({
id: comment.id,
data: {
time: comment.createdAt,
type: 'comment',
},
});
});
}
activityStream.sort((a, b) => (dayjs(a.data.time).isAfter(dayjs(b.data.time)) ? 1 : -1));
const saveDescription = () => { const saveDescription = () => {
onTaskDescriptionChange(task, taskDescriptionRef.current); onTaskDescriptionChange(task, taskDescriptionRef.current);
}; };
@ -585,28 +434,74 @@ const TaskDetails: React.FC<TaskDetailsProps> = ({
<TabBarItem>Activity</TabBarItem> <TabBarItem>Activity</TabBarItem>
</TabBarSection> </TabBarSection>
<ActivitySection> <ActivitySection>
{activityStream.map(stream => {task.activity &&
stream.data.type === 'comment' ? ( task.activity.map(activity => (
<StreamComment <ActivityItem>
onExtraActions={onCommentShowActions} <ActivityItemHeaderUser
onCancelCommentEdit={onCancelCommentEdit} size={32}
onUpdateComment={message => onUpdateComment(stream.id, message)} member={{
editable={stream.id === editableComment} id: activity.causedBy.id,
comment={task.comments && task.comments.find(comment => comment.id === stream.id)} fullName: activity.causedBy.fullName,
/> profileIcon: activity.causedBy.profileIcon
) : ( ? activity.causedBy.profileIcon
<StreamActivity activity={task.activity && task.activity.find(activity => activity.id === stream.id)} /> : {
), url: null,
)} initials: activity.causedBy.fullName.charAt(0),
bgColor: '#fff',
},
}}
/>
<ActivityItemHeader>
<ActivityItemHeaderTitle>
<ActivityItemHeaderTitleName>{activity.causedBy.fullName}</ActivityItemHeaderTitleName>
<ActivityMessage type={activity.type} data={activity.data} />
</ActivityItemHeaderTitle>
<ActivityItemTimestamp margin={0}>
{dayjs(activity.createdAt).format('MMM D [at] h:mm A')}
</ActivityItemTimestamp>
</ActivityItemHeader>
</ActivityItem>
))}
</ActivitySection> </ActivitySection>
</InnerContentContainer> </InnerContentContainer>
<CommentContainer> <CommentContainer>
{me && ( {me && (
<CommentCreator <CommentInnerWrapper>
me={me} <CommentProfile
onCreateComment={message => onCreateComment(task, message)} member={me}
onMemberProfile={onMemberProfile} size={32}
/> onMemberProfile={$target => {
onMemberProfile($target, me.id);
}}
/>
<CommentEditorContainer>
<CommentTextArea
disabled
placeholder="Write a comment..."
onFocus={() => {
setShowCommentActions(true);
}}
onBlur={() => {
setShowCommentActions(false);
}}
/>
<CommentEditorActions visible={showCommentActions}>
<CommentEditorActionIcon>
<Paperclip width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<At width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Smile width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorActionIcon>
<Task width={12} height={12} />
</CommentEditorActionIcon>
<CommentEditorSaveButton>Save</CommentEditorSaveButton>
</CommentEditorActions>
</CommentEditorContainer>
</CommentInnerWrapper>
)} )}
</CommentContainer> </CommentContainer>
</ContentContainer> </ContentContainer>

View File

@ -1,61 +0,0 @@
import visit from 'unist-util-visit';
import emoji from 'node-emoji';
import emoticon from 'emoticon';
import { Emoji } from 'emoji-mart';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
const RE_EMOJI = /:\+1:|:-1:|:[\w-]+:/g;
const RE_SHORT = /[$@|*'",;.=:\-)([\]\\/<>038BOopPsSdDxXzZ]{2,5}/g;
const DEFAULT_SETTINGS = {
padSpaceAfter: false,
emoticon: false,
};
function plugin(options) {
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
const pad = !!settings.padSpaceAfter;
const emoticonEnable = !!settings.emoticon;
function getEmojiByShortCode(match) {
// find emoji by shortcode - full match or with-out last char as it could be from text e.g. :-),
const iconFull = emoticon.find(e => e.emoticons.includes(match)); // full match
const iconPart = emoticon.find(e => e.emoticons.includes(match.slice(0, -1))); // second search pattern
const trimmedChar = iconPart ? match.slice(-1) : '';
const addPad = pad ? ' ' : '';
let icon = iconFull ? iconFull.emoji + addPad : iconPart && iconPart.emoji + addPad + trimmedChar;
return icon || match;
}
function getEmoji(match) {
console.log(match);
const got = emoji.get(match);
if (pad && got !== match) {
return got + ' ';
}
console.log(got);
return ReactDOMServer.renderToStaticMarkup(<Emoji set="google" emoji={match} size={16} />);
}
function transformer(tree) {
visit(tree, 'paragraph', function(node) {
console.log(tree);
// node.value = node.value.replace(RE_EMOJI, getEmoji);
node.type = 'html';
node.tagName = 'div';
node.value = node.children[0].value.replace(RE_EMOJI, getEmoji);
if (emoticonEnable) {
// node.value = node.value.replace(RE_SHORT, getEmojiByShortCode);
}
console.log(node);
});
}
return transformer;
}
export { plugin };

File diff suppressed because it is too large Load Diff

View File

@ -10,22 +10,6 @@ query findTask($taskID: UUID!) {
id id
name name
} }
comments {
id
pinned
message
createdAt
updatedAt
createdBy {
id
fullName
profileIcon {
initials
bgColor
url
}
}
}
activity { activity {
id id
type type

View File

@ -1,27 +0,0 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation createTaskComment($taskID: UUID!, $message: String!) {
createTaskComment(input: { taskID: $taskID, message: $message }) {
taskID
comment {
id
message
pinned
createdAt
updatedAt
createdBy {
id
fullName
profileIcon {
initials
bgColor
url
}
}
}
}
}
`;
export default CREATE_TASK_MUTATION;

View File

@ -1,11 +0,0 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation deleteTaskComment($commentID: UUID!) {
deleteTaskComment(input: { commentID: $commentID }) {
commentID
}
}
`;
export default CREATE_TASK_MUTATION;

View File

@ -1,15 +0,0 @@
import gql from 'graphql-tag';
const CREATE_TASK_MUTATION = gql`
mutation updateTaskComment($commentID: UUID!, $message: String!) {
updateTaskComment(input: { commentID: $commentID, message: $message }) {
comment {
id
updatedAt
message
}
}
}
`;
export default CREATE_TASK_MUTATION;

View File

@ -16,14 +16,10 @@ const useOnOutsideClick = (
const handleMouseUp = (event: any) => { const handleMouseUp = (event: any) => {
if (typeof $ignoredElementRefsMemoized !== 'undefined') { if (typeof $ignoredElementRefsMemoized !== 'undefined') {
const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(($elementRef: any) => { const isAnyIgnoredElementAncestorOfTarget = $ignoredElementRefsMemoized.some(
if ($elementRef && $elementRef.current) { ($elementRef: any) =>
return ( $elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target),
$elementRef.current.contains($mouseDownTargetRef.current) || $elementRef.current.contains(event.target) );
);
}
return false;
});
if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) { if (event.button === 0 && !isAnyIgnoredElementAncestorOfTarget) {
onOutsideClick(); onOutsideClick();
} }

View File

@ -1,21 +1,12 @@
import React from 'react'; import React from 'react';
import Icon, { IconProps } from './Icon';
export const AngleDown: React.FC<IconProps> = ({ width = '16px', height = '16px', className }) => {
return (
<Icon width={width} height={height} className={className} viewBox="0 0 512 512">
<path d="M143 352.3L7 216.3c-9.4-9.4-9.4-24.6 0-33.9l22.6-22.6c9.4-9.4 24.6-9.4 33.9 0l96.4 96.4 96.4-96.4c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9l-136 136c-9.2 9.4-24.4 9.4-33.8 0z" />
</Icon>
);
};
type Props = { type Props = {
width?: number | string; width: number | string;
height?: number | string; height: number | string;
color?: string; color: string;
}; };
const AngleDownOld = ({ width, height, color }: Props) => { const AngleDown = ({ width, height, color }: Props) => {
return ( return (
<svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"> <svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
<path <path
@ -26,10 +17,10 @@ const AngleDownOld = ({ width, height, color }: Props) => {
); );
}; };
AngleDownOld.defaultProps = { AngleDown.defaultProps = {
width: 24, width: 24,
height: 16, height: 16,
color: '#000', color: '#000',
}; };
export default AngleDownOld; export default AngleDown;

View File

@ -61,9 +61,9 @@ export const base = {
export const dark = { export const dark = {
...base, ...base,
background: 'transparent', background: 'transparent',
text: `${theme.colors.text.primary}`, text: `rgba(${theme.colors.text.primary})`,
code: `${theme.colors.text.primary}`, code: `rgba(${theme.colors.text.primary})`,
cursor: `${theme.colors.text.primary}`, cursor: `rgba(${theme.colors.text.primary})`,
divider: '#4E5C6E', divider: '#4E5C6E',
placeholder: '#52657A', placeholder: '#52657A',

View File

@ -206,10 +206,14 @@ type ImpactAction = {
type ItemElement = { type ItemElement = {
id: string; id: string;
parent: null | string; parent: null | string;
text: string;
focus: null | { caret: number | null };
zooming?: { x: number; y: number };
position: number; position: number;
collapsed: boolean; collapsed: boolean;
children?: Array<ItemElement>; children?: Array<ItemElement>;
}; };
type NodeDimensions = { type NodeDimensions = {
entry: React.RefObject<HTMLElement>; entry: React.RefObject<HTMLElement>;
children: React.RefObject<HTMLElement> | null; children: React.RefObject<HTMLElement> | null;

View File

@ -81,21 +81,6 @@ type TaskActivity = {
createdAt: string; createdAt: string;
}; };
type CreatedBy = {
id: string;
fullName: string;
profileIcon: ProfileIcon;
};
type TaskComment = {
id: string;
createdBy: CreatedBy;
createdAt: string;
updatedAt?: string | null;
pinned: boolean;
message: string;
};
type Task = { type Task = {
id: string; id: string;
taskGroup: InnerTaskGroup; taskGroup: InnerTaskGroup;
@ -110,7 +95,6 @@ type Task = {
assigned?: Array<TaskUser>; assigned?: Array<TaskUser>;
checklists?: Array<TaskChecklist> | null; checklists?: Array<TaskChecklist> | null;
activity?: Array<TaskActivity> | null; activity?: Array<TaskActivity> | null;
comments?: Array<TaskComment> | null;
}; };
type Project = { type Project = {

View File

@ -2820,20 +2820,6 @@
dependencies: dependencies:
date-fns "*" date-fns "*"
"@types/dompurify@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.0.4.tgz#25fce15f1f4b1bc0df0ad957040cf226416ac2d7"
integrity sha512-y6K7NyXTQvjr8hJNsAFAD8yshCsIJ0d+OYEFzULuIqWyWOKL2hRru1I+rorI5U0K4SLAROTNuSUFXPDTu278YA==
dependencies:
"@types/trusted-types" "*"
"@types/emoji-mart@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/emoji-mart/-/emoji-mart-3.0.4.tgz#418868330c13b510a7b4b86b1ee9904291daeec1"
integrity sha512-Uqegqi54lXzz1qlDnLYEbFlUXY46auTVwbeOO8mj+9maGu2WBx/lGnmLKg7WS4CC5lB8Q6ch8K5VPBR9bDjWgg==
dependencies:
"@types/react" "*"
"@types/eslint-visitor-keys@^1.0.0": "@types/eslint-visitor-keys@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@ -2948,6 +2934,11 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f"
integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg==
"@types/marked@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-1.2.2.tgz#1f858a0e690247ecf3b2eef576f98f86e8d960d4"
integrity sha512-wLfw1hnuuDYrFz97IzJja0pdVsC0oedtS4QsKH1/inyW9qkLQbXgMUqEQT0MVtUBx3twjWeInUfjQbhBVLECXw==
"@types/mdast@^3.0.0": "@types/mdast@^3.0.0":
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb"
@ -3102,6 +3093,13 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-window@^1.8.2":
version "1.8.2"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe"
integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ==
dependencies:
"@types/react" "*"
"@types/react@*": "@types/react@*":
version "17.0.0" version "17.0.0"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8"
@ -3149,11 +3147,6 @@
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74" resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74"
integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==
"@types/trusted-types@*":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-1.0.6.tgz#569b8a08121d3203398290d602d84d73c8dcf5da"
integrity sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==
"@types/uglify-js@*": "@types/uglify-js@*":
version "3.11.1" version "3.11.1"
resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.11.1.tgz#97ff30e61a0aa6876c270b5f538737e2d6ab8ceb" resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.11.1.tgz#97ff30e61a0aa6876c270b5f538737e2d6ab8ceb"
@ -6719,11 +6712,6 @@ domhandler@^4.0.0:
dependencies: dependencies:
domelementtype "^2.1.0" domelementtype "^2.1.0"
dompurify@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.6.tgz#54945dc5c0b45ce5ae228705777e8e59d7b2edc4"
integrity sha512-7b7ZArhhH0SP6W2R9cqK6RjaU82FZ2UPM7RO8qN1b1wyvC/NY1FNWcX1Pu00fFOAnzEORtwXe4bPaClg6pUybQ==
domutils@1.5.1: domutils@1.5.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
@ -6868,24 +6856,11 @@ elliptic@^6.5.3:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0" minimalistic-crypto-utils "^1.0.0"
emoji-mart@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-3.0.0.tgz#eca24a04881e27752a6921e09f65a86ce8539a50"
integrity sha512-r5DXyzOLJttdwRYfJmPq/XL3W5tiAE/VsRnS0Hqyn27SqPA/GOYwVUSx50px/dXdJyDSnvmoPbuJ/zzhwSaU4A==
dependencies:
"@babel/runtime" "^7.0.0"
prop-types "^15.6.0"
"emoji-regex@>=6.0.0 <=6.1.1": "emoji-regex@>=6.0.0 <=6.1.1":
version "6.1.1" version "6.1.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e"
integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4=
emoji-regex@^6.4.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2"
integrity sha512-PAHp6TxrCy7MGMFidro8uikr+zlJJKJ/Q6mm2ExZ7HwkyR9lSVFfE3kt36qcwa24BQL7y0G9axycGjK1A/0uNQ==
emoji-regex@^7.0.1, emoji-regex@^7.0.2: emoji-regex@^7.0.1, emoji-regex@^7.0.2:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
@ -6911,11 +6886,6 @@ emojis-list@^3.0.0:
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
emoticon@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f"
integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg==
emotion-theming@^10.0.19: emotion-theming@^10.0.19:
version "10.0.27" version "10.0.27"
resolved "https://registry.yarnpkg.com/emotion-theming/-/emotion-theming-10.0.27.tgz#1887baaec15199862c89b1b984b79806f2b9ab10" resolved "https://registry.yarnpkg.com/emotion-theming/-/emotion-theming-10.0.27.tgz#1887baaec15199862c89b1b984b79806f2b9ab10"
@ -9198,7 +9168,7 @@ interpret@^2.0.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
invariant@^2.2.1, invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4:
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
@ -10789,11 +10759,6 @@ lodash.debounce@^4.0.8:
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
lodash.flatten@^4.2.0, lodash.flatten@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
lodash.get@^4: lodash.get@^4:
version "4.4.2" version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
@ -10864,11 +10829,6 @@ lodash.throttle@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
lodash.toarray@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE=
lodash.uniq@4.5.0, lodash.uniq@^4.5.0: lodash.uniq@4.5.0, lodash.uniq@^4.5.0:
version "4.5.0" version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@ -11075,6 +11035,11 @@ markdown-to-jsx@^6.11.4:
prop-types "^15.6.2" prop-types "^15.6.2"
unquote "^1.1.0" unquote "^1.1.0"
marked@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/marked/-/marked-2.0.0.tgz#9662bbcb77ebbded0662a7be66ff929a8611cee5"
integrity sha512-NqRSh2+LlN2NInpqTQnS614Y/3NkVMFFU6sJlRFEpxJ/LHuK/qJECH7/fXZjk4VZstPW/Pevjil/VtSONsLc7Q==
material-colors@^1.2.1: material-colors@^1.2.1:
version "1.2.6" version "1.2.6"
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"
@ -11165,7 +11130,7 @@ mem@^4.0.0:
mimic-fn "^2.0.0" mimic-fn "^2.0.0"
p-is-promise "^2.0.0" p-is-promise "^2.0.0"
memoize-one@^5.0.0, memoize-one@^5.1.1: "memoize-one@>=3.1.1 <6", memoize-one@^5.0.0, memoize-one@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
@ -11562,13 +11527,6 @@ node-dir@^0.1.10, node-dir@^0.1.17:
dependencies: dependencies:
minimatch "^3.0.2" minimatch "^3.0.2"
node-emoji@^1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da"
integrity sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==
dependencies:
lodash.toarray "^4.4.0"
node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1: node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1:
version "2.6.1" version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
@ -13914,17 +13872,6 @@ react-element-to-jsx-string@^14.1.0:
"@base2/pretty-print-object" "1.0.0" "@base2/pretty-print-object" "1.0.0"
is-plain-object "3.0.1" is-plain-object "3.0.1"
react-emoji-render@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/react-emoji-render/-/react-emoji-render-1.2.4.tgz#fa3542a692e1eed3236f0f12b8e3a61b2818e2c2"
integrity sha512-AqktVXV38uDpgf02BoCXrzLYFsHAsxfdWwjrLexSJ22l1JgB01y1KejjxW/zTuCzod6O7BZfiMS866LEEfMHmA==
dependencies:
classnames "^2.2.5"
emoji-regex "^6.4.1"
lodash.flatten "^4.4.0"
prop-types "^15.5.8"
string-replace-to-array "^1.0.1"
react-error-overlay@^6.0.3, react-error-overlay@^6.0.7: react-error-overlay@^6.0.3, react-error-overlay@^6.0.7:
version "6.0.8" version "6.0.8"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de"
@ -14215,6 +14162,21 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.1:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react-visibility-sensor@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/react-visibility-sensor/-/react-visibility-sensor-5.1.1.tgz#5238380960d3a0b2be0b7faddff38541e337f5a9"
integrity sha512-cTUHqIK+zDYpeK19rzW6zF9YfT4486TIgizZW53wEZ+/GPBbK7cNS0EHyJVyHYacwFEvvHLEKfgJndbemWhB/w==
dependencies:
prop-types "^15.7.2"
react-window@^1.8.6:
version "1.8.6"
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.6.tgz#d011950ac643a994118632665aad0c6382e2a112"
integrity sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==
dependencies:
"@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6"
react@^16.12.0, react@^16.8.3: react@^16.12.0, react@^16.8.3:
version "16.14.0" version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
@ -15693,15 +15655,6 @@ string-length@^3.1.0:
astral-regex "^1.0.0" astral-regex "^1.0.0"
strip-ansi "^5.2.0" strip-ansi "^5.2.0"
string-replace-to-array@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/string-replace-to-array/-/string-replace-to-array-1.0.3.tgz#c93eba999a5ee24d731aebbaf5aba36b5f18f7bf"
integrity sha1-yT66mZpe4k1zGuu69auja18Y978=
dependencies:
invariant "^2.2.1"
lodash.flatten "^4.2.0"
lodash.isstring "^4.0.1"
string-width@^1.0.1: string-width@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"

1
go.mod
View File

@ -23,6 +23,5 @@ require (
github.com/spf13/viper v1.4.0 github.com/spf13/viper v1.4.0
github.com/vektah/gqlparser/v2 v2.0.1 github.com/vektah/gqlparser/v2 v2.0.1
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/mail.v2 v2.3.1 gopkg.in/mail.v2 v2.3.1
) )

3
go.sum
View File

@ -388,6 +388,7 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/matcornic/hermes v1.2.0 h1:AuqZpYcTOtTB7cahdevLfnhIpfzmpqw5Czv8vpdnFDU=
github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc= github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc=
github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI= github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI=
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg= github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007 h1:reVOUXwnhsYv/8UqjvhrMOu5CNT9UapHFLbQ2JcXsmg=
@ -891,8 +892,6 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

View File

@ -15,7 +15,6 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jordanknott/taskcafe/internal/route" "github.com/jordanknott/taskcafe/internal/route"
"github.com/jordanknott/taskcafe/internal/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -76,25 +75,11 @@ func newWebCmd() *cobra.Command {
log.Warn("server.secret is not set, generating a random secret") log.Warn("server.secret is not set, generating a random secret")
secret = uuid.New().String() secret = uuid.New().String()
} }
r, _ := route.NewRouter(db, utils.EmailConfig{ r, _ := route.NewRouter(db, []byte(secret))
From: viper.GetString("smtp.from"),
Host: viper.GetString("smtp.host"),
Port: viper.GetInt("smtp.port"),
Username: viper.GetString("smtp.username"),
Password: viper.GetString("smtp.password"),
InsecureSkipVerify: viper.GetBool("smtp.skip_verify"),
}, []byte(secret))
return http.ListenAndServe(viper.GetString("server.hostname"), r) return http.ListenAndServe(viper.GetString("server.hostname"), r)
}, },
} }
viper.SetDefault("smtp.from", "no-reply@example.com")
viper.SetDefault("smtp.host", "localhost")
viper.SetDefault("smtp.port", 587)
viper.SetDefault("smtp.username", "")
viper.SetDefault("smtp.password", "")
viper.SetDefault("smtp.skip_verify", false)
cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server") cc.Flags().Bool("migrate", false, "if true, auto run's schema migrations before starting the web server")
viper.BindPFlag("migrate", cc.Flags().Lookup("migrate")) viper.BindPFlag("migrate", cc.Flags().Lookup("migrate"))

View File

@ -145,16 +145,6 @@ type TaskChecklistItem struct {
DueDate sql.NullTime `json:"due_date"` DueDate sql.NullTime `json:"due_date"`
} }
type TaskComment struct {
TaskCommentID uuid.UUID `json:"task_comment_id"`
TaskID uuid.UUID `json:"task_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
CreatedBy uuid.UUID `json:"created_by"`
Pinned bool `json:"pinned"`
Message string `json:"message"`
}
type TaskGroup struct { type TaskGroup struct {
TaskGroupID uuid.UUID `json:"task_group_id"` TaskGroupID uuid.UUID `json:"task_group_id"`
ProjectID uuid.UUID `json:"project_id"` ProjectID uuid.UUID `json:"project_id"`

View File

@ -28,7 +28,6 @@ type Querier interface {
CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error) CreateTaskAssigned(ctx context.Context, arg CreateTaskAssignedParams) (TaskAssigned, error)
CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error) CreateTaskChecklist(ctx context.Context, arg CreateTaskChecklistParams) (TaskChecklist, error)
CreateTaskChecklistItem(ctx context.Context, arg CreateTaskChecklistItemParams) (TaskChecklistItem, error) CreateTaskChecklistItem(ctx context.Context, arg CreateTaskChecklistItemParams) (TaskChecklistItem, error)
CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error)
CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error) CreateTaskGroup(ctx context.Context, arg CreateTaskGroupParams) (TaskGroup, error)
CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error) CreateTaskLabelForTask(ctx context.Context, arg CreateTaskLabelForTaskParams) (TaskLabel, error)
CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error)
@ -49,7 +48,6 @@ type Querier interface {
DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error
DeleteTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) error DeleteTaskChecklistByID(ctx context.Context, taskChecklistID uuid.UUID) error
DeleteTaskChecklistItem(ctx context.Context, taskChecklistItemID uuid.UUID) error DeleteTaskChecklistItem(ctx context.Context, taskChecklistItemID uuid.UUID) error
DeleteTaskCommentByID(ctx context.Context, taskCommentID uuid.UUID) (TaskComment, error)
DeleteTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (int64, error) DeleteTaskGroupByID(ctx context.Context, taskGroupID uuid.UUID) (int64, error)
DeleteTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) error DeleteTaskLabelByID(ctx context.Context, taskLabelID uuid.UUID) error
DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error DeleteTaskLabelForTaskByProjectLabelID(ctx context.Context, arg DeleteTaskLabelForTaskByProjectLabelIDParams) error
@ -69,7 +67,6 @@ type Querier interface {
GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error) GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error)
GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error) GetAssignedMembersForTask(ctx context.Context, taskID uuid.UUID) ([]TaskAssigned, error)
GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error)
GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error) GetConfirmTokenByEmail(ctx context.Context, email string) (UserAccountConfirmToken, error)
GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error) GetConfirmTokenByID(ctx context.Context, confirmTokenID uuid.UUID) (UserAccountConfirmToken, error)
GetEntityForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetEntityForNotificationIDRow, error) GetEntityForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetEntityForNotificationIDRow, error)
@ -142,7 +139,6 @@ type Querier interface {
UpdateTaskChecklistItemName(ctx context.Context, arg UpdateTaskChecklistItemNameParams) (TaskChecklistItem, error) UpdateTaskChecklistItemName(ctx context.Context, arg UpdateTaskChecklistItemNameParams) (TaskChecklistItem, error)
UpdateTaskChecklistName(ctx context.Context, arg UpdateTaskChecklistNameParams) (TaskChecklist, error) UpdateTaskChecklistName(ctx context.Context, arg UpdateTaskChecklistNameParams) (TaskChecklist, error)
UpdateTaskChecklistPosition(ctx context.Context, arg UpdateTaskChecklistPositionParams) (TaskChecklist, error) UpdateTaskChecklistPosition(ctx context.Context, arg UpdateTaskChecklistPositionParams) (TaskChecklist, error)
UpdateTaskComment(ctx context.Context, arg UpdateTaskCommentParams) (TaskComment, error)
UpdateTaskDescription(ctx context.Context, arg UpdateTaskDescriptionParams) (Task, error) UpdateTaskDescription(ctx context.Context, arg UpdateTaskDescriptionParams) (Task, error)
UpdateTaskDueDate(ctx context.Context, arg UpdateTaskDueDateParams) (Task, error) UpdateTaskDueDate(ctx context.Context, arg UpdateTaskDueDateParams) (Task, error)
UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error) UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error)

View File

@ -43,16 +43,3 @@ UPDATE task SET complete = $2, completed_at = $3 WHERE task_id = $1 RETURNING *;
SELECT project_id FROM task SELECT project_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
WHERE task_id = $1; WHERE task_id = $1;
-- name: CreateTaskComment :one
INSERT INTO task_comment (task_id, message, created_at, created_by)
VALUES ($1, $2, $3, $4) RETURNING *;
-- name: GetCommentsForTaskID :many
SELECT * FROM task_comment WHERE task_id = $1 ORDER BY created_at;
-- name: DeleteTaskCommentByID :one
DELETE FROM task_comment WHERE task_comment_id = $1 RETURNING *;
-- name: UpdateTaskComment :one
UPDATE task_comment SET message = $2, updated_at = $3 WHERE task_comment_id = $1 RETURNING *;

View File

@ -85,38 +85,6 @@ func (q *Queries) CreateTaskAll(ctx context.Context, arg CreateTaskAllParams) (T
return i, err return i, err
} }
const createTaskComment = `-- name: CreateTaskComment :one
INSERT INTO task_comment (task_id, message, created_at, created_by)
VALUES ($1, $2, $3, $4) RETURNING task_comment_id, task_id, created_at, updated_at, created_by, pinned, message
`
type CreateTaskCommentParams struct {
TaskID uuid.UUID `json:"task_id"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
CreatedBy uuid.UUID `json:"created_by"`
}
func (q *Queries) CreateTaskComment(ctx context.Context, arg CreateTaskCommentParams) (TaskComment, error) {
row := q.db.QueryRowContext(ctx, createTaskComment,
arg.TaskID,
arg.Message,
arg.CreatedAt,
arg.CreatedBy,
)
var i TaskComment
err := row.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
)
return i, err
}
const deleteTaskByID = `-- name: DeleteTaskByID :exec const deleteTaskByID = `-- name: DeleteTaskByID :exec
DELETE FROM task WHERE task_id = $1 DELETE FROM task WHERE task_id = $1
` `
@ -126,25 +94,6 @@ func (q *Queries) DeleteTaskByID(ctx context.Context, taskID uuid.UUID) error {
return err return err
} }
const deleteTaskCommentByID = `-- name: DeleteTaskCommentByID :one
DELETE FROM task_comment WHERE task_comment_id = $1 RETURNING task_comment_id, task_id, created_at, updated_at, created_by, pinned, message
`
func (q *Queries) DeleteTaskCommentByID(ctx context.Context, taskCommentID uuid.UUID) (TaskComment, error) {
row := q.db.QueryRowContext(ctx, deleteTaskCommentByID, taskCommentID)
var i TaskComment
err := row.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
)
return i, err
}
const deleteTasksByTaskGroupID = `-- name: DeleteTasksByTaskGroupID :execrows const deleteTasksByTaskGroupID = `-- name: DeleteTasksByTaskGroupID :execrows
DELETE FROM task where task_group_id = $1 DELETE FROM task where task_group_id = $1
` `
@ -194,41 +143,6 @@ func (q *Queries) GetAllTasks(ctx context.Context) ([]Task, error) {
return items, nil return items, nil
} }
const getCommentsForTaskID = `-- name: GetCommentsForTaskID :many
SELECT task_comment_id, task_id, created_at, updated_at, created_by, pinned, message FROM task_comment WHERE task_id = $1 ORDER BY created_at
`
func (q *Queries) GetCommentsForTaskID(ctx context.Context, taskID uuid.UUID) ([]TaskComment, error) {
rows, err := q.db.QueryContext(ctx, getCommentsForTaskID, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TaskComment
for rows.Next() {
var i TaskComment
if err := rows.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
); 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 getProjectIDForTask = `-- name: GetProjectIDForTask :one const getProjectIDForTask = `-- name: GetProjectIDForTask :one
SELECT project_id FROM task SELECT project_id FROM task
INNER JOIN task_group ON task_group.task_group_id = task.task_group_id INNER JOIN task_group ON task_group.task_group_id = task.task_group_id
@ -327,31 +241,6 @@ func (q *Queries) SetTaskComplete(ctx context.Context, arg SetTaskCompleteParams
return i, err return i, err
} }
const updateTaskComment = `-- name: UpdateTaskComment :one
UPDATE task_comment SET message = $2, updated_at = $3 WHERE task_comment_id = $1 RETURNING task_comment_id, task_id, created_at, updated_at, created_by, pinned, message
`
type UpdateTaskCommentParams struct {
TaskCommentID uuid.UUID `json:"task_comment_id"`
Message string `json:"message"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
func (q *Queries) UpdateTaskComment(ctx context.Context, arg UpdateTaskCommentParams) (TaskComment, error) {
row := q.db.QueryRowContext(ctx, updateTaskComment, arg.TaskCommentID, arg.Message, arg.UpdatedAt)
var i TaskComment
err := row.Scan(
&i.TaskCommentID,
&i.TaskID,
&i.CreatedAt,
&i.UpdatedAt,
&i.CreatedBy,
&i.Pinned,
&i.Message,
)
return i, err
}
const updateTaskDescription = `-- name: UpdateTaskDescription :one const updateTaskDescription = `-- name: UpdateTaskDescription :one
UPDATE task SET description = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at UPDATE task SET description = $2 WHERE task_id = $1 RETURNING task_id, task_group_id, created_at, name, position, description, due_date, complete, completed_at
` `

File diff suppressed because it is too large Load Diff

View File

@ -26,11 +26,10 @@ import (
) )
// NewHandler returns a new graphql endpoint handler. // NewHandler returns a new graphql endpoint handler.
func NewHandler(repo db.Repository, emailConfig utils.EmailConfig) http.Handler { func NewHandler(repo db.Repository) http.Handler {
c := Config{ c := Config{
Resolvers: &Resolver{ Resolvers: &Resolver{
Repository: repo, Repository: repo,
EmailConfig: emailConfig,
}, },
} }
c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) { c.Directives.HasRole = func(ctx context.Context, obj interface{}, next graphql.Resolver, roles []RoleLevel, level ActionLevel, typeArg ObjectType) (interface{}, error) {
@ -268,16 +267,3 @@ type MasterEntry struct {
MemberType MemberType MemberType MemberType
ID uuid.UUID ID uuid.UUID
} }
const (
TASK_ADDED_TO_TASK_GROUP int32 = 1
TASK_MOVED_TO_TASK_GROUP int32 = 2
TASK_MARK_COMPLETE int32 = 3
TASK_MARK_INCOMPLETE int32 = 4
TASK_DUE_DATE_CHANGED int32 = 5
TASK_DUE_DATE_ADDED int32 = 6
TASK_DUE_DATE_REMOVED int32 = 7
TASK_CHECKLIST_CHANGED int32 = 8
TASK_CHECKLIST_ADDED int32 = 9
TASK_CHECKLIST_REMOVED int32 = 10
)

View File

@ -45,16 +45,6 @@ type CreateTaskChecklistItem struct {
Position float64 `json:"position"` Position float64 `json:"position"`
} }
type CreateTaskComment struct {
TaskID uuid.UUID `json:"taskID"`
Message string `json:"message"`
}
type CreateTaskCommentPayload struct {
TaskID uuid.UUID `json:"taskID"`
Comment *db.TaskComment `json:"comment"`
}
type CreateTeamMember struct { type CreateTeamMember struct {
UserID uuid.UUID `json:"userID"` UserID uuid.UUID `json:"userID"`
TeamID uuid.UUID `json:"teamID"` TeamID uuid.UUID `json:"teamID"`
@ -65,12 +55,6 @@ type CreateTeamMemberPayload struct {
TeamMember *Member `json:"teamMember"` TeamMember *Member `json:"teamMember"`
} }
type CreatedBy struct {
ID uuid.UUID `json:"id"`
FullName string `json:"fullName"`
ProfileIcon *ProfileIcon `json:"profileIcon"`
}
type DeleteInvitedProjectMember struct { type DeleteInvitedProjectMember struct {
ProjectID uuid.UUID `json:"projectID"` ProjectID uuid.UUID `json:"projectID"`
Email string `json:"email"` Email string `json:"email"`
@ -130,15 +114,6 @@ type DeleteTaskChecklistPayload struct {
TaskChecklist *db.TaskChecklist `json:"taskChecklist"` TaskChecklist *db.TaskChecklist `json:"taskChecklist"`
} }
type DeleteTaskComment struct {
CommentID uuid.UUID `json:"commentID"`
}
type DeleteTaskCommentPayload struct {
TaskID uuid.UUID `json:"taskID"`
CommentID uuid.UUID `json:"commentID"`
}
type DeleteTaskGroupInput struct { type DeleteTaskGroupInput struct {
TaskGroupID uuid.UUID `json:"taskGroupID"` TaskGroupID uuid.UUID `json:"taskGroupID"`
} }
@ -502,16 +477,6 @@ type UpdateTaskChecklistName struct {
Name string `json:"name"` Name string `json:"name"`
} }
type UpdateTaskComment struct {
CommentID uuid.UUID `json:"commentID"`
Message string `json:"message"`
}
type UpdateTaskCommentPayload struct {
TaskID uuid.UUID `json:"taskID"`
Comment *db.TaskComment `json:"comment"`
}
type UpdateTaskDescriptionInput struct { type UpdateTaskDescriptionInput struct {
TaskID uuid.UUID `json:"taskID"` TaskID uuid.UUID `json:"taskID"`
Description string `json:"description"` Description string `json:"description"`

View File

@ -7,12 +7,10 @@ import (
"sync" "sync"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/utils"
) )
// Resolver handles resolving GraphQL queries & mutations // Resolver handles resolving GraphQL queries & mutations
type Resolver struct { type Resolver struct {
Repository db.Repository Repository db.Repository
EmailConfig utils.EmailConfig mu sync.Mutex
mu sync.Mutex
} }

View File

@ -182,22 +182,6 @@ type Task {
checklists: [TaskChecklist!]! checklists: [TaskChecklist!]!
badges: TaskBadges! badges: TaskBadges!
activity: [TaskActivity!]! activity: [TaskActivity!]!
comments: [TaskComment!]!
}
type CreatedBy {
id: ID!
fullName: String!
profileIcon: ProfileIcon!
}
type TaskComment {
id: ID!
createdAt: Time!
updatedAt: Time
message: String!
createdBy: CreatedBy!
pinned: Boolean!
} }
type Organization { type Organization {
@ -631,44 +615,6 @@ type DeleteTaskChecklistPayload {
taskChecklist: TaskChecklist! taskChecklist: TaskChecklist!
} }
extend type Mutation {
createTaskComment(input: CreateTaskComment):
CreateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
deleteTaskComment(input: DeleteTaskComment):
DeleteTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
updateTaskComment(input: UpdateTaskComment):
UpdateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
}
input CreateTaskComment {
taskID: UUID!
message: String!
}
type CreateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input UpdateTaskComment {
commentID: UUID!
message: String!
}
type UpdateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input DeleteTaskComment {
commentID: UUID!
}
type DeleteTaskCommentPayload {
taskID: UUID!
commentID: UUID!
}
extend type Mutation { extend type Mutation {
createTaskGroup(input: NewTaskGroup!): createTaskGroup(input: NewTaskGroup!):
TaskGroup! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT) TaskGroup! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: PROJECT)
@ -943,3 +889,4 @@ type DeleteUserAccountPayload {
ok: Boolean! ok: Boolean!
userAccount: UserAccount! userAccount: UserAccount!
} }

View File

@ -5,6 +5,7 @@ package graph
import ( import (
"context" "context"
"crypto/tls"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
@ -15,11 +16,12 @@ import (
"github.com/jordanknott/taskcafe/internal/auth" "github.com/jordanknott/taskcafe/internal/auth"
"github.com/jordanknott/taskcafe/internal/db" "github.com/jordanknott/taskcafe/internal/db"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils"
"github.com/lithammer/fuzzysearch/fuzzy" "github.com/lithammer/fuzzysearch/fuzzy"
hermes "github.com/matcornic/hermes/v2"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/vektah/gqlparser/v2/gqlerror" "github.com/vektah/gqlparser/v2/gqlerror"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
gomail "gopkg.in/mail.v2"
) )
func (r *labelColorResolver) ID(ctx context.Context, obj *db.LabelColor) (uuid.UUID, error) { func (r *labelColorResolver) ID(ctx context.Context, obj *db.LabelColor) (uuid.UUID, error) {
@ -191,11 +193,79 @@ func (r *mutationResolver) InviteProjectMembers(ctx context.Context, input Invit
if err != nil { if err != nil {
return &InviteProjectMembersPayload{Ok: false}, err return &InviteProjectMembersPayload{Ok: false}, err
} }
invite := utils.EmailInvite{To: *invitedMember.Email, FullName: *invitedMember.Email, ConfirmToken: confirmToken.ConfirmTokenID.String()} // send out invitation
err = utils.SendEmailInvite(r.EmailConfig, invite) // add project invite entry
// send out notification?
h := hermes.Hermes{
// Optional Theme
Product: hermes.Product{
// Appears in header & footer of e-mails
Name: "Taskscafe",
Link: "http://localhost:3333/",
// Optional product logo
Logo: "https://github.com/JordanKnott/taskcafe/raw/master/.github/taskcafe-full.png",
},
}
email := hermes.Email{
Body: hermes.Body{
Name: "Jordan Knott",
Intros: []string{
"You have been invited to join Taskcafe",
},
Actions: []hermes.Action{
{
Instructions: "To get started with Taskcafe, please click here:",
Button: hermes.Button{
Color: "#7367F0", // Optional action button color
TextColor: "#FFFFFF",
Text: "Register your account",
Link: "http://localhost:3000/register?confirmToken=" + confirmToken.ConfirmTokenID.String(),
},
},
},
Outros: []string{
"Need help, or have questions? Just reply to this email, we'd love to help.",
},
},
}
// Generate an HTML email with the provided contents (for modern clients)
emailBody, err := h.GenerateHTML(email)
if err != nil { if err != nil {
logger.New(ctx).WithError(err).Error("issue sending email") panic(err) // Tip: Handle error with something else than a panic ;)
return &InviteProjectMembersPayload{Ok: false}, err }
emailBodyPlain, err := h.GeneratePlainText(email)
if err != nil {
panic(err) // Tip: Handle error with something else than a panic ;)
}
m := gomail.NewMessage()
// Set E-Mail sender
m.SetHeader("From", "no-reply@taskcafe.com")
// Set E-Mail receivers
m.SetHeader("To", invitedUser.Email)
// Set E-Mail subject
m.SetHeader("Subject", "You have been invited to Taskcafe")
// Set E-Mail body. You can set plain text or html with text/html
m.SetBody("text/html", emailBody)
m.AddAlternative("text/plain", emailBodyPlain)
// Settings for SMTP server
d := gomail.NewDialer("127.0.0.1", 11500, "no-reply@taskcafe.com", "")
// This is only needed when SSL/TLS certificate is not valid on server.
// In production this should be set to false.
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
// Now send E-Mail
if err := d.DialAndSend(m); err != nil {
fmt.Println(err)
panic(err)
} }
} else { } else {
return &InviteProjectMembersPayload{Ok: false}, err return &InviteProjectMembersPayload{Ok: false}, err
@ -304,13 +374,11 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input NewTask) (*db.T
data := map[string]string{ data := map[string]string{
"TaskGroup": taskGroup.Name, "TaskGroup": taskGroup.Name,
} }
userID, _ := GetUserID(ctx)
d, err := json.Marshal(data) d, err := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{ _, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: task.TaskID, TaskID: task.TaskID,
Data: d, Data: d,
CreatedAt: createdAt, CreatedAt: createdAt,
CausedBy: userID,
ActivityTypeID: 1, ActivityTypeID: 1,
}) })
@ -386,21 +454,6 @@ func (r *mutationResolver) UpdateTaskName(ctx context.Context, input UpdateTaskN
func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskComplete) (*db.Task, error) { func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskComplete) (*db.Task, error) {
completedAt := time.Now().UTC() completedAt := time.Now().UTC()
data := map[string]string{}
activityType := TASK_MARK_INCOMPLETE
if input.Complete {
activityType = TASK_MARK_COMPLETE
}
createdAt := time.Now().UTC()
userID, _ := GetUserID(ctx)
d, err := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: input.TaskID,
Data: d,
CausedBy: userID,
CreatedAt: createdAt,
ActivityTypeID: activityType,
})
task, err := r.Repository.SetTaskComplete(ctx, db.SetTaskCompleteParams{TaskID: input.TaskID, Complete: input.Complete, CompletedAt: sql.NullTime{Time: completedAt, Valid: true}}) task, err := r.Repository.SetTaskComplete(ctx, db.SetTaskCompleteParams{TaskID: input.TaskID, Complete: input.Complete, CompletedAt: sql.NullTime{Time: completedAt, Valid: true}})
if err != nil { if err != nil {
return &db.Task{}, err return &db.Task{}, err
@ -409,23 +462,6 @@ func (r *mutationResolver) SetTaskComplete(ctx context.Context, input SetTaskCom
} }
func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTaskDueDate) (*db.Task, error) { func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTaskDueDate) (*db.Task, error) {
userID, _ := GetUserID(ctx)
prevTask, err := r.Repository.GetTaskByID(ctx, input.TaskID)
if err != nil {
return &db.Task{}, err
}
data := map[string]string{}
var activityType = TASK_DUE_DATE_ADDED
if input.DueDate == nil && prevTask.DueDate.Valid {
activityType = TASK_DUE_DATE_REMOVED
data["PrevDueDate"] = prevTask.DueDate.Time.String()
} else if prevTask.DueDate.Valid {
activityType = TASK_DUE_DATE_CHANGED
data["PrevDueDate"] = prevTask.DueDate.Time.String()
data["CurDueDate"] = input.DueDate.String()
} else {
data["DueDate"] = input.DueDate.String()
}
var dueDate sql.NullTime var dueDate sql.NullTime
if input.DueDate == nil { if input.DueDate == nil {
dueDate = sql.NullTime{Valid: false, Time: time.Now()} dueDate = sql.NullTime{Valid: false, Time: time.Now()}
@ -436,15 +472,6 @@ func (r *mutationResolver) UpdateTaskDueDate(ctx context.Context, input UpdateTa
TaskID: input.TaskID, TaskID: input.TaskID,
DueDate: dueDate, DueDate: dueDate,
}) })
createdAt := time.Now().UTC()
d, err := json.Marshal(data)
_, err = r.Repository.CreateTaskActivity(ctx, db.CreateTaskActivityParams{
TaskID: task.TaskID,
Data: d,
CausedBy: userID,
CreatedAt: createdAt,
ActivityTypeID: activityType,
})
return &task, err return &task, err
} }
@ -586,33 +613,6 @@ func (r *mutationResolver) UpdateTaskChecklistItemLocation(ctx context.Context,
return &UpdateTaskChecklistItemLocationPayload{PrevChecklistID: currentChecklistItem.TaskChecklistID, TaskChecklistID: input.TaskChecklistID, ChecklistItem: &checklistItem}, err return &UpdateTaskChecklistItemLocationPayload{PrevChecklistID: currentChecklistItem.TaskChecklistID, TaskChecklistID: input.TaskChecklistID, ChecklistItem: &checklistItem}, err
} }
func (r *mutationResolver) CreateTaskComment(ctx context.Context, input *CreateTaskComment) (*CreateTaskCommentPayload, error) {
userID, _ := GetUserID(ctx)
createdAt := time.Now().UTC()
comment, err := r.Repository.CreateTaskComment(ctx, db.CreateTaskCommentParams{
TaskID: input.TaskID,
CreatedAt: createdAt,
CreatedBy: userID,
Message: input.Message,
})
return &CreateTaskCommentPayload{Comment: &comment, TaskID: input.TaskID}, err
}
func (r *mutationResolver) DeleteTaskComment(ctx context.Context, input *DeleteTaskComment) (*DeleteTaskCommentPayload, error) {
task, err := r.Repository.DeleteTaskCommentByID(ctx, input.CommentID)
return &DeleteTaskCommentPayload{TaskID: task.TaskID, CommentID: input.CommentID}, err
}
func (r *mutationResolver) UpdateTaskComment(ctx context.Context, input *UpdateTaskComment) (*UpdateTaskCommentPayload, error) {
updatedAt := time.Now().UTC()
comment, err := r.Repository.UpdateTaskComment(ctx, db.UpdateTaskCommentParams{
TaskCommentID: input.CommentID,
UpdatedAt: sql.NullTime{Valid: true, Time: updatedAt},
Message: input.Message,
})
return &UpdateTaskCommentPayload{Comment: &comment}, err
}
func (r *mutationResolver) CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*db.TaskGroup, error) { func (r *mutationResolver) CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*db.TaskGroup, error) {
createdAt := time.Now().UTC() createdAt := time.Now().UTC()
project, err := r.Repository.CreateTaskGroup(ctx, project, err := r.Repository.CreateTaskGroup(ctx,
@ -1617,14 +1617,6 @@ func (r *taskResolver) Activity(ctx context.Context, obj *db.Task) ([]db.TaskAct
return activity, err return activity, err
} }
func (r *taskResolver) Comments(ctx context.Context, obj *db.Task) ([]db.TaskComment, error) {
comments, err := r.Repository.GetCommentsForTaskID(ctx, obj.TaskID)
if err == sql.ErrNoRows {
return []db.TaskComment{}, nil
}
return comments, err
}
func (r *taskActivityResolver) ID(ctx context.Context, obj *db.TaskActivity) (uuid.UUID, error) { func (r *taskActivityResolver) ID(ctx context.Context, obj *db.TaskActivity) (uuid.UUID, error) {
return obj.TaskActivityID, nil return obj.TaskActivityID, nil
} }
@ -1699,31 +1691,6 @@ func (r *taskChecklistItemResolver) DueDate(ctx context.Context, obj *db.TaskChe
panic(fmt.Errorf("not implemented")) panic(fmt.Errorf("not implemented"))
} }
func (r *taskCommentResolver) ID(ctx context.Context, obj *db.TaskComment) (uuid.UUID, error) {
return obj.TaskCommentID, nil
}
func (r *taskCommentResolver) UpdatedAt(ctx context.Context, obj *db.TaskComment) (*time.Time, error) {
if obj.UpdatedAt.Valid {
return &obj.UpdatedAt.Time, nil
}
return nil, nil
}
func (r *taskCommentResolver) CreatedBy(ctx context.Context, obj *db.TaskComment) (*CreatedBy, error) {
user, err := r.Repository.GetUserAccountByID(ctx, obj.CreatedBy)
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
return &CreatedBy{
ID: obj.CreatedBy,
FullName: user.FullName,
ProfileIcon: profileIcon,
}, err
}
func (r *taskGroupResolver) ID(ctx context.Context, obj *db.TaskGroup) (uuid.UUID, error) { func (r *taskGroupResolver) ID(ctx context.Context, obj *db.TaskGroup) (uuid.UUID, error) {
return obj.TaskGroupID, nil return obj.TaskGroupID, nil
} }
@ -1885,9 +1852,6 @@ func (r *Resolver) TaskChecklistItem() TaskChecklistItemResolver {
return &taskChecklistItemResolver{r} return &taskChecklistItemResolver{r}
} }
// TaskComment returns TaskCommentResolver implementation.
func (r *Resolver) TaskComment() TaskCommentResolver { return &taskCommentResolver{r} }
// TaskGroup returns TaskGroupResolver implementation. // TaskGroup returns TaskGroupResolver implementation.
func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} } func (r *Resolver) TaskGroup() TaskGroupResolver { return &taskGroupResolver{r} }
@ -1912,7 +1876,6 @@ type taskResolver struct{ *Resolver }
type taskActivityResolver struct{ *Resolver } type taskActivityResolver struct{ *Resolver }
type taskChecklistResolver struct{ *Resolver } type taskChecklistResolver struct{ *Resolver }
type taskChecklistItemResolver struct{ *Resolver } type taskChecklistItemResolver struct{ *Resolver }
type taskCommentResolver struct{ *Resolver }
type taskGroupResolver struct{ *Resolver } type taskGroupResolver struct{ *Resolver }
type taskLabelResolver struct{ *Resolver } type taskLabelResolver struct{ *Resolver }
type teamResolver struct{ *Resolver } type teamResolver struct{ *Resolver }

View File

@ -182,22 +182,6 @@ type Task {
checklists: [TaskChecklist!]! checklists: [TaskChecklist!]!
badges: TaskBadges! badges: TaskBadges!
activity: [TaskActivity!]! activity: [TaskActivity!]!
comments: [TaskComment!]!
}
type CreatedBy {
id: ID!
fullName: String!
profileIcon: ProfileIcon!
}
type TaskComment {
id: ID!
createdAt: Time!
updatedAt: Time
message: String!
createdBy: CreatedBy!
pinned: Boolean!
} }
type Organization { type Organization {

View File

@ -1,37 +0,0 @@
extend type Mutation {
createTaskComment(input: CreateTaskComment):
CreateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
deleteTaskComment(input: DeleteTaskComment):
DeleteTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
updateTaskComment(input: UpdateTaskComment):
UpdateTaskCommentPayload! @hasRole(roles: [ADMIN, MEMBER], level: PROJECT, type: TASK)
}
input CreateTaskComment {
taskID: UUID!
message: String!
}
type CreateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input UpdateTaskComment {
commentID: UUID!
message: String!
}
type UpdateTaskCommentPayload {
taskID: UUID!
comment: TaskComment!
}
input DeleteTaskComment {
commentID: UUID!
}
type DeleteTaskCommentPayload {
taskID: UUID!
commentID: UUID!
}

View File

@ -16,7 +16,6 @@ import (
"github.com/jordanknott/taskcafe/internal/frontend" "github.com/jordanknott/taskcafe/internal/frontend"
"github.com/jordanknott/taskcafe/internal/graph" "github.com/jordanknott/taskcafe/internal/graph"
"github.com/jordanknott/taskcafe/internal/logger" "github.com/jordanknott/taskcafe/internal/logger"
"github.com/jordanknott/taskcafe/internal/utils"
) )
// FrontendHandler serves an embed React client through chi // FrontendHandler serves an embed React client through chi
@ -65,7 +64,7 @@ type TaskcafeHandler struct {
} }
// NewRouter creates a new router for chi // NewRouter creates a new router for chi
func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, jwtKey []byte) (chi.Router, error) { func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) {
formatter := new(log.TextFormatter) formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05" formatter.TimestampFormat = "02-01-2006 15:04:05"
formatter.FullTimestamp = true formatter.FullTimestamp = true
@ -95,7 +94,7 @@ func NewRouter(dbConnection *sqlx.DB, emailConfig utils.EmailConfig, jwtKey []by
r.Group(func(mux chi.Router) { r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware) mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload) mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Handle("/graphql", graph.NewHandler(*repository, emailConfig)) mux.Handle("/graphql", graph.NewHandler(*repository))
}) })
frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"} frontend := FrontendHandler{staticPath: "build", indexPath: "index.html"}

View File

@ -1,94 +0,0 @@
package utils
import (
"crypto/tls"
hermes "github.com/matcornic/hermes/v2"
gomail "gopkg.in/mail.v2"
)
type EmailConfig struct {
Host string
Port int
From string
Username string
Password string
SiteURL string
InsecureSkipVerify bool
}
type EmailInvite struct {
ConfirmToken string
FullName string
To string
}
func SendEmailInvite(config EmailConfig, invite EmailInvite) error {
h := hermes.Hermes{
Product: hermes.Product{
Name: "Taskscafe",
Link: config.SiteURL,
Logo: "https://github.com/JordanKnott/taskcafe/raw/master/.github/taskcafe-full.png",
},
}
email := hermes.Email{
Body: hermes.Body{
Name: invite.FullName,
Intros: []string{
"You have been invited to join Taskcafe",
},
Actions: []hermes.Action{
{
Instructions: "To get started with Taskcafe, please click here:",
Button: hermes.Button{
Color: "#7367F0", // Optional action button color
TextColor: "#FFFFFF",
Text: "Register your account",
Link: config.SiteURL + "/register?confirmToken=" + invite.ConfirmToken,
},
},
},
Outros: []string{
"Need help, or have questions? Just reply to this email, we'd love to help.",
},
},
}
emailBody, err := h.GenerateHTML(email)
if err != nil {
return err
}
emailBodyPlain, err := h.GeneratePlainText(email)
if err != nil {
return err
}
m := gomail.NewMessage()
// Set E-Mail sender
m.SetHeader("From", config.From)
// Set E-Mail receivers
m.SetHeader("To", invite.To)
// Set E-Mail subject
m.SetHeader("Subject", "You have been invited to Taskcafe")
// Set E-Mail body. You can set plain text or html with text/html
m.SetBody("text/html", emailBody)
m.AddAlternative("text/plain", emailBodyPlain)
// Settings for SMTP server
d := gomail.NewDialer(config.Host, config.Port, config.Username, config.Password)
// This is only needed when SSL/TLS certificate is not valid on server.
// In production this should be set to false.
d.TLSConfig = &tls.Config{InsecureSkipVerify: config.InsecureSkipVerify}
// Now send E-Mail
if err := d.DialAndSend(m); err != nil {
return err
}
return nil
}

View File

@ -25,3 +25,4 @@ CREATE TABLE task_activity (
activity_type_id int NOT NULL REFERENCES task_activity_type(task_activity_type_id), activity_type_id int NOT NULL REFERENCES task_activity_type(task_activity_type_id),
data jsonb data jsonb
); );

View File

@ -1,9 +0,0 @@
CREATE TABLE task_comment (
task_comment_id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id uuid NOT NULL REFERENCES task(task_id),
created_at timestamptz NOT NULL,
updated_at timestamptz,
created_by uuid NOT NULL REFERENCES user_account(user_id),
pinned boolean NOT NULL DEFAULT false,
message TEXT NOT NULL
);