mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-03-19 06:39:44 +01:00
feat: team member details (#5598)
* fix: change `topicIds` to `topicTitles` * fix: comma and gap * wip: member details page * fix: team member empty state * feat: add pagination * fix: add loading screen
This commit is contained in:
parent
fb7136e1b0
commit
63ad6fe1e9
@ -1,10 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { EmptyStream } from './EmptyStream';
|
||||
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from './ActivityTopicTitles.tsx';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
export const allowedActivityActionType = [
|
||||
'in_progress',
|
||||
@ -29,10 +30,11 @@ export type UserStreamActivity = {
|
||||
|
||||
type ActivityStreamProps = {
|
||||
activities: UserStreamActivity[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ActivityStream(props: ActivityStreamProps) {
|
||||
const { activities } = props;
|
||||
const { activities, className } = props;
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [selectedActivity, setSelectedActivity] =
|
||||
@ -48,7 +50,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
.slice(0, showAll ? activities.length : 10);
|
||||
|
||||
return (
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div className={cn('mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8', className)}>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Learning Activity
|
||||
</h2>
|
||||
@ -78,6 +80,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
updatedAt,
|
||||
topicTitles,
|
||||
isCustomResource,
|
||||
resourceSlug,
|
||||
} = activity;
|
||||
|
||||
const resourceUrl =
|
||||
@ -86,7 +89,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
: resourceType === 'best-practice'
|
||||
? `/best-practices/${resourceId}`
|
||||
: isCustomResource && resourceType === 'roadmap'
|
||||
? `/r/${resourceId}`
|
||||
? `/r/${resourceSlug}`
|
||||
: `/${resourceId}`;
|
||||
|
||||
const resourceLinkComponent = (
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { getPercentage } from '../../helper/number';
|
||||
import { ResourceProgressActions } from './ResourceProgressActions';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@ -15,10 +16,15 @@ type ResourceProgressType = {
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
showActions?: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true, isCustomResource } = props;
|
||||
const {
|
||||
showClearButton = true,
|
||||
isCustomResource,
|
||||
showActions = true,
|
||||
} = props;
|
||||
|
||||
const userId = getUser()?.id;
|
||||
|
||||
@ -52,7 +58,10 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
<a
|
||||
target="_blank"
|
||||
href={url}
|
||||
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
|
||||
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',
|
||||
showActions ? 'pr-7' : '',
|
||||
)}
|
||||
>
|
||||
<span className="flex-grow truncate">{title}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
@ -67,16 +76,18 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
></span>
|
||||
</a>
|
||||
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
{showActions && (
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ type TeamActivityItemProps = {
|
||||
name: string;
|
||||
avatar?: string | undefined;
|
||||
username?: string | undefined;
|
||||
memberId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -62,14 +63,17 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const username = (
|
||||
<>
|
||||
<a
|
||||
href={`/team/member?t=${teamId}&m=${user?.memberId}`}
|
||||
className="inline-flex items-center underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
<img
|
||||
className="mr-1 inline-block h-5 w-5 rounded-full"
|
||||
src={userAvatar}
|
||||
alt={user.name}
|
||||
/>
|
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>
|
||||
</>
|
||||
<span className="font-medium">{user?.name || 'Unknown'}</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
if (activities.length === 1) {
|
||||
@ -137,9 +141,9 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
|
||||
return (
|
||||
<li key={user._id} className="overflow-hidden rounded-md border">
|
||||
<h3 className="flex flex-wrap items-center gap-1 bg-gray-100 px-2 py-2.5 text-sm">
|
||||
{username} has {activities.length} updates in {uniqueResourcesCount}{' '}
|
||||
resource(s)
|
||||
<h3 className="flex flex-wrap items-center bg-gray-100 px-2 py-2.5 text-sm">
|
||||
{username} has {activities.length} updates in {uniqueResourcesCount}
|
||||
resource(s)
|
||||
</h3>
|
||||
<div className="py-3">
|
||||
<ul className="ml-2 flex flex-col divide-y pr-2 sm:ml-[36px]">
|
||||
|
@ -39,6 +39,7 @@ type GetTeamActivityResponse = {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
memberId?: string;
|
||||
}[];
|
||||
activities: TeamActivityStreamDocument[];
|
||||
};
|
||||
|
176
src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
Normal file
176
src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import type { TeamActivityStreamDocument } from '../TeamActivity/TeamActivityPage';
|
||||
import { ResourceProgress } from '../Activity/ResourceProgress';
|
||||
import { ActivityStream } from '../Activity/ActivityStream';
|
||||
import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
|
||||
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
|
||||
type GetTeamMemberProgressesResponse = TeamMemberDocument & {
|
||||
name: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
progresses: UserProgress[];
|
||||
};
|
||||
|
||||
type GetTeamMemberActivityResponse = {
|
||||
data: TeamActivityStreamDocument[];
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
currPage: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export function TeamMemberDetailsPage() {
|
||||
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [memberProgress, setMemberProgress] =
|
||||
useState<GetTeamMemberProgressesResponse | null>(null);
|
||||
const [memberActivity, setMemberActivity] =
|
||||
useState<GetTeamMemberActivityResponse | null>(null);
|
||||
const [currPage, setCurrPage] = useState(1);
|
||||
|
||||
const loadMemberProgress = async () => {
|
||||
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
|
||||
);
|
||||
if (error || !response) {
|
||||
pageProgressMessage.set('');
|
||||
toast.error(error?.message || 'Failed to load team member');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberProgress(response);
|
||||
};
|
||||
|
||||
const loadMemberActivity = async (currPage: number = 1) => {
|
||||
const { response, error } = await httpGet<GetTeamMemberActivityResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-activity/${teamId}/${memberId}`,
|
||||
{
|
||||
currPage,
|
||||
},
|
||||
);
|
||||
if (error || !response) {
|
||||
pageProgressMessage.set('');
|
||||
toast.error(error?.message || 'Failed to load team member activity');
|
||||
return;
|
||||
}
|
||||
|
||||
setMemberActivity(response);
|
||||
setCurrPage(response?.currPage || 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.allSettled([loadMemberProgress(), loadMemberActivity()]).finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
},
|
||||
);
|
||||
}, [teamId]);
|
||||
|
||||
if (!teamId || !memberId || !memberProgress || !memberActivity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarUrl = memberProgress?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${memberProgress?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={memberProgress?.name}
|
||||
className="h-24 w-24 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<MemberRoleBadge
|
||||
className="sm:inline-flex"
|
||||
role={memberProgress?.role!}
|
||||
/>
|
||||
<h1 className="mt-1 text-2xl font-medium">
|
||||
{memberProgress?.name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">{memberProgress?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-8 border-gray-200" />
|
||||
|
||||
{memberProgress?.progresses && memberProgress?.progresses?.length > 0 ? (
|
||||
<>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Progress Overview
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{memberProgress?.progresses?.map((progress) => {
|
||||
const learningCount = progress.learning || 0;
|
||||
const doneCount = progress.done || 0;
|
||||
const totalCount = progress.total || 0;
|
||||
const skippedCount = progress.skipped || 0;
|
||||
|
||||
return (
|
||||
<ResourceProgress
|
||||
key={progress.resourceId}
|
||||
isCustomResource={progress.isCustomResource!}
|
||||
doneCount={doneCount > totalCount ? totalCount : doneCount}
|
||||
learningCount={
|
||||
learningCount > totalCount ? totalCount : learningCount
|
||||
}
|
||||
totalCount={totalCount}
|
||||
skippedCount={skippedCount}
|
||||
resourceId={progress.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
updatedAt={progress.updatedAt}
|
||||
title={progress.resourceTitle}
|
||||
roadmapSlug={progress.roadmapSlug}
|
||||
showActions={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<TeamMemberEmptyPage teamId={teamId} />
|
||||
)}
|
||||
|
||||
{memberActivity?.data && memberActivity?.data?.length > 0 ? (
|
||||
<>
|
||||
<ActivityStream
|
||||
className="mt-8 p-0 md:m-0 md:mb-4 md:mt-8 md:p-0"
|
||||
activities={
|
||||
memberActivity?.data?.flatMap((act) => act.activity) || []
|
||||
}
|
||||
/>
|
||||
<Pagination
|
||||
currPage={currPage}
|
||||
totalPages={memberActivity?.totalPages || 1}
|
||||
totalCount={memberActivity?.totalCount || 0}
|
||||
perPage={memberActivity?.perPage || 10}
|
||||
onPageChange={(page) => {
|
||||
pageProgressMessage.set('Loading Activity');
|
||||
loadMemberActivity(page).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
29
src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
Normal file
29
src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon';
|
||||
|
||||
type TeamMemberEmptyPageProps = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export function TeamMemberEmptyPage(props: TeamMemberEmptyPageProps) {
|
||||
const { teamId } = props;
|
||||
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<RoadmapIcon className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />
|
||||
|
||||
<h2 className="text-lg font-bold sm:text-xl">No Progress</h2>
|
||||
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
|
||||
Progress will appear here as they start tracking their{' '}
|
||||
<a
|
||||
href={`/team/roadmaps?t=${teamId}`}
|
||||
className="mt-4 text-blue-500 hover:underline"
|
||||
>
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,12 +1,23 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
||||
|
||||
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
||||
type RoleBadgeProps = {
|
||||
role: AllowedRoles;
|
||||
className?: string;
|
||||
};
|
||||
export function MemberRoleBadge(props: RoleBadgeProps) {
|
||||
const { role, className } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs sm:flex items-center capitalize ${['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
||||
className={cn(
|
||||
`items-center rounded-full px-2 py-0.5 text-xs capitalize sm:flex ${
|
||||
['admin'].includes(role)
|
||||
? 'bg-blue-100 text-blue-700 '
|
||||
: 'bg-gray-100 text-gray-700 '
|
||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
|
@ -59,7 +59,12 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="inline-grid grid-cols-[auto_auto_auto] items-center font-medium">
|
||||
<span className="truncate">{member.name}</span>
|
||||
<a
|
||||
href={`/team/member?t=${member.teamId}&m=${member._id}`}
|
||||
className="truncate"
|
||||
>
|
||||
{member.name}
|
||||
</a>
|
||||
{showNoProgressBadge && (
|
||||
<span className="ml-2 rounded-full bg-red-400 px-2 py-0.5 text-xs font-normal text-white">
|
||||
No Progress
|
||||
@ -109,4 +114,4 @@ export function TeamMemberItem(props: TeamMemberProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,12 +5,18 @@ type MemberProgressItemProps = {
|
||||
member: TeamMember;
|
||||
onShowResourceProgress: (
|
||||
resourceId: string,
|
||||
isCustomResource: boolean
|
||||
isCustomResource: boolean,
|
||||
) => void;
|
||||
isMyProgress?: boolean;
|
||||
teamId: string;
|
||||
};
|
||||
export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
const { member, onShowResourceProgress, isMyProgress = false } = props;
|
||||
const {
|
||||
member,
|
||||
onShowResourceProgress,
|
||||
isMyProgress = false,
|
||||
teamId,
|
||||
} = props;
|
||||
|
||||
const memberProgress = member?.progress?.sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
@ -18,6 +24,8 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const memberDetailsUrl = `/team/member?t=${teamId}&m=${member._id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -36,11 +44,15 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
{!isMyProgress && (
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<a href={memberDetailsUrl} className="truncate font-medium">
|
||||
{member.name}
|
||||
</a>
|
||||
)}
|
||||
{isMyProgress && (
|
||||
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5">
|
||||
<h3 className="truncate font-medium">{member.name}</h3>
|
||||
<a href={memberDetailsUrl} className="truncate font-medium">
|
||||
{member.name}
|
||||
</a>
|
||||
<span className="rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
|
||||
You
|
||||
</span>
|
||||
@ -57,7 +69,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
onClick={() =>
|
||||
onShowResourceProgress(
|
||||
progress.resourceId,
|
||||
progress.isCustomResource!
|
||||
progress.isCustomResource!,
|
||||
)
|
||||
}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
@ -81,7 +93,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
)}
|
||||
|
||||
{memberProgress.length > 4 && !showAll && (
|
||||
|
@ -227,6 +227,7 @@ export function TeamProgressPage() {
|
||||
<MemberProgressItem
|
||||
key={member._id}
|
||||
member={member}
|
||||
teamId={teamId}
|
||||
isMyProgress={member?.email === user?.email}
|
||||
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||
setShowMemberProgress({
|
||||
|
15
src/pages/team/member.astro
Normal file
15
src/pages/team/member.astro
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||
import { TeamMemberDetailsPage } from '../../components/TeamMemberDetails/TeamMemberDetailsPage';
|
||||
---
|
||||
|
||||
<AccountLayout
|
||||
title='Team Members'
|
||||
noIndex={true}
|
||||
initialLoadingMessage='Loading member'
|
||||
>
|
||||
<TeamSidebar activePageId='members' client:load>
|
||||
<TeamMemberDetailsPage client:only='react' />
|
||||
</TeamSidebar>
|
||||
</AccountLayout>
|
Loading…
x
Reference in New Issue
Block a user