feat: team member progress modal (#5651)

* feat: restrict members

* feat: member progress modal
pull/5675/head
Arik Chakma 6 months ago committed by GitHub
parent de89e56a47
commit 6804c6ec00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 19
      src/components/Activity/ActivityStream.tsx
  2. 20
      src/components/Activity/ResourceProgress.tsx
  3. 20
      src/components/TeamActivity/TeamActivityItem.tsx
  4. 1
      src/components/TeamDropdown/TeamDropdown.tsx
  5. 41
      src/components/TeamMemberDetails/TeamMemberDetailsPage.tsx
  6. 22
      src/components/TeamMembers/TeamMemberItem.tsx
  7. 40
      src/components/TeamProgress/MemberProgressItem.tsx

@ -31,10 +31,15 @@ export type UserStreamActivity = {
type ActivityStreamProps = { type ActivityStreamProps = {
activities: UserStreamActivity[]; activities: UserStreamActivity[];
className?: string; className?: string;
onResourceClick?: (
resourceId: string,
resourceType: ResourceType,
isCustomResource: boolean,
) => void;
}; };
export function ActivityStream(props: ActivityStreamProps) { export function ActivityStream(props: ActivityStreamProps) {
const { activities, className } = props; const { activities, className, onResourceClick } = props;
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const [selectedActivity, setSelectedActivity] = const [selectedActivity, setSelectedActivity] =
@ -92,7 +97,17 @@ export function ActivityStream(props: ActivityStreamProps) {
? `/r/${resourceSlug}` ? `/r/${resourceSlug}`
: `/${resourceId}`; : `/${resourceId}`;
const resourceLinkComponent = ( 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 <a
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black" className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
target="_blank" target="_blank"

@ -17,6 +17,7 @@ type ResourceProgressType = {
isCustomResource: boolean; isCustomResource: boolean;
roadmapSlug?: string; roadmapSlug?: string;
showActions?: boolean; showActions?: boolean;
onResourceClick?: () => void;
}; };
export function ResourceProgress(props: ResourceProgressType) { export function ResourceProgress(props: ResourceProgressType) {
@ -24,6 +25,7 @@ export function ResourceProgress(props: ResourceProgressType) {
showClearButton = true, showClearButton = true,
isCustomResource, isCustomResource,
showActions = true, showActions = true,
onResourceClick,
} = props; } = props;
const userId = getUser()?.id; const userId = getUser()?.id;
@ -53,13 +55,21 @@ export function ResourceProgress(props: ResourceProgressType) {
const totalMarked = doneCount + skippedCount; const totalMarked = doneCount + skippedCount;
const progressPercentage = getPercentage(totalMarked, totalCount); const progressPercentage = getPercentage(totalMarked, totalCount);
const Slot = onResourceClick ? 'button' : 'a';
return ( return (
<div className="relative"> <div className="relative">
<a <Slot
target="_blank" {...(onResourceClick
href={url} ? {
onClick: onResourceClick,
}
: {
href: url,
target: '_blank',
})}
className={cn( 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' : '', showActions ? 'pr-7' : '',
)} )}
> >
@ -74,7 +84,7 @@ export function ResourceProgress(props: ResourceProgressType) {
width: `${progressPercentage}%`, width: `${progressPercentage}%`,
}} }}
></span> ></span>
</a> </Slot>
{showActions && ( {showActions && (
<div className="absolute right-2 top-0 flex h-full items-center"> <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 { ChevronsDown, ChevronsUp } from 'lucide-react';
import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles'; import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
type TeamActivityItemProps = { type TeamActivityItemProps = {
onTopicClick?: (activity: TeamStreamActivity) => void; onTopicClick?: (activity: TeamStreamActivity) => void;
@ -22,6 +24,7 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
const { user, onTopicClick, teamId } = props; const { user, onTopicClick, teamId } = props;
const { activities } = user; const { activities } = user;
const currentTeam = useStore($currentTeam);
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const resourceLink = (activity: TeamStreamActivity) => { const resourceLink = (activity: TeamStreamActivity) => {
@ -62,10 +65,25 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}` ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
: '/images/default-avatar.png'; : '/images/default-avatar.png';
const isPersonalProgressOnly =
currentTeam?.personalProgressOnly &&
currentTeam.role === 'member' &&
user.memberId !== currentTeam.memberId;
const username = ( const username = (
<a <a
href={`/team/member?t=${teamId}&m=${user?.memberId}`} 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 <img
className="inline-block h-5 w-5 rounded-full" className="inline-block h-5 w-5 rounded-full"

@ -23,6 +23,7 @@ export type UserTeamItem = {
role: AllowedRoles; role: AllowedRoles;
status: AllowedMemberStatus; status: AllowedMemberStatus;
memberId: string; memberId: string;
personalProgressOnly?: boolean;
}; };
export type TeamListResponse = UserTeamItem[]; export type TeamListResponse = UserTeamItem[];

@ -11,6 +11,10 @@ import { ActivityStream } from '../Activity/ActivityStream';
import { MemberRoleBadge } from '../TeamMembers/RoleBadge'; import { MemberRoleBadge } from '../TeamMembers/RoleBadge';
import { TeamMemberEmptyPage } from './TeamMemberEmptyPage'; import { TeamMemberEmptyPage } from './TeamMemberEmptyPage';
import { Pagination } from '../Pagination/Pagination'; 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 & { type GetTeamMemberProgressesResponse = TeamMemberDocument & {
name: string; name: string;
@ -31,6 +35,7 @@ export function TeamMemberDetailsPage() {
const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string }; const { t: teamId, m: memberId } = getUrlParams() as { t: string; m: string };
const toast = useToast(); const toast = useToast();
const currentTeam = useStore($currentTeam);
const [memberProgress, setMemberProgress] = const [memberProgress, setMemberProgress] =
useState<GetTeamMemberProgressesResponse | null>(null); useState<GetTeamMemberProgressesResponse | null>(null);
@ -38,6 +43,12 @@ export function TeamMemberDetailsPage() {
useState<GetTeamMemberActivityResponse | null>(null); useState<GetTeamMemberActivityResponse | null>(null);
const [currPage, setCurrPage] = useState(1); const [currPage, setCurrPage] = useState(1);
const [selectedResource, setSelectedResource] = useState<{
resourceId: string;
resourceType: ResourceType;
isCustomResource?: boolean;
} | null>(null);
const loadMemberProgress = async () => { const loadMemberProgress = async () => {
const { response, error } = await httpGet<GetTeamMemberProgressesResponse>( const { response, error } = await httpGet<GetTeamMemberProgressesResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`, `${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-progresses/${teamId}/${memberId}`,
@ -90,7 +101,25 @@ export function TeamMemberDetailsPage() {
return ( 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 <img
src={avatarUrl} src={avatarUrl}
alt={memberProgress?.name} alt={memberProgress?.name}
@ -130,6 +159,13 @@ export function TeamMemberDetailsPage() {
title={progress.resourceTitle} title={progress.resourceTitle}
roadmapSlug={progress.roadmapSlug} roadmapSlug={progress.roadmapSlug}
showActions={false} showActions={false}
onResourceClick={() => {
setSelectedResource({
resourceId: progress.resourceId,
resourceType: progress.resourceType,
isCustomResource: progress.isCustomResource,
});
}}
/> />
); );
})} })}
@ -146,6 +182,9 @@ export function TeamMemberDetailsPage() {
activities={ activities={
memberActivity?.data?.flatMap((act) => act.activity) || [] memberActivity?.data?.flatMap((act) => act.activity) || []
} }
onResourceClick={(resourceId, resourceType, isCustomResource) => {
setSelectedResource({ resourceId, resourceType, isCustomResource });
}}
/> />
<Pagination <Pagination
currPage={currPage} currPage={currPage}

@ -2,8 +2,10 @@ import { MailIcon } from '../ReactIcons/MailIcon';
import { MemberActionDropdown } from './MemberActionDropdown'; import { MemberActionDropdown } from './MemberActionDropdown';
import { MemberRoleBadge } from './RoleBadge'; import { MemberRoleBadge } from './RoleBadge';
import type { TeamMemberItem } from './TeamMembersPage'; import type { TeamMemberItem } from './TeamMembersPage';
import { $canManageCurrentTeam } from '../../stores/team'; import { $canManageCurrentTeam, $currentTeam } from '../../stores/team';
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { useAuth } from '../../hooks/use-auth';
import { cn } from '../../lib/classname';
type TeamMemberProps = { type TeamMemberProps = {
member: TeamMemberItem; member: TeamMemberItem;
@ -29,6 +31,7 @@ export function TeamMemberItem(props: TeamMemberProps) {
onSendProgressReminder, onSendProgressReminder,
} = props; } = props;
const currentTeam = useStore($currentTeam);
const canManageTeam = useStore($canManageCurrentTeam); const canManageTeam = useStore($canManageCurrentTeam);
const showNoProgressBadge = !member.hasProgress && member.status === 'joined'; const showNoProgressBadge = !member.hasProgress && member.status === 'joined';
const allowProgressReminder = const allowProgressReminder =
@ -36,6 +39,10 @@ export function TeamMemberItem(props: TeamMemberProps) {
!member.hasProgress && !member.hasProgress &&
member.status === 'joined' && member.status === 'joined' &&
member.userId !== userId; member.userId !== userId;
const isPersonalProgressOnly =
currentTeam?.personalProgressOnly &&
currentTeam.role === 'member' &&
String(member._id) !== currentTeam.memberId;
return ( return (
<div <div
@ -61,7 +68,18 @@ export function TeamMemberItem(props: TeamMemberProps) {
<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">
<a <a
href={`/team/member?t=${member.teamId}&m=${member._id}`} 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} {member.name}
</a> </a>

@ -1,5 +1,8 @@
import { useStore } from '@nanostores/react';
import type { TeamMember } from './TeamProgressPage'; import type { TeamMember } from './TeamProgressPage';
import { useState } from 'react'; import { useState } from 'react';
import { $currentTeam } from '../../stores/team';
import { cn } from '../../lib/classname';
type MemberProgressItemProps = { type MemberProgressItemProps = {
member: TeamMember; member: TeamMember;
@ -18,12 +21,17 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
teamId, teamId,
} = props; } = props;
const currentTeam = useStore($currentTeam);
const memberProgress = member?.progress?.sort((a, b) => { const memberProgress = member?.progress?.sort((a, b) => {
return b.done - a.done; return b.done - a.done;
}); });
const [showAll, setShowAll] = useState(false); 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}`; const memberDetailsUrl = `/team/member?t=${teamId}&m=${member._id}`;
return ( return (
@ -44,13 +52,41 @@ export function MemberProgressItem(props: MemberProgressItemProps) {
/> />
<div className="inline-grid w-full"> <div className="inline-grid w-full">
{!isMyProgress && ( {!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} {member.name}
</a> </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">
<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} {member.name}
</a> </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">

Loading…
Cancel
Save