From 9e9322ca62c86f151865989f76bc697530979749 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 13:15:39 -0400 Subject: [PATCH 01/18] Refactor members dashboard experience. Restructure the members page around a richer client table and dialog-based invite flow, while preserving the corrected removal and inviter behaviors with focused coverage. Made-with: Cursor --- src/__test__/unit/member-table-utils.test.ts | 102 +++++++ src/app/dashboard/[teamSlug]/members/page.tsx | 14 +- src/configs/layout.ts | 2 +- src/core/modules/teams/models.ts | 1 + .../modules/teams/teams-repository.server.ts | 1 + .../dashboard/members/add-member-dialog.tsx | 43 +++ .../dashboard/members/add-member-form.tsx | 56 ++-- .../dashboard/members/member-card.tsx | 64 ++-- .../dashboard/members/member-table-body.tsx | 68 ----- .../dashboard/members/member-table-row.tsx | 283 +++++++++++++----- .../dashboard/members/member-table.tsx | 89 +++--- .../dashboard/members/member-table.utils.ts | 29 ++ .../members/members-page-content.tsx | 72 +++++ 13 files changed, 589 insertions(+), 235 deletions(-) create mode 100644 src/__test__/unit/member-table-utils.test.ts create mode 100644 src/features/dashboard/members/add-member-dialog.tsx delete mode 100644 src/features/dashboard/members/member-table-body.tsx create mode 100644 src/features/dashboard/members/member-table.utils.ts create mode 100644 src/features/dashboard/members/members-page-content.tsx diff --git a/src/__test__/unit/member-table-utils.test.ts b/src/__test__/unit/member-table-utils.test.ts new file mode 100644 index 000000000..05d29d10b --- /dev/null +++ b/src/__test__/unit/member-table-utils.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import type { TeamMember } from '@/core/modules/teams/models' +import { + getAddedByMember, + isSystemAddedMember, + shouldShowRemoveMemberAction, +} from '@/features/dashboard/members/member-table.utils' + +const createMember = ({ + addedBy = null, + email, + id, + isDefault = false, + name, +}: { + addedBy?: string | null + email: string + id: string + isDefault?: boolean + name?: string +}): TeamMember => ({ + info: { + id, + email, + name, + createdAt: '2026-04-08T00:00:00.000Z', + }, + relation: { + added_by: addedBy, + is_default: isDefault, + }, +}) + +describe('member table utils', () => { + it('finds the inviter from the full member list', () => { + const owner = createMember({ + email: 'owner@example.com', + id: 'owner-id', + isDefault: true, + name: 'Owner', + }) + const invited = createMember({ + addedBy: owner.info.id, + email: 'invited@example.com', + id: 'invited-id', + name: 'Invited', + }) + + expect(getAddedByMember([owner, invited], invited.relation.added_by)).toBe( + owner + ) + }) + + it('hides removal for default members and the current user', () => { + const defaultMember = createMember({ + email: 'default@example.com', + id: 'default-id', + isDefault: true, + }) + const currentUser = createMember({ + email: 'me@example.com', + id: 'me-id', + }) + const invited = createMember({ + email: 'invited@example.com', + id: 'invited-id', + }) + + expect(shouldShowRemoveMemberAction(defaultMember, 'someone-else')).toBe( + false + ) + expect(shouldShowRemoveMemberAction(currentUser, currentUser.info.id)).toBe( + false + ) + expect(shouldShowRemoveMemberAction(invited, currentUser.info.id)).toBe( + true + ) + }) + + it('treats self-added or unresolved rows as system-added', () => { + const owner = createMember({ + email: 'owner@example.com', + id: 'owner-id', + isDefault: true, + }) + const selfAdded = createMember({ + addedBy: owner.info.id, + email: 'owner@example.com', + id: 'owner-id', + isDefault: true, + }) + const invited = createMember({ + addedBy: owner.info.id, + email: 'invited@example.com', + id: 'invited-id', + }) + + expect(isSystemAddedMember(selfAdded, owner)).toBe(true) + expect(isSystemAddedMember(invited, owner)).toBe(false) + expect(isSystemAddedMember(invited, undefined)).toBe(true) + }) +}) diff --git a/src/app/dashboard/[teamSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx index 6ce32e938..6f6b16a46 100644 --- a/src/app/dashboard/[teamSlug]/members/page.tsx +++ b/src/app/dashboard/[teamSlug]/members/page.tsx @@ -1,5 +1,4 @@ import { MemberCard } from '@/features/dashboard/members/member-card' -import Frame from '@/ui/frame' interface MembersPageProps { params: Promise<{ @@ -9,15 +8,8 @@ interface MembersPageProps { export default async function MembersPage({ params }: MembersPageProps) { return ( - -
- -
- +
+ +
) } diff --git a/src/configs/layout.ts b/src/configs/layout.ts index e7a4d6e6a..1c67d67d0 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -90,7 +90,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< type: 'default', }), '/dashboard/*/members': () => ({ - title: 'Members', + title: 'MEMBERS', type: 'default', }), diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts index b77242521..9dd922c85 100644 --- a/src/core/modules/teams/models.ts +++ b/src/core/modules/teams/models.ts @@ -9,6 +9,7 @@ export type TeamMemberInfo = { name?: string avatar_url?: string providers?: string[] + createdAt: string | null } export type TeamMemberRelation = { diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index a3a5ef9df..219764395 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -87,6 +87,7 @@ export function createTeamsRepository( name: user?.user_metadata?.name, avatar_url: user?.user_metadata?.avatar_url, providers: extractSignInProviders(user), + createdAt: member.createdAt, }, relation: { added_by: member.addedBy ?? null, diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx new file mode 100644 index 000000000..74e19d877 --- /dev/null +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -0,0 +1,43 @@ +'use client' + +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { AddMemberEmailForm } from '@/features/dashboard/members/add-member-form' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' + +export const AddMemberDialog = () => { + const [open, setOpen] = useState(false) + + return ( + + + + + + + Add member + + setOpen(false)} + /> + + + ) +} diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index 68f7f9130..371031266 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -27,19 +27,28 @@ const addMemberSchema = z.object({ email: z.email(), }) -type AddMemberForm = z.infer +type AddMemberFormValues = z.infer -interface AddMemberFormProps { +interface AddMemberEmailFormProps { className?: string + /** Called after a successful invite (e.g. close dialog). */ + onSuccess?: () => void + submitLabel?: string + showLabel?: boolean } -export default function AddMemberForm({ className }: AddMemberFormProps) { +export const AddMemberEmailForm = ({ + className, + onSuccess, + submitLabel = 'Add member', + showLabel = true, +}: AddMemberEmailFormProps) => { 'use no memo' const { team } = useDashboard() const { toast } = useToast() - const form = useForm({ + const form = useForm({ resolver: zodResolver(addMemberSchema), defaultValues: { email: '', @@ -50,16 +59,15 @@ export default function AddMemberForm({ className }: AddMemberFormProps) { onSuccess: () => { toast(defaultSuccessToast('The member has been added to the team.')) form.reset() + onSuccess?.() }, onError: ({ error }) => { toast(defaultErrorToast(error.serverError || 'An error occurred.')) }, }) - function onSubmit(data: AddMemberForm) { - if (!team) { - return - } + const onSubmit = (data: AddMemberFormValues) => { + if (!team) return execute({ teamSlug: team.slug, @@ -71,31 +79,31 @@ export default function AddMemberForm({ className }: AddMemberFormProps) {
( - - E-mail -
- - - - -
+ + {showLabel ? E-mail : null} + + + )} /> +
+ +
) diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx index 8df8f25be..49a97bc29 100644 --- a/src/features/dashboard/members/member-card.tsx +++ b/src/features/dashboard/members/member-card.tsx @@ -1,3 +1,6 @@ +import { Suspense } from 'react' +import { getTeamMembers } from '@/core/server/functions/team/get-team-members' +import { ErrorIndicator } from '@/ui/error-indicator' import { Card, CardContent, @@ -5,8 +8,8 @@ import { CardHeader, CardTitle, } from '@/ui/primitives/card' -import AddMemberForm from './add-member-form' -import MemberTable from './member-table' +import { Loader } from '@/ui/primitives/loader_d' +import MembersPageContent from './members-page-content' interface MemberCardProps { params: Promise<{ @@ -15,21 +18,44 @@ interface MemberCardProps { className?: string } -export function MemberCard({ params, className }: MemberCardProps) { - return ( - - - Members - Manage your team members. - - -
- -
- -
-
-
-
- ) +export const MemberCard = ({ params, className }: MemberCardProps) => ( + + + Members + Manage your team members. + + + }> + + + + +) + +const MembersPageContentLoader = async ({ params }: MemberCardProps) => { + const { teamSlug } = await params + + try { + const result = await getTeamMembers({ teamSlug }) + + if (!result?.data || result.serverError || result.validationErrors) { + throw new Error(result?.serverError || 'Unknown error') + } + + return + } catch (error) { + return ( + + ) + } } + +const MembersPageContentLoading = () => ( +
+ +
+) diff --git a/src/features/dashboard/members/member-table-body.tsx b/src/features/dashboard/members/member-table-body.tsx deleted file mode 100644 index ab218c0ca..000000000 --- a/src/features/dashboard/members/member-table-body.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { getTeamMembers } from '@/core/server/functions/team/get-team-members' -import { ErrorIndicator } from '@/ui/error-indicator' -import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' -import { TableCell, TableRow } from '@/ui/primitives/table' -import MemberTableRow from './member-table-row' - -interface TableBodyContentProps { - params: Promise<{ - teamSlug: string - }> -} - -export default async function MemberTableBody({ - params, -}: TableBodyContentProps) { - const { teamSlug } = await params - - try { - const result = await getTeamMembers({ teamSlug }) - - if (!result?.data || result.serverError || result.validationErrors) { - throw new Error(result?.serverError || 'Unknown error') - } - - const members = result.data - - if (members.length === 0) { - return ( - - - - No Members - No team members found. - - - - ) - } - - return ( - <> - {members.map((member, index) => ( - m.info.id === member.relation.added_by)?.info - .email - } - /> - ))} - - ) - } catch (error) { - return ( - - - - - - ) - } -} diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index afd83c8ee..14fa4467a 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -1,5 +1,7 @@ 'use client' +import { format, parseISO } from 'date-fns' +import { Mail, Trash2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' import { useState } from 'react' @@ -15,16 +17,20 @@ import { useToast, } from '@/lib/hooks/use-toast' import { AlertDialog } from '@/ui/alert-dialog' +import { E2BLogo } from '@/ui/brand' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { TableCell, TableRow } from '@/ui/primitives/table' +import { + isSystemAddedMember, + shouldShowRemoveMemberAction, +} from './member-table.utils' import { useDashboard } from '../context' interface TableRowProps { member: TeamMember - addedByEmail?: string - index: number + addedByMember?: TeamMember } type MemberProvider = { @@ -33,6 +39,16 @@ type MemberProvider = { Icon: IconType } +// "2025-08-20T..." -> "Aug 20, 2025" +const formatDate = (iso: string | null | undefined) => { + if (!iso) return null + try { + return format(parseISO(iso), 'MMM d, yyyy') + } catch { + return null + } +} + function normalizeProvider(provider: string): string { const value = provider.toLowerCase() if (value.includes('google')) return 'google' @@ -43,27 +59,19 @@ function normalizeProvider(provider: string): string { function toMemberProvider(provider: string): MemberProvider | null { const normalized = normalizeProvider(provider) - if (normalized === 'google') { + if (normalized === 'google') return { key: normalized, label: 'Google', Icon: FaGoogle } - } - if (normalized === 'github') { + if (normalized === 'github') return { key: normalized, label: 'GitHub', Icon: FaGithub } - } - if (normalized === 'email') { + if (normalized === 'email') return { key: normalized, label: 'Email', Icon: FiMail } - } return null } -export default function MemberTableRow({ - member, - addedByEmail, - index, -}: TableRowProps) { +const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { const { toast } = useToast() - const { team } = useDashboard() const router = useRouter() - const { user } = useDashboard() + const { team, user } = useDashboard() const [removeDialogOpen, setRemoveDialogOpen] = useState(false) const { execute: removeMember, isExecuting: isRemoving } = useAction( @@ -88,11 +96,8 @@ export default function MemberTableRow({ } ) - const handleRemoveMember = async (userId: string) => { - removeMember({ - teamSlug: team.slug, - userId, - }) + const handleRemoveMember = (userId: string) => { + removeMember({ teamSlug: team.slug, userId }) } const providers = @@ -100,63 +105,189 @@ export default function MemberTableRow({ ?.map(toMemberProvider) .filter((provider): provider is MemberProvider => provider !== null) ?? [] + const isCurrentUser = member.info.id === user?.id + const isPending = providers.length === 0 && !member.info.name + const showRemove = shouldShowRemoveMemberAction(member, user?.id) + const dateStr = formatDate(member.info.createdAt) + const addedBySystem = isSystemAddedMember(member, addedByMember) + return ( - - - - - - {member.info?.email?.charAt(0).toUpperCase() || '?'} - - - - - {member.info.id === user?.id - ? 'You' - : (member.info.name ?? 'Anonymous')} - - {member.info.email} - - {providers.length > 0 ? ( -
- {providers.map(({ key, label, Icon }) => ( - - - {label} - - ))} -
- ) : ( - - - )} -
- - {member.relation.added_by === user?.id ? 'You' : (addedByEmail ?? '')} - - - {!member.relation.is_default && user?.id !== member.info.id && ( - handleRemoveMember(member.info.id)} - confirmProps={{ - loading: isRemoving, - }} - trigger={ - - } - open={removeDialogOpen} - onOpenChange={setRemoveDialogOpen} - /> - )} - + + + + handleRemoveMember(member.info.id)} + removeDialogOpen={removeDialogOpen} + setRemoveDialogOpen={setRemoveDialogOpen} + showRemove={showRemove} + /> ) } + +const NameCell = ({ + avatarUrl, + email, + isCurrentUser, + isPending, + name, +}: { + avatarUrl?: string + email: string + isCurrentUser: boolean + isPending: boolean + name?: string +}) => ( + +
+ {isPending ? ( +
+ +
+ ) : ( + + + + {(name?.charAt(0) ?? email.charAt(0)).toUpperCase()} + + + )} +
+
+ + {isPending ? email : (name ?? 'Anonymous')} + + {isCurrentUser && !isPending ? ( + + You + + ) : null} +
+ {!isPending ? ( + + {email} + + ) : null} +
+
+
+) + +const ProvidersCell = ({ + isPending, + providers, +}: { + isPending: boolean + providers: MemberProvider[] +}) => ( + + {isPending ? ( + -- + ) : providers.length > 0 ? ( +
+ {providers.map(({ key, label, Icon }) => ( + + + {label} + + ))} +
+ ) : ( + -- + )} +
+) + +const AddedCell = ({ + addedByMember, + addedBySystem, + dateStr, + isRemoving, + isPending, + memberEmail, + memberName, + onRemove, + removeDialogOpen, + setRemoveDialogOpen, + showRemove, +}: { + addedByMember?: TeamMember + addedBySystem: boolean + dateStr: string | null + isRemoving: boolean + isPending: boolean + memberEmail: string + memberName?: string + onRemove: () => void + removeDialogOpen: boolean + setRemoveDialogOpen: (v: boolean) => void + showRemove: boolean +}) => ( + +
+ + {isPending ? 'Pending...' : (dateStr ?? '—')} + + {addedBySystem ? ( +
+ +
+ ) : ( + + + + {addedByMember?.info.email?.charAt(0).toUpperCase() ?? '?'} + + + )} + {showRemove ? ( + + + + } + /> + ) : null} +
+
+) + +export default MemberTableRow diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index 4b08a33cb..0629e504c 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -1,7 +1,8 @@ -import { type FC, Suspense } from 'react' +'use client' + +import type { FC } from 'react' +import type { TeamMember } from '@/core/modules/teams/models' import { cn } from '@/lib/utils' -import { Alert, AlertDescription, AlertTitle } from '@/ui/primitives/alert' -import { Loader } from '@/ui/primitives/loader_d' import { Table, TableBody, @@ -10,49 +11,65 @@ import { TableHeader, TableRow, } from '@/ui/primitives/table' -import MemberTableBody from './member-table-body' +import { getAddedByMember } from './member-table.utils' +import MemberTableRow from './member-table-row' interface MemberTableProps { - params: Promise<{ - teamSlug: string - }> + allMembers: TeamMember[] + members: TeamMember[] + /** Full list length before client-side search filter (for empty copy). */ + totalMemberCount: number className?: string } -const MemberTable: FC = ({ params, className }) => { - return ( - +const MemberTable: FC = ({ + allMembers, + members, + totalMemberCount, + className, +}) => ( +
+ + + + + - - - Name - E-Mail - Providers - Added By - + + + NAME + + + PROVIDERS + + + ADDED + - - - - - - Loading members... - - This may take a moment. - - - - } - > - - + {members.length === 0 ? ( + + + {totalMemberCount === 0 + ? 'No team members found.' + : 'No members match your search.'} + + + ) : ( + members.map((member) => ( + + )) + )}
- ) -} +) export default MemberTable diff --git a/src/features/dashboard/members/member-table.utils.ts b/src/features/dashboard/members/member-table.utils.ts new file mode 100644 index 000000000..1eedda112 --- /dev/null +++ b/src/features/dashboard/members/member-table.utils.ts @@ -0,0 +1,29 @@ +import type { TeamMember } from '@/core/modules/teams/models' + +// Returns the inviter member for a row. Example: ([alice, bob], bob.added_by=alice.id) -> alice. +const getAddedByMember = ( + allMembers: TeamMember[], + addedById: string | null +): TeamMember | undefined => { + if (!addedById) return undefined + return allMembers.find((member) => member.info.id === addedById) +} + +// Returns whether the row should be treated as system-added. Example: (bob, undefined) -> true. +const isSystemAddedMember = ( + member: TeamMember, + addedByMember?: TeamMember +): boolean => !addedByMember || addedByMember.info.id === member.info.id + +// Returns whether remove should be shown. Example: (default member, current user) -> false. +const shouldShowRemoveMemberAction = ( + member: TeamMember, + currentUserId?: string +): boolean => + !member.relation.is_default && member.info.id !== currentUserId + +export { + getAddedByMember, + isSystemAddedMember, + shouldShowRemoveMemberAction, +} diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx new file mode 100644 index 000000000..b4b535d65 --- /dev/null +++ b/src/features/dashboard/members/members-page-content.tsx @@ -0,0 +1,72 @@ +'use client' + +import { Search } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { TeamMember } from '@/core/modules/teams/models' +import { cn } from '@/lib/utils' +import { Input } from '@/ui/primitives/input' +import { AddMemberDialog } from './add-member-dialog' +import MemberTable from './member-table' + +interface MembersPageContentProps { + members: TeamMember[] + className?: string +} + +const MembersPageContent = ({ + members, + className, +}: MembersPageContentProps) => { + const [query, setQuery] = useState('') + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return members + + return members.filter((m) => { + const name = (m.info.name ?? '').toLowerCase() + const email = m.info.email.toLowerCase() + return name.includes(q) || email.includes(q) + }) + }, [members, query]) + + const totalLabel = + members.length === 1 ? '1 member total' : `${members.length} members total` + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Search by name or email" + type="search" + value={query} + /> +
+ +
+ +
+

