From 5ec0fb36dafd425028043a72eef3a6bacc93e2fb Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Thu, 12 Oct 2023 21:11:46 +0600 Subject: [PATCH] Implement Custom Roadmap Modal --- .../MemberCustomProgressModal.tsx | 430 ++++++++++++++++++ .../TeamProgress/TeamProgressPage.tsx | 8 +- 2 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 src/components/TeamProgress/MemberCustomProgressModal.tsx diff --git a/src/components/TeamProgress/MemberCustomProgressModal.tsx b/src/components/TeamProgress/MemberCustomProgressModal.tsx new file mode 100644 index 000000000..6ac8dc13d --- /dev/null +++ b/src/components/TeamProgress/MemberCustomProgressModal.tsx @@ -0,0 +1,430 @@ +import { + useCallback, + useEffect, + useState, + type MouseEvent, + useMemo, + useRef, +} from 'react'; +import { Spinner } from '../ReactIcons/Spinner'; +import '../FrameRenderer/FrameRenderer.css'; +import type { TeamMember } from './TeamProgressPage'; +import { httpGet } from '../../lib/http'; +import { + renderTopicProgress, + type ResourceProgressType, + type ResourceType, + updateResourceProgress, +} from '../../lib/resource-progress'; +import CloseIcon from '../../icons/close.svg'; +import { useToast } from '../../hooks/use-toast'; +import { useAuth } from '../../hooks/use-auth'; +import { pageProgressMessage } from '../../stores/page'; +import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap'; +import { ReadonlyEditor } from '../../../editor/readonly-editor'; +import type { Node } from 'reactflow'; +import { + INITIAL_DESKTOP_ZOOM, + INITIAL_MOBILE_ZOOM, + calculateDimensions, +} from '../../../editor/utils/roadmap'; +import { isMobile } from '../../../editor/utils/is-mobile'; +import { useKeydown } from '../../hooks/use-keydown'; +import { useOutsideClick } from '../../hooks/use-outside-click'; + +export type ProgressMapProps = { + member: TeamMember; + teamId: string; + resourceId: string; + resourceType: 'roadmap' | 'best-practice'; + onClose: () => void; + onShowMyProgress: () => void; + isCustomResource?: boolean; +}; + +type MemberProgressResponse = { + removed: string[]; + done: string[]; + learning: string[]; + skipped: string[]; +}; + +export function MemberCustomProgressModal(props: ProgressMapProps) { + const { + resourceId, + member, + resourceType, + onShowMyProgress, + teamId, + onClose, + isCustomResource, + } = props; + + const user = useAuth(); + const isCurrentUser = user?.email === member.email; + + const popupBodyEl = useRef(null); + const [roadmap, setRoadmap] = useState(null); + const [memberProgress, setMemberProgress] = + useState(); + const [isLoading, setIsLoading] = useState(true); + const toast = useToast(); + + const initialZoom = useMemo( + () => (isMobile() ? INITIAL_MOBILE_ZOOM : INITIAL_DESKTOP_ZOOM), + [] + ); + + const { measuredHeight } = useMemo( + () => + calculateDimensions({ + nodes: roadmap?.nodes || [], + padding: 100, + }), + [roadmap?.nodes] + ); + + useKeydown('Escape', () => onClose()); + useOutsideClick(popupBodyEl, () => onClose()); + + async function getMemberProgress( + teamId: string, + memberId: string, + resourceType: string, + resourceId: string + ) { + const { error, response } = await httpGet( + `${ + import.meta.env.PUBLIC_API_URL + }/v1-get-member-resource-progress/${teamId}/${memberId}?resourceType=${resourceType}&resourceId=${resourceId}` + ); + if (error || !response) { + toast.error(error?.message || 'Failed to get member progress'); + return; + } + + setMemberProgress(response); + + return response; + } + + async function getRoadmap() { + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${resourceId}` + ); + + if (error || !response) { + toast.error(error?.message || 'Failed to load roadmap'); + return; + } + + setRoadmap(response); + + return response; + } + + useEffect(() => { + if (!resourceId || !resourceType || !teamId) { + return; + } + + setIsLoading(true); + Promise.all([ + getRoadmap(), + getMemberProgress(teamId, member._id, resourceType, resourceId), + ]) + .then(() => {}) + .catch((err) => { + console.error(err); + toast.error(err?.message || 'Something went wrong. Please try again!'); + }) + .finally(() => { + setIsLoading(false); + }); + }, [member]); + + function updateTopicStatus(topicId: string, newStatus: ResourceProgressType) { + if (!resourceId || !resourceType || !isCurrentUser) { + return; + } + + pageProgressMessage.set('Updating progress'); + updateResourceProgress( + { + resourceId: resourceId, + resourceType: resourceType as ResourceType, + topicId, + }, + newStatus + ) + .then(() => { + renderTopicProgress(topicId, newStatus); + getMemberProgress(teamId, member._id, resourceType, resourceId).then( + (data) => { + setMemberProgress(data); + } + ); + }) + .catch((err) => { + alert('Something went wrong, please try again.'); + console.error(err); + }) + .finally(() => { + pageProgressMessage.set(''); + }); + + return; + } + + const handleTopicRightClick = useCallback((e: MouseEvent, node: Node) => { + if (!isCurrentUser) { + return; + } + + const target = e?.currentTarget as HTMLDivElement; + if (!target) { + return; + } + + const isCurrentStatusDone = target?.classList.contains('done'); + updateTopicStatus(node.id, isCurrentStatusDone ? 'pending' : 'done'); + }, []); + + const handleTopicShiftClick = useCallback((e: MouseEvent, node: Node) => { + if (!isCurrentUser) { + return; + } + + const target = e?.currentTarget as HTMLDivElement; + if (!target) { + return; + } + + const isCurrentStatusLearning = target?.classList.contains('learning'); + updateTopicStatus( + node.id, + isCurrentStatusLearning ? 'pending' : 'learning' + ); + }, []); + + const handleTopicAltClick = useCallback((e: MouseEvent, node: Node) => { + if (!isCurrentUser) { + return; + } + + const target = e?.currentTarget as HTMLDivElement; + if (!target) { + return; + } + + const isCurrentStatusSkipped = target?.classList.contains('skipped'); + updateTopicStatus(node.id, isCurrentStatusSkipped ? 'pending' : 'skipped'); + }, []); + + const handleLinkClick = useCallback((linkId: string, href: string) => { + if (!href || !isCurrentUser) { + return; + } + + const isExternalLink = href.startsWith('http'); + if (isExternalLink) { + window.open(href, '_blank'); + } else { + window.location.href = href; + } + }, []); + + const removedTopics = memberProgress?.removed || []; + const memberDone = + memberProgress?.done.filter((id) => !removedTopics.includes(id)).length || + 0; + const memberLearning = + memberProgress?.learning.filter((id) => !removedTopics.includes(id)) + .length || 0; + const memberSkipped = + memberProgress?.skipped.filter((id) => !removedTopics.includes(id)) + .length || 0; + + const currProgress = member.progress.find((p) => p.resourceId === resourceId); + const memberTotal = currProgress?.total || 0; + + const progressPercentage = Math.round((memberDone / memberTotal) * 100); + + return ( +
+
+
+ {isCurrentUser && ( +
+

+ Follow the Instructions below to update your progress +

+
    +
  • + + Right Mouse Click + {' '} + on a topic to mark as{' '} + Done. +
  • +
  • + + Shift + {' '} + +{' '} + + Click + {' '} + on a topic to mark as{' '} + In progress. +
  • +
+
+ )} + +
+ {!isCurrentUser && ( +
+

+ {member.name}'s Progress +

+

+ You are looking at {member.name}'s progress.{' '} + + . +

+

+ +

+
+ )} +

