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
pull/4269/head
Arik Chakma 1 year ago committed by GitHub
parent 543d3b47ce
commit fc8ce296be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 23
      src/components/ReactIcons/MailIcon.tsx
  2. 24
      src/components/TeamMembers/MemberActionDropdown.tsx
  3. 3
      src/components/TeamMembers/RoleBadge.tsx
  4. 132
      src/components/TeamMembers/TeamMemberItem.tsx
  5. 211
      src/components/TeamMembers/TeamMembersPage.tsx

@ -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,8 +3,7 @@ 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' : ''}`}

@ -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,81 +197,114 @@ 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" });
}}
onUpdateMember={() => {
setMemberToUpdate(member);
}}
handleSendReminder={() => {
handleSendReminder(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
/> />
<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>
<div className="flex items-center text-sm"> {invitedMembers.length > 0 && (
<span class={'hidden sm:block'}> <div className="mt-6">
<MemberRoleBadge role={member.role} /> <h3 className="text-xl font-medium">Invited Members</h3>
</span> <div className="mt-2 rounded-b-sm rounded-t-md border">
{canManageCurrentTeam && ( {invitedMembers.map((member, index) => {
<MemberActionDropdown return (
onDeleteMember={() => { <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(() => { deleteMember(teamId, member._id!).finally(() => {
pageProgressMessage.set(''); pageProgressMessage.set('');
}); });
}} }}
isDisabled={member.userId === user?.id}
onUpdateMember={() => { onUpdateMember={() => {
setMemberToUpdate(member); setMemberToUpdate(member);
}} }}
member={member} handleSendReminder={() => {
handleSendReminder(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
/> />
)} );
})}
</div> </div>
</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>
)}
</div>
{canManageCurrentTeam && ( {canManageCurrentTeam && (
<div className="mt-4"> <div className="mt-4">

Loading…
Cancel
Save