mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-09-24 15:00:31 +08:00
feat: team member progress modal (#5651)
* feat: restrict members * feat: member progress modal
This commit is contained in:
@@ -31,10 +31,15 @@ export type UserStreamActivity = {
|
||||
type ActivityStreamProps = {
|
||||
activities: UserStreamActivity[];
|
||||
className?: string;
|
||||
onResourceClick?: (
|
||||
resourceId: string,
|
||||
resourceType: ResourceType,
|
||||
isCustomResource: boolean,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function ActivityStream(props: ActivityStreamProps) {
|
||||
const { activities, className } = props;
|
||||
const { activities, className, onResourceClick } = props;
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [selectedActivity, setSelectedActivity] =
|
||||
@@ -92,15 +97,25 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
? `/r/${resourceSlug}`
|
||||
: `/${resourceId}`;
|
||||
|
||||
const resourceLinkComponent = (
|
||||
<a
|
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||
target="_blank"
|
||||
href={resourceUrl}
|
||||
>
|
||||
{resourceTitle}
|
||||
</a>
|
||||
);
|
||||
const resourceLinkComponent =
|
||||
onResourceClick && resourceType !== 'question' ? (
|
||||
<button
|
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||
onClick={() =>
|
||||
onResourceClick(resourceId, resourceType, isCustomResource!)
|
||||
}
|
||||
>
|
||||
{resourceTitle}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||
target="_blank"
|
||||
href={resourceUrl}
|
||||
>
|
||||
{resourceTitle}
|
||||
</a>
|
||||
);
|
||||
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type ResourceProgressType = {
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
showActions?: boolean;
|
||||
onResourceClick?: () => void;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
@@ -24,6 +25,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
showClearButton = true,
|
||||
isCustomResource,
|
||||
showActions = true,
|
||||
onResourceClick,
|
||||
} = props;
|
||||
|
||||
const userId = getUser()?.id;
|
||||
@@ -53,13 +55,21 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = getPercentage(totalMarked, totalCount);
|
||||
|
||||
const Slot = onResourceClick ? 'button' : 'a';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<a
|
||||
target="_blank"
|
||||
href={url}
|
||||
<Slot
|
||||
{...(onResourceClick
|
||||
? {
|
||||
onClick: onResourceClick,
|
||||
}
|
||||
: {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
})}
|
||||
className={cn(
|
||||
'group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400',
|
||||
'group relative flex w-full items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400',
|
||||
showActions ? 'pr-7' : '',
|
||||
)}
|
||||
>
|
||||
@@ -74,7 +84,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
</a>
|
||||
</Slot>
|
||||
|
||||
{showActions && (
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { TeamStreamActivity } from './TeamActivityPage';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
|
||||
type TeamActivityItemProps = {
|
||||
onTopicClick?: (activity: TeamStreamActivity) => void;
|
||||
@@ -22,6 +24,7 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
const { user, onTopicClick, teamId } = props;
|
||||
const { activities } = user;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const resourceLink = (activity: TeamStreamActivity) => {
|
||||
@@ -62,10 +65,25 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
user.memberId !== currentTeam.memberId;
|
||||
const username = (
|
||||
<a
|
||||
href={`/team/member?t=${teamId}&m=${user?.memberId}`}
|
||||
className="inline-flex items-center gap-1.5 underline underline-offset-2 hover:underline"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 underline underline-offset-2 hover:underline',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
<img
|
||||
className="inline-block h-5 w-5 rounded-full"
|
||||
|
||||
@@ -23,6 +23,7 @@ export type UserTeamItem = {
|
||||
role: AllowedRoles;
|
||||
status: AllowedMemberStatus;
|
||||
memberId: string;
|
||||
personalProgressOnly?: boolean;
|
||||
};
|
||||
|
||||
export type TeamListResponse = UserTeamItem[];
|
||||
|
||||
@@ -11,6 +11,10 @@ import { ActivityStream } from '../Activity/ActivityStream';
|
||||
import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
|
||||
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { MemberProgressModal } from '../TeamProgress/MemberProgressModal';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
|
||||
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
|
||||
name: string;
|
||||
@@ -31,6 +35,7 @@ export function TeamMemberDetailsPage() {
|
||||
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
|
||||
|
||||
const toast = useToast();
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<GetTeamMemberProgressesResponse | null>(null);
|
||||
@@ -38,6 +43,12 @@ export function TeamMemberDetailsPage() {
|
||||
useState<GetTeamMemberActivityResponse | null>(null);
|
||||
const [currPage, setCurrPage] = useState(1);
|
||||
|
||||
const [selectedResource, setSelectedResource] = useState<{
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
isCustomResource?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const loadMemberProgress = async () => {
|
||||
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
|
||||
@@ -90,7 +101,25 @@ export function TeamMemberDetailsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
{selectedResource && (
|
||||
<MemberProgressModal
|
||||
teamId={teamId}
|
||||
member={{
|
||||
...memberProgress,
|
||||
_id: memberId,
|
||||
updatedAt: new Date(memberProgress.updatedAt).toISOString(),
|
||||
progress: memberProgress.progresses,
|
||||
}}
|
||||
resourceId={selectedResource.resourceId}
|
||||
resourceType={selectedResource.resourceType}
|
||||
isCustomResource={selectedResource.isCustomResource}
|
||||
onClose={() => setSelectedResource(null)}
|
||||
onShowMyProgress={() => {
|
||||
window.location.href = `/team/member?t=${teamId}&m=${currentTeam?.memberId}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={memberProgress?.name}
|
||||
@@ -130,6 +159,13 @@ export function TeamMemberDetailsPage() {
|
||||
title={progress.resourceTitle}
|
||||
roadmapSlug={progress.roadmapSlug}
|
||||
showActions={false}
|
||||
onResourceClick={() => {
|
||||
setSelectedResource({
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isCustomResource: progress.isCustomResource,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -146,6 +182,9 @@ export function TeamMemberDetailsPage() {
|
||||
activities={
|
||||
memberActivity?.data?.flatMap((act) => act.activity) || []
|
||||
}
|
||||
onResourceClick={(resourceId, resourceType, isCustomResource) => {
|
||||
setSelectedResource({ resourceId, resourceType, isCustomResource });
|
||||
}}
|
||||
/>
|
||||
<Pagination
|
||||
currPage={currPage}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { MailIcon } from '../ReactIcons/MailIcon';
|
||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||
import { MemberRoleBadge } from './RoleBadge';
|
||||
import type { TeamMemberItem } from './TeamMembersPage';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { $canManageCurrentTeam, $currentTeam } from '../../stores/team';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type TeamMemberProps = {
|
||||
member: TeamMemberItem;
|
||||
@@ -29,6 +31,7 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
onSendProgressReminder,
|
||||
} = props;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const canManageTeam = useStore($canManageCurrentTeam);
|
||||
const showNoProgressBadge = !member.hasProgress && member.status === 'joined';
|
||||
const allowProgressReminder =
|
||||
@@ -36,6 +39,10 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
!member.hasProgress &&
|
||||
member.status === 'joined' &&
|
||||
member.userId !== userId;
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
String(member._id) !== currentTeam.memberId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -61,7 +68,18 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
<h3 className="inline-grid grid-cols-[auto_auto_auto] items-center font-medium">
|
||||
<a
|
||||
href={`/team/member?t=${member.teamId}&m=${member._id}`}
|
||||
className="truncate"
|
||||
className={cn(
|
||||
'truncate',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { TeamMember } from './TeamProgressPage';
|
||||
import { useState } from 'react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type MemberProgressItemProps = {
|
||||
member: TeamMember;
|
||||
@@ -18,12 +21,17 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
teamId,
|
||||
} = props;
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
});
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const isPersonalProgressOnly =
|
||||
currentTeam?.personalProgressOnly &&
|
||||
currentTeam.role === 'member' &&
|
||||
String(member._id) !== currentTeam.memberId;
|
||||
const memberDetailsUrl = `/team/member?t=${teamId}&m=${member._id}`;
|
||||
|
||||
return (
|
||||
@@ -44,13 +52,41 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
{!isMyProgress && (
|
||||
<a href={memberDetailsUrl} className="truncate font-medium">
|
||||
<a
|
||||
href={memberDetailsUrl}
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
)}
|
||||
{isMyProgress && (
|
||||
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5">
|
||||
<a href={memberDetailsUrl} className="truncate font-medium">
|
||||
<a
|
||||
href={memberDetailsUrl}
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isPersonalProgressOnly
|
||||
? 'pointer-events-none cursor-default no-underline'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isPersonalProgressOnly) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-disabled={isPersonalProgressOnly}
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
<span className="rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
|
||||
|
||||
Reference in New Issue
Block a user