+ + {progressPercentage}% Done + + + + {memberDone} of {memberTotal} done + +

+ +
+ + {!isLoading && roadmap && ( +
+ { + const { + removed = [], + done = [], + learning = [], + skipped = [], + } = memberProgress || {}; + + done.forEach((id: string) => renderTopicProgress(id, 'done')); + learning.forEach((id: string) => + renderTopicProgress(id, 'learning') + ); + skipped.forEach((id: string) => + renderTopicProgress(id, 'skipped') + ); + removed.forEach((id: string) => + renderTopicProgress(id, 'removed') + ); + }} + onTopicClick={(e) => { + e.preventDefault(); + }} + onTopicRightClick={handleTopicRightClick} + onTopicShiftClick={handleTopicShiftClick} + onTopicAltClick={handleTopicAltClick} + onButtonNodeClick={handleLinkClick} + onLinkClick={handleLinkClick} + fontFamily="Balsamiq Sans" + fontURL="/fonts/balsamiq.woff2" + /> +
+ )} + + {isLoading && ( +
+ +
+ )} + + +
+
+
+ ); +} diff --git a/src/components/TeamProgress/TeamProgressPage.tsx b/src/components/TeamProgress/TeamProgressPage.tsx index 8a9678a73..5724f49cd 100644 --- a/src/components/TeamProgress/TeamProgressPage.tsx +++ b/src/components/TeamProgress/TeamProgressPage.tsx @@ -9,6 +9,7 @@ import { GroupRoadmapItem } from './GroupRoadmapItem'; import { getUrlParams, setUrlParams } from '../../lib/browser'; import { useAuth } from '../../hooks/use-auth'; import { MemberProgressModal } from './MemberProgressModal'; +import { MemberCustomProgressModal } from './MemberCustomProgressModal'; export type UserProgress = { resourceTitle: string; @@ -152,10 +153,15 @@ export function TeamProgressPage() { return null; } + const ProgressModal = + showMemberProgress && !showMemberProgress.isCustomResource + ? MemberProgressModal + : MemberCustomProgressModal; + return (
{showMemberProgress && ( -