All members have the same roles & permissions

+

{totalLabel}

+
+ +
+ +
+
+ ) +} + +export default MembersPageContent From 1b2d3848576ff4f4a0cb574cc5c0baa37e150aa9 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 14:24:54 -0400 Subject: [PATCH 02/18] Enhance member management features and UI. - Introduced `isPendingTeamMember` utility to determine pending invites based on provider recognition. - Updated member table row logic to utilize the new utility for better clarity on member status. - Refined the Add Member dialog and form for improved user experience, including UI adjustments and label updates. - Enhanced members page content to display pending member counts alongside total members. These changes aim to streamline the member management process and improve the overall dashboard experience. --- src/__test__/unit/member-table-utils.test.ts | 21 ++++++++++ .../dashboard/members/add-member-dialog.tsx | 13 +++--- .../dashboard/members/add-member-form.tsx | 41 ++++++++++--------- .../dashboard/members/member-table-row.tsx | 5 ++- .../dashboard/members/member-table.utils.ts | 15 +++++++ .../members/members-page-content.tsx | 11 ++++- 6 files changed, 76 insertions(+), 30 deletions(-) diff --git a/src/__test__/unit/member-table-utils.test.ts b/src/__test__/unit/member-table-utils.test.ts index 05d29d10b..dc52675d9 100644 --- a/src/__test__/unit/member-table-utils.test.ts +++ b/src/__test__/unit/member-table-utils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { TeamMember } from '@/core/modules/teams/models' import { getAddedByMember, + isPendingTeamMember, isSystemAddedMember, shouldShowRemoveMemberAction, } from '@/features/dashboard/members/member-table.utils' @@ -12,17 +13,20 @@ const createMember = ({ id, isDefault = false, name, + providers, }: { addedBy?: string | null email: string id: string isDefault?: boolean name?: string + providers?: string[] }): TeamMember => ({ info: { id, email, name, + providers, createdAt: '2026-04-08T00:00:00.000Z', }, relation: { @@ -99,4 +103,21 @@ describe('member table utils', () => { expect(isSystemAddedMember(invited, owner)).toBe(false) expect(isSystemAddedMember(invited, undefined)).toBe(true) }) + + it('treats invites without recognized providers as pending', () => { + const pendingInvite = createMember({ + email: 'pending@example.com', + id: 'pending-id', + providers: ['saml'], + }) + const activeMember = createMember({ + email: 'active@example.com', + id: 'active-id', + name: 'Active Member', + providers: ['github'], + }) + + expect(isPendingTeamMember(pendingInvite)).toBe(true) + expect(isPendingTeamMember(activeMember)).toBe(false) + }) }) diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index 74e19d877..289e5c608 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -28,15 +28,14 @@ export const AddMemberDialog = () => { Add new member - + - Add member + Add new member - setOpen(false)} - /> + setOpen(false)} /> ) diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index 371031266..56b9dc22f 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -1,6 +1,7 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' +import { Plus } from 'lucide-react' import { useAction } from 'next-safe-action/hooks' import { useForm } from 'react-hook-form' import { z } from 'zod' @@ -31,17 +32,12 @@ type AddMemberFormValues = z.infer interface AddMemberEmailFormProps { className?: string - /** Called after a successful invite (e.g. close dialog). */ onSuccess?: () => void - submitLabel?: string - showLabel?: boolean } export const AddMemberEmailForm = ({ className, onSuccess, - submitLabel = 'Add member', - showLabel = true, }: AddMemberEmailFormProps) => { 'use no memo' @@ -50,6 +46,7 @@ export const AddMemberEmailForm = ({ const form = useForm({ resolver: zodResolver(addMemberSchema), + mode: 'onChange', defaultValues: { email: '', }, @@ -79,31 +76,37 @@ export const AddMemberEmailForm = ({
( - - {showLabel ? E-mail : null} + + Email - + )} /> -
- -
+ ) diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 14fa4467a..2f5c64851 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -22,11 +22,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { TableCell, TableRow } from '@/ui/primitives/table' +import { useDashboard } from '../context' import { + isPendingTeamMember, isSystemAddedMember, shouldShowRemoveMemberAction, } from './member-table.utils' -import { useDashboard } from '../context' interface TableRowProps { member: TeamMember @@ -106,7 +107,7 @@ const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { .filter((provider): provider is MemberProvider => provider !== null) ?? [] const isCurrentUser = member.info.id === user?.id - const isPending = providers.length === 0 && !member.info.name + const isPending = isPendingTeamMember(member) const showRemove = shouldShowRemoveMemberAction(member, user?.id) const dateStr = formatDate(member.info.createdAt) const addedBySystem = isSystemAddedMember(member, addedByMember) diff --git a/src/features/dashboard/members/member-table.utils.ts b/src/features/dashboard/members/member-table.utils.ts index 1eedda112..7e9f2ec07 100644 --- a/src/features/dashboard/members/member-table.utils.ts +++ b/src/features/dashboard/members/member-table.utils.ts @@ -15,6 +15,20 @@ const isSystemAddedMember = ( addedByMember?: TeamMember ): boolean => !addedByMember || addedByMember.info.id === member.info.id +// Returns whether a row should render as a pending invite. Example: ({ name: undefined, providers: ['saml'] }) -> true. +const isPendingTeamMember = (member: TeamMember): boolean => { + const hasRecognizedProvider = member.info.providers?.some((provider) => { + const value = provider.toLowerCase() + return ( + value.includes('google') || + value.includes('github') || + value.includes('email') + ) + }) + + return !hasRecognizedProvider && !member.info.name +} + // Returns whether remove should be shown. Example: (default member, current user) -> false. const shouldShowRemoveMemberAction = ( member: TeamMember, @@ -24,6 +38,7 @@ const shouldShowRemoveMemberAction = ( export { getAddedByMember, + isPendingTeamMember, isSystemAddedMember, shouldShowRemoveMemberAction, } diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index b4b535d65..7e1eca8a3 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils' import { Input } from '@/ui/primitives/input' import { AddMemberDialog } from './add-member-dialog' import MemberTable from './member-table' +import { isPendingTeamMember } from './member-table.utils' interface MembersPageContentProps { members: TeamMember[] @@ -30,8 +31,14 @@ const MembersPageContent = ({ }) }, [members, query]) - const totalLabel = - members.length === 1 ? '1 member total' : `${members.length} members total` + const pendingCount = members.filter(isPendingTeamMember).length + + const totalLabel = [ + members.length === 1 ? '1 member total' : `${members.length} members total`, + pendingCount > 0 ? `${pendingCount} pending` : null, + ] + .filter(Boolean) + .join(' · ') return (
From fa6f937eb17b1defacb251dbb9b5618230a433de Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 15:43:46 -0400 Subject: [PATCH 03/18] Refactor member management UI components. - Simplified the Add Member dialog by consolidating class names for better styling. - Removed unnecessary Card components from MemberCard for a cleaner layout. - Introduced a new RemoveMemberDialog component to enhance member removal interactions. - Adjusted member table row and cell components for improved responsiveness and clarity. These changes aim to streamline the user experience in managing team members on the dashboard. --- .../dashboard/members/add-member-dialog.tsx | 5 +- .../dashboard/members/member-card.tsx | 7 -- .../dashboard/members/member-table-row.tsx | 115 +++++++++++++++--- .../dashboard/members/member-table.tsx | 8 +- .../members/members-page-content.tsx | 2 +- 5 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index 289e5c608..04e9d66b0 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -28,10 +28,7 @@ export const AddMemberDialog = () => { Add new member - + Add new member diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx index 49a97bc29..d33d112ac 100644 --- a/src/features/dashboard/members/member-card.tsx +++ b/src/features/dashboard/members/member-card.tsx @@ -4,9 +4,6 @@ import { ErrorIndicator } from '@/ui/error-indicator' import { Card, CardContent, - CardDescription, - CardHeader, - CardTitle, } from '@/ui/primitives/card' import { Loader } from '@/ui/primitives/loader_d' import MembersPageContent from './members-page-content' @@ -20,10 +17,6 @@ interface MemberCardProps { export const MemberCard = ({ params, className }: MemberCardProps) => ( - - Members - Manage your team members. - }> diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 2f5c64851..e5f5bda97 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -1,14 +1,15 @@ 'use client' import { format, parseISO } from 'date-fns' -import { Mail, Trash2 } from 'lucide-react' +import { Mail } from 'lucide-react' import { useRouter } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' -import { useState } from 'react' +import { type ReactNode, useState } from 'react' import type { IconType } from 'react-icons' import { FaGithub, FaGoogle } from 'react-icons/fa' import { FiMail } from 'react-icons/fi' import { PROTECTED_URLS } from '@/configs/urls' +import { getTeamDisplayName } from '@/core/modules/teams/utils' import { removeTeamMemberAction } from '@/core/server/actions/team-actions' import type { TeamMember } from '@/core/server/functions/team/types' import { @@ -16,11 +17,19 @@ import { defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' -import { AlertDialog } from '@/ui/alert-dialog' import { E2BLogo } from '@/ui/brand' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { useDashboard } from '../context' import { @@ -134,6 +143,7 @@ const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { removeDialogOpen={removeDialogOpen} setRemoveDialogOpen={setRemoveDialogOpen} showRemove={showRemove} + teamName={getTeamDisplayName(team)} /> ) @@ -168,12 +178,15 @@ const NameCell = ({ )}
- + {isPending ? email : (name ?? 'Anonymous')} {isCurrentUser && !isPending ? ( {!isPending ? ( - + {email} ) : null} @@ -199,7 +212,7 @@ const ProvidersCell = ({ isPending: boolean providers: MemberProvider[] }) => ( - + {isPending ? ( -- ) : providers.length > 0 ? ( @@ -221,6 +234,75 @@ const ProvidersCell = ({ ) +const RemoveMemberDialog = ({ + isRemoving, + memberEmail, + memberName, + onRemove, + open, + setOpen, + teamName, + trigger, +}: { + isRemoving: boolean + memberEmail: string + memberName?: string + onRemove: () => void + open: boolean + setOpen: (v: boolean) => void + teamName?: string | null + trigger: ReactNode +}) => { + const shortMemberName = memberName?.trim().split(/\s+/)[0] || memberEmail + const fullMemberName = memberName ?? memberEmail + const teamLabel = teamName || 'this team' + + return ( + + {trigger} + +
+
+ + Remove {shortMemberName}? + + + {fullMemberName} will be removed from {teamLabel} + +
+
+ + + + +
+
+
+
+ ) +} + const AddedCell = ({ addedByMember, addedBySystem, @@ -233,6 +315,7 @@ const AddedCell = ({ removeDialogOpen, setRemoveDialogOpen, showRemove, + teamName, }: { addedByMember?: TeamMember addedBySystem: boolean @@ -245,6 +328,7 @@ const AddedCell = ({ removeDialogOpen: boolean setRemoveDialogOpen: (v: boolean) => void showRemove: boolean + teamName?: string | null }) => (
@@ -267,22 +351,23 @@ const AddedCell = ({ )} {showRemove ? ( - - + } /> diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index 0629e504c..bbbcca463 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -31,15 +31,15 @@ const MemberTable: FC = ({ - - + + - + NAME - + PROVIDERS diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index 7e1eca8a3..12bc282b8 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -43,7 +43,7 @@ const MembersPageContent = ({ return (
-
+
Date: Wed, 8 Apr 2026 16:07:58 -0400 Subject: [PATCH 04/18] Refactor member table responsiveness and layout. - Updated the ProvidersCell component to improve responsiveness by displaying a single provider badge on smaller screens and all providers on larger screens. - Adjusted column widths in the MemberTable for better alignment and consistency across different screen sizes. These changes enhance the user experience by ensuring that member information is displayed clearly and effectively across various devices. --- .../dashboard/members/member-table-row.tsx | 40 +++++++++++++------ .../dashboard/members/member-table.tsx | 6 +-- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index e5f5bda97..7cda6a790 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -212,22 +212,36 @@ const ProvidersCell = ({ isPending: boolean providers: MemberProvider[] }) => ( - + {isPending ? ( -- ) : providers.length > 0 ? ( -
- {providers.map(({ key, label, Icon }) => ( - - - {label} - - ))} -
+ <> +
+ {providers.slice(0, 1).map(({ key, label, Icon }) => ( + + + {label} + + ))} +
+
+ {providers.map(({ key, label, Icon }) => ( + + + {label} + + ))} +
+ ) : ( -- )} diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index bbbcca463..855a4ccca 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -31,15 +31,15 @@ const MemberTable: FC = ({
- - + + NAME - + PROVIDERS From 58dc5a86019b11b8bbaf9bf3a7aa1496a433689e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 16:39:15 -0400 Subject: [PATCH 05/18] Refactor member UI components for consistency and clarity. - Removed unnecessary `not-italic` class from various buttons and input fields in the Add Member dialog and form for a cleaner look. - Adjusted the `NameCell` and `ProvidersCell` components to enhance readability and responsiveness. - Updated the search input in the MembersPageContent to streamline styling. These changes aim to improve the overall user experience and maintain a consistent design across member management components. --- src/features/dashboard/members/add-member-dialog.tsx | 2 +- src/features/dashboard/members/add-member-form.tsx | 4 ++-- src/features/dashboard/members/member-table-row.tsx | 11 +++++++---- .../dashboard/members/members-page-content.tsx | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index 04e9d66b0..8d0bd7146 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -19,7 +19,7 @@ export const AddMemberDialog = () => {
- - - - - - - - - NAME - - - PROVIDERS - - - ADDED - +
+ + + + + + + + + NAME + + + PROVIDERS + + + ADDED + + + + + {members.length === 0 ? ( + + + {totalMemberCount === 0 + ? 'No team members found.' + : 'No members match your search.'} + - - - {members.length === 0 ? ( - - - {totalMemberCount === 0 - ? 'No team members found.' - : 'No members match your search.'} - - - ) : ( - members.map((member) => ( - - )) - )} - -
+ ) : ( + members.map((member) => ( + + )) + )} + + ) export default MemberTable diff --git a/src/features/dashboard/members/member-table.utils.ts b/src/features/dashboard/members/member-table.utils.ts index 7e9f2ec07..841ea12f3 100644 --- a/src/features/dashboard/members/member-table.utils.ts +++ b/src/features/dashboard/members/member-table.utils.ts @@ -33,8 +33,7 @@ const isPendingTeamMember = (member: TeamMember): boolean => { const shouldShowRemoveMemberAction = ( member: TeamMember, currentUserId?: string -): boolean => - !member.relation.is_default && member.info.id !== currentUserId +): boolean => !member.relation.is_default && member.info.id !== currentUserId export { getAddedByMember, From b3a7a336471cb3a18a0a1444e98719b151b42b25 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 16:55:15 -0400 Subject: [PATCH 07/18] Refactor MembersPage layout to utilize new Page component. - Introduced a new `Page` component to standardize layout across dashboard pages. - Updated `MembersPage` to wrap content in the `Page` component for improved structure and styling consistency. These changes enhance the overall user experience by providing a unified layout approach for member management components. --- src/app/dashboard/[teamSlug]/members/page.tsx | 5 +++-- src/features/dashboard/layouts/page.tsx | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 src/features/dashboard/layouts/page.tsx diff --git a/src/app/dashboard/[teamSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx index 6f6b16a46..1fbdd1bf9 100644 --- a/src/app/dashboard/[teamSlug]/members/page.tsx +++ b/src/app/dashboard/[teamSlug]/members/page.tsx @@ -1,3 +1,4 @@ +import { Page } from '@/features/dashboard/layouts/page' import { MemberCard } from '@/features/dashboard/members/member-card' interface MembersPageProps { @@ -8,8 +9,8 @@ interface MembersPageProps { export default async function MembersPage({ params }: MembersPageProps) { return ( -
+ -
+ ) } diff --git a/src/features/dashboard/layouts/page.tsx b/src/features/dashboard/layouts/page.tsx new file mode 100644 index 000000000..ab28a6947 --- /dev/null +++ b/src/features/dashboard/layouts/page.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface PageProps { + children: ReactNode + className?: string +} + +export const Page = ({ children, className }: PageProps) => ( +
+ {children} +
+) From 09ac3a81656ad14176972bdcd651ba09daf2f0f5 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 16:59:14 -0400 Subject: [PATCH 08/18] Refactor Add Member components for clarity and consistency. - Renamed `AddMemberEmailForm` to `AddMemberForm` for better alignment with its functionality. - Updated type definitions to reflect the new naming convention. - Adjusted the Add Member dialog to utilize the renamed form component. These changes enhance code readability and maintain a consistent naming structure across member management components. --- src/features/dashboard/members/add-member-dialog.tsx | 4 ++-- src/features/dashboard/members/add-member-form.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/features/dashboard/members/add-member-dialog.tsx b/src/features/dashboard/members/add-member-dialog.tsx index 8d0bd7146..61494b43a 100644 --- a/src/features/dashboard/members/add-member-dialog.tsx +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -2,7 +2,7 @@ import { Plus } from 'lucide-react' import { useState } from 'react' -import { AddMemberEmailForm } from '@/features/dashboard/members/add-member-form' +import { AddMemberForm } from '@/features/dashboard/members/add-member-form' import { Button } from '@/ui/primitives/button' import { Dialog, @@ -32,7 +32,7 @@ export const AddMemberDialog = () => { Add new member - setOpen(false)} /> + setOpen(false)} /> ) diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index 85d9c93a7..d2e365b3c 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -28,23 +28,23 @@ const addMemberSchema = z.object({ email: z.email(), }) -type AddMemberFormValues = z.infer +type AddMemberForm = z.infer -interface AddMemberEmailFormProps { +interface AddMemberFormProps { className?: string onSuccess?: () => void } -export const AddMemberEmailForm = ({ +export const AddMemberForm = ({ className, onSuccess, -}: AddMemberEmailFormProps) => { +}: AddMemberFormProps) => { 'use no memo' const { team } = useDashboard() const { toast } = useToast() - const form = useForm({ + const form = useForm({ resolver: zodResolver(addMemberSchema), mode: 'onChange', defaultValues: { @@ -63,7 +63,7 @@ export const AddMemberEmailForm = ({ }, }) - const onSubmit = (data: AddMemberFormValues) => { + const onSubmit = (data: AddMemberForm) => { if (!team) return execute({ From 10453dff5c00bb92751fb8a57067149420131078 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 18:13:26 -0400 Subject: [PATCH 09/18] Implement date formatting utility and enhance member table components. - Added `formatDate` utility to format dates with specified structures, improving date handling across the application. - Updated `MemberTableRow` to utilize the new `formatDate` function for displaying member creation dates. - Introduced `TableEmptyState` component to replace the previous empty state implementation in the member table for better clarity and consistency. These changes enhance the user experience by providing clearer date formatting and improving the overall structure of the member table. --- src/__test__/unit/formatting.test.ts | 17 ++++++++++++++++ .../dashboard/members/member-table-row.tsx | 20 +++++-------------- .../dashboard/members/member-table.tsx | 17 +++++++--------- src/lib/utils/formatting.ts | 15 +++++++++++++- src/ui/primitives/table.tsx | 19 ++++++++++++++++++ 5 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/__test__/unit/formatting.test.ts b/src/__test__/unit/formatting.test.ts index 70d7c77eb..b9276e79c 100644 --- a/src/__test__/unit/formatting.test.ts +++ b/src/__test__/unit/formatting.test.ts @@ -5,6 +5,7 @@ import { formatChartTimestampUTC, formatCompactDate, formatCPUCores, + formatDate, formatDecimal, formatDuration, formatMemory, @@ -75,6 +76,22 @@ describe('Date & Time Formatting', () => { }) }) + describe('formatDate', () => { + it('formats a date with the requested structure', () => { + const date = new Date('2024-01-05T14:30:00Z') + expect(formatDate(date, 'MMM d')).toBe('Jan 5') + }) + + it('supports a format that includes the year', () => { + const date = new Date('2024-01-05T14:30:00Z') + expect(formatDate(date, 'MMM d, yyyy')).toBe('Jan 5, 2024') + }) + + it('returns null for invalid dates', () => { + expect(formatDate(new Date('not-a-date'), 'MMM d')).toBeNull() + }) + }) + describe('parseUTCDateComponents', () => { it('parses UTC date into components', () => { const date = new Date('2024-01-05T14:30:45Z') diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index b5f1437cd..1803bafc4 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -1,6 +1,5 @@ 'use client' -import { format, parseISO } from 'date-fns' import { Mail } from 'lucide-react' import { useRouter } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' @@ -17,6 +16,7 @@ import { defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' +import { formatDate } from '@/lib/utils/formatting' import { E2BLogo } from '@/ui/brand' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { Badge } from '@/ui/primitives/badge' @@ -49,16 +49,6 @@ type MemberProvider = { Icon: IconType } -// "2025-08-20T..." -> "Aug 20, 2025" -const formatDate = (iso: string | null | undefined) => { - if (!iso) return null - try { - return format(parseISO(iso), 'MMM d, yyyy') - } catch { - return null - } -} - function normalizeProvider(provider: string): string { const value = provider.toLowerCase() if (value.includes('google')) return 'google' @@ -78,7 +68,7 @@ function toMemberProvider(provider: string): MemberProvider | null { return null } -const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { +export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { const { toast } = useToast() const router = useRouter() const { team, user } = useDashboard() @@ -118,7 +108,9 @@ const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { const isCurrentUser = member.info.id === user?.id const isPending = isPendingTeamMember(member) const showRemove = shouldShowRemoveMemberAction(member, user?.id) - const dateStr = formatDate(member.info.createdAt) + const dateStr = member.info.createdAt + ? formatDate(new Date(member.info.createdAt), 'MMM d, yyyy') + : null const addedBySystem = isSystemAddedMember(member, addedByMember) return ( @@ -392,5 +384,3 @@ const AddedCell = ({
) - -export default MemberTableRow diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index 27f6989bd..ab78e123e 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -6,18 +6,17 @@ import { cn } from '@/lib/utils' import { Table, TableBody, - TableCell, + TableEmptyState, TableHead, TableHeader, TableRow, } from '@/ui/primitives/table' import { getAddedByMember } from './member-table.utils' -import MemberTableRow from './member-table-row' +import { MemberTableRow } from './member-table-row' interface MemberTableProps { allMembers: TeamMember[] members: TeamMember[] - /** Full list length before client-side search filter (for empty copy). */ totalMemberCount: number className?: string } @@ -49,13 +48,11 @@ const MemberTable: FC = ({ {members.length === 0 ? ( - - - {totalMemberCount === 0 - ? 'No team members found.' - : 'No members match your search.'} - - + + {totalMemberCount === 0 + ? 'No team members found.' + : 'No members match your search.'} + ) : ( members.map((member) => ( 'Apr 8, 2026'. +export const formatDate = ( + date: Date, + dateStructure: DateStructure +): string | null => { + if (!isValid(date)) return null + return format(date, dateStructure) +} + export function formatDay(timestamp: number): string { if (isThisYear(timestamp)) { return new Intl.DateTimeFormat('en-US', { @@ -450,7 +463,7 @@ export function tryParseDatetime(input: string): Date | null { // Try parsing as timestamp first (for performance with numeric inputs) const timestamp = Number(input) - if (!isNaN(timestamp)) { + if (!Number.isNaN(timestamp)) { // if timestamp is less than 10 digits, multiply by 1000 to get milliseconds const date = new Date( timestamp < 10000000000 ? timestamp * 1000 : timestamp diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx index 999bcd77f..d386b33be 100644 --- a/src/ui/primitives/table.tsx +++ b/src/ui/primitives/table.tsx @@ -128,11 +128,30 @@ const TableCaption = React.forwardRef< )) TableCaption.displayName = 'TableCaption' +interface TableEmptyStateProps { + colSpan: number + children: React.ReactNode + className?: string +} + +const TableEmptyState = ({ + colSpan, + children, + className, +}: TableEmptyStateProps) => ( + + + {children} + + +) + export { Table, TableBody, TableCaption, TableCell, + TableEmptyState, TableFooter, TableHead, TableHeader, From 1755e96afab8d7334ac647c45fef53d948765d92 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 18:17:54 -0400 Subject: [PATCH 10/18] Run biome format --- src/features/dashboard/members/add-member-form.tsx | 5 +---- src/ui/primitives/table.tsx | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index d2e365b3c..65c1a4895 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -35,10 +35,7 @@ interface AddMemberFormProps { onSuccess?: () => void } -export const AddMemberForm = ({ - className, - onSuccess, -}: AddMemberFormProps) => { +export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { 'use no memo' const { team } = useDashboard() diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx index d386b33be..204c06372 100644 --- a/src/ui/primitives/table.tsx +++ b/src/ui/primitives/table.tsx @@ -140,7 +140,10 @@ const TableEmptyState = ({ className, }: TableEmptyStateProps) => ( - + {children} From 096f6a96b668be0fb3299c43818c833bd2d440f4 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 10:25:10 -0400 Subject: [PATCH 11/18] Undo uppercase for layout titles --- src/configs/layout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 1c67d67d0..e7a4d6e6a 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -90,7 +90,7 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< type: 'default', }), '/dashboard/*/members': () => ({ - title: 'MEMBERS', + title: 'Members', type: 'default', }), From 0c1f82b8f7432c4c8110ea4b4d481e8f8996707c Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 10:33:31 -0400 Subject: [PATCH 12/18] Refactor member removal dialog for improved clarity and structure. - Moved the `RemoveMemberDialog` component to its own file for better organization and maintainability. - Simplified the `member-table-row` by removing the inline dialog implementation, enhancing readability. - Updated the dialog's structure and styling for a more consistent user experience. These changes aim to streamline the member removal process and improve code organization within the dashboard components. --- .../dashboard/members/member-table-row.tsx | 80 +------------------ .../members/remove-member-dialog.tsx | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 78 deletions(-) create mode 100644 src/features/dashboard/members/remove-member-dialog.tsx diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 1803bafc4..6da8c579f 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -3,7 +3,7 @@ import { Mail } from 'lucide-react' import { useRouter } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' -import { type ReactNode, useState } from 'react' +import { useState } from 'react' import type { IconType } from 'react-icons' import { FaGithub, FaGoogle } from 'react-icons/fa' import { FiMail } from 'react-icons/fi' @@ -21,14 +21,6 @@ import { E2BLogo } from '@/ui/brand' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from '@/ui/primitives/dialog' import { TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { useDashboard } from '../context' @@ -37,6 +29,7 @@ import { isSystemAddedMember, shouldShowRemoveMemberAction, } from './member-table.utils' +import { RemoveMemberDialog } from './remove-member-dialog' interface TableRowProps { member: TeamMember @@ -243,75 +236,6 @@ const ProvidersCell = ({
) -const RemoveMemberDialog = ({ - isRemoving, - memberEmail, - memberName, - onRemove, - open, - setOpen, - teamName, - trigger, -}: { - isRemoving: boolean - memberEmail: string - memberName?: string - onRemove: () => void - open: boolean - setOpen: (v: boolean) => void - teamName?: string | null - trigger: ReactNode -}) => { - const shortMemberName = memberName?.trim().split(/\s+/)[0] || memberEmail - const fullMemberName = memberName ?? memberEmail - const teamLabel = teamName || 'this team' - - return ( - - {trigger} - -
-
- - Remove {shortMemberName}? - - - {fullMemberName} will be removed from {teamLabel} - -
-
- - - - -
-
-
-
- ) -} - const AddedCell = ({ addedByMember, addedBySystem, diff --git a/src/features/dashboard/members/remove-member-dialog.tsx b/src/features/dashboard/members/remove-member-dialog.tsx new file mode 100644 index 000000000..db40343c6 --- /dev/null +++ b/src/features/dashboard/members/remove-member-dialog.tsx @@ -0,0 +1,80 @@ +'use client' + +import type { ReactNode } from 'react' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { TrashIcon } from '@/ui/primitives/icons' + +interface RemoveMemberDialogProps { + isRemoving: boolean + memberEmail: string + memberName?: string + onRemove: () => void + open: boolean + setOpen: (v: boolean) => void + teamName?: string | null + trigger: ReactNode +} + +export const RemoveMemberDialog = ({ + isRemoving, + memberEmail, + memberName, + onRemove, + open, + setOpen, + teamName, + trigger, +}: RemoveMemberDialogProps) => { + const shortMemberName = memberName?.trim().split(/\s+/)[0] || memberEmail + const fullMemberName = memberName ?? memberEmail + const teamLabel = teamName || 'this team' + + return ( + + {trigger} + +
+ + Remove {shortMemberName}? + + {fullMemberName} will be removed from {teamLabel} + + +
+ + + + +
+
+
+
+ ) +} From 0adaed1b36c69aa3f7046cc160c03f9cab4843ae Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 10:39:23 -0400 Subject: [PATCH 13/18] Refactor member table utility functions for clarity and consistency. - Renamed `isSystemAddedMember` to `wasAddedBySystem` and `isPendingTeamMember` to `isPendingInvite` for improved clarity in their functionality. - Updated all relevant tests and components to reflect the new function names, ensuring consistent usage across the application. These changes enhance code readability and maintain a clear understanding of member status within the dashboard components. --- src/__test__/unit/member-table-utils.test.ts | 14 +++++++------- .../dashboard/members/member-table-row.tsx | 8 ++++---- .../dashboard/members/member-table.utils.ts | 15 +++------------ .../dashboard/members/members-page-content.tsx | 4 ++-- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/src/__test__/unit/member-table-utils.test.ts b/src/__test__/unit/member-table-utils.test.ts index dc52675d9..91906de36 100644 --- a/src/__test__/unit/member-table-utils.test.ts +++ b/src/__test__/unit/member-table-utils.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest' import type { TeamMember } from '@/core/modules/teams/models' import { getAddedByMember, - isPendingTeamMember, - isSystemAddedMember, + isPendingInvite, shouldShowRemoveMemberAction, + wasAddedBySystem, } from '@/features/dashboard/members/member-table.utils' const createMember = ({ @@ -99,9 +99,9 @@ describe('member table utils', () => { id: 'invited-id', }) - expect(isSystemAddedMember(selfAdded, owner)).toBe(true) - expect(isSystemAddedMember(invited, owner)).toBe(false) - expect(isSystemAddedMember(invited, undefined)).toBe(true) + expect(wasAddedBySystem(selfAdded, owner)).toBe(true) + expect(wasAddedBySystem(invited, owner)).toBe(false) + expect(wasAddedBySystem(invited, undefined)).toBe(true) }) it('treats invites without recognized providers as pending', () => { @@ -117,7 +117,7 @@ describe('member table utils', () => { providers: ['github'], }) - expect(isPendingTeamMember(pendingInvite)).toBe(true) - expect(isPendingTeamMember(activeMember)).toBe(false) + expect(isPendingInvite(pendingInvite)).toBe(true) + expect(isPendingInvite(activeMember)).toBe(false) }) }) diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 6da8c579f..767a0bcdb 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -25,9 +25,9 @@ import { TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { useDashboard } from '../context' import { - isPendingTeamMember, - isSystemAddedMember, + isPendingInvite, shouldShowRemoveMemberAction, + wasAddedBySystem, } from './member-table.utils' import { RemoveMemberDialog } from './remove-member-dialog' @@ -99,12 +99,12 @@ export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { .filter((provider): provider is MemberProvider => provider !== null) ?? [] const isCurrentUser = member.info.id === user?.id - const isPending = isPendingTeamMember(member) + const isPending = isPendingInvite(member) const showRemove = shouldShowRemoveMemberAction(member, user?.id) const dateStr = member.info.createdAt ? formatDate(new Date(member.info.createdAt), 'MMM d, yyyy') : null - const addedBySystem = isSystemAddedMember(member, addedByMember) + const addedBySystem = wasAddedBySystem(member, addedByMember) return ( diff --git a/src/features/dashboard/members/member-table.utils.ts b/src/features/dashboard/members/member-table.utils.ts index 841ea12f3..c1d43ed32 100644 --- a/src/features/dashboard/members/member-table.utils.ts +++ b/src/features/dashboard/members/member-table.utils.ts @@ -1,6 +1,5 @@ import type { TeamMember } from '@/core/modules/teams/models' -// Returns the inviter member for a row. Example: ([alice, bob], bob.added_by=alice.id) -> alice. const getAddedByMember = ( allMembers: TeamMember[], addedById: string | null @@ -9,14 +8,12 @@ const getAddedByMember = ( return allMembers.find((member) => member.info.id === addedById) } -// Returns whether the row should be treated as system-added. Example: (bob, undefined) -> true. -const isSystemAddedMember = ( +const wasAddedBySystem = ( member: TeamMember, addedByMember?: TeamMember ): boolean => !addedByMember || addedByMember.info.id === member.info.id -// Returns whether a row should render as a pending invite. Example: ({ name: undefined, providers: ['saml'] }) -> true. -const isPendingTeamMember = (member: TeamMember): boolean => { +const isPendingInvite = (member: TeamMember): boolean => { const hasRecognizedProvider = member.info.providers?.some((provider) => { const value = provider.toLowerCase() return ( @@ -29,15 +26,9 @@ const isPendingTeamMember = (member: TeamMember): boolean => { return !hasRecognizedProvider && !member.info.name } -// Returns whether remove should be shown. Example: (default member, current user) -> false. const shouldShowRemoveMemberAction = ( member: TeamMember, currentUserId?: string ): boolean => !member.relation.is_default && member.info.id !== currentUserId -export { - getAddedByMember, - isPendingTeamMember, - isSystemAddedMember, - shouldShowRemoveMemberAction, -} +export { getAddedByMember, isPendingInvite, shouldShowRemoveMemberAction, wasAddedBySystem } diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index 248ec4bde..50d932bd1 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' import { Input } from '@/ui/primitives/input' import { AddMemberDialog } from './add-member-dialog' import MemberTable from './member-table' -import { isPendingTeamMember } from './member-table.utils' +import { isPendingInvite } from './member-table.utils' interface MembersPageContentProps { members: TeamMember[] @@ -31,7 +31,7 @@ const MembersPageContent = ({ }) }, [members, query]) - const pendingCount = members.filter(isPendingTeamMember).length + const pendingCount = members.filter(isPendingInvite).length const totalLabel = [ members.length === 1 ? '1 member total' : `${members.length} members total`, From 680c26ccdb72d62f10c993e68ff23aa6ae708d6e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 10:52:49 -0400 Subject: [PATCH 14/18] Add pluralization utility and update member table components. - Introduced a new `pluralize` function to handle singular and plural word forms based on count, enhancing text display consistency. - Updated `MembersPageContent` to utilize the `pluralize` function for member count display. - Removed the `isPendingInvite` function from member table utilities and adjusted related components to streamline member status handling. These changes improve code clarity and enhance the user experience by providing accurate member count representations. --- src/__test__/unit/formatting.test.ts | 20 ++++++++ src/__test__/unit/member-table-utils.test.ts | 17 ------- .../dashboard/members/member-table-row.tsx | 51 +++++-------------- .../dashboard/members/member-table.utils.ts | 15 +----- .../members/members-page-content.tsx | 11 +--- src/lib/utils/formatting.ts | 23 +++++++++ 6 files changed, 60 insertions(+), 77 deletions(-) diff --git a/src/__test__/unit/formatting.test.ts b/src/__test__/unit/formatting.test.ts index b9276e79c..60f092fdd 100644 --- a/src/__test__/unit/formatting.test.ts +++ b/src/__test__/unit/formatting.test.ts @@ -12,6 +12,7 @@ import { formatNumber, formatTimeAxisLabel, parseUTCDateComponents, + pluralize, } from '@/lib/utils/formatting' describe('Date & Time Formatting', () => { @@ -202,4 +203,23 @@ describe('Number Formatting', () => { expect(formatCPUCores(4)).toBe('4 cores') }) }) + + describe('pluralize', () => { + it('returns the singular word for a count of one', () => { + expect(pluralize(1, 'member')).toBe('member') + }) + + it('adds es for words ending in s-like sounds', () => { + expect(pluralize(2, 'class')).toBe('classes') + expect(pluralize(2, 'match')).toBe('matches') + }) + + it('changes a trailing consonant y to ies', () => { + expect(pluralize(2, 'company')).toBe('companies') + }) + + it('supports an explicit plural override for irregular nouns', () => { + expect(pluralize(2, 'person', 'people')).toBe('people') + }) + }) }) diff --git a/src/__test__/unit/member-table-utils.test.ts b/src/__test__/unit/member-table-utils.test.ts index 91906de36..d5cbd9ff7 100644 --- a/src/__test__/unit/member-table-utils.test.ts +++ b/src/__test__/unit/member-table-utils.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import type { TeamMember } from '@/core/modules/teams/models' import { getAddedByMember, - isPendingInvite, shouldShowRemoveMemberAction, wasAddedBySystem, } from '@/features/dashboard/members/member-table.utils' @@ -104,20 +103,4 @@ describe('member table utils', () => { expect(wasAddedBySystem(invited, undefined)).toBe(true) }) - it('treats invites without recognized providers as pending', () => { - const pendingInvite = createMember({ - email: 'pending@example.com', - id: 'pending-id', - providers: ['saml'], - }) - const activeMember = createMember({ - email: 'active@example.com', - id: 'active-id', - name: 'Active Member', - providers: ['github'], - }) - - expect(isPendingInvite(pendingInvite)).toBe(true) - expect(isPendingInvite(activeMember)).toBe(false) - }) }) diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 767a0bcdb..341d68708 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -1,6 +1,5 @@ 'use client' -import { Mail } from 'lucide-react' import { useRouter } from 'next/navigation' import { useAction } from 'next-safe-action/hooks' import { useState } from 'react' @@ -25,7 +24,6 @@ import { TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { useDashboard } from '../context' import { - isPendingInvite, shouldShowRemoveMemberAction, wasAddedBySystem, } from './member-table.utils' @@ -99,7 +97,6 @@ export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { .filter((provider): provider is MemberProvider => provider !== null) ?? [] const isCurrentUser = member.info.id === user?.id - const isPending = isPendingInvite(member) const showRemove = shouldShowRemoveMemberAction(member, user?.id) const dateStr = member.info.createdAt ? formatDate(new Date(member.info.createdAt), 'MMM d, yyyy') @@ -112,16 +109,14 @@ export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { avatarUrl={member.info.avatar_url} email={member.info.email} isCurrentUser={isCurrentUser} - isPending={isPending} name={member.info.name} /> - + handleRemoveMember(member.info.id)} @@ -138,38 +133,30 @@ const NameCell = ({ avatarUrl, email, isCurrentUser, - isPending, name, }: { avatarUrl?: string email: string isCurrentUser: boolean - isPending: boolean name?: string }) => (
- {isPending ? ( -
- -
- ) : ( - - - - {(name?.charAt(0) ?? email.charAt(0)).toUpperCase()} - - - )} + + + + {(name?.charAt(0) ?? email.charAt(0)).toUpperCase()} + +
- {isPending ? email : (name ?? 'Anonymous')} + {name ?? email} - {isCurrentUser && !isPending ? ( + {isCurrentUser ? ( ) : null}
- {!isPending ? ( + {name ? ( ) -const ProvidersCell = ({ - isPending, - providers, -}: { - isPending: boolean - providers: MemberProvider[] -}) => ( +const ProvidersCell = ({ providers }: { providers: MemberProvider[] }) => ( - {isPending ? ( - -- - ) : providers.length > 0 ? ( + {providers.length > 0 ? ( <>
{providers.map(({ key, label, Icon }) => ( @@ -241,7 +220,6 @@ const AddedCell = ({ addedBySystem, dateStr, isRemoving, - isPending, memberEmail, memberName, onRemove, @@ -254,7 +232,6 @@ const AddedCell = ({ addedBySystem: boolean dateStr: string | null isRemoving: boolean - isPending: boolean memberEmail: string memberName?: string onRemove: () => void @@ -266,7 +243,7 @@ const AddedCell = ({
- {isPending ? 'Pending...' : (dateStr ?? '—')} + {dateStr ?? '—'} {addedBySystem ? (
diff --git a/src/features/dashboard/members/member-table.utils.ts b/src/features/dashboard/members/member-table.utils.ts index c1d43ed32..638fbb5af 100644 --- a/src/features/dashboard/members/member-table.utils.ts +++ b/src/features/dashboard/members/member-table.utils.ts @@ -13,22 +13,9 @@ const wasAddedBySystem = ( addedByMember?: TeamMember ): boolean => !addedByMember || addedByMember.info.id === member.info.id -const isPendingInvite = (member: TeamMember): boolean => { - const hasRecognizedProvider = member.info.providers?.some((provider) => { - const value = provider.toLowerCase() - return ( - value.includes('google') || - value.includes('github') || - value.includes('email') - ) - }) - - return !hasRecognizedProvider && !member.info.name -} - const shouldShowRemoveMemberAction = ( member: TeamMember, currentUserId?: string ): boolean => !member.relation.is_default && member.info.id !== currentUserId -export { getAddedByMember, isPendingInvite, shouldShowRemoveMemberAction, wasAddedBySystem } +export { getAddedByMember, shouldShowRemoveMemberAction, wasAddedBySystem } diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index 50d932bd1..1e8b4cc37 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -4,10 +4,10 @@ import { Search } from 'lucide-react' import { useMemo, useState } from 'react' import type { TeamMember } from '@/core/modules/teams/models' import { cn } from '@/lib/utils' +import { pluralize } from '@/lib/utils/formatting' import { Input } from '@/ui/primitives/input' import { AddMemberDialog } from './add-member-dialog' import MemberTable from './member-table' -import { isPendingInvite } from './member-table.utils' interface MembersPageContentProps { members: TeamMember[] @@ -31,14 +31,7 @@ const MembersPageContent = ({ }) }, [members, query]) - const pendingCount = members.filter(isPendingInvite).length - - const totalLabel = [ - members.length === 1 ? '1 member total' : `${members.length} members total`, - pendingCount > 0 ? `${pendingCount} pending` : null, - ] - .filter(Boolean) - .join(' · ') + const totalLabel = `${members.length} ${pluralize(members.length, 'member')} total` return (
diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index a4ef2a65b..682310bc0 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -420,6 +420,29 @@ export function formatCPUCores( return `${formatNumber(cores, locale)} core${cores !== 1 ? 's' : ''}` } +/** + * Returns the singular or plural word for a count + * @param count - Number used to determine singular vs plural form + * @param singular - Singular form of the word + * @param plural - Optional plural form override (defaults to an inferred plural form) + * @returns Singular or plural word (e.g., "member" or "members") + */ +export const pluralize = ( + count: number, + singular: string, + plural?: string +): string => { + if (count === 1) return singular + if (plural) return plural + if (/[sxz]$/i.test(singular) || /(ch|sh)$/i.test(singular)) { + return `${singular}es` + } + if (/[^aeiou]y$/i.test(singular)) { + return `${singular.slice(0, -1)}ies` + } + return `${singular}s` +} + /** * Format a number for chart axis labels with smart abbreviation * Uses whole numbers when possible, abbreviated for large numbers From 69020e769395a9acb4f2a173afc81626c8ec056f Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 10:56:07 -0400 Subject: [PATCH 15/18] Update comment for consistency with other formatting utils --- src/lib/utils/formatting.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index 682310bc0..20f0b04d3 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -146,7 +146,12 @@ const DATE_STRUCTURES = ['MMM d', 'MMM d, yyyy'] as const type DateStructure = (typeof DATE_STRUCTURES)[number] -// Returns a formatted date string. Example: (new Date('2026-04-08'), 'MMM d, yyyy') -> 'Apr 8, 2026'. +/** + * Returns a formatted date string + * @param date - Date to format + * @param dateStructure - Supported date format structure + * @returns Formatted date string (e.g., "Apr 8, 2026") or null for invalid dates + */ export const formatDate = ( date: Date, dateStructure: DateStructure From 7dde923d48469a34438b01fa0b3278edcab717e1 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 11:17:07 -0400 Subject: [PATCH 16/18] Refactor InvoicesEmpty component to use TableEmptyState for improved consistency and clarity. - Replaced the previous implementation of the InvoicesEmpty component with the new TableEmptyState component, enhancing the visual structure of empty states. - Updated the member table to streamline the rendering of empty states, ensuring a more uniform approach across the application. These changes improve code maintainability and user experience by providing a consistent empty state presentation. --- src/features/dashboard/billing/invoices.tsx | 51 ++++++------------- .../dashboard/members/member-table.tsx | 8 +-- .../primitives}/table-empty-row-border.tsx | 1 + src/ui/primitives/table.tsx | 26 ++++++++-- 4 files changed, 42 insertions(+), 44 deletions(-) rename src/{features/dashboard/billing => ui/primitives}/table-empty-row-border.tsx (99%) diff --git a/src/features/dashboard/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx index f603d5885..f561445ce 100644 --- a/src/features/dashboard/billing/invoices.tsx +++ b/src/features/dashboard/billing/invoices.tsx @@ -17,12 +17,12 @@ import { Table, TableBody, TableCell, + TableEmptyState, TableHead, TableHeader, TableRow, } from '@/ui/primitives/table' import { useInvoices } from './hooks' -import { TableEmptyRowBorder } from './table-empty-row-border' const COLUMN_WIDTHS = { date: 120, @@ -48,34 +48,19 @@ interface InvoicesEmptyProps { function InvoicesEmpty({ error }: InvoicesEmptyProps) { return ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - - - {index === 1 && ( - <> - - -

- {error ? error : 'No invoices yet'} -

- - )} -
- ))} -
+ + +

+ {error ? error : 'No invoices yet'} +

+
) } @@ -146,13 +131,7 @@ export default function BillingInvoicesTable() { )} - {showEmpty && ( - - - - - - )} + {showEmpty && } {hasData && invoices.map((invoice) => ( diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index ab78e123e..f0f1e80bb 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -49,9 +49,11 @@ const MemberTable: FC = ({ {members.length === 0 ? ( - {totalMemberCount === 0 - ? 'No team members found.' - : 'No members match your search.'} +

+ {totalMemberCount === 0 + ? 'No team members found.' + : 'No members match your search.'} +

) : ( members.map((member) => ( diff --git a/src/features/dashboard/billing/table-empty-row-border.tsx b/src/ui/primitives/table-empty-row-border.tsx similarity index 99% rename from src/features/dashboard/billing/table-empty-row-border.tsx rename to src/ui/primitives/table-empty-row-border.tsx index aa7ec16f1..7e13f39ae 100644 --- a/src/features/dashboard/billing/table-empty-row-border.tsx +++ b/src/ui/primitives/table-empty-row-border.tsx @@ -12,6 +12,7 @@ const PATTERN_PATH_2 = `M32.944 -12.8C32.944 -13.344 32.76 -13.8 32.392 -14.168C export function TableEmptyRowBorder({ className }: TableEmptyRowBorderProps) { return (
+ {EMPTY_STATE_ROWS.map((_, index) => ( +
+ + + {index === 1 && children} +
+ ))} +
) From 78b562f58da23110f12d9422a0fab0a83b1e921d Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 11:18:28 -0400 Subject: [PATCH 17/18] Run biome format --- src/__test__/unit/member-table-utils.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/__test__/unit/member-table-utils.test.ts b/src/__test__/unit/member-table-utils.test.ts index d5cbd9ff7..4d85cfe7b 100644 --- a/src/__test__/unit/member-table-utils.test.ts +++ b/src/__test__/unit/member-table-utils.test.ts @@ -102,5 +102,4 @@ describe('member table utils', () => { expect(wasAddedBySystem(invited, owner)).toBe(false) expect(wasAddedBySystem(invited, undefined)).toBe(true) }) - }) From 3bd14d22de812679002ab6d4f6fab8995c6ed055 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 14:29:10 -0400 Subject: [PATCH 18/18] Refactor member components for improved consistency and responsiveness. - Replaced the Plus icon with AddIcon in the AddMemberForm for better visual consistency. - Adjusted column widths in MemberTable for improved responsiveness across different screen sizes. - Updated layout classes in MembersPageContent to enhance responsiveness and maintain consistency in styling. These changes aim to streamline the user interface and improve the overall user experience in the dashboard. --- src/features/dashboard/members/add-member-form.tsx | 4 ++-- src/features/dashboard/members/member-table.tsx | 6 +++--- src/features/dashboard/members/members-page-content.tsx | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index 65c1a4895..80d656cf2 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -1,7 +1,6 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { Plus } from 'lucide-react' import { useAction } from 'next-safe-action/hooks' import { useForm } from 'react-hook-form' import { z } from 'zod' @@ -21,6 +20,7 @@ import { FormLabel, FormMessage, } from '@/ui/primitives/form' +import { AddIcon } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { useDashboard } from '../context' @@ -101,7 +101,7 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { size="md" variant="default" > - + Add diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index f0f1e80bb..06d9bc32f 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -29,9 +29,9 @@ const MemberTable: FC = ({ }) => ( - - - + + + diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index 1e8b4cc37..b43e7e772 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -35,8 +35,8 @@ const MembersPageContent = ({ return (
-
-
+
+
-
+

All members have the same roles & permissions

{totalLabel}