diff --git a/src/__test__/unit/formatting.test.ts b/src/__test__/unit/formatting.test.ts index 70d7c77eb..60f092fdd 100644 --- a/src/__test__/unit/formatting.test.ts +++ b/src/__test__/unit/formatting.test.ts @@ -5,12 +5,14 @@ import { formatChartTimestampUTC, formatCompactDate, formatCPUCores, + formatDate, formatDecimal, formatDuration, formatMemory, formatNumber, formatTimeAxisLabel, parseUTCDateComponents, + pluralize, } from '@/lib/utils/formatting' describe('Date & Time Formatting', () => { @@ -75,6 +77,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') @@ -185,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 new file mode 100644 index 000000000..4d85cfe7b --- /dev/null +++ b/src/__test__/unit/member-table-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import type { TeamMember } from '@/core/modules/teams/models' +import { + getAddedByMember, + shouldShowRemoveMemberAction, + wasAddedBySystem, +} from '@/features/dashboard/members/member-table.utils' + +const createMember = ({ + addedBy = null, + email, + 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: { + 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(wasAddedBySystem(selfAdded, owner)).toBe(true) + expect(wasAddedBySystem(invited, owner)).toBe(false) + expect(wasAddedBySystem(invited, undefined)).toBe(true) + }) +}) diff --git a/src/app/dashboard/[teamSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx index 6ce32e938..1fbdd1bf9 100644 --- a/src/app/dashboard/[teamSlug]/members/page.tsx +++ b/src/app/dashboard/[teamSlug]/members/page.tsx @@ -1,5 +1,5 @@ +import { Page } from '@/features/dashboard/layouts/page' import { MemberCard } from '@/features/dashboard/members/member-card' -import Frame from '@/ui/frame' interface MembersPageProps { params: Promise<{ @@ -9,15 +9,8 @@ interface MembersPageProps { export default async function MembersPage({ params }: MembersPageProps) { return ( - -
- -
- + + + ) } 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/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/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} +
+) 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..61494b43a --- /dev/null +++ b/src/features/dashboard/members/add-member-dialog.tsx @@ -0,0 +1,39 @@ +'use client' + +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { AddMemberForm } 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 new 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..80d656cf2 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -20,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' @@ -31,9 +32,10 @@ type AddMemberForm = z.infer interface AddMemberFormProps { className?: string + onSuccess?: () => void } -export default function AddMemberForm({ className }: AddMemberFormProps) { +export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { 'use no memo' const { team } = useDashboard() @@ -41,6 +43,7 @@ export default function AddMemberForm({ className }: AddMemberFormProps) { const form = useForm({ resolver: zodResolver(addMemberSchema), + mode: 'onChange', defaultValues: { email: '', }, @@ -50,16 +53,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: AddMemberForm) => { + if (!team) return execute({ teamSlug: team.slug, @@ -71,31 +73,37 @@ export default function AddMemberForm({ className }: AddMemberFormProps) {
( - - E-mail -
- - - - -
+ + Email + + + )} /> + ) diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx index 8df8f25be..8d5d97c4e 100644 --- a/src/features/dashboard/members/member-card.tsx +++ b/src/features/dashboard/members/member-card.tsx @@ -1,12 +1,9 @@ -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' -import AddMemberForm from './add-member-form' -import MemberTable from './member-table' +import { Suspense } from 'react' +import { getTeamMembers } from '@/core/server/functions/team/get-team-members' +import { ErrorIndicator } from '@/ui/error-indicator' +import { Card, CardContent } from '@/ui/primitives/card' +import { Loader } from '@/ui/primitives/loader_d' +import MembersPageContent from './members-page-content' interface MemberCardProps { params: Promise<{ @@ -15,21 +12,40 @@ interface MemberCardProps { className?: string } -export function MemberCard({ params, className }: MemberCardProps) { - return ( - - - Members - Manage your team members. - - -
- -
- -
-
-
-
- ) +export const MemberCard = ({ params, className }: MemberCardProps) => ( + + + }> + + + + +) + +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..341d68708 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -7,6 +7,7 @@ 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 { @@ -14,17 +15,23 @@ import { defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' -import { AlertDialog } from '@/ui/alert-dialog' +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' import { Button } from '@/ui/primitives/button' +import { TrashIcon } from '@/ui/primitives/icons' import { TableCell, TableRow } from '@/ui/primitives/table' import { useDashboard } from '../context' +import { + shouldShowRemoveMemberAction, + wasAddedBySystem, +} from './member-table.utils' +import { RemoveMemberDialog } from './remove-member-dialog' interface TableRowProps { member: TeamMember - addedByEmail?: string - index: number + addedByMember?: TeamMember } type MemberProvider = { @@ -43,27 +50,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) { +export 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 +87,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 +96,192 @@ export default function MemberTableRow({ ?.map(toMemberProvider) .filter((provider): provider is MemberProvider => provider !== null) ?? [] + const isCurrentUser = member.info.id === user?.id + const showRemove = shouldShowRemoveMemberAction(member, user?.id) + const dateStr = member.info.createdAt + ? formatDate(new Date(member.info.createdAt), 'MMM d, yyyy') + : null + const addedBySystem = wasAddedBySystem(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} + teamName={getTeamDisplayName(team)} + /> ) } + +const NameCell = ({ + avatarUrl, + email, + isCurrentUser, + name, +}: { + avatarUrl?: string + email: string + isCurrentUser: boolean + name?: string +}) => ( + +
+ + + + {(name?.charAt(0) ?? email.charAt(0)).toUpperCase()} + + +
+
+ + {name ?? email} + + {isCurrentUser ? ( + + You + + ) : null} +
+ {name ? ( + + {email} + + ) : null} +
+
+
+) + +const ProvidersCell = ({ providers }: { providers: MemberProvider[] }) => ( + + {providers.length > 0 ? ( + <> +
+ {providers.map(({ key, label, Icon }) => ( + + + {label} + + ))} +
+
+ {providers.map(({ key, label, Icon }) => ( + + + {label} + + ))} +
+ + ) : ( + -- + )} +
+) + +const AddedCell = ({ + addedByMember, + addedBySystem, + dateStr, + isRemoving, + memberEmail, + memberName, + onRemove, + removeDialogOpen, + setRemoveDialogOpen, + showRemove, + teamName, +}: { + addedByMember?: TeamMember + addedBySystem: boolean + dateStr: string | null + isRemoving: boolean + memberEmail: string + memberName?: string + onRemove: () => void + removeDialogOpen: boolean + setRemoveDialogOpen: (v: boolean) => void + showRemove: boolean + teamName?: string | null +}) => ( + +
+ + {dateStr ?? '—'} + + {addedBySystem ? ( +
+ +
+ ) : ( + + + + {addedByMember?.info.email?.charAt(0).toUpperCase() ?? '?'} + + + )} + {showRemove ? ( + + + + } + /> + ) : null} +
+
+) diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index 4b08a33cb..06d9bc32f 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -1,58 +1,74 @@ -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, - TableCell, + TableEmptyState, TableHead, 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[] + totalMemberCount: number className?: string } -const MemberTable: FC = ({ params, className }) => { - return ( - - - - - Name - E-Mail - Providers - Added By - - - - - - - - - - Loading members... - - This may take a moment. - - - - } - > - - - -
- ) -} +const MemberTable: FC = ({ + allMembers, + members, + totalMemberCount, + className, +}) => ( + + + + + + + + + + NAME + + + PROVIDERS + + + ADDED + + + + + {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..638fbb5af --- /dev/null +++ b/src/features/dashboard/members/member-table.utils.ts @@ -0,0 +1,21 @@ +import type { TeamMember } from '@/core/modules/teams/models' + +const getAddedByMember = ( + allMembers: TeamMember[], + addedById: string | null +): TeamMember | undefined => { + if (!addedById) return undefined + return allMembers.find((member) => member.info.id === addedById) +} + +const wasAddedBySystem = ( + member: TeamMember, + addedByMember?: TeamMember +): boolean => !addedByMember || addedByMember.info.id === member.info.id + +const shouldShowRemoveMemberAction = ( + member: TeamMember, + currentUserId?: string +): boolean => !member.relation.is_default && member.info.id !== currentUserId + +export { getAddedByMember, shouldShowRemoveMemberAction, wasAddedBySystem } 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..b43e7e772 --- /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 { pluralize } from '@/lib/utils/formatting' +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} ${pluralize(members.length, 'member')} 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 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} + + +
+ + + + +
+
+
+
+ ) +} diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index b065142cd..20f0b04d3 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -142,6 +142,24 @@ export function formatCompactDate(timestamp: number): string { return format(date, 'yyyy MMM d, h:mm:ss a zzz') } +const DATE_STRUCTURES = ['MMM d', 'MMM d, yyyy'] as const + +type DateStructure = (typeof DATE_STRUCTURES)[number] + +/** + * 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 +): 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', { @@ -407,6 +425,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 @@ -450,7 +491,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/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} +
+ ))} +
+ +
+) + export { Table, TableBody, TableCaption, TableCell, + TableEmptyState, TableFooter, TableHead, TableHeader,