diff --git a/.gitignore b/.gitignore index 8d1a59ed8..26a5f848a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ next-env.d.ts # AI agents and related files CLAUDE.md -.cursor +.cursor/ .agent diff --git a/src/__test__/unit/teams-repository.test.ts b/src/__test__/unit/teams-repository.test.ts index bd9c6a2c3..3b4f61d8a 100644 --- a/src/__test__/unit/teams-repository.test.ts +++ b/src/__test__/unit/teams-repository.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import type { components as DashboardComponents } from '@/contracts/dashboard-api' import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' vi.mock('@/core/shared/clients/supabase/admin', () => ({ @@ -12,63 +11,7 @@ vi.mock('@/core/shared/clients/supabase/admin', () => ({ }, })) -function createApiResponse(input: { - ok: boolean - status: number - data?: T - error?: { message?: string } | null -}) { - return { - data: input.data, - error: input.error ?? null, - response: { - ok: input.ok, - status: input.status, - }, - } -} - describe('createTeamsRepository', () => { - it('returns a validation repo error when createTeam gets a 400 response', async () => { - const apiClient = { - POST: vi.fn().mockResolvedValue( - createApiResponse< - DashboardComponents['schemas']['TeamResolveResponse'] - >({ - ok: false, - status: 400, - error: { message: 'Team name is invalid' }, - }) - ), - GET: vi.fn(), - PATCH: vi.fn(), - DELETE: vi.fn(), - } - - const repository = createTeamsRepository( - { accessToken: 'token' }, - { - apiClient: - apiClient as unknown as typeof import('@/core/shared/clients/api').api, - authHeaders: vi.fn(() => ({ 'X-Supabase-Token': 'token' })), - adminClient: { - auth: { admin: { getUserById: vi.fn() } }, - } as unknown as typeof import('@/core/shared/clients/supabase/admin').supabaseAdmin, - } - ) - - const result = await repository.createTeam('bad name') - - expect(result).toEqual({ - ok: false, - error: expect.objectContaining({ - code: 'validation', - status: 400, - message: 'Team name is invalid', - }), - }) - }) - it('returns a repo error instead of throwing when a team-scoped method has no teamId', async () => { const repository = createTeamsRepository( { accessToken: 'token' }, diff --git a/src/__test__/unit/user-teams-repository.test.ts b/src/__test__/unit/user-teams-repository.test.ts new file mode 100644 index 000000000..624f334e3 --- /dev/null +++ b/src/__test__/unit/user-teams-repository.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import type { components as DashboardComponents } from '@/contracts/dashboard-api' +import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' + +function createApiResponse(input: { + ok: boolean + status: number + data?: T + error?: { message?: string } | null +}) { + return { + data: input.data, + error: input.error ?? null, + response: { + ok: input.ok, + status: input.status, + }, + } +} + +describe('createUserTeamsRepository', () => { + it('returns a validation repo error when createTeam gets a 400 response', async () => { + const apiClient = { + POST: vi.fn().mockResolvedValue( + createApiResponse< + DashboardComponents['schemas']['TeamResolveResponse'] + >({ + ok: false, + status: 400, + error: { message: 'Team name is invalid' }, + }) + ), + GET: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + } + + const repository = createUserTeamsRepository( + { accessToken: 'token' }, + { + apiClient: + apiClient as unknown as typeof import('@/core/shared/clients/api').api, + authHeaders: vi.fn(() => ({ 'X-Supabase-Token': 'token' })), + } + ) + + const result = await repository.createTeam('bad name') + + expect(result).toEqual({ + ok: false, + error: expect.objectContaining({ + code: 'validation', + status: 400, + message: 'Team name is invalid', + }), + }) + }) +}) diff --git a/src/app/dashboard/[teamSlug]/general/page.tsx b/src/app/dashboard/[teamSlug]/general/page.tsx index 60519292a..a8931db70 100644 --- a/src/app/dashboard/[teamSlug]/general/page.tsx +++ b/src/app/dashboard/[teamSlug]/general/page.tsx @@ -1,29 +1,17 @@ -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/app/dashboard/[teamSlug]/members/page.tsx b/src/app/dashboard/[teamSlug]/members/page.tsx index 1fbdd1bf9..cd907cfda 100644 --- a/src/app/dashboard/[teamSlug]/members/page.tsx +++ b/src/app/dashboard/[teamSlug]/members/page.tsx @@ -1,5 +1,6 @@ import { Page } from '@/features/dashboard/layouts/page' -import { MemberCard } from '@/features/dashboard/members/member-card' +import { MembersPageContent } from '@/features/dashboard/members/members-page-content' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' interface MembersPageProps { params: Promise<{ @@ -8,9 +9,15 @@ interface MembersPageProps { } export default async function MembersPage({ params }: MembersPageProps) { + const { teamSlug } = await params + + prefetch(trpc.teams.members.queryOptions({ teamSlug })) + 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/schemas.ts b/src/core/modules/teams/schemas.ts index 91ea1d39f..ec072aada 100644 --- a/src/core/modules/teams/schemas.ts +++ b/src/core/modules/teams/schemas.ts @@ -1,23 +1,43 @@ import { z } from 'zod' import { TeamSlugSchema } from '@/core/shared/schemas/team' -export { TeamSlugSchema } +const TEAM_NAME_MAX_LENGTH = 32 -export const TeamNameSchema = z +const TeamNameSchema = z .string() .trim() .min(1, { message: 'Team name cannot be empty' }) - .max(32, { message: 'Team name cannot be longer than 32 characters' }) + .max(TEAM_NAME_MAX_LENGTH, { + message: `Team name cannot be longer than ${TEAM_NAME_MAX_LENGTH} characters`, + }) .regex(/^[a-zA-Z0-9]+(?:[ _.-][a-zA-Z0-9]+)*$/, { message: '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, + TEAM_NAME_MAX_LENGTH, + TeamNameSchema, + TeamSlugSchema, + UpdateTeamNameSchema, +} diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index 127c6eb4e..11d0124c5 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -21,9 +21,6 @@ export type TeamsRequestScope = RequestScope & { } export interface TeamsRepository { - createTeam( - name: string - ): Promise> listTeamMembers(): Promise> updateTeamName( name: string @@ -31,7 +28,7 @@ export interface TeamsRepository { addTeamMember(email: string): Promise> removeTeamMember(userId: string): Promise> updateTeamProfilePictureUrl( - profilePictureUrl: string + profilePictureUrl: string | null ): Promise> } @@ -73,39 +70,6 @@ export function createTeamsRepository( } ): TeamsRepository { return { - async createTeam( - name - ): Promise< - RepoResult - > { - const { data, error, response } = await deps.apiClient.POST('/teams', { - headers: deps.authHeaders(scope.accessToken), - body: { name }, - }) - - if (!response.ok || error || !data) { - if (response.status === 400) { - return err( - createRepoError({ - code: 'validation', - status: response.status, - message: error?.message ?? 'Failed to create team', - cause: error, - }) - ) - } - - return err( - repoErrorFromHttp( - response.status, - error?.message ?? 'Failed to create team', - error - ) - ) - } - - return ok(data) - }, async listTeamMembers(): Promise> { const teamId = requireTeamId(scope) if (!teamId.ok) { diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts index d974a0dda..de701eabf 100644 --- a/src/core/modules/teams/user-teams-repository.server.ts +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -2,8 +2,9 @@ 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 { repoErrorFromHttp } from '@/core/shared/errors' +import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors' import type { RequestScope } from '@/core/shared/repository-scope' import { err, ok, type RepoResult } from '@/core/shared/result' import type { ResolvedTeam, TeamModel } from './models' @@ -17,6 +18,9 @@ export type UserTeamsRequestScope = RequestScope export interface UserTeamsRepository { listUserTeams(): Promise> + createTeam( + name: string + ): Promise> resolveTeamBySlug( slug: string, next?: { tags?: string[] } @@ -52,11 +56,40 @@ export function createUserTeamsRepository( async listUserTeams(): Promise> { const teamsResult = await listApiUserTeams() - if (!teamsResult.ok) { - return teamsResult + return teamsResult + }, + async createTeam( + name + ): Promise< + RepoResult + > { + const { data, error, response } = await deps.apiClient.POST('/teams', { + headers: deps.authHeaders(scope.accessToken), + body: { name }, + }) + + if (!response.ok || error || !data) { + if (response.status === 400) { + return err( + createRepoError({ + code: 'validation', + status: response.status, + message: error?.message ?? 'Failed to create team', + cause: error, + }) + ) + } + + return err( + repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to create team', + error + ) + ) } - return ok(teamsResult.data) + return ok(data) }, async resolveTeamBySlug( slug: string, diff --git a/src/core/server/actions/team-actions.ts b/src/core/server/actions/team-actions.ts deleted file mode 100644 index d9a6315f7..000000000 --- a/src/core/server/actions/team-actions.ts +++ /dev/null @@ -1,205 +0,0 @@ -'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 { - CreateTeamSchema, - UpdateTeamNameSchema, -} from '@/core/modules/teams/schemas' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -import { - authActionClient, - withAuthedRequestRepository, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' -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 withAuthedTeamsRepository = withAuthedRequestRepository( - createTeamsRepository, - (teamsRepository) => ({ teamsRepository }) -) - -const withTeamsRepository = withTeamAuthedRequestRepository( - createTeamsRepository, - (teamsRepository) => ({ teamsRepository }) -) - -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) - .metadata({ actionName: 'createTeam' }) - .use(withAuthedTeamsRepository) - .action(async ({ parsedInput, ctx }) => { - const { name } = parsedInput - - const result = await ctx.teamsRepository.createTeam(name) - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - - return result.data - }) - -const UploadTeamProfilePictureSchema = zfd.formData( - z.object({ - teamSlug: zfd.text(), - image: zfd.file(), - }) -) - -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 currentFileName = fileName - const folderPath = `teams/${teamId}` - const files = await getFiles(folderPath) - - for (const file of files) { - const filePath = file.name - if (filePath === `${folderPath}/${currentFileName}`) { - 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/support.ts b/src/core/server/api/routers/support.ts index 5b3443898..5184f814e 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, @@ -28,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 03a2d6a88..b0afd0abc 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -1,18 +1,53 @@ +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 { + AddTeamMemberSchema, + CreateTeamSchema, + 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' -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 +55,157 @@ export const teamsRouter = createTRPCRouter({ return teamsResult.data }), + create: userTeamsRepositoryProcedure + .input(CreateTeamSchema) + .mutation(async ({ ctx, input }) => { + const result = await ctx.userTeamsRepository.createTeam(input.name) + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + return result.data + }), + members: teamsRepositoryProcedure.query(async ({ ctx }) => { + const result = await ctx.teamsRepository.listTeamMembers() + + if (!result.ok) throwTRPCErrorFromRepoError(result.error) + + return result.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 + }), + 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) + + 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/server/functions/team/get-team-members.ts b/src/core/server/functions/team/get-team-members.ts deleted file mode 100644 index d8b8fffd4..000000000 --- a/src/core/server/functions/team/get-team-members.ts +++ /dev/null @@ -1,33 +0,0 @@ -import 'server-only' - -import { z } from 'zod' -import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -import { - authActionClient, - withTeamAuthedRequestRepository, - withTeamSlugResolution, -} from '@/core/server/actions/client' -import { toActionErrorFromRepoError } from '@/core/server/adapters/errors' -import { TeamSlugSchema } from '@/core/shared/schemas/team' - -const withTeamsRepository = withTeamAuthedRequestRepository( - createTeamsRepository, - (teamsRepository) => ({ teamsRepository }) -) - -const GetTeamMembersSchema = z.object({ - teamSlug: TeamSlugSchema, -}) - -export const getTeamMembers = authActionClient - .schema(GetTeamMembersSchema) - .metadata({ serverFunctionName: 'getTeamMembers' }) - .use(withTeamSlugResolution) - .use(withTeamsRepository) - .action(async ({ ctx }) => { - const result = await ctx.teamsRepository.listTeamMembers() - if (!result.ok) { - return toActionErrorFromRepoError(result.error) - } - return result.data - }) diff --git a/src/core/server/functions/team/types.ts b/src/core/server/functions/team/types.ts deleted file mode 100644 index 616cb87a9..000000000 --- a/src/core/server/functions/team/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { - ResolvedTeam, - TeamMember, - TeamMemberInfo, - TeamMemberRelation, -} from '@/core/modules/teams/models' -export { - CreateTeamSchema, - TeamNameSchema, - UpdateTeamNameSchema, -} from '@/core/modules/teams/schemas' diff --git a/src/core/shared/schemas/file.ts b/src/core/shared/schemas/file.ts new file mode 100644 index 000000000..f42c561b5 --- /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/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx index 3971d032c..ff4071d40 100644 --- a/src/features/dashboard/billing/invoices.tsx +++ b/src/features/dashboard/billing/invoices.tsx @@ -11,8 +11,6 @@ import { InvoiceIcon, } from '@/ui/primitives/icons' import { Label } from '@/ui/primitives/label' -import { Loader } from '@/ui/primitives/loader' -import { Skeleton } from '@/ui/primitives/skeleton' import { Table, TableBody, @@ -20,6 +18,7 @@ import { TableEmptyState, TableHead, TableHeader, + TableLoadingState, TableRow, } from '@/ui/primitives/table' import { useInvoices } from './hooks' @@ -64,30 +63,6 @@ function InvoicesEmpty({ error }: InvoicesEmptyProps) { ) } -function InvoicesLoading() { - return ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - - {index === 1 && ( - <> - - - Loading invoices - - - )} -
- ))} -
- ) -} - export default function BillingInvoicesTable() { const { invoices, isLoading, error } = useInvoices() @@ -124,13 +99,8 @@ export default function BillingInvoicesTable() { {showLoader && ( - - - - - + )} - {showEmpty && } {hasData && diff --git a/src/features/dashboard/members/add-member-form.tsx b/src/features/dashboard/members/add-member-form.tsx index fa64da3fd..e3e87985d 100644 --- a/src/features/dashboard/members/add-member-form.tsx +++ b/src/features/dashboard/members/add-member-form.tsx @@ -1,16 +1,16 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' 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, @@ -20,6 +20,7 @@ import { FormLabel, FormMessage, } from '@/ui/primitives/form' +import { AddIcon } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { useDashboard } from '../context' @@ -38,6 +39,8 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => { 'use no memo' const { team } = useDashboard() + const trpc = useTRPC() + const queryClient = useQueryClient() const { toast } = useToast() const form = useForm({ @@ -48,21 +51,26 @@ 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: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.members.queryKey({ teamSlug: team.slug }), + }) + toast(defaultSuccessToast('The member has been added to the team.')) + form.reset() + 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, }) @@ -72,31 +80,36 @@ export const AddMemberForm = ({ className, onSuccess }: AddMemberFormProps) => {
( - - E-mail -
- - - - -
+ + Email + + + )} /> + ) diff --git a/src/features/dashboard/members/member-card.tsx b/src/features/dashboard/members/member-card.tsx deleted file mode 100644 index 8d5d97c4e..000000000 --- a/src/features/dashboard/members/member-card.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Suspense } from 'react' -import { getTeamMembers } from '@/core/server/functions/team/get-team-members' -import { ErrorIndicator } from '@/ui/error-indicator' -import { Card, CardContent } from '@/ui/primitives/card' -import { Loader } from '@/ui/primitives/loader_d' -import MembersPageContent from './members-page-content' - -interface MemberCardProps { - params: Promise<{ - teamSlug: string - }> - className?: string -} - -export const MemberCard = ({ params, className }: MemberCardProps) => ( - - - }> - - - - -) - -const MembersPageContentLoader = async ({ params }: MemberCardProps) => { - const { teamSlug } = await params - - try { - const result = await getTeamMembers({ teamSlug }) - - if (!result?.data || result.serverError || result.validationErrors) { - throw new Error(result?.serverError || 'Unknown error') - } - - return - } catch (error) { - return ( - - ) - } -} - -const MembersPageContentLoading = () => ( -
- -
-) diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 947160011..2d47015c1 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -1,21 +1,21 @@ 'use client' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { useAction } from 'next-safe-action/hooks' import { 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 type { TeamMember } from '@/core/modules/teams/models' 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 { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { formatDate } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' import { E2BLogo } from '@/ui/brand' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' import { Badge } from '@/ui/primitives/badge' @@ -62,33 +62,42 @@ function toMemberProvider(provider: string): MemberProvider | null { export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { const { toast } = useToast() const router = useRouter() + const trpc = useTRPC() + const queryClient = useQueryClient() const { team, user } = useDashboard() const [removeDialogOpen, setRemoveDialogOpen] = useState(false) - const { execute: removeMember, isExecuting: isRemoving } = useAction( - removeTeamMemberAction, - { - onSuccess: ({ input }) => { + const removeMemberMutation = useMutation( + trpc.teams.removeMember.mutationOptions({ + onSuccess: async (_, input) => { if (input.userId === user?.id) { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + router.push(PROTECTED_URLS.DASHBOARD) + toast(defaultSuccessToast('You have left the team.')) } else { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.members.queryKey({ teamSlug: team.slug }), + }) toast( defaultSuccessToast('The member has been removed from the team.') ) } }, - onError: ({ error }) => { - toast(defaultErrorToast(error.serverError || 'Unknown error.')) + onError: (error) => { + toast(defaultErrorToast(error.message || 'Unknown error.')) }, onSettled: () => { setRemoveDialogOpen(false) }, - } + }) ) const handleRemoveMember = (userId: string) => { - removeMember({ teamSlug: team.slug, userId }) + removeMemberMutation.mutate({ teamSlug: team.slug, userId }) } const providers = @@ -116,7 +125,7 @@ export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { addedByMember={addedByMember} addedBySystem={addedBySystem} dateStr={dateStr} - isRemoving={isRemoving} + isRemoving={removeMemberMutation.isPending} memberEmail={member.info.email} memberName={member.info.name} onRemove={() => handleRemoveMember(member.info.id)} diff --git a/src/features/dashboard/members/member-table.tsx b/src/features/dashboard/members/member-table.tsx index 06d9bc32f..3948e02fb 100644 --- a/src/features/dashboard/members/member-table.tsx +++ b/src/features/dashboard/members/member-table.tsx @@ -1,32 +1,19 @@ -'use client' - -import type { FC } from 'react' -import type { TeamMember } from '@/core/modules/teams/models' +import type { FC, ReactNode } from 'react' import { cn } from '@/lib/utils' import { Table, TableBody, - TableEmptyState, TableHead, TableHeader, TableRow, } from '@/ui/primitives/table' -import { getAddedByMember } from './member-table.utils' -import { MemberTableRow } from './member-table-row' interface MemberTableProps { - allMembers: TeamMember[] - members: TeamMember[] - totalMemberCount: number + children: ReactNode className?: string } -const MemberTable: FC = ({ - allMembers, - members, - totalMemberCount, - className, -}) => ( +export const MemberTable: FC = ({ children, className }) => ( @@ -46,29 +33,6 @@ const MemberTable: FC = ({ - - {members.length === 0 ? ( - -

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

-
- ) : ( - members.map((member) => ( - - )) - )} -
+ {children}
) - -export default MemberTable diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index 66e343c4f..f2bf12cdb 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -1,24 +1,41 @@ 'use client' -import { useMemo, useState } from 'react' -import type { TeamMember } from '@/core/modules/teams/models' +import { useSuspenseQuery } from '@tanstack/react-query' +import { Suspense, useMemo, useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' import { cn } from '@/lib/utils' import { pluralize } from '@/lib/utils/formatting' +import { useTRPC } from '@/trpc/client' +import { CatchErrorBoundary } from '@/ui/error' +import { Card, CardContent } from '@/ui/primitives/card' import { SearchIcon } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' +import { Skeleton } from '@/ui/primitives/skeleton' +import { TableEmptyState, TableLoadingState } from '@/ui/primitives/table' import { AddMemberDialog } from './add-member-dialog' -import MemberTable from './member-table' +import { MemberTable } from './member-table' +import { getAddedByMember } from './member-table.utils' +import { MemberTableRow } from './member-table-row' -interface MembersPageContentProps { - members: TeamMember[] - className?: string +const useMembersQuery = () => { + const { team } = useDashboard() + const trpc = useTRPC() + return useSuspenseQuery( + trpc.teams.members.queryOptions({ teamSlug: team.slug }) + ) } -const MembersPageContent = ({ - members, - className, -}: MembersPageContentProps) => { - const [query, setQuery] = useState('') +const MembersTotal = () => { + const { data: members } = useMembersQuery() + return ( +

+ {members.length} {pluralize(members.length, 'member')} total +

+ ) +} + +const MembersTableRows = ({ query }: { query: string }) => { + const { data: members } = useMembersQuery() const filtered = useMemo(() => { const q = query.trim().toLowerCase() @@ -31,42 +48,76 @@ const MembersPageContent = ({ }) }, [members, query]) - const totalLabel = `${members.length} ${pluralize(members.length, 'member')} total` + if (filtered.length === 0) { + return ( + +

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

+
+ ) + } + + return filtered.map((member) => ( + + )) +} + +interface MembersPageContentProps { + className?: string +} + +export const MembersPageContent = ({ className }: MembersPageContentProps) => { + const [query, setQuery] = useState('') return ( -
-
-
- - setQuery(e.target.value)} - placeholder="Search by name or email" - type="search" - value={query} - /> + + +
+
+ + setQuery(e.target.value)} + placeholder="Search by name or email" + type="search" + value={query} + /> +
+
- -
-
-

All members have the same roles & permissions

-

{totalLabel}

-
+ +
+

All members have the same roles & permissions

+ }> + + +
-
- -
-
+
+ + + } + > + + + +
+ + + ) } - -export default MembersPageContent 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 19f52428b..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 3a39fa5cb..000000000 --- a/src/features/dashboard/settings/general/name-card.tsx +++ /dev/null @@ -1,174 +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 { useMemo } from '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 { cn } from '@/lib/utils/ui' -import { useTRPC } from '@/trpc/client' -import { Button, buttonVariants } 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' -import { Loader } from '@/ui/primitives/loader' - -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 - ) - - const name = watch('name') - const isNameDirty = useMemo(() => name !== team.name, [name, team.name]) - - return ( - - - Name - - Change your team name to display on your invoices and receipts. - - - -
- - ( - - - - - - {displayedNameHint && ( - - Seen as -{' '} - - {displayedNameHint} - - - )} - - - - )} - /> - {isExecuting ? ( -
- {' '} - Saving... -
- ) : ( - - )} - - -
-
- ) -} 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 c41c952dc..000000000 --- a/src/features/dashboard/settings/general/profile-picture-card.tsx +++ /dev/null @@ -1,192 +0,0 @@ -'use client' - -import { useQueryClient } from '@tanstack/react-query' -import { AnimatePresence, motion } from 'framer-motion' -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' -import { - EditIcon, - PhotoIcon, - SpinnerIcon, - UploadIcon, -} from '@/ui/primitives/icons' - -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 ( - <> - - - - ) -} 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..06e593663 --- /dev/null +++ b/src/features/dashboard/settings/general/remove-photo-dialog.tsx @@ -0,0 +1,60 @@ +'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..287811ee4 --- /dev/null +++ b/src/features/dashboard/settings/general/team-avatar.tsx @@ -0,0 +1,175 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { type ReactElement, useRef, useState } from 'react' +import { USER_MESSAGES } from '@/configs/user-messages' +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 { IconButton } from '@/ui/primitives/icon-button' +import { EditIcon, PhotoIcon, TrashIcon } from '@/ui/primitives/icons' +import { RemovePhotoDialog } from './remove-photo-dialog' + +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 = () => { + const result = typeof reader.result === 'string' ? reader.result : null + resolve(result?.split(',')[1] ?? '') + } + reader.onerror = reject + reader.readAsDataURL(file) + }) + +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 (): Promise => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + } + + const uploadProfilePictureMutation = useMutation( + trpc.teams.uploadProfilePicture.mutationOptions({ + onSuccess: async () => { + await invalidateTeams() + toast(defaultSuccessToast(USER_MESSAGES.teamLogoUpdated.message)) + }, + onError: (error) => { + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedUpdateLogo.message + ) + ) + }, + onSettled: resetFileInput, + }) + ) + + const removeProfilePictureMutation = useMutation( + trpc.teams.removeProfilePicture.mutationOptions({ + onSuccess: async () => { + await invalidateTeams() + setRemoveDialogOpen(false) + toast(defaultSuccessToast(USER_MESSAGES.teamLogoRemoved.message)) + }, + onError: (error) => { + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedRemoveLogo.message + ) + ) + }, + }) + ) + + const handleUpload = async ({ + target, + }: React.ChangeEvent): Promise => { + const file = target.files?.[0] + if (!file) return + + if (file.size > MAX_PROFILE_PICTURE_SIZE_BYTES) { + toast(defaultErrorToast('Profile picture must be less than 5MB.')) + resetFileInput() + return + } + + try { + 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.')) + resetFileInput() + } + } + + const handleUploadClick = (): void => fileInputRef.current?.click() + const handleRemoveClick = (): void => setRemoveDialogOpen(true) + const handleRemoveConfirm = (): void => + 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 new file mode 100644 index 000000000..1c63c3406 --- /dev/null +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import { formatDate } from '@/lib/utils/formatting' + +const InfoRow = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+) + +export const TeamInfo = () => { + const { team } = useDashboard() + const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--' + + return ( +
+ + +
+ ) +} 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..e787a7da8 --- /dev/null +++ b/src/features/dashboard/settings/general/team-name.tsx @@ -0,0 +1,216 @@ +'use client' + +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { + type ReactElement, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react' +import { USER_MESSAGES } from '@/configs/user-messages' +import { TEAM_NAME_MAX_LENGTH } from '@/core/modules/teams/schemas' +import { useDashboard } from '@/features/dashboard/context' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { getTRPCValidationMessages } from '@/lib/utils/trpc-errors' +import { useTRPC } from '@/trpc/client' +import { Button } from '@/ui/primitives/button' +import { IconButton } from '@/ui/primitives/icon-button' +import { CheckIcon, EditIcon } from '@/ui/primitives/icons' +import { Loader } from '@/ui/primitives/loader' + +const TEAM_NAME_MAX_FONT_SIZE_PX = 32 +const TEAM_NAME_MIN_FONT_SIZE_PX = 18 +const TEAM_NAME_LINE_HEIGHT_PX = 32 + +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() + const { toast } = useToast() + const [isEditing, setIsEditing] = useState(false) + const [name, setName] = useState(team.name) + 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({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) + toast(defaultSuccessToast(USER_MESSAGES.teamNameUpdated.message)) + setIsEditing(false) + }, + onError: (error): void => { + const validationMessages = getTRPCValidationMessages(error) + if (validationMessages.length > 0) { + toast( + defaultErrorToast(getValidationToastContent(validationMessages)) + ) + return + } + + toast( + defaultErrorToast( + error.message || USER_MESSAGES.failedUpdateTeamName.message + ) + ) + }, + }) + ) + + const handleSubmit = (event?: React.FormEvent): void => { + event?.preventDefault() + if (updateNameMutation.isPending || isSaveDisabled) return + updateNameMutation.mutate({ teamSlug: team.slug, name: trimmedName }) + } + + const handleCancel = (): void => { + setName(team.name) + setIsEditing(false) + } + + useEffect(() => { + if (!isEditing || !inputRef.current) return + inputRef.current.focus() + const cursorPosition = inputRef.current.value.length + inputRef.current.setSelectionRange(cursorPosition, cursorPosition) + }, [isEditing]) + + useEffect(() => { + const input = inputRef.current + const textMeasure = textMeasureRef.current + if (!input || !textMeasure) return + + let frameId = 0 + + const updateFontSize = (): void => { + const availableWidth = input.clientWidth + if (!availableWidth) return + + let nextFontSize = TEAM_NAME_MAX_FONT_SIZE_PX + + textMeasure.textContent = name || ' ' + textMeasure.style.fontSize = `${nextFontSize}px` + + while ( + nextFontSize > TEAM_NAME_MIN_FONT_SIZE_PX && + textMeasure.scrollWidth > availableWidth + ) { + nextFontSize -= 1 + textMeasure.style.fontSize = `${nextFontSize}px` + } + + setFontSize((currentFontSize) => + currentFontSize === nextFontSize ? currentFontSize : nextFontSize + ) + } + + const scheduleFontSizeUpdate = (): void => { + window.cancelAnimationFrame(frameId) + frameId = window.requestAnimationFrame(updateFontSize) + } + + scheduleFontSizeUpdate() + + const resizeObserver = new ResizeObserver(scheduleFontSizeUpdate) + resizeObserver.observe(input) + + return () => { + resizeObserver.disconnect() + window.cancelAnimationFrame(frameId) + } + }, [name]) + + const handleStartEditing = (): void => { + setName(team.name) + setIsEditing(true) + } + + const handleNameChange = ({ + target, + }: React.ChangeEvent): void => setName(target.value) + + return ( +
+
+
+ + name + + + +
+
+ {isEditing ? ( + <> + + + {updateNameMutation.isPending ? ( + + ) : ( + + )} + + + ) : ( + + + + )} +
+
+
+ ) +} diff --git a/src/features/dashboard/sidebar/create-team-dialog.tsx b/src/features/dashboard/sidebar/create-team-dialog.tsx index eb49e461e..9bd3b0301 100644 --- a/src/features/dashboard/sidebar/create-team-dialog.tsx +++ b/src/features/dashboard/sidebar/create-team-dialog.tsx @@ -1,16 +1,18 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' +import { useForm } from 'react-hook-form' import { PROTECTED_URLS } from '@/configs/urls' -import { createTeamAction } from '@/core/server/actions/team-actions' -import { CreateTeamSchema } from '@/core/server/functions/team/types' +import { CreateTeamSchema } from '@/core/modules/teams/schemas' import { defaultErrorToast, defaultSuccessToast, toast, } from '@/lib/hooks/use-toast' +import { getTRPCValidationMessages } from '@/lib/utils/trpc-errors' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Dialog, @@ -29,55 +31,63 @@ import { FormMessage, } from '@/ui/primitives/form' import { Input } from '@/ui/primitives/input' -import { Loader } from '@/ui/primitives/loader' interface CreateTeamDialogProps { open: boolean onOpenChange: (open: boolean) => void } -export function CreateTeamDialog({ +export const CreateTeamDialog = ({ open, onOpenChange, -}: CreateTeamDialogProps) { +}: CreateTeamDialogProps) => { 'use no memo' const router = useRouter() - - const { - form, - resetFormAndAction, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(createTeamAction, zodResolver(CreateTeamSchema), { - formProps: { - defaultValues: { - name: '', - }, + const trpc = useTRPC() + const queryClient = useQueryClient() + const form = useForm({ + resolver: zodResolver(CreateTeamSchema), + defaultValues: { + name: '', }, - actionProps: { - onError: async ({ error }) => { - toast(defaultErrorToast(error.serverError || 'Failed to create team')) + }) + const createTeamMutation = useMutation( + trpc.teams.create.mutationOptions({ + onError: async (error) => { + const validationMessages = getTRPCValidationMessages(error) + if (validationMessages.length > 0) { + toast(defaultErrorToast(validationMessages[0])) + return + } + + toast(defaultErrorToast(error.message || 'Failed to create team')) }, - onSuccess: async (result) => { + onSuccess: async (team) => { + await queryClient.invalidateQueries({ + queryKey: trpc.teams.list.queryKey(), + }) toast(defaultSuccessToast('Team was created')) + handleDialogChange(false) - if (result.data && result.data.slug) { - router.push(PROTECTED_URLS.SANDBOXES(result.data.slug)) - router.refresh() - } + if (!team.slug) return - handleDialogChange(false) + router.push(PROTECTED_URLS.SANDBOXES(team.slug)) }, - }, - }) + }) + ) + + const handleSubmit = form.handleSubmit(({ name }) => + createTeamMutation.mutate({ name }) + ) const handleDialogChange = (value: boolean) => { onOpenChange(value) if (value) return - resetFormAndAction() + form.reset() + createTeamMutation.reset() } return ( @@ -91,7 +101,7 @@ export function CreateTeamDialog({
- +
@@ -113,30 +123,24 @@ export function CreateTeamDialog({
- {isExecuting ? ( -
- - Creating Team... -
- ) : ( - <> - - - - )} + +
diff --git a/src/features/dashboard/sidebar/menu-teams.tsx b/src/features/dashboard/sidebar/menu-teams.tsx index 18ed1ae54..8f855a62b 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 @@ -72,12 +72,10 @@ export default function DashboardSidebarMenuTeams() { value={team.id} className="h-9 [&_svg]:size-5" > - - - - {team.name?.charAt(0).toUpperCase() || '?'} - - + {getTeamDisplayName(team)} diff --git a/src/features/dashboard/sidebar/menu.tsx b/src/features/dashboard/sidebar/menu.tsx index 3d7499bda..cd844f3bb 100644 --- a/src/features/dashboard/sidebar/menu.tsx +++ b/src/features/dashboard/sidebar/menu.tsx @@ -6,7 +6,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, @@ -25,6 +24,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' export default function DashboardSidebarMenu() { const { team } = useDashboard() @@ -40,23 +40,19 @@ export default function DashboardSidebarMenu() { - - - - {team.name?.charAt(0).toUpperCase() || '?'} - - +
TEAM @@ -65,7 +61,7 @@ export default function DashboardSidebarMenu() { {getTeamDisplayName(team)}
- +
{ + if (!team.profilePictureUrl) { + return ( + + + {team.name?.charAt(0).toUpperCase() || '?'} + + + ) + } + + return ( + + + + ) +} diff --git a/src/lib/utils/trpc-errors.ts b/src/lib/utils/trpc-errors.ts index 0ef73764b..f08ced663 100644 --- a/src/lib/utils/trpc-errors.ts +++ b/src/lib/utils/trpc-errors.ts @@ -1,11 +1,26 @@ import { TRPCClientError, type TRPCClientErrorLike } from '@trpc/client' +import { z } from 'zod' import type { TRPCAppRouter } from '@/core/server/api/routers' -export function isNotFoundError( +const TrpcErrorWithZodDataSchema = z.object({ + data: z + .object({ + zodError: z + .object({ + formErrors: z.array(z.string()), + fieldErrors: z.record(z.string(), z.array(z.string()).optional()), + }) + .nullable() + .optional(), + }) + .optional(), +}) + +const isNotFoundError = ( error: unknown ): error is | TRPCClientErrorLike - | TRPCClientError { + | TRPCClientError => { if (error instanceof TRPCClientError) { return error.data?.code === 'NOT_FOUND' } @@ -24,3 +39,17 @@ export function isNotFoundError( trpcLikeError.shape?.data?.code === 'NOT_FOUND' ) } + +const getTRPCValidationMessages = (error: unknown): string[] => { + const parsedError = TrpcErrorWithZodDataSchema.safeParse(error) + if (!parsedError.success || !parsedError.data.data?.zodError) return [] + + const { formErrors, fieldErrors } = parsedError.data.data.zodError + + return [ + ...formErrors, + ...Object.values(fieldErrors).flatMap((messages) => messages ?? []), + ] +} + +export { getTRPCValidationMessages, isNotFoundError } 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 } diff --git a/src/ui/primitives/loader.module.css b/src/ui/primitives/loader.module.css new file mode 100644 index 000000000..dfb7be5d0 --- /dev/null +++ b/src/ui/primitives/loader.module.css @@ -0,0 +1,82 @@ +.loader { + display: inline-flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.sizeSm { + font-size: 0.875rem; +} +.sizeMd { + font-size: 1rem; +} +.sizeLg { + font-size: 1.125rem; +} +.sizeXl { + font-size: 1.5rem; +} + +.variantSlash .content::before { + content: "|"; + animation: loader-slash 0.4s linear infinite; +} + +.variantSquare .content::before { + content: "◰"; + animation: loader-square 0.4s linear infinite; +} + +.variantDots .content::before { + content: "."; + animation: loader-dots 0.6s step-end infinite; +} + +@keyframes loader-slash { + 0% { + content: "|"; + } + 25% { + content: "/"; + } + 50% { + content: "-"; + } + 75% { + content: "\\"; + } + 100% { + content: "|"; + } +} + +@keyframes loader-square { + 0% { + content: "◰"; + } + 25% { + content: "◳"; + } + 50% { + content: "◲"; + } + 75% { + content: "◱"; + } + 100% { + content: "◰"; + } +} + +@keyframes loader-dots { + 0% { + content: "."; + } + 33% { + content: ".."; + } + 66% { + content: "..."; + } +} diff --git a/src/ui/primitives/loader.tsx b/src/ui/primitives/loader.tsx index f2e0f5c21..362b23148 100644 --- a/src/ui/primitives/loader.tsx +++ b/src/ui/primitives/loader.tsx @@ -1,132 +1,48 @@ -'use client' +import type { HTMLAttributes, Ref } from 'react' +import { cn } from '@/lib/utils' +import styles from './loader.module.css' -import * as React from 'react' -import styled, { css } from 'styled-components' +type LoaderVariant = 'slash' | 'square' | 'dots' +type LoaderSize = 'sm' | 'md' | 'lg' | 'xl' -interface LoaderProps extends React.HTMLAttributes { - variant?: 'slash' | 'square' | 'dots' - size?: 'sm' | 'md' | 'lg' | 'xl' +interface LoaderProps extends HTMLAttributes { + variant?: LoaderVariant + size?: LoaderSize + ref?: Ref } -interface StyledLoaderProps { - $variant: string - $size: string +const variantClassMap: Record = { + slash: styles.variantSlash, + square: styles.variantSquare, + dots: styles.variantDots, } -const StyledLoader = styled.div` - display: inline-flex; - align-items: center; - justify-content: center; - user-select: none; - - ${({ $size }: StyledLoaderProps) => { - switch ($size) { - case 'sm': - return css` - font-size: 0.875rem; - ` - case 'lg': - return css` - font-size: 1.125rem; - ` - case 'xl': - return css` - font-size: 1.5rem; - ` - default: - return css` - font-size: 1rem; - ` - } - }} - - .loader-content::before { - ${({ $variant }: StyledLoaderProps) => { - switch ($variant) { - case 'slash': - return css` - content: '|'; - animation: slashAnimation 0.4s linear infinite; - ` - case 'dots': - return css` - content: '.'; - animation: dotsAnimation 0.6s step-end infinite; - ` - default: - return css` - content: '◰'; - animation: squareAnimation 0.4s linear infinite; - ` - } - }} - } -` +const sizeClassMap: Record = { + sm: styles.sizeSm, + md: styles.sizeMd, + lg: styles.sizeLg, + xl: styles.sizeXl, +} -const Loader = React.forwardRef( - ({ className, variant = 'slash', size = 'md', ...props }, ref) => { - return ( - <> - - - - - - ) - } +const Loader = ({ + className, + variant = 'slash', + size = 'md', + ref, + ...props +}: LoaderProps) => ( +
+ +
) -Loader.displayName = 'Loader' export { Loader } diff --git a/src/ui/primitives/table.tsx b/src/ui/primitives/table.tsx index 67bdc1eb9..b75848ace 100644 --- a/src/ui/primitives/table.tsx +++ b/src/ui/primitives/table.tsx @@ -1,6 +1,8 @@ import * as React from 'react' import { cn } from '@/lib/utils' +import { Loader } from './loader' +import { Skeleton } from './skeleton' import { TableEmptyRowBorder } from './table-empty-row-border' const Table = React.forwardRef< @@ -135,6 +137,13 @@ interface TableEmptyStateProps { className?: string } +interface TableLoadingStateProps { + colSpan: number + label: string + rowCount?: number + className?: string +} + const EMPTY_STATE_ROWS = Array.from({ length: 3 }) const TableEmptyState = ({ @@ -165,6 +174,42 @@ const TableEmptyState = ({ ) +const TableLoadingState = ({ + colSpan, + label, + rowCount = 3, + className, +}: TableLoadingStateProps) => ( + + +
+ {Array.from({ length: rowCount }).map((_, index) => ( +
+ + + {index === Math.floor(rowCount / 2) ? ( + <> + + + {label} + + + ) : null} +
+ ))} +
+
+
+) + export { Table, TableBody, @@ -174,5 +219,6 @@ export { TableFooter, TableHead, TableHeader, + TableLoadingState, TableRow, }