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
pull/3534/head^2
Arik Chakma 7 months ago committed by GitHub
parent fb7136e1b0
commit 63ad6fe1e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      src/components/Activity/ActivityStream.tsx
  2. 35
      src/components/Activity/ResourceProgress.tsx
  3. 16
      src/components/TeamActivity/TeamActivityItem.tsx
  4. 1
      src/components/TeamActivity/TeamActivityPage.tsx
  5. 176
      src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
  6. 29
      src/components/TeamMemberDetails/TeamMemberEmptyPage.tsx
  7. 21
      src/components/TeamMembers/RoleBadge.tsx
  8. 7
      src/components/TeamMembers/TeamMemberItem.tsx
  9. 24
      src/components/TeamProgress/MemberProgressItem.tsx
  10. 1
      src/components/TeamProgress/TeamProgressPage.tsx
  11. 15
      src/pages/team/member.astro

@ -1,10 +1,11 @@
import { useMemo, useState } from 'react'; import { useState } from 'react';
import { getRelativeTimeString } from '../../lib/date'; import { getRelativeTimeString } from '../../lib/date';
import type { ResourceType } from '../../lib/resource-progress'; import type { ResourceType } from '../../lib/resource-progress';
import { EmptyStream } from './EmptyStream'; import { EmptyStream } from './EmptyStream';
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx'; import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
import { ChevronsDown, ChevronsUp } from 'lucide-react'; import { ChevronsDown, ChevronsUp } from 'lucide-react';
import { ActivityTopicTitles } from './ActivityTopicTitles.tsx'; import { ActivityTopicTitles } from './ActivityTopicTitles.tsx';
import { cn } from '../../lib/classname.ts';
export const allowedActivityActionType = [ export const allowedActivityActionType = [
'in_progress', 'in_progress',
@ -29,10 +30,11 @@ export type UserStreamActivity = {
type ActivityStreamProps = { type ActivityStreamProps = {
activities: UserStreamActivity[]; activities: UserStreamActivity[];
className?: string;
}; };
export function ActivityStream(props: ActivityStreamProps) { export function ActivityStream(props: ActivityStreamProps) {
const { activities } = props; const { activities, className } = props;
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const [selectedActivity, setSelectedActivity] = const [selectedActivity, setSelectedActivity] =
@ -48,7 +50,7 @@ export function ActivityStream(props: ActivityStreamProps) {
.slice(0, showAll ? activities.length : 10); .slice(0, showAll ? activities.length : 10);
return ( 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"> <h2 className="mb-3 text-xs uppercase text-gray-400">
Learning Activity Learning Activity
</h2> </h2>
@ -78,6 +80,7 @@ export function ActivityStream(props: ActivityStreamProps) {
updatedAt, updatedAt,
topicTitles, topicTitles,
isCustomResource, isCustomResource,
resourceSlug,
} = activity; } = activity;
const resourceUrl = const resourceUrl =
@ -86,7 +89,7 @@ export function ActivityStream(props: ActivityStreamProps) {
: resourceType === 'best-practice' : resourceType === 'best-practice'
? `/best-practices/${resourceId}` ? `/best-practices/${resourceId}`
: isCustomResource && resourceType === 'roadmap' : isCustomResource && resourceType === 'roadmap'
? `/r/${resourceId}` ? `/r/${resourceSlug}`
: `/${resourceId}`; : `/${resourceId}`;
const resourceLinkComponent = ( const resourceLinkComponent = (

@ -1,6 +1,7 @@
import { getUser } from '../../lib/jwt'; import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number'; import { getPercentage } from '../../helper/number';
import { ResourceProgressActions } from './ResourceProgressActions'; import { ResourceProgressActions } from './ResourceProgressActions';
import { cn } from '../../lib/classname';
type ResourceProgressType = { type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice'; resourceType: 'roadmap' | 'best-practice';
@ -15,10 +16,15 @@ type ResourceProgressType = {
showClearButton?: boolean; showClearButton?: boolean;
isCustomResource: boolean; isCustomResource: boolean;
roadmapSlug?: string; roadmapSlug?: string;
showActions?: boolean;
}; };
export function ResourceProgress(props: ResourceProgressType) { export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true, isCustomResource } = props; const {
showClearButton = true,
isCustomResource,
showActions = true,
} = props;
const userId = getUser()?.id; const userId = getUser()?.id;
@ -52,7 +58,10 @@ export function ResourceProgress(props: ResourceProgressType) {
<a <a
target="_blank" target="_blank"
href={url} 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="flex-grow truncate">{title}</span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
@ -67,16 +76,18 @@ export function ResourceProgress(props: ResourceProgressType) {
></span> ></span>
</a> </a>
<div className="absolute right-2 top-0 flex h-full items-center"> {showActions && (
<ResourceProgressActions <div className="absolute right-2 top-0 flex h-full items-center">
userId={userId!} <ResourceProgressActions
resourceType={resourceType} userId={userId!}
resourceId={resourceId} resourceType={resourceType}
isCustomResource={isCustomResource} resourceId={resourceId}
onCleared={onCleared} isCustomResource={isCustomResource}
showClearButton={showClearButton} onCleared={onCleared}
/> showClearButton={showClearButton}
</div> />
</div>
)}
</div> </div>
); );
} }

@ -14,6 +14,7 @@ type TeamActivityItemProps = {
name: string; name: string;
avatar?: string | undefined; avatar?: string | undefined;
username?: string | undefined; username?: string | undefined;
memberId?: string;
}; };
}; };
@ -62,14 +63,17 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
: '/images/default-avatar.png'; : '/images/default-avatar.png';
const username = ( const username = (
<> <a
href={`/team/member?t=${teamId}&m=${user?.memberId}`}
className="inline-flex items-center underline underline-offset-2 hover:no-underline"
>
<img <img
className="mr-1 inline-block h-5 w-5 rounded-full" className="mr-1 inline-block h-5 w-5 rounded-full"
src={userAvatar} src={userAvatar}
alt={user.name} alt={user.name}
/> />
<span className="font-medium">{user?.name || 'Unknown'}</span> <span className="font-medium">{user?.name || 'Unknown'}</span>&nbsp;
</> </a>
); );
if (activities.length === 1) { if (activities.length === 1) {
@ -137,9 +141,9 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
return ( return (
<li key={user._id} className="overflow-hidden rounded-md border"> <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"> <h3 className="flex flex-wrap items-center bg-gray-100 px-2 py-2.5 text-sm">
{username} has {activities.length} updates in {uniqueResourcesCount}{' '} {username} has {activities.length} updates in {uniqueResourcesCount}
resource(s) &nbsp;resource(s)
</h3> </h3>
<div className="py-3"> <div className="py-3">
<ul className="ml-2 flex flex-col divide-y pr-2 sm:ml-[36px]"> <ul className="ml-2 flex flex-col divide-y pr-2 sm:ml-[36px]">

@ -39,6 +39,7 @@ type GetTeamActivityResponse = {
name: string; name: string;
avatar?: string; avatar?: string;
username?: string; username?: string;
memberId?: string;
}[]; }[];
activities: TeamActivityStreamDocument[]; activities: TeamActivityStreamDocument[];
}; };

@ -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}
</>
);
}

@ -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'; 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 ( return (
<span <span
className={`rounded-full px-2 py-0.5 text-xs sm:flex items-center capitalize ${['admin'].includes(role) className={cn(
? 'bg-blue-100 text-blue-700 ' `items-center rounded-full px-2 py-0.5 text-xs capitalize sm:flex ${
: 'bg-gray-100 text-gray-700 ' ['admin'].includes(role)
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`} ? 'bg-blue-100 text-blue-700 '
: 'bg-gray-100 text-gray-700 '
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`,
className,
)}
> >
{role} {role}
</span> </span>

@ -59,7 +59,12 @@ export function TeamMemberItem(props: TeamMemberProps) {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<h3 className="inline-grid grid-cols-[auto_auto_auto] items-center font-medium"> <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 && ( {showNoProgressBadge && (
<span className="ml-2 rounded-full bg-red-400 px-2 py-0.5 text-xs font-normal text-white"> <span className="ml-2 rounded-full bg-red-400 px-2 py-0.5 text-xs font-normal text-white">
No Progress No Progress

@ -5,12 +5,18 @@ type MemberProgressItemProps = {
member: TeamMember; member: TeamMember;
onShowResourceProgress: ( onShowResourceProgress: (
resourceId: string, resourceId: string,
isCustomResource: boolean isCustomResource: boolean,
) => void; ) => void;
isMyProgress?: boolean; isMyProgress?: boolean;
teamId: string;
}; };
export function MemberProgressItem(props: MemberProgressItemProps) { 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) => { const memberProgress = member?.progress?.sort((a, b) => {
return b.done - a.done; return b.done - a.done;
@ -18,6 +24,8 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const memberDetailsUrl = `/team/member?t=${teamId}&m=${member._id}`;
return ( return (
<> <>
<div <div
@ -36,11 +44,15 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
/> />
<div className="inline-grid w-full"> <div className="inline-grid w-full">
{!isMyProgress && ( {!isMyProgress && (
<h3 className="truncate font-medium">{member.name}</h3> <a href={memberDetailsUrl} className="truncate font-medium">
{member.name}
</a>
)} )}
{isMyProgress && ( {isMyProgress && (
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5"> <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"> <span className="rounded-md bg-red-500 px-1 py-0.5 text-xs text-white">
You You
</span> </span>
@ -57,7 +69,7 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
onClick={() => onClick={() =>
onShowResourceProgress( onShowResourceProgress(
progress.resourceId, 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" 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> </button>
); );
} },
)} )}
{memberProgress.length > 4 && !showAll && ( {memberProgress.length > 4 && !showAll && (

@ -227,6 +227,7 @@ export function TeamProgressPage() {
<MemberProgressItem <MemberProgressItem
key={member._id} key={member._id}
member={member} member={member}
teamId={teamId}
isMyProgress={member?.email === user?.email} isMyProgress={member?.email === user?.email}
onShowResourceProgress={(resourceId, isCustomResource) => { onShowResourceProgress={(resourceId, isCustomResource) => {
setShowMemberProgress({ setShowMemberProgress({

@ -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…
Cancel
Save