mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-11 03:34:00 +02:00
Add update progress functionality in modal (#4256)
* chore: add update progress in modal * chore: show tracking for current user * chore: current user header --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
22
src/components/ReactIcons/CloseIcon.tsx
Normal file
22
src/components/ReactIcons/CloseIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
type CloseIconProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CloseIcon(props: CloseIconProps) {
|
||||||
|
const { className } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { ClearProgress } from './Activity/ClearProgress';
|
|
||||||
import AstroIcon from './AstroIcon.astro';
|
import AstroIcon from './AstroIcon.astro';
|
||||||
import Icon from './AstroIcon.astro';
|
import Icon from './AstroIcon.astro';
|
||||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||||
|
@@ -6,9 +6,19 @@ import { useOutsideClick } from '../../hooks/use-outside-click';
|
|||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
import type { TeamMember } from './TeamProgressPage';
|
import type { TeamMember } from './TeamProgressPage';
|
||||||
import { httpGet } from '../../lib/http';
|
import { httpGet } from '../../lib/http';
|
||||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
import {
|
||||||
|
ResourceProgressType,
|
||||||
|
ResourceType,
|
||||||
|
renderTopicProgress,
|
||||||
|
updateResourceProgress,
|
||||||
|
} from '../../lib/resource-progress';
|
||||||
import CloseIcon from '../../icons/close.svg';
|
import CloseIcon from '../../icons/close.svg';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { useAuth } from '../../hooks/use-auth';
|
||||||
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
|
import { ProgressHint } from './ProgressHint';
|
||||||
|
import QuestionIcon from '../../icons/question.svg';
|
||||||
|
import { InfoIcon } from '../ReactIcons/InfoIcon';
|
||||||
|
|
||||||
export type ProgressMapProps = {
|
export type ProgressMapProps = {
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
@@ -27,10 +37,13 @@ type MemberProgressResponse = {
|
|||||||
|
|
||||||
export function MemberProgressModal(props: ProgressMapProps) {
|
export function MemberProgressModal(props: ProgressMapProps) {
|
||||||
const { resourceId, member, resourceType, teamId, onClose } = props;
|
const { resourceId, member, resourceType, teamId, onClose } = props;
|
||||||
|
const user = useAuth();
|
||||||
|
const isCurrentUser = user?.email === member.email;
|
||||||
|
|
||||||
const containerEl = useRef<HTMLDivElement>(null);
|
const containerEl = useRef<HTMLDivElement>(null);
|
||||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [showProgressHint, setShowProgressHint] = useState(false);
|
||||||
const [memberProgress, setMemberProgress] =
|
const [memberProgress, setMemberProgress] =
|
||||||
useState<MemberProgressResponse>();
|
useState<MemberProgressResponse>();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -75,10 +88,16 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useKeydown('Escape', () => {
|
useKeydown('Escape', () => {
|
||||||
|
if (showProgressHint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
});
|
});
|
||||||
|
|
||||||
useOutsideClick(popupBodyEl, () => {
|
useOutsideClick(popupBodyEl, () => {
|
||||||
|
if (showProgressHint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onClose();
|
onClose();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,10 +138,128 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRightClick(e: MouseEvent) {
|
||||||
|
const targetGroup = (e.target as HTMLElement)?.closest('g');
|
||||||
|
if (!targetGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||||
|
if (!groupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetGroup.classList.contains('removed')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||||
|
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||||
|
updateTopicStatus(
|
||||||
|
normalizedGroupId,
|
||||||
|
!isCurrentStatusDone ? 'done' : 'pending'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClick(e: MouseEvent) {
|
||||||
|
const targetGroup = (e.target as HTMLElement)?.closest('g');
|
||||||
|
if (!targetGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||||
|
if (!groupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetGroup.classList.contains('removed')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||||
|
|
||||||
|
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
||||||
|
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateTopicStatus(
|
||||||
|
normalizedGroupId,
|
||||||
|
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
updateTopicStatus(
|
||||||
|
normalizedGroupId,
|
||||||
|
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCurrentUser || !containerEl.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
containerEl.current?.addEventListener('contextmenu', handleRightClick);
|
||||||
|
containerEl.current?.addEventListener('click', handleClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
containerEl.current?.removeEventListener('contextmenu', handleRightClick);
|
||||||
|
containerEl.current?.removeEventListener('click', handleClick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 currProgress = member.progress.find((p) => p.resourceId === resourceId);
|
||||||
const memberDone = currProgress?.done || 0;
|
|
||||||
const memberLearning = currProgress?.learning || 0;
|
|
||||||
const memberSkipped = currProgress?.skipped || 0;
|
|
||||||
const memberTotal = currProgress?.total || 0;
|
const memberTotal = currProgress?.total || 0;
|
||||||
|
|
||||||
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
|
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
|
||||||
@@ -134,38 +271,65 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
ref={popupBodyEl}
|
ref={popupBodyEl}
|
||||||
class="popup-body relative rounded-lg bg-white shadow"
|
class="popup-body relative rounded-lg bg-white shadow"
|
||||||
>
|
>
|
||||||
|
{showProgressHint && (
|
||||||
|
<ProgressHint
|
||||||
|
onClose={() => {
|
||||||
|
setShowProgressHint(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
|
{isCurrentUser ? (
|
||||||
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
|
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
|
||||||
{member.name}'s Progress
|
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
|
||||||
</h2>
|
Your Progress
|
||||||
<p
|
</h2>
|
||||||
className={
|
<p className={'text-gray-500'}>
|
||||||
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
|
You can{' '}
|
||||||
}
|
<button
|
||||||
>
|
className="inline-flex items-center text-blue-600 underline"
|
||||||
You are looking at {member.name}'s progress.{' '}
|
onClick={() => {
|
||||||
<a
|
setShowProgressHint(true);
|
||||||
target={'_blank'}
|
}}
|
||||||
href={`/${resourceId}?t=${teamId}`}
|
>
|
||||||
className="text-blue-600 underline"
|
follow these instructions
|
||||||
|
</button>{' '}
|
||||||
|
to update your progress below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
|
||||||
|
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
|
||||||
|
{member.name}'s Progress
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
View your progress
|
You are looking at {member.name}'s progress.{' '}
|
||||||
</a>
|
<a
|
||||||
.
|
target={'_blank'}
|
||||||
</p>
|
href={`/${resourceId}?t=${teamId}`}
|
||||||
<p className={'block text-gray-500 md:hidden'}>
|
className="text-blue-600 underline"
|
||||||
View your progress
|
>
|
||||||
<a
|
View your progress
|
||||||
target={'_blank'}
|
</a>
|
||||||
href={`/${resourceId}?t=${teamId}`}
|
.
|
||||||
className="text-blue-600 underline"
|
</p>
|
||||||
>
|
<p className={'block text-gray-500 md:hidden'}>
|
||||||
on the roadmap page.
|
View your progress
|
||||||
</a>
|
<a
|
||||||
</p>
|
target={'_blank'}
|
||||||
</div>
|
href={`/${resourceId}?t=${teamId}`}
|
||||||
<p class="-mx-4 mb-3 flex items-center justify-start border-b border-t py-2 text-sm sm:hidden px-4">
|
className="text-blue-600 underline"
|
||||||
|
>
|
||||||
|
on the roadmap page.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p class="-mx-4 mb-3 flex items-center justify-start border-b border-t px-4 py-2 text-sm sm:hidden">
|
||||||
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||||
<span>{progressPercentage}</span>% Done
|
<span>{progressPercentage}</span>% Done
|
||||||
</span>
|
</span>
|
||||||
|
70
src/components/TeamProgress/ProgressHint.tsx
Normal file
70
src/components/TeamProgress/ProgressHint.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
|
import { CloseIcon } from '../ReactIcons/CloseIcon';
|
||||||
|
|
||||||
|
type ProgressHintProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProgressHint(props: ProgressHintProps) {
|
||||||
|
const { onClose } = props;
|
||||||
|
const containerEl = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useOutsideClick(containerEl, onClose);
|
||||||
|
useKeydown('Escape', () => {
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||||
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-lg rounded-md border border-yellow-300 bg-yellow-50 px-3 py-3 text-gray-500"
|
||||||
|
ref={containerEl}
|
||||||
|
>
|
||||||
|
<span className="mb-1.5 block text-xs font-medium uppercase text-green-600">
|
||||||
|
Update Progress
|
||||||
|
</span>
|
||||||
|
<p className="text-sm">Use the keyboard shortcuts listed below.</p>
|
||||||
|
|
||||||
|
<ul className="mb-1.5 mt-3 flex flex-col gap-1">
|
||||||
|
<li className="text-sm leading-loose">
|
||||||
|
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||||
|
Right Mouse Click
|
||||||
|
</kbd>{' '}
|
||||||
|
to mark as Done.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm leading-loose">
|
||||||
|
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||||
|
Shift
|
||||||
|
</kbd>{' '}
|
||||||
|
+{' '}
|
||||||
|
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||||
|
Click
|
||||||
|
</kbd>{' '}
|
||||||
|
to mark as in progress.
|
||||||
|
</li>
|
||||||
|
<li className="text-sm leading-loose">
|
||||||
|
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||||
|
Option / Alt
|
||||||
|
</kbd>{' '}
|
||||||
|
+{' '}
|
||||||
|
<kbd className="rounded-md bg-gray-900 px-2 py-1.5 text-xs text-white">
|
||||||
|
Click
|
||||||
|
</kbd>{' '}
|
||||||
|
to mark as skipped.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-1.5 top-1.5 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-yellow-200 hover:text-yellow-900"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
<span class="sr-only">Close modal</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -88,10 +88,12 @@ export function TeamProgressPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTeamProgress().finally(() => {
|
getTeamProgress().then(
|
||||||
pageProgressMessage.set('');
|
() => {
|
||||||
setIsLoading(false);
|
pageProgressMessage.set('');
|
||||||
});
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -144,11 +146,10 @@ export function TeamProgressPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{groupingTypes.map((grouping) => (
|
{groupingTypes.map((grouping) => (
|
||||||
<button
|
<button
|
||||||
className={`rounded-md border p-1 px-2 text-sm ${
|
className={`rounded-md border p-1 px-2 text-sm ${selectedGrouping === grouping.value
|
||||||
selectedGrouping === grouping.value
|
? ' border-gray-400 bg-gray-200 '
|
||||||
? ' border-gray-400 bg-gray-200 '
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => setSelectedGrouping(grouping.value)}
|
onClick={() => setSelectedGrouping(grouping.value)}
|
||||||
>
|
>
|
||||||
{grouping.label}
|
{grouping.label}
|
||||||
|
Reference in New Issue
Block a user