mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-04-19 14:51:58 +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:
parent
543d3b47ce
commit
fc8ce296be
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,
|
||||
onUpdateMember,
|
||||
onDeleteMember,
|
||||
onResendInvite,
|
||||
isDisabled = false,
|
||||
}: {
|
||||
onDeleteMember: () => void;
|
||||
onUpdateMember: () => void;
|
||||
onResendInvite: () => void;
|
||||
isDisabled: boolean;
|
||||
member: TeamMemberDocument;
|
||||
}) {
|
||||
@ -25,23 +27,6 @@ export function MemberActionDropdown({
|
||||
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 = [
|
||||
{
|
||||
name: 'Delete',
|
||||
@ -61,7 +46,10 @@ export function MemberActionDropdown({
|
||||
? [
|
||||
{
|
||||
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 }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs 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={`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' : ''}`}
|
||||
>
|
||||
{role}
|
||||
</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 { httpDelete, httpGet } from '../../lib/http';
|
||||
import { httpDelete, httpGet, httpPatch } from '../../lib/http';
|
||||
import { MemberActionDropdown } from './MemberActionDropdown';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
@ -14,6 +14,7 @@ import { useStore } from '@nanostores/preact';
|
||||
import { $canManageCurrentTeam } from '../../stores/team';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { MemberRoleBadge } from './RoleBadge';
|
||||
import { TeamMemberItem } from './TeamMemberItem';
|
||||
|
||||
export interface TeamMemberDocument {
|
||||
_id?: string;
|
||||
@ -26,9 +27,23 @@ export interface TeamMemberDocument {
|
||||
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;
|
||||
avatar: string;
|
||||
progress: UserResourceProgressDocument[];
|
||||
}
|
||||
|
||||
export function TeamMembersPage() {
|
||||
@ -79,7 +94,6 @@ export function TeamMembersPage() {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [teamId]);
|
||||
|
||||
async function deleteMember(teamId: string, memberId: string) {
|
||||
pageProgressMessage.set('Deleting member');
|
||||
const { response, error } = await httpDelete(
|
||||
@ -98,6 +112,50 @@ export function TeamMembersPage() {
|
||||
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 (
|
||||
<div>
|
||||
{memberToUpdate && (
|
||||
@ -139,80 +197,113 @@ export function TeamMembersPage() {
|
||||
</p>
|
||||
<LeaveTeamButton teamId={team?._id!} />
|
||||
</div>
|
||||
{teamMembers.map((member, index) => {
|
||||
{joinedMembers.map((member, index) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-2 p-3 ${
|
||||
index === 0 ? '' : 'border-t'
|
||||
} ${member.status === 'invited' ? 'bg-gray-50' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<span class={'mb-1 block sm:hidden'}>
|
||||
<MemberRoleBadge role={member.role} />
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<h3 className="inline-grid grid-cols-[auto_auto] items-center font-medium">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
{canManageCurrentTeam && (
|
||||
|
Loading…
x
Reference in New Issue
Block a user