From 9e9322ca62c86f151865989f76bc697530979749 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 8 Apr 2026 13:15:39 -0400 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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 3afb7f0bd24de5324f01b58ed9a0d6a2218dbee1 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 15:03:33 -0400 Subject: [PATCH 18/36] Update dashboard general page layout and enhance user messages. - Refactored the GeneralPage component to utilize a new layout structure, improving the organization of team-related information. - Added new user messages for team logo removal scenarios to enhance user feedback. - Updated the TeamsRepository interface to allow for null profile picture URLs, improving flexibility in team profile management. - Added .cursor/ to .gitignore to prevent tracking of cursor-related files. These changes improve the user experience and code maintainability across the application. --- .gitignore | 1 + src/app/dashboard/[teamSlug]/general/page.tsx | 38 +++++++------------ src/configs/user-messages.ts | 6 +++ .../modules/teams/teams-repository.server.ts | 2 +- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index b6b85df6d..26a5f848a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ next-env.d.ts # AI agents and related files CLAUDE.md +.cursor/ .agent diff --git a/src/app/dashboard/[teamSlug]/general/page.tsx b/src/app/dashboard/[teamSlug]/general/page.tsx index 60519292a..a7ce9d9c1 100644 --- a/src/app/dashboard/[teamSlug]/general/page.tsx +++ b/src/app/dashboard/[teamSlug]/general/page.tsx @@ -1,29 +1,19 @@ -import { InfoCard } from '@/features/dashboard/settings/general/info-card' -import { NameCard } from '@/features/dashboard/settings/general/name-card' -import { ProfilePictureCard } from '@/features/dashboard/settings/general/profile-picture-card' -import Frame from '@/ui/frame' +import { Page } from '@/features/dashboard/layouts/page' +import { TeamAvatar } from '@/features/dashboard/settings/general/team-avatar' +import { TeamInfo } from '@/features/dashboard/settings/general/team-info' +import { TeamName } from '@/features/dashboard/settings/general/team-name' -interface GeneralPageProps { - params: Promise<{ - teamSlug: string - }> -} - -export default async function GeneralPage({ params }: GeneralPageProps) { +export default async function GeneralPage() { return ( - -
-
- - + +
+ +
+ +
+
- -
- +
+ ) } diff --git a/src/configs/user-messages.ts b/src/configs/user-messages.ts index 3f51a1b55..c43bb7436 100644 --- a/src/configs/user-messages.ts +++ b/src/configs/user-messages.ts @@ -57,6 +57,12 @@ export const USER_MESSAGES = { failedUpdateLogo: { message: 'Failed to update logo.', }, + teamLogoRemoved: { + message: 'Your team logo has been removed.', + }, + failedRemoveLogo: { + message: 'Failed to remove logo.', + }, emailInUse: { message: 'E-mail already in use.', }, diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index 219764395..dcb0e1998 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -26,7 +26,7 @@ export interface TeamsRepository { addTeamMember(email: string): Promise> removeTeamMember(userId: string): Promise> updateTeamProfilePictureUrl( - profilePictureUrl: string + profilePictureUrl: string | null ): Promise> } From 3776c02dcf3cefb58f444c687ff366c599619d1e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 9 Apr 2026 17:47:15 -0400 Subject: [PATCH 19/36] Enhance team settings page with new components and functionality - Added DangerZone component to the GeneralPage for team deletion functionality. - Introduced TeamAvatar component for improved avatar handling in sidebar menus. - Implemented TeamInfo component to display team creation date and primary email. - Created RemovePhotoDialog for confirming profile picture removal. - Updated models to include createdAt field for teams. - Refactored user-teams-repository to fetch team creation dates from the database. These changes improve user experience and maintainability of the team settings interface. --- src/app/dashboard/[teamSlug]/general/page.tsx | 3 + src/core/modules/teams/models.ts | 4 +- .../teams/user-teams-repository.server.ts | 37 ++++- src/core/server/actions/team-actions.ts | 39 +++++ src/features/dashboard/layouts/page.tsx | 2 +- .../settings/general/danger-zone.tsx | 14 ++ .../settings/general/remove-photo-dialog.tsx | 55 +++++++ .../settings/general/team-avatar.tsx | 153 ++++++++++++++++++ .../dashboard/settings/general/team-info.tsx | 42 +++++ .../dashboard/settings/general/team-name.tsx | 119 ++++++++++++++ src/features/dashboard/sidebar/menu-teams.tsx | 9 +- src/features/dashboard/sidebar/menu.tsx | 16 +- .../dashboard/sidebar/team-avatar.tsx | 34 ++++ src/ui/primitives/avatar.tsx | 90 ++++++++++- 14 files changed, 593 insertions(+), 24 deletions(-) create mode 100644 src/features/dashboard/settings/general/danger-zone.tsx create mode 100644 src/features/dashboard/settings/general/remove-photo-dialog.tsx create mode 100644 src/features/dashboard/settings/general/team-avatar.tsx create mode 100644 src/features/dashboard/settings/general/team-info.tsx create mode 100644 src/features/dashboard/settings/general/team-name.tsx create mode 100644 src/features/dashboard/sidebar/team-avatar.tsx diff --git a/src/app/dashboard/[teamSlug]/general/page.tsx b/src/app/dashboard/[teamSlug]/general/page.tsx index a7ce9d9c1..4f6798a09 100644 --- a/src/app/dashboard/[teamSlug]/general/page.tsx +++ b/src/app/dashboard/[teamSlug]/general/page.tsx @@ -1,4 +1,5 @@ import { Page } from '@/features/dashboard/layouts/page' +import { DangerZone } from '@/features/dashboard/settings/general/danger-zone' import { TeamAvatar } from '@/features/dashboard/settings/general/team-avatar' import { TeamInfo } from '@/features/dashboard/settings/general/team-info' import { TeamName } from '@/features/dashboard/settings/general/team-name' @@ -14,6 +15,8 @@ export default async function GeneralPage() {
+
+ ) } diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts index 9dd922c85..7cf4526ba 100644 --- a/src/core/modules/teams/models.ts +++ b/src/core/modules/teams/models.ts @@ -1,6 +1,8 @@ import type { components as DashboardComponents } from '@/contracts/dashboard-api' -export type TeamModel = DashboardComponents['schemas']['UserTeam'] +export type TeamModel = DashboardComponents['schemas']['UserTeam'] & { + createdAt: string | null +} export type TeamLimits = DashboardComponents['schemas']['UserTeamLimits'] export type TeamMemberInfo = { diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts index 5e4277332..4a8b4bbd4 100644 --- a/src/core/modules/teams/user-teams-repository.server.ts +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -1,8 +1,10 @@ import 'server-only' -import { secondsInDay, secondsInMinute } from 'date-fns/constants' +import { secondsInMinute } from 'date-fns/constants' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { components as DashboardComponents } from '@/contracts/dashboard-api' import { api } from '@/core/shared/clients/api' +import { supabaseAdmin } from '@/core/shared/clients/supabase/admin' import { repoErrorFromHttp } from '@/core/shared/errors' import type { RequestScope } from '@/core/shared/repository-scope' import { err, ok, type RepoResult } from '@/core/shared/result' @@ -11,6 +13,7 @@ import type { ResolvedTeam, TeamModel } from './models' type UserTeamsRepositoryDeps = { apiClient: typeof api authHeaders: typeof SUPABASE_AUTH_HEADERS + adminClient: typeof supabaseAdmin } export type UserTeamsRequestScope = RequestScope @@ -28,9 +31,12 @@ export function createUserTeamsRepository( deps: UserTeamsRepositoryDeps = { apiClient: api, authHeaders: SUPABASE_AUTH_HEADERS, + adminClient: supabaseAdmin, } ): UserTeamsRepository { - const listApiUserTeams = async (): Promise> => { + const listApiUserTeams = async (): Promise< + RepoResult + > => { const { data, error, response } = await deps.apiClient.GET('/teams', { headers: deps.authHeaders(scope.accessToken), }) @@ -56,7 +62,32 @@ export function createUserTeamsRepository( return teamsResult } - return ok(teamsResult.data) + const teamIds = teamsResult.data.map((team) => team.id) + + const { data: createdAtRows, error } = await deps.adminClient + .from('teams') + .select('id, created_at') + .in('id', teamIds) + + if (error) { + return ok( + teamsResult.data.map((team) => ({ + ...team, + createdAt: null, + })) + ) + } + + const createdAtById = new Map( + createdAtRows.map((team) => [team.id, team.created_at]) + ) + + return ok( + teamsResult.data.map((team) => ({ + ...team, + createdAt: createdAtById.get(team.id) ?? null, + })) + ) }, async resolveTeamBySlug( slug: string, diff --git a/src/core/server/actions/team-actions.ts b/src/core/server/actions/team-actions.ts index 16044dca7..12f63202e 100644 --- a/src/core/server/actions/team-actions.ts +++ b/src/core/server/actions/team-actions.ts @@ -131,6 +131,45 @@ const UploadTeamProfilePictureSchema = zfd.formData( }) ) +const RemoveTeamProfilePictureSchema = z.object({ + teamSlug: zfd.text(), +}) + +export const removeTeamProfilePictureAction = authActionClient + .schema(RemoveTeamProfilePictureSchema) + .metadata({ actionName: 'removeTeamProfilePicture' }) + .use(withTeamSlugResolution) + .use(withTeamsRepository) + .action(async ({ parsedInput, ctx }) => { + const { teamSlug } = parsedInput + const { teamId, teamsRepository } = ctx + + const result = await teamsRepository.updateTeamProfilePictureUrl(null) + if (!result.ok) { + return toActionErrorFromRepoError(result.error) + } + + after(async () => { + try { + const folderPath = `teams/${teamId}` + const files = await getFiles(folderPath) + for (const file of files) { + await deleteFile(file.name) + } + } catch (cleanupError) { + l.warn({ + key: 'remove_team_profile_picture_action:cleanup_error', + error: serializeErrorForLog(cleanupError), + team_id: teamId, + }) + } + }) + + revalidatePath(`/dashboard/${teamSlug}/general`, 'page') + + return result.data + }) + export const uploadTeamProfilePictureAction = authActionClient .schema(UploadTeamProfilePictureSchema) .metadata({ actionName: 'uploadTeamProfilePicture' }) diff --git a/src/features/dashboard/layouts/page.tsx b/src/features/dashboard/layouts/page.tsx index ab28a6947..73ff02af3 100644 --- a/src/features/dashboard/layouts/page.tsx +++ b/src/features/dashboard/layouts/page.tsx @@ -7,7 +7,7 @@ interface PageProps { } export const Page = ({ children, className }: PageProps) => ( -
+
{children}
) diff --git a/src/features/dashboard/settings/general/danger-zone.tsx b/src/features/dashboard/settings/general/danger-zone.tsx new file mode 100644 index 000000000..bebe61bb0 --- /dev/null +++ b/src/features/dashboard/settings/general/danger-zone.tsx @@ -0,0 +1,14 @@ +'use client' + +import { Button } from '@/ui/primitives/button' +import { TrashIcon } from '@/ui/primitives/icons' + +export const DangerZone = () => ( +
+ danger zone + +
+) diff --git a/src/features/dashboard/settings/general/remove-photo-dialog.tsx b/src/features/dashboard/settings/general/remove-photo-dialog.tsx new file mode 100644 index 000000000..a36595760 --- /dev/null +++ b/src/features/dashboard/settings/general/remove-photo-dialog.tsx @@ -0,0 +1,55 @@ +'use client' + +import { Button } from '@/ui/primitives/button' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/ui/primitives/dialog' +import { TrashIcon } from '@/ui/primitives/icons' + +interface RemovePhotoDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + isRemoving: boolean +} + +export const RemovePhotoDialog = ({ + open, + onOpenChange, + onConfirm, + isRemoving, +}: RemovePhotoDialogProps) => ( + + +
+
+ Remove profile photo? + + It will be replaced by a default one + +
+
+ + +
+
+
+
+) diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx new file mode 100644 index 000000000..5ee52b8c0 --- /dev/null +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { useAction } from 'next-safe-action/hooks' +import { useRef, useState } from 'react' +import { USER_MESSAGES } from '@/configs/user-messages' +import { + removeTeamProfilePictureAction, + uploadTeamProfilePictureAction, +} from '@/core/server/actions/team-actions' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Avatar, AvatarImage, PatternAvatar } from '@/ui/primitives/avatar' +import { Button } from '@/ui/primitives/button' +import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons' +import { RemovePhotoDialog } from './remove-photo-dialog' + +export const TeamAvatar = () => { + const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() + const { toast } = useToast() + const fileInputRef = useRef(null) + const [removeDialogOpen, setRemoveDialogOpen] = useState(false) + + const invalidateTeams = async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + } + + const { execute: uploadProfilePicture, isExecuting: isUploading } = useAction( + uploadTeamProfilePictureAction, + { + onSuccess: async () => { + await invalidateTeams() + toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message)) + }, + onError: ({ error }) => { + if (error.validationErrors?.fieldErrors.image) { + toast(defaultErrorToast(error.validationErrors.fieldErrors.image[0])) + return + } + toast( + defaultErrorToast( + error.serverError || USER_MESSAGES.failedUpdateLogo.message + ) + ) + }, + onSettled: () => { + if (fileInputRef.current) fileInputRef.current.value = '' + }, + } + ) + + const { execute: removeProfilePicture, isExecuting: isRemoving } = useAction( + removeTeamProfilePictureAction, + { + onSuccess: async () => { + await invalidateTeams() + setRemoveDialogOpen(false) + toast(defaultSuccessToast(USER_MESSAGES.teamLogoRemoved.message)) + }, + onError: ({ error }) => { + toast( + defaultErrorToast( + error.serverError || USER_MESSAGES.failedRemoveLogo.message + ) + ) + }, + } + ) + + const handleUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + const MAX_FILE_SIZE = 5 * 1024 * 1024 + + if (file.size > MAX_FILE_SIZE) { + toast(defaultErrorToast('Profile picture must be less than 5MB.')) + if (fileInputRef.current) fileInputRef.current.value = '' + return + } + + uploadProfilePicture({ teamSlug: team.slug, image: file }) + } + + const hasPhoto = !!team.profilePictureUrl + + return ( +
+ {hasPhoto ? ( + + + + ) : ( + + )} +
+ + {hasPhoto && ( + + )} +
+ + removeProfilePicture({ teamSlug: team.slug })} + /> +
+ ) +} diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx new file mode 100644 index 000000000..ea1cddab9 --- /dev/null +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -0,0 +1,42 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' + +const InfoRow = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+) + +interface TeamInfoProps { + createdAt?: string | null +} + +// "jan 1, 2025" — e.g. formatDate("2025-01-01T00:00:00Z") → "jan 1, 2025" +const formatDate = (iso: string) => { + const date = new Date(iso) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +export const TeamInfo = ({ createdAt }: TeamInfoProps) => { + const { team } = useDashboard() + const resolvedCreatedAt = createdAt ?? team.createdAt + + return ( +
+ {resolvedCreatedAt && ( + + )} + +
+ ) +} diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx new file mode 100644 index 000000000..c8ef6349b --- /dev/null +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -0,0 +1,119 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { useAction } from 'next-safe-action/hooks' +import { useEffect, useRef, useState } from 'react' +import { USER_MESSAGES } from '@/configs/user-messages' +import { updateTeamNameAction } from '@/core/server/actions/team-actions' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { CheckIcon, EditIcon } from '@/ui/primitives/icons' + +const NAME_INPUT_CLASS = + 'w-full bg-transparent p-0 text-[32px] leading-8 font-semibold tracking-[-0.32px] text-fg caret-accent-main-highlight outline-none' +const NAME_ACTION_ICON_CLASS = 'size-4 shrink-0' + +export const TeamName = () => { + const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() + const { toast } = useToast() + const [isEditing, setIsEditing] = useState(false) + const [name, setName] = useState(team.name) + const inputRef = useRef(null) + + const { execute, isExecuting } = useAction(updateTeamNameAction, { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) + setIsEditing(false) + }, + onError: ({ error }) => { + toast( + defaultErrorToast( + error.serverError || USER_MESSAGES.failedUpdateTeamName.message + ) + ) + }, + }) + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault() + if (!name.trim() || name.trim() === team.name) return + execute({ teamSlug: team.slug, name: name.trim() }) + } + + const handleCancel = () => { + setName(team.name) + setIsEditing(false) + } + + useEffect(() => { + if (isEditing) inputRef.current?.focus() + }, [isEditing]) + + const handleStartEditing = () => { + setName(team.name) + setIsEditing(true) + } + + return ( +
+
+ + name + + setName(e.target.value)} + readOnly={!isEditing} + className={NAME_INPUT_CLASS} + /> +
+
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ ) +} diff --git a/src/features/dashboard/sidebar/menu-teams.tsx b/src/features/dashboard/sidebar/menu-teams.tsx index 1a41b6159..d5ca86260 100644 --- a/src/features/dashboard/sidebar/menu-teams.tsx +++ b/src/features/dashboard/sidebar/menu-teams.tsx @@ -4,7 +4,6 @@ import { useCallback } from 'react' import { TEAM_SPECIFIC_RESOURCE_SEGMENTS } from '@/configs/urls' import type { TeamModel } from '@/core/modules/teams/models' import { getTeamDisplayName } from '@/core/modules/teams/utils' -import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { DropdownMenuItem, DropdownMenuLabel, @@ -12,6 +11,7 @@ import { DropdownMenuRadioItem, } from '@/ui/primitives/dropdown-menu' import { useDashboard } from '../context' +import { TeamAvatar } from './team-avatar' const PRESERVED_SEARCH_PARAMS = ['tab'] as const @@ -67,12 +67,7 @@ export default function DashboardSidebarMenuTeams() { teams.map((team) => ( - - - - {team.name?.charAt(0).toUpperCase() || '?'} - - + {getTeamDisplayName(team)} diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index 8beb64593..88c20b9ce 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -7,7 +7,6 @@ import { PROTECTED_URLS } from '@/configs/urls' import { getTeamDisplayName } from '@/core/modules/teams/utils' import { signOutAction } from '@/core/server/actions/auth-actions' import { cn } from '@/lib/utils' -import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { DropdownMenu, DropdownMenuContent, @@ -20,6 +19,7 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/ui/primitives/sidebar' import { useDashboard } from '../context' import { CreateTeamDialog } from './create-team-dialog' import DashboardSidebarMenuTeams from './menu-teams' +import { TeamAvatar } from './team-avatar' interface DashboardSidebarMenuProps { className?: string @@ -49,7 +49,8 @@ export default function DashboardSidebarMenu({ className )} > - - - - {team.name?.charAt(0).toUpperCase() || '?'} - - + imageClassName="group-data-[collapsible=icon]:size-full" + />
TEAM diff --git a/src/features/dashboard/sidebar/team-avatar.tsx b/src/features/dashboard/sidebar/team-avatar.tsx new file mode 100644 index 000000000..558815f38 --- /dev/null +++ b/src/features/dashboard/sidebar/team-avatar.tsx @@ -0,0 +1,34 @@ +import type { TeamModel } from '@/core/modules/teams/models' +import { cn } from '@/lib/utils' +import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' + +interface TeamAvatarProps { + team: TeamModel + className?: string + imageClassName?: string +} + +export const TeamAvatar = ({ + team, + className, + imageClassName, +}: TeamAvatarProps) => { + if (!team.profilePictureUrl) { + return ( + + + {team.name?.charAt(0).toUpperCase() || '?'} + + + ) + } + + return ( + + + + ) +} diff --git a/src/ui/primitives/avatar.tsx b/src/ui/primitives/avatar.tsx index c70e9049b..d0ad3c247 100644 --- a/src/ui/primitives/avatar.tsx +++ b/src/ui/primitives/avatar.tsx @@ -2,6 +2,7 @@ import * as AvatarPrimitive from '@radix-ui/react-avatar' import * as React from 'react' +import { useId } from 'react' import { cn } from '@/lib/utils' const Avatar = React.forwardRef< @@ -49,4 +50,91 @@ const AvatarFallback = React.forwardRef< )) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName -export { Avatar, AvatarFallback, AvatarImage } +const PATTERN_COLUMN_COUNT = 16 +const PATTERN_ROW_COUNT = 16 +const PATTERN_CELL_SIZE = 8 +const PATTERN_FONT_SIZE = 10 + +const patternCells = Array.from( + { length: PATTERN_ROW_COUNT * PATTERN_COLUMN_COUNT }, + (_, index) => { + const row = Math.floor(index / PATTERN_COLUMN_COUNT) + const col = index % PATTERN_COLUMN_COUNT + const glyph = (row + col * 2) % 5 === 0 ? '-' : '*' + const isAccent = (row * 5 + col * 3) % 11 === 0 + + return { + col, + glyph, + isAccent, + row, + x: 8 + col * PATTERN_CELL_SIZE, + y: 14 + row * PATTERN_CELL_SIZE, + } + } +) + +interface PatternAvatarProps { + className?: string + letter: string +} + +const PatternAvatar = ({ className, letter }: PatternAvatarProps) => { + const clipPathId = useId().replaceAll(':', '') + const normalizedLetter = letter.trim().charAt(0).toUpperCase() || '?' + + return ( +
+ +
+ ) +} + +export { Avatar, AvatarFallback, AvatarImage, PatternAvatar } From 2f927ea4e5f0b89e84c1491184035becdf24a5b7 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 12:54:09 -0400 Subject: [PATCH 20/36] Refactor team models and repository to streamline data handling - Removed the createdAt field from the TeamModel type to simplify the model structure. - Updated the UserTeamsRepository to directly return team data without fetching creation dates from the database. - Adjusted the TeamInfo component to reflect the removal of the createdAt prop, displaying a placeholder instead. These changes enhance code clarity and maintainability by reducing unnecessary complexity in team data management. --- src/core/modules/teams/models.ts | 4 +- .../teams/user-teams-repository.server.ts | 39 +------------------ .../dashboard/settings/general/team-info.tsx | 21 +--------- 3 files changed, 5 insertions(+), 59 deletions(-) diff --git a/src/core/modules/teams/models.ts b/src/core/modules/teams/models.ts index 7cf4526ba..9dd922c85 100644 --- a/src/core/modules/teams/models.ts +++ b/src/core/modules/teams/models.ts @@ -1,8 +1,6 @@ import type { components as DashboardComponents } from '@/contracts/dashboard-api' -export type TeamModel = DashboardComponents['schemas']['UserTeam'] & { - createdAt: string | null -} +export type TeamModel = DashboardComponents['schemas']['UserTeam'] export type TeamLimits = DashboardComponents['schemas']['UserTeamLimits'] export type TeamMemberInfo = { diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts index 4a8b4bbd4..02635d74d 100644 --- a/src/core/modules/teams/user-teams-repository.server.ts +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -2,9 +2,7 @@ import 'server-only' import { secondsInMinute } from 'date-fns/constants' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' -import type { components as DashboardComponents } from '@/contracts/dashboard-api' import { api } from '@/core/shared/clients/api' -import { supabaseAdmin } from '@/core/shared/clients/supabase/admin' import { repoErrorFromHttp } from '@/core/shared/errors' import type { RequestScope } from '@/core/shared/repository-scope' import { err, ok, type RepoResult } from '@/core/shared/result' @@ -13,7 +11,6 @@ import type { ResolvedTeam, TeamModel } from './models' type UserTeamsRepositoryDeps = { apiClient: typeof api authHeaders: typeof SUPABASE_AUTH_HEADERS - adminClient: typeof supabaseAdmin } export type UserTeamsRequestScope = RequestScope @@ -31,12 +28,9 @@ export function createUserTeamsRepository( deps: UserTeamsRepositoryDeps = { apiClient: api, authHeaders: SUPABASE_AUTH_HEADERS, - adminClient: supabaseAdmin, } ): UserTeamsRepository { - const listApiUserTeams = async (): Promise< - RepoResult - > => { + const listApiUserTeams = async (): Promise> => { const { data, error, response } = await deps.apiClient.GET('/teams', { headers: deps.authHeaders(scope.accessToken), }) @@ -58,36 +52,7 @@ export function createUserTeamsRepository( async listUserTeams(): Promise> { const teamsResult = await listApiUserTeams() - if (!teamsResult.ok) { - return teamsResult - } - - const teamIds = teamsResult.data.map((team) => team.id) - - const { data: createdAtRows, error } = await deps.adminClient - .from('teams') - .select('id, created_at') - .in('id', teamIds) - - if (error) { - return ok( - teamsResult.data.map((team) => ({ - ...team, - createdAt: null, - })) - ) - } - - const createdAtById = new Map( - createdAtRows.map((team) => [team.id, team.created_at]) - ) - - return ok( - teamsResult.data.map((team) => ({ - ...team, - createdAt: createdAtById.get(team.id) ?? null, - })) - ) + return teamsResult }, async resolveTeamBySlug( slug: string, diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx index ea1cddab9..8ab5c265f 100644 --- a/src/features/dashboard/settings/general/team-info.tsx +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -13,29 +13,12 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => (
) -interface TeamInfoProps { - createdAt?: string | null -} - -// "jan 1, 2025" — e.g. formatDate("2025-01-01T00:00:00Z") → "jan 1, 2025" -const formatDate = (iso: string) => { - const date = new Date(iso) - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) -} - -export const TeamInfo = ({ createdAt }: TeamInfoProps) => { +export const TeamInfo = () => { const { team } = useDashboard() - const resolvedCreatedAt = createdAt ?? team.createdAt return (
- {resolvedCreatedAt && ( - - )} +
) From cf7dba818680615b012dd67dd750d5b8aedb50f5 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 12:59:39 -0400 Subject: [PATCH 21/36] Refactor file path handling in team actions for improved clarity - Introduced a utility function `getStorageFilePath` to streamline file path construction for team-related storage. - Updated the `removeTeamProfilePictureAction` and `uploadTeamProfilePictureAction` to utilize the new utility function, enhancing code readability and maintainability. These changes simplify file path management in team actions, contributing to cleaner code. --- src/core/server/actions/team-actions.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/server/actions/team-actions.ts b/src/core/server/actions/team-actions.ts index 12f63202e..1f039a71f 100644 --- a/src/core/server/actions/team-actions.ts +++ b/src/core/server/actions/team-actions.ts @@ -31,6 +31,8 @@ const withTeamsRepository = withTeamAuthedRequestRepository( createTeamsRepository, (teamsRepository) => ({ teamsRepository }) ) +const getStorageFilePath = (folderPath: string, fileName: string) => + `${folderPath}/${fileName}` export const updateTeamNameAction = authActionClient .schema(UpdateTeamNameSchema) @@ -154,7 +156,7 @@ export const removeTeamProfilePictureAction = authActionClient const folderPath = `teams/${teamId}` const files = await getFiles(folderPath) for (const file of files) { - await deleteFile(file.name) + await deleteFile(getStorageFilePath(folderPath, file.name)) } } catch (cleanupError) { l.warn({ @@ -231,15 +233,13 @@ export const uploadTeamProfilePictureAction = authActionClient after(async () => { try { - const currentFileName = fileName const folderPath = `teams/${teamId}` + const currentFilePath = getStorageFilePath(folderPath, fileName) const files = await getFiles(folderPath) for (const file of files) { - const filePath = file.name - if (filePath === `${folderPath}/${currentFileName}`) { - continue - } + const filePath = getStorageFilePath(folderPath, file.name) + if (filePath === currentFilePath) continue await deleteFile(filePath) } From 1ed774cb1500861f43ede6ca63620262b02120ae Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 13:00:08 -0400 Subject: [PATCH 22/36] Remove unused components --- .../dashboard/settings/general/info-card.tsx | 50 ----- .../dashboard/settings/general/name-card.tsx | 162 --------------- .../settings/general/profile-picture-card.tsx | 187 ------------------ 3 files changed, 399 deletions(-) delete mode 100644 src/features/dashboard/settings/general/info-card.tsx delete mode 100644 src/features/dashboard/settings/general/name-card.tsx delete mode 100644 src/features/dashboard/settings/general/profile-picture-card.tsx diff --git a/src/features/dashboard/settings/general/info-card.tsx b/src/features/dashboard/settings/general/info-card.tsx deleted file mode 100644 index aaab3dd2a..000000000 --- a/src/features/dashboard/settings/general/info-card.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client' - -import { useDashboard } from '@/features/dashboard/context' -import CopyButton from '@/ui/copy-button' -import { Badge } from '@/ui/primitives/badge' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' - -interface InfoCardProps { - className?: string -} - -export function InfoCard({ className }: InfoCardProps) { - const { team } = useDashboard() - - return ( - - - Information - - Additional information about this team. - - - -
-
- E-Mail - {team.email} - -
-
- Team ID - {team.id} - -
-
- Team Slug - {team.slug} - -
-
-
-
- ) -} diff --git a/src/features/dashboard/settings/general/name-card.tsx b/src/features/dashboard/settings/general/name-card.tsx deleted file mode 100644 index b9d629b78..000000000 --- a/src/features/dashboard/settings/general/name-card.tsx +++ /dev/null @@ -1,162 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormOptimisticAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import { useQueryClient } from '@tanstack/react-query' -import { AnimatePresence, motion } from 'motion/react' -import { USER_MESSAGES } from '@/configs/user-messages' -import { getTransformedDefaultTeamName } from '@/core/modules/teams/utils' -import { updateTeamNameAction } from '@/core/server/actions/team-actions' -import { UpdateTeamNameSchema } from '@/core/server/functions/team/types' -import { useDashboard } from '@/features/dashboard/context' -import { - defaultErrorToast, - defaultSuccessToast, - useToast, -} from '@/lib/hooks/use-toast' -import { exponentialSmoothing } from '@/lib/utils' -import { useTRPC } from '@/trpc/client' -import { Button } from '@/ui/primitives/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/ui/primitives/card' -import { - Form, - FormControl, - FormField, - FormItem, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' - -interface NameCardProps { - className?: string -} - -export function NameCard({ className }: NameCardProps) { - 'use no memo' - - const { team } = useDashboard() - const trpc = useTRPC() - const queryClient = useQueryClient() - - const { toast } = useToast() - - const { - form, - handleSubmitWithAction, - action: { isExecuting, optimisticState }, - } = useHookFormOptimisticAction( - updateTeamNameAction, - zodResolver(UpdateTeamNameSchema), - { - formProps: { - defaultValues: { - teamSlug: team.slug, - name: team.name, - }, - }, - actionProps: { - currentState: { - team, - }, - updateFn: (state, input) => { - if (!state.team) return state - - return { - team: { - ...state.team, - name: input.name, - }, - } - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: trpc.teams.list.queryKey(), - }) - toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) - }, - onError: ({ error }) => { - if (!error.serverError) return - - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateTeamName.message - ) - ) - }, - }, - } - ) - - const { watch } = form - const displayedNameHint = getTransformedDefaultTeamName( - optimisticState?.team ?? team - ) - - return ( - - - Name - - Change your team name to display on your invoices and receipts. - - - -
- - ( - - - - - - {displayedNameHint && ( - - Seen as -{' '} - - {displayedNameHint} - - - )} - - - - )} - /> - - - -
-
- ) -} diff --git a/src/features/dashboard/settings/general/profile-picture-card.tsx b/src/features/dashboard/settings/general/profile-picture-card.tsx deleted file mode 100644 index e6193d522..000000000 --- a/src/features/dashboard/settings/general/profile-picture-card.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'use client' - -import { useQueryClient } from '@tanstack/react-query' -import { AnimatePresence, motion } from 'framer-motion' -import { ChevronsUp, ImagePlusIcon, Loader2, Pencil } from 'lucide-react' -import { useAction } from 'next-safe-action/hooks' -import { useRef, useState } from 'react' -import { USER_MESSAGES } from '@/configs/user-messages' -import { uploadTeamProfilePictureAction } from '@/core/server/actions/team-actions' -import { useDashboard } from '@/features/dashboard/context' -import { - defaultErrorToast, - defaultSuccessToast, - useToast, -} from '@/lib/hooks/use-toast' -import { cn, exponentialSmoothing } from '@/lib/utils' -import { useTRPC } from '@/trpc/client' -import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' -import { Badge } from '@/ui/primitives/badge' -import { cardVariants } from '@/ui/primitives/card' - -interface ProfilePictureCardProps { - className?: string -} - -export function ProfilePictureCard({ className }: ProfilePictureCardProps) { - const { team } = useDashboard() - const trpc = useTRPC() - const queryClient = useQueryClient() - const { toast } = useToast() - const fileInputRef = useRef(null) - const [isHovered, setIsHovered] = useState(false) - - const { execute: uploadProfilePicture, isExecuting: isUploading } = useAction( - uploadTeamProfilePictureAction, - { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: trpc.teams.list.queryKey(), - }) - toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message)) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors.image) { - toast(defaultErrorToast(error.validationErrors.fieldErrors.image[0])) - return - } - - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateLogo.message - ) - ) - }, - onSettled: () => { - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - }, - } - ) - - const handleUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB in bytes - - if (file.size > MAX_FILE_SIZE) { - toast( - defaultErrorToast( - `Profile picture must be less than ${MAX_FILE_SIZE / (1024 * 1024)}MB.` - ) - ) - - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - return - } - - uploadProfilePicture({ - teamSlug: team.slug, - image: file, - }) - } - } - - const handleAvatarClick = () => { - fileInputRef.current?.click() - } - - return ( - <> - - - - ) -} From 44e6d702aae6829d369796fffbf0b9a2bbafb9fb Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 13:45:25 -0400 Subject: [PATCH 23/36] Refactor team avatar and name handling in dashboard settings - Introduced file upload functionality for team profile pictures, including validation for file type and size. - Updated the TeamAvatar component to handle image uploads and removals using TRPC mutations. - Refactored TeamName component to utilize TRPC for updating team names, enhancing state management and user feedback. - Created a new file schema for consistent file handling across components. These changes improve the user experience and maintainability of the team settings interface. --- src/core/server/api/routers/support.ts | 7 +- src/core/server/api/routers/teams.ts | 161 +++++++++++++++++- src/core/shared/schemas/file.ts | 9 + .../settings/general/team-avatar.tsx | 68 +++++--- .../dashboard/settings/general/team-name.tsx | 70 ++++---- 5 files changed, 240 insertions(+), 75 deletions(-) create mode 100644 src/core/shared/schemas/file.ts diff --git a/src/core/server/api/routers/support.ts b/src/core/server/api/routers/support.ts index 5b3443898..954239689 100644 --- a/src/core/server/api/routers/support.ts +++ b/src/core/server/api/routers/support.ts @@ -5,15 +5,10 @@ import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { fileSchema } from '@/core/shared/schemas/file' const E2B_API_KEY_REGEX = /e2b_[a-f0-9]{40}/i -const fileSchema = z.object({ - name: z.string(), - type: z.string(), - base64: z.string(), -}) - const supportRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( createSupportRepository, diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index 03a2d6a88..b016c51ae 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,18 +1,48 @@ +import { TRPCError } from '@trpc/server' +import { fileTypeFromBuffer } from 'file-type' +import { revalidatePath } from 'next/cache' +import { after } from 'next/server' +import { z } from 'zod' +import { TeamNameSchema } from '@/core/modules/teams/schemas' +import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' -import { withAuthedRequestRepository } from '@/core/server/api/middlewares/repository' +import { + withAuthedRequestRepository, + withTeamAuthedRequestRepository, +} from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' -import { protectedProcedure } from '@/core/server/trpc/procedures' +import { + protectedProcedure, + protectedTeamProcedure, +} from '@/core/server/trpc/procedures' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { deleteFile, getFiles, uploadFile } from '@/core/shared/clients/storage' +import { fileSchema } from '@/core/shared/schemas/file' -const teamsRepositoryProcedure = protectedProcedure.use( - withAuthedRequestRepository(createUserTeamsRepository, (teamsRepository) => ({ +const MAX_FILE_SIZE = 5 * 1024 * 1024 + +const userTeamsRepositoryProcedure = protectedProcedure.use( + withAuthedRequestRepository( + createUserTeamsRepository, + (userTeamsRepository) => ({ + userTeamsRepository, + }) + ) +) + +const teamsRepositoryProcedure = protectedTeamProcedure.use( + withTeamAuthedRequestRepository(createTeamsRepository, (teamsRepository) => ({ teamsRepository, })) ) +const getStorageFilePath = (folderPath: string, fileName: string) => + `${folderPath}/${fileName}` + export const teamsRouter = createTRPCRouter({ - list: teamsRepositoryProcedure.query(async ({ ctx }) => { - const teamsResult = await ctx.teamsRepository.listUserTeams() + list: userTeamsRepositoryProcedure.query(async ({ ctx }) => { + const teamsResult = await ctx.userTeamsRepository.listUserTeams() if (!teamsResult.ok) { throwTRPCErrorFromRepoError(teamsResult.error) @@ -20,4 +50,123 @@ export const teamsRouter = createTRPCRouter({ return teamsResult.data }), + updateName: teamsRepositoryProcedure + .input( + z.object({ + name: TeamNameSchema, + }) + ) + .mutation(async ({ ctx, input }) => { + const result = await ctx.teamsRepository.updateTeamName(input.name) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + revalidatePath(`/dashboard/${input.teamSlug}/general`, 'page') + + return result.data + }), + removeProfilePicture: teamsRepositoryProcedure.mutation( + async ({ ctx, input }) => { + const result = await ctx.teamsRepository.updateTeamProfilePictureUrl(null) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + after(async () => { + try { + const folderPath = `teams/${ctx.teamId}` + + const files = await getFiles(folderPath) + + for (const file of files) { + await deleteFile(getStorageFilePath(folderPath, file.name)) + } + } catch (cleanupError) { + l.warn({ + key: 'remove_team_profile_picture_trpc:cleanup_error', + error: serializeErrorForLog(cleanupError), + team_id: ctx.teamId, + }) + } + }) + + revalidatePath(`/dashboard/${input.teamSlug}/general`, 'page') + + return result.data + } + ), + uploadProfilePicture: teamsRepositoryProcedure + .input( + z.object({ + image: fileSchema, + }) + ) + .mutation(async ({ ctx, input }) => { + const allowedTypes = ['image/jpeg', 'image/png'] + if (!allowedTypes.includes(input.image.type)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'File must be JPG or PNG format', + }) + } + + const buffer = Buffer.from(input.image.base64, 'base64') + if (buffer.length > MAX_FILE_SIZE) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'File size must be less than 5MB', + }) + } + + const fileType = await fileTypeFromBuffer(buffer) + if (!fileType) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Unable to determine file type', + }) + } + + const allowedMimeTypes = ['image/jpeg', 'image/png'] + if (!allowedMimeTypes.includes(fileType.mime)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Invalid file type. Only JPEG and PNG images are allowed. File appears to be: ${fileType.mime}`, + }) + } + + const fileName = `${Date.now()}.${fileType.ext}` + const storagePath = getStorageFilePath(`teams/${ctx.teamId}`, fileName) + const publicUrl = await uploadFile(buffer, storagePath, fileType.mime) + + const result = + await ctx.teamsRepository.updateTeamProfilePictureUrl(publicUrl) + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + after(async () => { + try { + const folderPath = `teams/${ctx.teamId}` + const currentFilePath = getStorageFilePath(folderPath, fileName) + const files = await getFiles(folderPath) + + for (const file of files) { + const filePath = getStorageFilePath(folderPath, file.name) + if (filePath === currentFilePath) continue + + await deleteFile(filePath) + } + } catch (cleanupError) { + l.warn({ + key: 'upload_team_profile_picture_trpc:cleanup_error', + error: serializeErrorForLog(cleanupError), + team_id: ctx.teamId, + context: { + image: input.image.name, + }, + }) + } + }) + + revalidatePath(`/dashboard/${input.teamSlug}/general`, 'page') + + return result.data + }), }) diff --git a/src/core/shared/schemas/file.ts b/src/core/shared/schemas/file.ts new file mode 100644 index 000000000..0133373d0 --- /dev/null +++ b/src/core/shared/schemas/file.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +const fileSchema = z.object({ + name: z.string(), + type: z.string(), + base64: z.string(), +}) + +export { fileSchema } diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx index 5ee52b8c0..0dcb34483 100644 --- a/src/features/dashboard/settings/general/team-avatar.tsx +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -1,13 +1,8 @@ 'use client' -import { useQueryClient } from '@tanstack/react-query' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRef, useState } from 'react' import { USER_MESSAGES } from '@/configs/user-messages' -import { - removeTeamProfilePictureAction, - uploadTeamProfilePictureAction, -} from '@/core/server/actions/team-actions' import { useDashboard } from '@/features/dashboard/context' import { defaultErrorToast, @@ -20,6 +15,18 @@ import { Button } from '@/ui/primitives/button' import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons' import { RemovePhotoDialog } from './remove-photo-dialog' +// Convert a File into base64 payload text. e.g. fileToBase64(imageFile) -> "iVBORw0KGgoAAAANSUhEUgAA..." +const fileToBase64 = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : null + resolve(result?.split(',')[1] ?? '') + } + reader.onerror = reject + reader.readAsDataURL(file) + }) + export const TeamAvatar = () => { const { team } = useDashboard() const trpc = useTRPC() @@ -34,49 +41,43 @@ export const TeamAvatar = () => { }) } - const { execute: uploadProfilePicture, isExecuting: isUploading } = useAction( - uploadTeamProfilePictureAction, - { + const uploadProfilePictureMutation = useMutation( + trpc.teams.uploadProfilePicture.mutationOptions({ onSuccess: async () => { await invalidateTeams() toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message)) }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors.image) { - toast(defaultErrorToast(error.validationErrors.fieldErrors.image[0])) - return - } + onError: (error) => { toast( defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateLogo.message + error.message || USER_MESSAGES.failedUpdateLogo.message ) ) }, onSettled: () => { if (fileInputRef.current) fileInputRef.current.value = '' }, - } + }) ) - const { execute: removeProfilePicture, isExecuting: isRemoving } = useAction( - removeTeamProfilePictureAction, - { + const removeProfilePictureMutation = useMutation( + trpc.teams.removeProfilePicture.mutationOptions({ onSuccess: async () => { await invalidateTeams() setRemoveDialogOpen(false) toast(defaultSuccessToast(USER_MESSAGES.teamLogoRemoved.message)) }, - onError: ({ error }) => { + onError: (error) => { toast( defaultErrorToast( - error.serverError || USER_MESSAGES.failedRemoveLogo.message + error.message || USER_MESSAGES.failedRemoveLogo.message ) ) }, - } + }) ) - const handleUpload = (e: React.ChangeEvent) => { + const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return @@ -88,7 +89,16 @@ export const TeamAvatar = () => { return } - uploadProfilePicture({ teamSlug: team.slug, image: file }) + const base64 = await fileToBase64(file) + + uploadProfilePictureMutation.mutate({ + teamSlug: team.slug, + image: { + base64, + name: file.name, + type: file.type, + }, + }) } const hasPhoto = !!team.profilePictureUrl @@ -110,7 +120,7 @@ export const TeamAvatar = () => { variant="outline" className={hasPhoto ? '' : 'w-full'} onClick={() => fileInputRef.current?.click()} - loading={isUploading} + loading={uploadProfilePictureMutation.isPending} > {hasPhoto ? ( <> @@ -140,13 +150,15 @@ export const TeamAvatar = () => { className="hidden" accept="image/jpeg, image/png" onChange={handleUpload} - disabled={isUploading} + disabled={uploadProfilePictureMutation.isPending} /> removeProfilePicture({ teamSlug: team.slug })} + isRemoving={removeProfilePictureMutation.isPending} + onConfirm={() => + removeProfilePictureMutation.mutate({ teamSlug: team.slug }) + } />
) diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx index c8ef6349b..6ff14352b 100644 --- a/src/features/dashboard/settings/general/team-name.tsx +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -1,10 +1,8 @@ 'use client' -import { useQueryClient } from '@tanstack/react-query' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateTeamNameAction } from '@/core/server/actions/team-actions' import { useDashboard } from '@/features/dashboard/context' import { defaultErrorToast, @@ -15,10 +13,6 @@ import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { CheckIcon, EditIcon } from '@/ui/primitives/icons' -const NAME_INPUT_CLASS = - 'w-full bg-transparent p-0 text-[32px] leading-8 font-semibold tracking-[-0.32px] text-fg caret-accent-main-highlight outline-none' -const NAME_ACTION_ICON_CLASS = 'size-4 shrink-0' - export const TeamName = () => { const { team } = useDashboard() const trpc = useTRPC() @@ -28,27 +22,29 @@ export const TeamName = () => { const [name, setName] = useState(team.name) const inputRef = useRef(null) - const { execute, isExecuting } = useAction(updateTeamNameAction, { - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: trpc.teams.list.queryKey(), - }) - toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) - setIsEditing(false) - }, - onError: ({ error }) => { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateTeamName.message + const updateNameMutation = useMutation( + trpc.teams.updateName.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) + setIsEditing(false) + }, + onError: (error) => { + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedUpdateTeamName.message + ) ) - ) - }, - }) + }, + }) + ) const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault() if (!name.trim() || name.trim() === team.name) return - execute({ teamSlug: team.slug, name: name.trim() }) + updateNameMutation.mutate({ teamSlug: team.slug, name: name.trim() }) } const handleCancel = () => { @@ -57,7 +53,15 @@ export const TeamName = () => { } useEffect(() => { - if (isEditing) inputRef.current?.focus() + if (!isEditing) return + + const input = inputRef.current + if (!input) return + + input.focus() + + const cursorPosition = input.value.length + input.setSelectionRange(cursorPosition, cursorPosition) }, [isEditing]) const handleStartEditing = () => { @@ -79,28 +83,24 @@ export const TeamName = () => { value={name} onChange={(e) => setName(e.target.value)} readOnly={!isEditing} - className={NAME_INPUT_CLASS} + className="w-full bg-transparent p-0 text-[32px] leading-8 font-semibold tracking-[-0.32px] text-fg caret-accent-main-highlight outline-none" /> -
+
{isEditing ? ( <> - + ) : ( @@ -110,7 +110,7 @@ export const TeamName = () => { className="size-9" onClick={handleStartEditing} > - + )}
From e8fc524f769cfb9d69d6a88a3ec79ade119d6a82 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 13:56:43 -0400 Subject: [PATCH 24/36] Refactor team schemas and actions for improved member management - Consolidated team member schemas, including AddTeamMemberSchema and RemoveTeamMemberSchema, for better organization and clarity. - Updated team actions to utilize TRPC for adding and removing team members, enhancing state management and user feedback. - Refactored the AddMemberForm and MemberTableRow components to integrate the new TRPC mutations, improving the user experience when managing team members. These changes streamline team member management and enhance the maintainability of the codebase. --- src/core/modules/teams/schemas.ts | 25 +- src/core/server/actions/team-actions.ts | 223 +----------------- src/core/server/api/routers/teams.ts | 24 +- .../dashboard/members/add-member-form.tsx | 34 +-- .../dashboard/members/member-table-row.tsx | 23 +- 5 files changed, 77 insertions(+), 252 deletions(-) diff --git a/src/core/modules/teams/schemas.ts b/src/core/modules/teams/schemas.ts index 91ea1d39f..b077ff1ae 100644 --- a/src/core/modules/teams/schemas.ts +++ b/src/core/modules/teams/schemas.ts @@ -1,9 +1,7 @@ import { z } from 'zod' import { TeamSlugSchema } from '@/core/shared/schemas/team' -export { TeamSlugSchema } - -export const TeamNameSchema = z +const TeamNameSchema = z .string() .trim() .min(1, { message: 'Team name cannot be empty' }) @@ -13,11 +11,28 @@ export const TeamNameSchema = z 'Names can only contain letters and numbers, separated by spaces, underscores, hyphens, or dots', }) -export const UpdateTeamNameSchema = z.object({ +const UpdateTeamNameSchema = z.object({ teamSlug: TeamSlugSchema, name: TeamNameSchema, }) -export const CreateTeamSchema = z.object({ +const CreateTeamSchema = z.object({ name: TeamNameSchema, }) + +const AddTeamMemberSchema = z.object({ + email: z.email(), +}) + +const RemoveTeamMemberSchema = z.object({ + userId: z.uuid(), +}) + +export { + AddTeamMemberSchema, + CreateTeamSchema, + RemoveTeamMemberSchema, + TeamNameSchema, + TeamSlugSchema, + UpdateTeamNameSchema, +} diff --git a/src/core/server/actions/team-actions.ts b/src/core/server/actions/team-actions.ts index 1f039a71f..b34758704 100644 --- a/src/core/server/actions/team-actions.ts +++ b/src/core/server/actions/team-actions.ts @@ -1,98 +1,13 @@ 'use server' -import { fileTypeFromBuffer } from 'file-type' -import { revalidatePath } from 'next/cache' -import { after } from 'next/server' -import { returnValidationErrors } from 'next-safe-action' -import { z } from 'zod' -import { zfd } from 'zod-form-data' import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import type { CreateTeamsResponse } from '@/core/modules/billing/models' -import { - CreateTeamSchema, - UpdateTeamNameSchema, -} from '@/core/modules/teams/schemas' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' +import { CreateTeamSchema } from '@/core/modules/teams/schemas' +import { authActionClient } from '@/core/server/actions/client' import { handleDefaultInfraError, returnServerError, } from '@/core/server/actions/utils' -import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { deleteFile, getFiles, uploadFile } from '@/core/shared/clients/storage' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const withTeamsRepository = withTeamAuthedRequestRepository( - createTeamsRepository, - (teamsRepository) => ({ teamsRepository }) -) -const getStorageFilePath = (folderPath: string, fileName: string) => - `${folderPath}/${fileName}` - -export const updateTeamNameAction = authActionClient - .schema(UpdateTeamNameSchema) - .metadata({ actionName: 'updateTeamName' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { name, teamSlug } = parsedInput - const result = await ctx.teamsRepository.updateTeamName(name) - - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - - return result.data - }) - -const AddTeamMemberSchema = z.object({ - teamSlug: TeamSlugSchema, - email: z.email(), -}) - -export const addTeamMemberAction = authActionClient - .schema(AddTeamMemberSchema) - .metadata({ actionName: 'addTeamMember' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { email, teamSlug } = parsedInput - const result = await ctx.teamsRepository.addTeamMember(email) - - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - }) - -const RemoveTeamMemberSchema = z.object({ - teamSlug: TeamSlugSchema, - userId: z.uuid(), -}) - -export const removeTeamMemberAction = authActionClient - .schema(RemoveTeamMemberSchema) - .metadata({ actionName: 'removeTeamMember' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { userId, teamSlug } = parsedInput - const result = await ctx.teamsRepository.removeTeamMember(userId) - - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - }) export const createTeamAction = authActionClient .schema(CreateTeamSchema) @@ -125,137 +40,3 @@ export const createTeamAction = authActionClient return data }) - -const UploadTeamProfilePictureSchema = zfd.formData( - z.object({ - teamSlug: zfd.text(), - image: zfd.file(), - }) -) - -const RemoveTeamProfilePictureSchema = z.object({ - teamSlug: zfd.text(), -}) - -export const removeTeamProfilePictureAction = authActionClient - .schema(RemoveTeamProfilePictureSchema) - .metadata({ actionName: 'removeTeamProfilePicture' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { teamSlug } = parsedInput - const { teamId, teamsRepository } = ctx - - const result = await teamsRepository.updateTeamProfilePictureUrl(null) - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - after(async () => { - try { - const folderPath = `teams/${teamId}` - const files = await getFiles(folderPath) - for (const file of files) { - await deleteFile(getStorageFilePath(folderPath, file.name)) - } - } catch (cleanupError) { - l.warn({ - key: 'remove_team_profile_picture_action:cleanup_error', - error: serializeErrorForLog(cleanupError), - team_id: teamId, - }) - } - }) - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - - return result.data - }) - -export const uploadTeamProfilePictureAction = authActionClient - .schema(UploadTeamProfilePictureSchema) - .metadata({ actionName: 'uploadTeamProfilePicture' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { image, teamSlug } = parsedInput - const { teamId, teamsRepository } = ctx - - const allowedTypes = ['image/jpeg', 'image/png'] - - if (!allowedTypes.includes(image.type)) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { _errors: ['File must be JPG or PNG format'] }, - }) - } - - const MAX_FILE_SIZE = 5 * 1024 * 1024 - - if (image.size > MAX_FILE_SIZE) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { _errors: ['File size must be less than 5MB'] }, - }) - } - - const arrayBuffer = await image.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - const fileType = await fileTypeFromBuffer(buffer) - - if (!fileType) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { _errors: ['Unable to determine file type'] }, - }) - } - - const allowedMimeTypes = ['image/jpeg', 'image/png'] - if (!allowedMimeTypes.includes(fileType.mime)) { - return returnValidationErrors(UploadTeamProfilePictureSchema, { - image: { - _errors: [ - 'Invalid file type. Only JPEG and PNG images are allowed. File appears to be: ' + - fileType.mime, - ], - }, - }) - } - - const extension = fileType.ext - const fileName = `${Date.now()}.${extension}` - const storagePath = `teams/${teamId}/${fileName}` - - const publicUrl = await uploadFile(buffer, storagePath, fileType.mime) - - const result = await teamsRepository.updateTeamProfilePictureUrl(publicUrl) - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - after(async () => { - try { - const folderPath = `teams/${teamId}` - const currentFilePath = getStorageFilePath(folderPath, fileName) - const files = await getFiles(folderPath) - - for (const file of files) { - const filePath = getStorageFilePath(folderPath, file.name) - if (filePath === currentFilePath) continue - - await deleteFile(filePath) - } - } catch (cleanupError) { - l.warn({ - key: 'upload_team_profile_picture_action:cleanup_error', - error: serializeErrorForLog(cleanupError), - team_id: teamId, - context: { - image: image.name, - }, - }) - } - }) - - revalidatePath(`/dashboard/${teamSlug}/general`, 'page') - - return result.data - }) diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index b016c51ae..eb8d8378b 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -3,7 +3,11 @@ import { fileTypeFromBuffer } from 'file-type' import { revalidatePath } from 'next/cache' import { after } from 'next/server' import { z } from 'zod' -import { TeamNameSchema } from '@/core/modules/teams/schemas' +import { + AddTeamMemberSchema, + RemoveTeamMemberSchema, + TeamNameSchema, +} from '@/core/modules/teams/schemas' import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' @@ -65,6 +69,24 @@ export const teamsRouter = createTRPCRouter({ return result.data }), + addMember: teamsRepositoryProcedure + .input(AddTeamMemberSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.teamsRepository.addTeamMember(input.email) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + revalidatePath(`/dashboard/${input.teamSlug}/members`, 'page') + }), + removeMember: teamsRepositoryProcedure + .input(RemoveTeamMemberSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.teamsRepository.removeTeamMember(input.userId) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + revalidatePath(`/dashboard/${input.teamSlug}/members`, 'page') + }), removeProfilePicture: teamsRepositoryProcedure.mutation( async ({ ctx, input }) => { const result = await ctx.teamsRepository.updateTeamProfilePictureUrl(null) diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index 80d656cf2..ba6660bcb 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -1,16 +1,17 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' +import { useMutation } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' import { useForm } from 'react-hook-form' import { z } from 'zod' -import { addTeamMemberAction } from '@/core/server/actions/team-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Form, @@ -39,6 +40,8 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { 'use no memo' const { team } = useDashboard() + const router = useRouter() + const trpc = useTRPC() const { toast } = useToast() const form = useForm({ @@ -49,21 +52,24 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { }, }) - const { execute, isExecuting } = useAction(addTeamMemberAction, { - onSuccess: () => { - toast(defaultSuccessToast('The member has been added to the team.')) - form.reset() - onSuccess?.() - }, - onError: ({ error }) => { - toast(defaultErrorToast(error.serverError || 'An error occurred.')) - }, - }) + const addMemberMutation = useMutation( + trpc.teams.addMember.mutationOptions({ + onSuccess: () => { + toast(defaultSuccessToast('The member has been added to the team.')) + form.reset() + router.refresh() + onSuccess?.() + }, + onError: (error) => { + toast(defaultErrorToast(error.message || 'An error occurred.')) + }, + }) + ) const onSubmit = (data: AddMemberForm) => { if (!team) return - execute({ + addMemberMutation.mutate({ teamSlug: team.slug, email: data.email, }) @@ -95,7 +101,7 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { /> -
-) diff --git a/src/ui/primitives/loader.tsx b/src/ui/primitives/loader.tsx index 0fbb58cb5..5a9fccd3d 100644 --- a/src/ui/primitives/loader.tsx +++ b/src/ui/primitives/loader.tsx @@ -1,3 +1,5 @@ +'use client' + import * as React from 'react' import styled, { css } from 'styled-components' From 91edd639c7369d7e00e1351ca61dc13671e6bab8 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 15:22:48 -0400 Subject: [PATCH 28/36] Refactor MemberCard component to simplify props and enhance layout - Removed the className prop from the MemberCard component, streamlining its interface. - Updated the Card component to eliminate unnecessary className usage, improving layout consistency. - Adjusted CardContent to include padding directly, enhancing visual presentation. These changes improve the maintainability and clarity of the MemberCard component in the dashboard. --- src/features/dashboard/members/member-card.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx index edc23ea75..99e79fc19 100644 --- a/src/features/dashboard/members/member-card.tsx +++ b/src/features/dashboard/members/member-card.tsx @@ -7,11 +7,7 @@ import { ErrorIndicator } from '@/ui/error-indicator' import { Card, CardContent } from '@/ui/primitives/card' import MembersPageContent from './members-page-content' -interface MemberCardProps { - className?: string -} - -export const MemberCard = ({ className }: MemberCardProps) => { +export const MemberCard = () => { const { team } = useDashboard() const trpc = useTRPC() const { @@ -21,8 +17,8 @@ export const MemberCard = ({ className }: MemberCardProps) => { } = useQuery(trpc.teams.members.queryOptions({ teamSlug: team.slug })) return ( - - + + {error ? ( Date: Fri, 10 Apr 2026 15:30:28 -0400 Subject: [PATCH 29/36] Run biome format --- .../dashboard/settings/general/remove-photo-dialog.tsx | 7 ++++++- src/lib/utils/trpc-errors.ts | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/settings/general/remove-photo-dialog.tsx b/src/features/dashboard/settings/general/remove-photo-dialog.tsx index a36595760..2ae1a6c4c 100644 --- a/src/features/dashboard/settings/general/remove-photo-dialog.tsx +++ b/src/features/dashboard/settings/general/remove-photo-dialog.tsx @@ -1,7 +1,12 @@ 'use client' import { Button } from '@/ui/primitives/button' -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/ui/primitives/dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/ui/primitives/dialog' import { TrashIcon } from '@/ui/primitives/icons' interface RemovePhotoDialogProps { diff --git a/src/lib/utils/trpc-errors.ts b/src/lib/utils/trpc-errors.ts index 377fb48ec..f08ced663 100644 --- a/src/lib/utils/trpc-errors.ts +++ b/src/lib/utils/trpc-errors.ts @@ -46,7 +46,10 @@ const getTRPCValidationMessages = (error: unknown): string[] => { const { formErrors, fieldErrors } = parsedError.data.data.zodError - return [...formErrors, ...Object.values(fieldErrors).flatMap((messages) => messages ?? [])] + return [ + ...formErrors, + ...Object.values(fieldErrors).flatMap((messages) => messages ?? []), + ] } export { getTRPCValidationMessages, isNotFoundError } From 8edc72c26be12494bf05b6b89d09947ef52d7f68 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 15:36:00 -0400 Subject: [PATCH 30/36] Enhance TeamName component with pending state check during submission - Added a check to prevent form submission if the updateNameMutation is pending, improving user experience by avoiding duplicate requests. - Ensured that the form only submits when the name is valid and different from the current team name. These changes enhance the reliability and responsiveness of the TeamName component in the dashboard settings. --- src/features/dashboard/settings/general/team-name.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx index 3a0b89bf4..1bf33e194 100644 --- a/src/features/dashboard/settings/general/team-name.tsx +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -64,6 +64,7 @@ export const TeamName = () => { const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault() + if (updateNameMutation.isPending) return if (!name.trim() || name.trim() === team.name) return updateNameMutation.mutate({ teamSlug: team.slug, name: name.trim() }) } From c238e8950c85a0139ce542587d418296e4060506 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 15:37:15 -0400 Subject: [PATCH 31/36] Improve error handling in TeamAvatar component during file upload - Added try-catch block around the fileToBase64 function to handle potential errors when reading files. - Implemented user feedback via toast notifications for failed file reads, enhancing the user experience. - Cleared the file input on error to prevent invalid submissions. These changes enhance the robustness and user-friendliness of the TeamAvatar component in the dashboard settings. --- .../dashboard/settings/general/team-avatar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx index 0dcb34483..3a9337fac 100644 --- a/src/features/dashboard/settings/general/team-avatar.tsx +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -15,7 +15,6 @@ import { Button } from '@/ui/primitives/button' import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons' import { RemovePhotoDialog } from './remove-photo-dialog' -// Convert a File into base64 payload text. e.g. fileToBase64(imageFile) -> "iVBORw0KGgoAAAANSUhEUgAA..." const fileToBase64 = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader() @@ -89,7 +88,14 @@ export const TeamAvatar = () => { return } - const base64 = await fileToBase64(file) + let base64: string + try { + base64 = await fileToBase64(file) + } catch { + toast(defaultErrorToast('Failed to read file. Please try again.')) + if (fileInputRef.current) fileInputRef.current.value = '' + return + } uploadProfilePictureMutation.mutate({ teamSlug: team.slug, From 9b7f9f11a3fc1014084da597eaa4f3a765e41197 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 15:40:39 -0400 Subject: [PATCH 32/36] Refactor TeamName component for improved layout and error handling - Updated the form structure to enhance layout consistency and user experience. - Improved error handling by consolidating validation messages into a single toast notification. - Ensured the input field and buttons are properly organized for better accessibility and usability. These changes enhance the maintainability and user-friendliness of the TeamName component in the dashboard settings. --- .../dashboard/settings/general/team-name.tsx | 102 +++++++++--------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx index 1bf33e194..b314d7768 100644 --- a/src/features/dashboard/settings/general/team-name.tsx +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -41,15 +41,18 @@ export const TeamName = () => { onError: (error) => { const validationMessages = getTRPCValidationMessages(error) if (validationMessages.length > 0) { - toast( - defaultErrorToast( + const validationToastContent = + validationMessages.length === 1 ? ( + validationMessages[0] + ) : (
    {validationMessages.map((message) => (
  • {message}
  • ))}
) - ) + + toast(defaultErrorToast(validationToastContent)) return } @@ -140,58 +143,61 @@ export const TeamName = () => {
- - name - - setName(e.target.value)} - readOnly={!isEditing} - className="h-10 w-full bg-transparent p-0 text-[32px] leading-8 font-semibold tracking-[-0.32px] text-fg caret-accent-main-highlight outline-none" - style={{ - fontSize: `${fontSize}px`, - lineHeight: `${TEAM_NAME_INPUT_HEIGHT_PX}px`, - }} - /> - -
-
- {isEditing ? ( - <> - +
+ + name + + setName(e.target.value)} + readOnly={!isEditing} + className="h-10 w-full bg-transparent p-0 text-[32px] leading-8 font-semibold tracking-[-0.32px] text-fg caret-accent-main-highlight outline-none" + style={{ + fontSize: `${fontSize}px`, + lineHeight: `${TEAM_NAME_INPUT_HEIGHT_PX}px`, + }} + /> + +
+
+ {isEditing ? ( + <> + + + + ) : ( - - ) : ( - - )} -
+ )} +
+
) } From c281ff0b53bd08e890a1e6650ebd3b0d2b877e96 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 10 Apr 2026 15:42:01 -0400 Subject: [PATCH 33/36] =?UTF-8?q?fileSchema=20=E2=86=92=20FileSchema=20for?= =?UTF-8?q?=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/server/api/routers/support.ts | 4 ++-- src/core/server/api/routers/teams.ts | 4 ++-- src/core/shared/schemas/file.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/core/server/api/routers/support.ts b/src/core/server/api/routers/support.ts index 954239689..5184f814e 100644 --- a/src/core/server/api/routers/support.ts +++ b/src/core/server/api/routers/support.ts @@ -5,7 +5,7 @@ import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' -import { fileSchema } from '@/core/shared/schemas/file' +import { FileSchema } from '@/core/shared/schemas/file' const E2B_API_KEY_REGEX = /e2b_[a-f0-9]{40}/i @@ -23,7 +23,7 @@ export const supportRouter = createTRPCRouter({ .input( z.object({ description: z.string().min(1), - files: z.array(fileSchema).max(5).optional(), + files: z.array(FileSchema).max(5).optional(), }) ) .mutation(async ({ ctx, input }) => { diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index 348386d9f..a3758d06c 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -22,7 +22,7 @@ import { } from '@/core/server/trpc/procedures' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { deleteFile, getFiles, uploadFile } from '@/core/shared/clients/storage' -import { fileSchema } from '@/core/shared/schemas/file' +import { FileSchema } from '@/core/shared/schemas/file' const MAX_FILE_SIZE = 5 * 1024 * 1024 @@ -126,7 +126,7 @@ export const teamsRouter = createTRPCRouter({ uploadProfilePicture: teamsRepositoryProcedure .input( z.object({ - image: fileSchema, + image: FileSchema, }) ) .mutation(async ({ ctx, input }) => { diff --git a/src/core/shared/schemas/file.ts b/src/core/shared/schemas/file.ts index 0133373d0..f42c561b5 100644 --- a/src/core/shared/schemas/file.ts +++ b/src/core/shared/schemas/file.ts @@ -1,9 +1,9 @@ import { z } from 'zod' -const fileSchema = z.object({ +const FileSchema = z.object({ name: z.string(), type: z.string(), base64: z.string(), }) -export { fileSchema } +export { FileSchema } From 2f114015d9f14411b943be64600d0a5c487b562c Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Mon, 13 Apr 2026 17:19:45 -0400 Subject: [PATCH 34/36] Add createdAt property to UserTeam schema and update TeamInfo component - Added createdAt field to the UserTeam schema in the OpenAPI specification and TypeScript types for better tracking of team creation dates. - Updated the TeamInfo component to display the formatted creation date, enhancing the user interface with relevant team information. These changes improve data representation and user experience in the dashboard settings. --- spec/openapi.dashboard-api.yaml | 4 ++++ src/core/shared/contracts/dashboard-api.types.ts | 2 ++ src/features/dashboard/settings/general/team-info.tsx | 4 +++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 9d0a4fbac..f3725972e 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -386,6 +386,7 @@ components: - blockedReason - isDefault - limits + - createdAt properties: id: type: string @@ -412,6 +413,9 @@ components: type: boolean limits: $ref: "#/components/schemas/UserTeamLimits" + createdAt: + type: string + format: date-time UserTeamsResponse: type: object diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 020cd4adf..0b0e6f649 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -660,6 +660,8 @@ export interface components { blockedReason: string | null isDefault: boolean limits: components['schemas']['UserTeamLimits'] + /** Format: date-time */ + createdAt: string } UserTeamsResponse: { teams: components['schemas']['UserTeam'][] diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx index 8ab5c265f..6880d5677 100644 --- a/src/features/dashboard/settings/general/team-info.tsx +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -1,6 +1,7 @@ 'use client' import { useDashboard } from '@/features/dashboard/context' +import { formatDate } from '@/lib/utils/formatting' const InfoRow = ({ label, value }: { label: string; value: string }) => (
@@ -15,10 +16,11 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( export const TeamInfo = () => { const { team } = useDashboard() + const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--' return (
- +
) From 9fa2fe677a4570879eb0dd2c4a77e6e926ebe7eb Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 11:16:13 -0400 Subject: [PATCH 35/36] Refactor TeamAvatar and TeamName components for improved functionality and user experience - Updated the TeamAvatar component to include type annotations and enhanced file upload handling, including a maximum file size constant and improved reset logic for the file input. - Refactored the TeamName component to streamline validation error handling and introduced a new function for rendering validation messages, improving user feedback during form submissions. - Enhanced both components with better type definitions and organized event handling for improved maintainability. These changes enhance the robustness and user-friendliness of the dashboard settings interface. --- .../settings/general/team-avatar.tsx | 87 +++++++++---------- .../dashboard/settings/general/team-name.tsx | 78 +++++++++-------- 2 files changed, 83 insertions(+), 82 deletions(-) diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx index 3a9337fac..9d0eaab5a 100644 --- a/src/features/dashboard/settings/general/team-avatar.tsx +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -1,7 +1,7 @@ 'use client' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useRef, useState } from 'react' +import { type ReactElement, useRef, useState } from 'react' import { USER_MESSAGES } from '@/configs/user-messages' import { useDashboard } from '@/features/dashboard/context' import { @@ -15,7 +15,10 @@ import { Button } from '@/ui/primitives/button' import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons' import { RemovePhotoDialog } from './remove-photo-dialog' -const fileToBase64 = (file: File) => +const MAX_PROFILE_PICTURE_SIZE_BYTES = 5 * 1024 * 1024 + +// Converts a file into a base64 payload string; example: File("logo.png") -> "iVBORw0KGgo..." +const fileToBase64 = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { @@ -26,15 +29,22 @@ const fileToBase64 = (file: File) => reader.readAsDataURL(file) }) -export const TeamAvatar = () => { +export const TeamAvatar = (): ReactElement => { const { team } = useDashboard() const trpc = useTRPC() const queryClient = useQueryClient() const { toast } = useToast() const fileInputRef = useRef(null) const [removeDialogOpen, setRemoveDialogOpen] = useState(false) + const hasPhoto = Boolean(team.profilePictureUrl) + const UploadIcon = hasPhoto ? EditIcon : PhotoIcon + const uploadLabel = hasPhoto ? 'Change' : 'Add photo' + + const resetFileInput = (): void => { + if (fileInputRef.current) fileInputRef.current.value = '' + } - const invalidateTeams = async () => { + const invalidateTeams = async (): Promise => { await queryClient.invalidateQueries({ queryKey: trpc.teams.list.queryKey(), }) @@ -53,9 +63,7 @@ export const TeamAvatar = () => { ) ) }, - onSettled: () => { - if (fileInputRef.current) fileInputRef.current.value = '' - }, + onSettled: resetFileInput, }) ) @@ -76,38 +84,38 @@ export const TeamAvatar = () => { }) ) - const handleUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] + const handleUpload = async ({ + target, + }: React.ChangeEvent): Promise => { + const file = target.files?.[0] if (!file) return - const MAX_FILE_SIZE = 5 * 1024 * 1024 - - if (file.size > MAX_FILE_SIZE) { + if (file.size > MAX_PROFILE_PICTURE_SIZE_BYTES) { toast(defaultErrorToast('Profile picture must be less than 5MB.')) - if (fileInputRef.current) fileInputRef.current.value = '' + resetFileInput() return } - let base64: string try { - base64 = await fileToBase64(file) + const base64 = await fileToBase64(file) + uploadProfilePictureMutation.mutate({ + teamSlug: team.slug, + image: { + base64, + name: file.name, + type: file.type, + }, + }) } catch { toast(defaultErrorToast('Failed to read file. Please try again.')) - if (fileInputRef.current) fileInputRef.current.value = '' - return + resetFileInput() } - - uploadProfilePictureMutation.mutate({ - teamSlug: team.slug, - image: { - base64, - name: file.name, - type: file.type, - }, - }) } - const hasPhoto = !!team.profilePictureUrl + const handleUploadClick = (): void => fileInputRef.current?.click() + const handleRemoveClick = (): void => setRemoveDialogOpen(true) + const handleRemoveConfirm = (): void => + removeProfilePictureMutation.mutate({ teamSlug: team.slug }) return (
@@ -125,27 +133,14 @@ export const TeamAvatar = () => { {hasPhoto && ( - )} @@ -162,9 +157,7 @@ export const TeamAvatar = () => { open={removeDialogOpen} onOpenChange={setRemoveDialogOpen} isRemoving={removeProfilePictureMutation.isPending} - onConfirm={() => - removeProfilePictureMutation.mutate({ teamSlug: team.slug }) - } + onConfirm={handleRemoveConfirm} />
) diff --git a/src/features/dashboard/settings/general/team-name.tsx b/src/features/dashboard/settings/general/team-name.tsx index b314d7768..4be003a65 100644 --- a/src/features/dashboard/settings/general/team-name.tsx +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -1,7 +1,13 @@ 'use client' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useEffect, useRef, useState } from 'react' +import { + type ReactElement, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react' import { USER_MESSAGES } from '@/configs/user-messages' import { useDashboard } from '@/features/dashboard/context' import { @@ -18,7 +24,18 @@ const TEAM_NAME_MAX_FONT_SIZE_PX = 32 const TEAM_NAME_MIN_FONT_SIZE_PX = 18 const TEAM_NAME_INPUT_HEIGHT_PX = 40 -export const TeamName = () => { +const getValidationToastContent = (messages: string[]): ReactNode => + messages.length === 1 ? ( + messages[0] + ) : ( +
    + {messages.map((message) => ( +
  • {message}
  • + ))} +
+ ) + +export const TeamName = (): ReactElement => { const { team } = useDashboard() const trpc = useTRPC() const queryClient = useQueryClient() @@ -28,6 +45,8 @@ export const TeamName = () => { const [fontSize, setFontSize] = useState(TEAM_NAME_MAX_FONT_SIZE_PX) const inputRef = useRef(null) const textMeasureRef = useRef(null) + const trimmedName = name.trim() + const isSaveDisabled = !trimmedName || trimmedName === team.name const updateNameMutation = useMutation( trpc.teams.updateName.mutationOptions({ @@ -38,21 +57,12 @@ export const TeamName = () => { toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) setIsEditing(false) }, - onError: (error) => { + onError: (error): void => { const validationMessages = getTRPCValidationMessages(error) if (validationMessages.length > 0) { - const validationToastContent = - validationMessages.length === 1 ? ( - validationMessages[0] - ) : ( -
    - {validationMessages.map((message) => ( -
  • {message}
  • - ))} -
- ) - - toast(defaultErrorToast(validationToastContent)) + toast( + defaultErrorToast(getValidationToastContent(validationMessages)) + ) return } @@ -65,28 +75,22 @@ export const TeamName = () => { }) ) - const handleSubmit = (e?: React.FormEvent) => { - e?.preventDefault() - if (updateNameMutation.isPending) return - if (!name.trim() || name.trim() === team.name) return - updateNameMutation.mutate({ teamSlug: team.slug, name: name.trim() }) + const handleSubmit = (event?: React.FormEvent): void => { + event?.preventDefault() + if (updateNameMutation.isPending || isSaveDisabled) return + updateNameMutation.mutate({ teamSlug: team.slug, name: trimmedName }) } - const handleCancel = () => { + const handleCancel = (): void => { setName(team.name) setIsEditing(false) } useEffect(() => { - if (!isEditing) return - - const input = inputRef.current - if (!input) return - - input.focus() - - const cursorPosition = input.value.length - input.setSelectionRange(cursorPosition, cursorPosition) + if (!isEditing || !inputRef.current) return + inputRef.current.focus() + const cursorPosition = inputRef.current.value.length + inputRef.current.setSelectionRange(cursorPosition, cursorPosition) }, [isEditing]) useEffect(() => { @@ -96,7 +100,7 @@ export const TeamName = () => { let frameId = 0 - const updateFontSize = () => { + const updateFontSize = (): void => { const availableWidth = input.clientWidth if (!availableWidth) return @@ -118,7 +122,7 @@ export const TeamName = () => { ) } - const scheduleFontSizeUpdate = () => { + const scheduleFontSizeUpdate = (): void => { window.cancelAnimationFrame(frameId) frameId = window.requestAnimationFrame(updateFontSize) } @@ -134,11 +138,15 @@ export const TeamName = () => { } }, [name]) - const handleStartEditing = () => { + const handleStartEditing = (): void => { setName(team.name) setIsEditing(true) } + const handleNameChange = ({ + target, + }: React.ChangeEvent): void => setName(target.value) + return (
{ setName(e.target.value)} + onChange={handleNameChange} readOnly={!isEditing} className="h-10 w-full bg-transparent p-0 text-[32px] leading-8 font-semibold tracking-[-0.32px] text-fg caret-accent-main-highlight outline-none" style={{ @@ -180,7 +188,7 @@ export const TeamName = () => { size="icon" className="size-9" loading={updateNameMutation.isPending} - disabled={!name.trim() || name.trim() === team.name} + disabled={isSaveDisabled} > From 936cf0a7111f95cfb451c07b91350837540a92e2 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 15 Apr 2026 11:55:10 -0400 Subject: [PATCH 36/36] Fix spacing and gaps --- src/app/dashboard/[teamSlug]/general/page.tsx | 2 +- src/features/dashboard/layouts/page.tsx | 2 +- src/features/dashboard/layouts/wrapper.tsx | 2 +- src/features/dashboard/members/member-card.tsx | 4 ++-- .../dashboard/members/members-page-content.tsx | 4 +--- .../dashboard/settings/general/team-avatar.tsx | 16 +++++++++++++--- .../dashboard/settings/general/team-info.tsx | 4 ++-- 7 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/general/page.tsx b/src/app/dashboard/[teamSlug]/general/page.tsx index 5defb6c1f..a8931db70 100644 --- a/src/app/dashboard/[teamSlug]/general/page.tsx +++ b/src/app/dashboard/[teamSlug]/general/page.tsx @@ -5,7 +5,7 @@ import { TeamName } from '@/features/dashboard/settings/general/team-name' export default async function GeneralPage() { return ( - +
diff --git a/src/features/dashboard/layouts/page.tsx b/src/features/dashboard/layouts/page.tsx index 73ff02af3..505068d3d 100644 --- a/src/features/dashboard/layouts/page.tsx +++ b/src/features/dashboard/layouts/page.tsx @@ -7,7 +7,7 @@ interface PageProps { } export const Page = ({ children, className }: PageProps) => ( -
+
{children}
) diff --git a/src/features/dashboard/layouts/wrapper.tsx b/src/features/dashboard/layouts/wrapper.tsx index 2c6c4d078..4b723e047 100644 --- a/src/features/dashboard/layouts/wrapper.tsx +++ b/src/features/dashboard/layouts/wrapper.tsx @@ -11,7 +11,7 @@ export function DefaultDashboardLayout({ }) { return (
-
+
{ const { team } = useDashboard() @@ -18,7 +18,7 @@ export const MemberCard = () => { return ( - + {error ? ( ) } - -export default MembersPageContent diff --git a/src/features/dashboard/settings/general/team-avatar.tsx b/src/features/dashboard/settings/general/team-avatar.tsx index 9d0eaab5a..0bb505ed7 100644 --- a/src/features/dashboard/settings/general/team-avatar.tsx +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -118,7 +118,7 @@ export const TeamAvatar = (): ReactElement => { removeProfilePictureMutation.mutate({ teamSlug: team.slug }) return ( -
+
{hasPhoto ? ( {
{hasPhoto && ( - )} diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx index 6880d5677..1c63c3406 100644 --- a/src/features/dashboard/settings/general/team-info.tsx +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -8,7 +8,7 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( {label} - + {value}
@@ -19,7 +19,7 @@ export const TeamInfo = () => { const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--' return ( -
+