mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-07-31 06:20:14 +02:00
Team Member listing and Progress Reminder (#4264)
* wip: team member listing * wip: no progress alert * wip: mail icon * feat: Send progress reminder * fix: guard clause * chore: resend invite
This commit is contained in:
23
src/components/ReactIcons/MailIcon.tsx
Normal file
23
src/components/ReactIcons/MailIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
interface MailIconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
export function MailIcon(props: MailIconProps) {
|
||||||
|
const { className } = props;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
@@ -9,10 +9,12 @@ export function MemberActionDropdown({
|
|||||||
member,
|
member,
|
||||||
onUpdateMember,
|
onUpdateMember,
|
||||||
onDeleteMember,
|
onDeleteMember,
|
||||||
|
onResendInvite,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
}: {
|
}: {
|
||||||
onDeleteMember: () => void;
|
onDeleteMember: () => void;
|
||||||
onUpdateMember: () => void;
|
onUpdateMember: () => void;
|
||||||
|
onResendInvite: () => void;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
member: TeamMemberDocument;
|
member: TeamMemberDocument;
|
||||||
}) {
|
}) {
|
||||||
@@ -25,23 +27,6 @@ export function MemberActionDropdown({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function resendInvite() {
|
|
||||||
const { response, error } = await httpPatch(
|
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-resend-invite/${member.teamId}/${
|
|
||||||
member._id
|
|
||||||
}`,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error || !response) {
|
|
||||||
setIsLoading(false);
|
|
||||||
toast.error(error?.message || 'Something went wrong');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
@@ -61,7 +46,10 @@ export function MemberActionDropdown({
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
name: 'Resend Invite',
|
name: 'Resend Invite',
|
||||||
handleClick: resendInvite,
|
handleClick: () => {
|
||||||
|
onResendInvite();
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
@@ -3,11 +3,10 @@ import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
|
|||||||
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-xs capitalize ${
|
className={`rounded-full px-2 py-0.5 text-xs sm:flex items-center capitalize ${['admin'].includes(role)
|
||||||
['admin'].includes(role)
|
? 'bg-blue-100 text-blue-700 '
|
||||||
? 'bg-blue-100 text-blue-700 '
|
: 'bg-gray-100 text-gray-700 '
|
||||||
: 'bg-gray-100 text-gray-700 '
|
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
||||||
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
|
|
||||||
>
|
>
|
||||||
{role}
|
{role}
|
||||||
</span>
|
</span>
|
||||||
|
132
src/components/TeamMembers/TeamMemberItem.tsx
Normal file
132
src/components/TeamMembers/TeamMemberItem.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { MailIcon } from '../ReactIcons/MailIcon';
|
||||||
|
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||||
|
import { MemberRoleBadge } from './RoleBadge';
|
||||||
|
import type { TeamMemberItem } from './TeamMembersPage';
|
||||||
|
|
||||||
|
type TeamMemberProps = {
|
||||||
|
member: TeamMemberItem;
|
||||||
|
userId: string;
|
||||||
|
index: number;
|
||||||
|
teamId: string;
|
||||||
|
canManageCurrentTeam: boolean;
|
||||||
|
handleDeleteMember: () => void;
|
||||||
|
onUpdateMember: () => void;
|
||||||
|
handleSendReminder: () => void;
|
||||||
|
onResendInvite: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamMemberItem(props: TeamMemberProps) {
|
||||||
|
const {
|
||||||
|
member,
|
||||||
|
index,
|
||||||
|
onResendInvite,
|
||||||
|
onUpdateMember,
|
||||||
|
canManageCurrentTeam,
|
||||||
|
userId,
|
||||||
|
handleDeleteMember,
|
||||||
|
handleSendReminder,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const showNoProgress =
|
||||||
|
member.progress.length === 0 && member.status === 'joined';
|
||||||
|
const showReminder =
|
||||||
|
member.progress.length === 0 &&
|
||||||
|
member.status === 'joined' &&
|
||||||
|
!(member.userId === userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between gap-2 p-3 ${
|
||||||
|
index === 0 ? '' : 'border-t'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
member.avatar
|
||||||
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}`
|
||||||
|
: '/images/default-avatar.png'
|
||||||
|
}
|
||||||
|
alt={member.name || ''}
|
||||||
|
className="hidden h-10 w-10 rounded-full sm:block"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-2 sm:hidden">
|
||||||
|
<MemberRoleBadge role={member.role} />
|
||||||
|
{showReminder && (
|
||||||
|
<SendProgressReminder handleSendReminder={handleSendReminder} />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
{showNoProgress && (
|
||||||
|
<span className="ml-2 rounded-full bg-gray-600 px-2 py-0.5 text-xs font-normal text-white sm:inline">
|
||||||
|
No Progress
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{member.userId === userId && (
|
||||||
|
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="ml-2 flex items-center gap-0.5">
|
||||||
|
{member.status === 'invited' && (
|
||||||
|
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs text-yellow-700">
|
||||||
|
Invited
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{member.status === 'rejected' && (
|
||||||
|
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-sm text-gray-500">
|
||||||
|
{member.invitedEmail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center text-sm">
|
||||||
|
{showReminder && (
|
||||||
|
<span className="hidden sm:block">
|
||||||
|
<SendProgressReminder handleSendReminder={handleSendReminder} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span class={'hidden sm:block'}>
|
||||||
|
<MemberRoleBadge role={member.role} />
|
||||||
|
</span>
|
||||||
|
{canManageCurrentTeam && (
|
||||||
|
<MemberActionDropdown
|
||||||
|
onResendInvite={onResendInvite}
|
||||||
|
onDeleteMember={handleDeleteMember}
|
||||||
|
isDisabled={member.userId === userId}
|
||||||
|
onUpdateMember={onUpdateMember}
|
||||||
|
member={member}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendProgressReminderProps = {
|
||||||
|
handleSendReminder: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SendProgressReminder(props: SendProgressReminderProps) {
|
||||||
|
const { handleSendReminder } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleSendReminder}
|
||||||
|
className="mr-2 flex items-center gap-1.5 whitespace-nowrap rounded-full bg-orange-100 px-2 py-0.5 text-xs text-orange-700"
|
||||||
|
>
|
||||||
|
<MailIcon className="h-3 w-3" />
|
||||||
|
<span>Reminder</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { httpDelete, httpGet } from '../../lib/http';
|
import { httpDelete, httpGet, httpPatch } from '../../lib/http';
|
||||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||||
import { useAuth } from '../../hooks/use-auth';
|
import { useAuth } from '../../hooks/use-auth';
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
@@ -14,6 +14,7 @@ import { useStore } from '@nanostores/preact';
|
|||||||
import { $canManageCurrentTeam } from '../../stores/team';
|
import { $canManageCurrentTeam } from '../../stores/team';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { MemberRoleBadge } from './RoleBadge';
|
import { MemberRoleBadge } from './RoleBadge';
|
||||||
|
import { TeamMemberItem } from './TeamMemberItem';
|
||||||
|
|
||||||
export interface TeamMemberDocument {
|
export interface TeamMemberDocument {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
@@ -26,9 +27,23 @@ export interface TeamMemberDocument {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamMemberItem extends TeamMemberDocument {
|
export interface UserResourceProgressDocument {
|
||||||
|
_id?: string;
|
||||||
|
userId: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceType: 'roadmap' | 'best-practice';
|
||||||
|
isFavorite?: boolean;
|
||||||
|
done: string[];
|
||||||
|
learning: string[];
|
||||||
|
skipped: string[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMemberItem extends TeamMemberDocument {
|
||||||
name: string;
|
name: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
progress: UserResourceProgressDocument[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamMembersPage() {
|
export function TeamMembersPage() {
|
||||||
@@ -79,7 +94,6 @@ export function TeamMembersPage() {
|
|||||||
pageProgressMessage.set('');
|
pageProgressMessage.set('');
|
||||||
});
|
});
|
||||||
}, [teamId]);
|
}, [teamId]);
|
||||||
|
|
||||||
async function deleteMember(teamId: string, memberId: string) {
|
async function deleteMember(teamId: string, memberId: string) {
|
||||||
pageProgressMessage.set('Deleting member');
|
pageProgressMessage.set('Deleting member');
|
||||||
const { response, error } = await httpDelete(
|
const { response, error } = await httpDelete(
|
||||||
@@ -98,6 +112,50 @@ export function TeamMembersPage() {
|
|||||||
await getTeamMemberList();
|
await getTeamMemberList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resendInvite(teamId: string, memberId: string) {
|
||||||
|
pageProgressMessage.set('Resending Invite');
|
||||||
|
const { response, error } = await httpPatch(
|
||||||
|
`${
|
||||||
|
import.meta.env.PUBLIC_API_URL
|
||||||
|
}/v1-resend-invite/${teamId}/${memberId}`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error || !response) {
|
||||||
|
toast.error(error?.message || 'Something went wrong');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Invite has been sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendReminder(teamId: string, memberId: string) {
|
||||||
|
pageProgressMessage.set('Sending Reminder');
|
||||||
|
const { response, error } = await httpPatch(
|
||||||
|
`${
|
||||||
|
import.meta.env.PUBLIC_API_URL
|
||||||
|
}/v1-send-progress-reminder/${teamId}/${memberId}`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error || !response) {
|
||||||
|
toast.error(error?.message || 'Something went wrong');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Reminder has been sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinedMembers = teamMembers.filter(
|
||||||
|
(member) => member.status === 'joined'
|
||||||
|
);
|
||||||
|
const invitedMembers = teamMembers.filter(
|
||||||
|
(member) => member.status === 'invited'
|
||||||
|
);
|
||||||
|
const rejectedMembers = teamMembers.filter(
|
||||||
|
(member) => member.status === 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{memberToUpdate && (
|
{memberToUpdate && (
|
||||||
@@ -139,80 +197,113 @@ export function TeamMembersPage() {
|
|||||||
</p>
|
</p>
|
||||||
<LeaveTeamButton teamId={team?._id!} />
|
<LeaveTeamButton teamId={team?._id!} />
|
||||||
</div>
|
</div>
|
||||||
{teamMembers.map((member, index) => {
|
{joinedMembers.map((member, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<TeamMemberItem
|
||||||
className={`flex items-center justify-between gap-2 p-3 ${
|
key={index}
|
||||||
index === 0 ? '' : 'border-t'
|
member={member}
|
||||||
} ${member.status === 'invited' ? 'bg-gray-50' : ''}`}
|
index={index}
|
||||||
>
|
teamId={teamId}
|
||||||
<div className="flex items-center gap-3">
|
userId={user?.id!}
|
||||||
<img
|
onResendInvite={() => {
|
||||||
src={
|
resendInvite(teamId, member._id!).finally(() => {
|
||||||
member.avatar
|
pageProgressMessage.set('');
|
||||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
});
|
||||||
member.avatar
|
}}
|
||||||
}`
|
canManageCurrentTeam={canManageCurrentTeam}
|
||||||
: '/images/default-avatar.png'
|
handleDeleteMember={() => {
|
||||||
}
|
deleteMember(teamId, member._id!).finally(() => {
|
||||||
alt={member.name || ''}
|
pageProgressMessage.set('');
|
||||||
className="hidden h-10 w-10 rounded-full sm:block"
|
});
|
||||||
/>
|
}}
|
||||||
<div>
|
onUpdateMember={() => {
|
||||||
<span class={'mb-1 block sm:hidden'}>
|
setMemberToUpdate(member);
|
||||||
<MemberRoleBadge role={member.role} />
|
}}
|
||||||
</span>
|
handleSendReminder={() => {
|
||||||
<div className="flex items-center">
|
handleSendReminder(teamId, member._id!).finally(() => {
|
||||||
<h3 className="inline-grid grid-cols-[auto_auto] items-center font-medium">
|
pageProgressMessage.set('');
|
||||||
<span className="truncate">{member.name}</span>
|
});
|
||||||
{member.userId === user?.id && (
|
}}
|
||||||
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
|
/>
|
||||||
You
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="ml-2 flex items-center gap-0.5">
|
|
||||||
{member.status === 'invited' && (
|
|
||||||
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs text-yellow-700">
|
|
||||||
Invited
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{member.status === 'rejected' && (
|
|
||||||
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
|
|
||||||
Rejected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{member.invitedEmail}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center text-sm">
|
|
||||||
<span class={'hidden sm:block'}>
|
|
||||||
<MemberRoleBadge role={member.role} />
|
|
||||||
</span>
|
|
||||||
{canManageCurrentTeam && (
|
|
||||||
<MemberActionDropdown
|
|
||||||
onDeleteMember={() => {
|
|
||||||
deleteMember(teamId, member._id!).finally(() => {
|
|
||||||
pageProgressMessage.set('');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
isDisabled={member.userId === user?.id}
|
|
||||||
onUpdateMember={() => {
|
|
||||||
setMemberToUpdate(member);
|
|
||||||
}}
|
|
||||||
member={member}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{invitedMembers.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-xl font-medium">Invited Members</h3>
|
||||||
|
<div className="mt-2 rounded-b-sm rounded-t-md border">
|
||||||
|
{invitedMembers.map((member, index) => {
|
||||||
|
return (
|
||||||
|
<TeamMemberItem
|
||||||
|
key={index}
|
||||||
|
member={member}
|
||||||
|
index={index}
|
||||||
|
teamId={teamId}
|
||||||
|
userId={user?.id!}
|
||||||
|
onResendInvite={() => {
|
||||||
|
resendInvite(teamId, member._id!).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
canManageCurrentTeam={canManageCurrentTeam}
|
||||||
|
handleDeleteMember={() => {
|
||||||
|
deleteMember(teamId, member._id!).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onUpdateMember={() => {
|
||||||
|
setMemberToUpdate(member);
|
||||||
|
}}
|
||||||
|
handleSendReminder={() => {
|
||||||
|
handleSendReminder(teamId, member._id!).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rejectedMembers.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-xl font-medium">Rejected Members</h3>
|
||||||
|
<div className="mt-2 rounded-b-sm rounded-t-md border">
|
||||||
|
{rejectedMembers.map((member, index) => {
|
||||||
|
return (
|
||||||
|
<TeamMemberItem
|
||||||
|
key={index}
|
||||||
|
member={member}
|
||||||
|
index={index}
|
||||||
|
teamId={teamId}
|
||||||
|
userId={user?.id!}
|
||||||
|
onResendInvite={() => {
|
||||||
|
resendInvite(teamId, member._id!).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
canManageCurrentTeam={canManageCurrentTeam}
|
||||||
|
handleDeleteMember={() => {
|
||||||
|
deleteMember(teamId, member._id!).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onUpdateMember={() => {
|
||||||
|
setMemberToUpdate(member);
|
||||||
|
}}
|
||||||
|
handleSendReminder={() => {
|
||||||
|
handleSendReminder(teamId, member._id!).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManageCurrentTeam && (
|
{canManageCurrentTeam && (
|
||||||
|
Reference in New Issue
Block a user