From 9fee107f14f8aa412f4a36283fed851532c30a31 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Wed, 31 Dec 2025 18:47:48 -0300 Subject: [PATCH 01/21] add files --- .../common/graphql/queries/CommentItem.ts | 2 + .../comments/web/CommentUpdate/index.tsx | 44 ++- .../modules/files/common/constants.ts | 13 + .../context/FileUploadProvider/index.tsx | 197 +++++++++++++ .../context/FileUploadProvider/types.ts | 16 ++ .../common/context/useFileUpload/index.ts | 3 + .../common/graphql/fragments/FileItem.ts | 19 ++ .../graphql/mutations/FileAttachToTarget.ts | 66 +++++ .../files/common/graphql/queries/FilesList.ts | 20 ++ .../common/hooks/useChunkedUpload/index.ts | 269 ++++++++++++++++++ .../common/hooks/useChunkedUpload/types.ts | 10 + .../components/modules/files/common/index.ts | 9 + .../components/modules/files/common/types.ts | 43 +++ .../modules/files/common/utils/chunkFile.ts | 13 + .../modules/files/common/utils/index.ts | 3 + .../modules/files/common/utils/uploadChunk.ts | 55 ++++ .../files/common/utils/uploadChunks.ts | 94 ++++++ .../components/modules/files/native/index.ts | 2 + .../files/web/AttachedFileItem/index.tsx | 112 ++++++++ .../modules/files/web/FileUpload/index.tsx | 105 +++++++ .../modules/files/web/FileUpload/types.ts | 15 + .../files/web/FileUploadDropzone/index.tsx | 45 +++ .../files/web/FileUploadDropzone/types.ts | 9 + .../UploadingFileItem/FileProgress.tsx | 66 +++++ .../UploadingFileItem/index.tsx | 127 +++++++++ .../files/web/FileUploadList/index.tsx | 66 +++++ .../modules/files/web/FileUploadList/types.ts | 8 + .../components/modules/files/web/index.ts | 10 + packages/components/package.json | 5 +- 29 files changed, 1430 insertions(+), 16 deletions(-) create mode 100644 packages/components/modules/files/common/constants.ts create mode 100644 packages/components/modules/files/common/context/FileUploadProvider/index.tsx create mode 100644 packages/components/modules/files/common/context/FileUploadProvider/types.ts create mode 100644 packages/components/modules/files/common/context/useFileUpload/index.ts create mode 100644 packages/components/modules/files/common/graphql/fragments/FileItem.ts create mode 100644 packages/components/modules/files/common/graphql/mutations/FileAttachToTarget.ts create mode 100644 packages/components/modules/files/common/graphql/queries/FilesList.ts create mode 100644 packages/components/modules/files/common/hooks/useChunkedUpload/index.ts create mode 100644 packages/components/modules/files/common/hooks/useChunkedUpload/types.ts create mode 100644 packages/components/modules/files/common/index.ts create mode 100644 packages/components/modules/files/common/types.ts create mode 100644 packages/components/modules/files/common/utils/chunkFile.ts create mode 100644 packages/components/modules/files/common/utils/index.ts create mode 100644 packages/components/modules/files/common/utils/uploadChunk.ts create mode 100644 packages/components/modules/files/common/utils/uploadChunks.ts create mode 100644 packages/components/modules/files/native/index.ts create mode 100644 packages/components/modules/files/web/AttachedFileItem/index.tsx create mode 100644 packages/components/modules/files/web/FileUpload/index.tsx create mode 100644 packages/components/modules/files/web/FileUpload/types.ts create mode 100644 packages/components/modules/files/web/FileUploadDropzone/index.tsx create mode 100644 packages/components/modules/files/web/FileUploadDropzone/types.ts create mode 100644 packages/components/modules/files/web/FileUploadList/UploadingFileItem/FileProgress.tsx create mode 100644 packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx create mode 100644 packages/components/modules/files/web/FileUploadList/index.tsx create mode 100644 packages/components/modules/files/web/FileUploadList/types.ts create mode 100644 packages/components/modules/files/web/index.ts diff --git a/packages/components/modules/comments/common/graphql/queries/CommentItem.ts b/packages/components/modules/comments/common/graphql/queries/CommentItem.ts index 6e20e8189..50c78fdc4 100644 --- a/packages/components/modules/comments/common/graphql/queries/CommentItem.ts +++ b/packages/components/modules/comments/common/graphql/queries/CommentItem.ts @@ -52,6 +52,8 @@ export const CommentItemFragmentQuery = graphql` ...ReactionButton_target ...CommentItem_target + + ...FilesList_target } ` diff --git a/packages/components/modules/comments/web/CommentUpdate/index.tsx b/packages/components/modules/comments/web/CommentUpdate/index.tsx index 28aae4df3..45d30001c 100644 --- a/packages/components/modules/comments/web/CommentUpdate/index.tsx +++ b/packages/components/modules/comments/web/CommentUpdate/index.tsx @@ -2,6 +2,7 @@ import { FC, useEffect, useMemo, useRef } from 'react' +import { FileUpload } from '@baseapp-frontend/components/files/web' import { setFormRelayErrors } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -156,21 +157,34 @@ const CommentUpdate: FC = ({ }, [inputRef]) return ( - + <> + + console.log('Uploaded:', fileIds)} + /> + ) } diff --git a/packages/components/modules/files/common/constants.ts b/packages/components/modules/files/common/constants.ts new file mode 100644 index 000000000..e362893a6 --- /dev/null +++ b/packages/components/modules/files/common/constants.ts @@ -0,0 +1,13 @@ +export const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB - Backend minimum for multipart (except last part) +export const MAX_CONCURRENT_CHUNKS = 3 // Limit concurrent uploads to avoid overwhelming browser +export const RETRY_ATTEMPTS = 3 +export const RETRY_DELAY = 1000 // ms + +export enum FileUploadStatus { + PENDING = 'pending', + UPLOADING = 'uploading', + PAUSED = 'paused', + COMPLETED = 'completed', + FAILED = 'failed', + ABORTED = 'aborted', +} diff --git a/packages/components/modules/files/common/context/FileUploadProvider/index.tsx b/packages/components/modules/files/common/context/FileUploadProvider/index.tsx new file mode 100644 index 000000000..0dc6abeae --- /dev/null +++ b/packages/components/modules/files/common/context/FileUploadProvider/index.tsx @@ -0,0 +1,197 @@ +import { create } from 'zustand' + +import { FileUploadStatus } from '../../constants' +import type { FileUploadProgress } from '../../types' +import type { FileUploadState } from './types' + +export const useFileUploadStore = create((set, get) => ({ + files: new Map(), + + addFile: (file: File) => { + const id = `${Date.now()}-${file.name}-${Math.random()}` + const fileProgress: FileUploadProgress = { + id, + file, + fileName: file.name, + fileSize: file.size, + status: FileUploadStatus.PENDING, + uploadedBytes: 0, + totalChunks: 0, + completedChunks: 0, + chunkProgress: new Map(), + etags: [], + } + + set((state) => { + const newFiles = new Map(state.files) + newFiles.set(id, fileProgress) + return { files: newFiles } + }) + + return id + }, + + updateFileProgress: (id: string, progress: Partial) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(id) + + if (existing) { + newFiles.set(id, { ...existing, ...progress }) + } + + return { files: newFiles } + }) + }, + + updateChunkProgress: (fileId: string, chunkIndex: number, loaded: number, total: number) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(fileId) + + if (existing) { + const newChunkProgress = new Map(existing.chunkProgress) + newChunkProgress.set(chunkIndex, { loaded, total }) + + let uploadedBytes = 0 + newChunkProgress.forEach((chunk) => { + uploadedBytes += chunk.loaded + }) + + newFiles.set(fileId, { + ...existing, + chunkProgress: newChunkProgress, + uploadedBytes, + }) + } + + return { files: newFiles } + }) + }, + + removeFile: (id: string) => { + set((state) => { + const newFiles = new Map(state.files) + const file = newFiles.get(id) + + if (file?.abortController) { + file.abortController.abort() + } + + newFiles.delete(id) + return { files: newFiles } + }) + }, + + pauseFile: (id: string) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(id) + + if (existing && existing.status === FileUploadStatus.UPLOADING) { + existing.abortController?.abort() + + newFiles.set(id, { + ...existing, + status: FileUploadStatus.PAUSED, + abortController: undefined, + }) + } + + return { files: newFiles } + }) + }, + + resumeFile: (id: string) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(id) + + if (existing && existing.status === FileUploadStatus.PAUSED) { + newFiles.set(id, { + ...existing, + status: FileUploadStatus.PENDING, + }) + } + + return { files: newFiles } + }) + }, + + retryFile: (id: string) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(id) + + if (existing && existing.status === FileUploadStatus.FAILED) { + newFiles.set(id, { + ...existing, + status: FileUploadStatus.PENDING, + error: undefined, + completedChunks: 0, + uploadedBytes: 0, + chunkProgress: new Map(), + etags: [], + }) + } + + return { files: newFiles } + }) + }, + + abortFile: (id: string) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(id) + + if (existing) { + existing.abortController?.abort() + + newFiles.set(id, { + ...existing, + status: FileUploadStatus.ABORTED, + abortController: undefined, + }) + } + + return { files: newFiles } + }) + }, + + clearCompleted: () => { + set((state) => { + const newFiles = new Map(state.files) + + Array.from(newFiles.entries()).forEach(([id, file]) => { + if (file.status === FileUploadStatus.COMPLETED) { + newFiles.delete(id) + } + }) + + return { files: newFiles } + }) + }, + + getCompletedFileIds: () => { + const state = get() + return Array.from(state.files.values()) + .filter((f) => f.status === FileUploadStatus.COMPLETED && f.fileRelayId) + .map((f) => f.fileRelayId!) + }, + + getTotalProgress: () => { + const state = get() + const files = Array.from(state.files.values()) + + if (files.length === 0) return 0 + + const totalProgress = files.reduce((sum, file) => { + const fileProgress = file.fileSize > 0 ? (file.uploadedBytes / file.fileSize) * 100 : 0 + return sum + fileProgress + }, 0) + + return totalProgress / files.length + }, +})) + +export type { FileUploadState } from './types' diff --git a/packages/components/modules/files/common/context/FileUploadProvider/types.ts b/packages/components/modules/files/common/context/FileUploadProvider/types.ts new file mode 100644 index 000000000..9cf3f8534 --- /dev/null +++ b/packages/components/modules/files/common/context/FileUploadProvider/types.ts @@ -0,0 +1,16 @@ +import type { FileUploadProgress } from '../../types' + +export interface FileUploadState { + files: Map + addFile: (file: File) => string + updateFileProgress: (id: string, progress: Partial) => void + updateChunkProgress: (fileId: string, chunkIndex: number, loaded: number, total: number) => void + removeFile: (id: string) => void + pauseFile: (id: string) => void + resumeFile: (id: string) => void + retryFile: (id: string) => void + abortFile: (id: string) => void + clearCompleted: () => void + getCompletedFileIds: () => string[] + getTotalProgress: () => number +} diff --git a/packages/components/modules/files/common/context/useFileUpload/index.ts b/packages/components/modules/files/common/context/useFileUpload/index.ts new file mode 100644 index 000000000..ed96b8da3 --- /dev/null +++ b/packages/components/modules/files/common/context/useFileUpload/index.ts @@ -0,0 +1,3 @@ +import { useFileUploadStore } from '../FileUploadProvider' + +export const useFileUpload = () => useFileUploadStore() diff --git a/packages/components/modules/files/common/graphql/fragments/FileItem.ts b/packages/components/modules/files/common/graphql/fragments/FileItem.ts new file mode 100644 index 000000000..86489a3d8 --- /dev/null +++ b/packages/components/modules/files/common/graphql/fragments/FileItem.ts @@ -0,0 +1,19 @@ +import { graphql } from 'react-relay' + +export const FileItemFragment = graphql` + fragment FileItem_file on File { + id + fileName + fileSize + fileContentType + uploadStatus + file + description + created + createdBy { + id + fullName + } + hasPerm(perm: "change_file") + } +` diff --git a/packages/components/modules/files/common/graphql/mutations/FileAttachToTarget.ts b/packages/components/modules/files/common/graphql/mutations/FileAttachToTarget.ts new file mode 100644 index 000000000..06f27d2b1 --- /dev/null +++ b/packages/components/modules/files/common/graphql/mutations/FileAttachToTarget.ts @@ -0,0 +1,66 @@ +import { useNotification } from '@baseapp-frontend/utils' + +import { type Disposable, type UseMutationConfig, graphql, useMutation } from 'react-relay' + +import type { FileAttachToTargetMutation } from '../../../../../__generated__/FileAttachToTargetMutation.graphql' + +export const FileAttachToTargetMutationQuery = graphql` + mutation FileAttachToTargetMutation($input: FileAttachToTargetInput!, $connections: [ID!]!) { + fileAttachToTarget(input: $input) { + attachedFiles @prependEdge(connections: $connections) { + node { + ...FileItem_file + } + } + target { + filesCount + isFilesEnabled + } + errors { + field + messages + } + } + } +` + +export const useFileAttachToTargetMutation = (): [ + (config: UseMutationConfig) => Disposable, + boolean, +] => { + const { sendToast } = useNotification() + const [commitMutation, isMutationInFlight] = useMutation( + FileAttachToTargetMutationQuery, + ) + + const commit = (config: UseMutationConfig) => + commitMutation({ + ...config, + onCompleted: (response, errors) => { + if (errors?.length) { + errors.forEach((error) => { + sendToast(error.message, { type: 'error' }) + }) + } + + const mutationErrors = response?.fileAttachToTarget?.errors + if (mutationErrors?.length) { + mutationErrors.forEach((error) => { + if (error?.messages) { + sendToast(error.messages.join(', '), { type: 'error' }) + } + }) + } else { + sendToast('Files attached successfully', { type: 'success' }) + } + + config?.onCompleted?.(response, errors) + }, + onError: (error: Error) => { + sendToast(error.message, { type: 'error' }) + config?.onError?.(error) + }, + }) + + return [commit, isMutationInFlight] +} diff --git a/packages/components/modules/files/common/graphql/queries/FilesList.ts b/packages/components/modules/files/common/graphql/queries/FilesList.ts new file mode 100644 index 000000000..8895990cd --- /dev/null +++ b/packages/components/modules/files/common/graphql/queries/FilesList.ts @@ -0,0 +1,20 @@ +import { graphql } from 'react-relay' + +export const FilesListFragment = graphql` + fragment FilesList_target on FilesInterface + @refetchable(queryName: "FilesListRefetchQuery") + @argumentDefinitions(first: { type: "Int", defaultValue: 20 }, after: { type: "String" }) { + ... on Node { + id + } + isFilesEnabled + filesCount + files(first: $first, after: $after) @connection(key: "FilesList_files") { + edges { + node { + ...FileItem_file + } + } + } + } +` diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts new file mode 100644 index 000000000..a83eab500 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts @@ -0,0 +1,269 @@ +import { useCallback } from 'react' + +import { axios } from '@baseapp-frontend/utils' + +import { CHUNK_SIZE, FileUploadStatus } from '../../constants' +import { useFileUploadStore } from '../../context/FileUploadProvider' +import { useFileUpload } from '../../context/useFileUpload' +import type { CompleteUploadPart, InitiateUploadResponse } from '../../types' +import { chunkFile, uploadChunks } from '../../utils' +import type { UploadFileOptions, UseChunkedUploadOptions } from './types' + +export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { + const { addFile, updateFileProgress, updateChunkProgress } = useFileUpload() + + const uploadFile = useCallback( + async (file: File, uploadOptions?: UploadFileOptions) => { + const fileId = addFile(file) + + try { + const chunks = chunkFile(file) + const abortController = new AbortController() + + updateFileProgress(fileId, { + totalChunks: chunks.length, + abortController, + }) + + const data: InitiateUploadResponse = await axios.post('files/uploads', { + fileName: file.name, + fileSize: file.size, + fileContentType: file.type, + numParts: chunks.length, + partSize: CHUNK_SIZE, + ...(uploadOptions?.targetObjectId && + uploadOptions?.parentContentType && { + parentContentType: uploadOptions.parentContentType, + parentObjectId: uploadOptions.targetObjectId, + }), + }) + + updateFileProgress(fileId, { + status: FileUploadStatus.UPLOADING, + uploadId: data.uploadId, + fileRelayId: data.relayId, + presignedUrls: data.presignedUrls, + }) + + const etags = await uploadChunks({ + chunks, + presignedUrls: data.presignedUrls.map((p: { url: string }) => p.url), + abortSignal: abortController.signal, + onProgress: (chunkIndex, loaded, total) => { + updateChunkProgress(fileId, chunkIndex, loaded, total) + }, + onChunkComplete: (chunkIndex) => { + updateFileProgress(fileId, { + completedChunks: chunkIndex + 1, + }) + }, + }) + + const parts: CompleteUploadPart[] = etags.map((etag, idx) => ({ + partNumber: idx + 1, + etag, + })) + + await axios.post(`files/uploads/${data.id}/complete`, { + parts, + }) + + updateFileProgress(fileId, { + status: FileUploadStatus.COMPLETED, + etags, + }) + + options?.onUploadComplete?.(fileId, data.relayId) + + return data.relayId + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Upload failed' + + updateFileProgress(fileId, { + status: FileUploadStatus.FAILED, + error: errorMessage, + }) + + options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) + + throw error + } + }, + [addFile, updateFileProgress, updateChunkProgress, options], + ) + + const pauseUpload = useCallback((fileId: string) => { + const { pauseFile } = useFileUploadStore.getState() + pauseFile(fileId) + }, []) + + const resumeUpload = useCallback( + async (fileId: string) => { + const { resumeFile } = useFileUploadStore.getState() + const { files } = useFileUploadStore.getState() + const fileProgress = files.get(fileId) + + if (!fileProgress || fileProgress.status !== FileUploadStatus.PAUSED) { + return + } + + resumeFile(fileId) + + try { + const chunks = chunkFile(fileProgress.file) + const abortController = new AbortController() + + updateFileProgress(fileId, { + status: FileUploadStatus.UPLOADING, + abortController, + }) + + const { completedChunks } = fileProgress + const remainingChunks = chunks.slice(completedChunks) + const remainingUrls = + fileProgress.presignedUrls?.slice(completedChunks).map((p: { url: string }) => p.url) || + [] + + const etags = await uploadChunks({ + chunks: remainingChunks, + presignedUrls: remainingUrls, + abortSignal: abortController.signal, + onProgress: (chunkIndex, loaded, total) => { + updateChunkProgress(fileId, completedChunks + chunkIndex, loaded, total) + }, + onChunkComplete: (chunkIndex) => { + updateFileProgress(fileId, { + completedChunks: completedChunks + chunkIndex + 1, + }) + }, + }) + + const allEtags = [...fileProgress.etags, ...etags] + + const parts: CompleteUploadPart[] = allEtags.map((etag, idx) => ({ + partNumber: idx + 1, + etag, + })) + + await axios.post(`files/uploads/${fileProgress.id}/complete`, { + parts, + }) + + updateFileProgress(fileId, { + status: FileUploadStatus.COMPLETED, + etags: allEtags, + }) + + options?.onUploadComplete?.(fileId, fileProgress.fileRelayId!) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Resume failed' + + updateFileProgress(fileId, { + status: FileUploadStatus.FAILED, + error: errorMessage, + }) + + options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) + + throw error + } + }, + [updateFileProgress, updateChunkProgress, options], + ) + + const retryUpload = useCallback( + async (fileId: string, uploadOptions?: UploadFileOptions) => { + const { files } = useFileUploadStore.getState() + const fileProgress = files.get(fileId) + + if (!fileProgress || fileProgress.status !== FileUploadStatus.FAILED) { + return undefined + } + + const { file } = fileProgress + + try { + const chunks = chunkFile(file) + const abortController = new AbortController() + + updateFileProgress(fileId, { + status: FileUploadStatus.UPLOADING, + totalChunks: chunks.length, + completedChunks: 0, + uploadedBytes: 0, + chunkProgress: new Map(), + etags: [], + error: undefined, + abortController, + }) + + const data: InitiateUploadResponse = await axios.post('files/uploads', { + fileName: file.name, + fileSize: file.size, + fileContentType: file.type, + numParts: chunks.length, + partSize: CHUNK_SIZE, + ...(uploadOptions?.targetObjectId && + uploadOptions?.parentContentType && { + parentContentType: uploadOptions.parentContentType, + parentObjectId: uploadOptions.targetObjectId, + }), + }) + + updateFileProgress(fileId, { + uploadId: data.uploadId, + fileRelayId: data.relayId, + presignedUrls: data.presignedUrls, + }) + + const etags = await uploadChunks({ + chunks, + presignedUrls: data.presignedUrls.map((p: { url: string }) => p.url), + abortSignal: abortController.signal, + onProgress: (chunkIndex, loaded, total) => { + updateChunkProgress(fileId, chunkIndex, loaded, total) + }, + onChunkComplete: (chunkIndex) => { + updateFileProgress(fileId, { + completedChunks: chunkIndex + 1, + }) + }, + }) + + const parts: CompleteUploadPart[] = etags.map((etag, idx) => ({ + partNumber: idx + 1, + etag, + })) + + await axios.post(`files/uploads/${data.id}/complete`, { + parts, + }) + + updateFileProgress(fileId, { + status: FileUploadStatus.COMPLETED, + etags, + }) + + options?.onUploadComplete?.(fileId, data.relayId) + + return data.relayId + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Retry failed' + + updateFileProgress(fileId, { + status: FileUploadStatus.FAILED, + error: errorMessage, + }) + + options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) + + throw error + } + }, + [updateFileProgress, updateChunkProgress, options], + ) + + return { uploadFile, pauseUpload, resumeUpload, retryUpload } +} + +export type { UseChunkedUploadOptions, UploadFileOptions } from './types' diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts new file mode 100644 index 000000000..c9843fbc7 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts @@ -0,0 +1,10 @@ +export interface UseChunkedUploadOptions { + onUploadComplete?: (fileId: string, fileRelayId: string) => void + onUploadError?: (fileId: string, error: Error) => void + onUploadProgress?: (fileId: string, progress: number) => void +} + +export interface UploadFileOptions { + targetObjectId?: string + parentContentType?: string +} diff --git a/packages/components/modules/files/common/index.ts b/packages/components/modules/files/common/index.ts new file mode 100644 index 000000000..53d37ed08 --- /dev/null +++ b/packages/components/modules/files/common/index.ts @@ -0,0 +1,9 @@ +export * from './constants' +export type * from './types' +export * from './utils' +export * from './context/FileUploadProvider' +export * from './context/useFileUpload' +export * from './hooks/useChunkedUpload' +export * from './graphql/mutations/FileAttachToTarget' +export * from './graphql/queries/FilesList' +export * from './graphql/fragments/FileItem' diff --git a/packages/components/modules/files/common/types.ts b/packages/components/modules/files/common/types.ts new file mode 100644 index 000000000..2ffd144d1 --- /dev/null +++ b/packages/components/modules/files/common/types.ts @@ -0,0 +1,43 @@ +import { FileUploadStatus } from './constants' + +export interface ChunkProgress { + loaded: number + total: number +} + +export interface PresignedUrl { + partNumber: number + url: string +} + +export interface FileUploadProgress { + id: string // Local ID for tracking + file: File // Original File object + fileName: string + fileSize: number + status: FileUploadStatus + uploadedBytes: number + totalChunks: number + completedChunks: number + chunkProgress: Map + error?: string + uploadId?: string // Backend upload ID + fileRelayId?: string // GraphQL relay ID after completion + etags: string[] // ETags from S3 + presignedUrls?: PresignedUrl[] + abortController?: AbortController // For pausing/aborting uploads +} + +export interface InitiateUploadResponse { + id: number + relayId: string + uploadId: string + uploadStatus: string + expiresIn: number + presignedUrls: PresignedUrl[] +} + +export interface CompleteUploadPart { + partNumber: number + etag: string +} diff --git a/packages/components/modules/files/common/utils/chunkFile.ts b/packages/components/modules/files/common/utils/chunkFile.ts new file mode 100644 index 000000000..a7e77a375 --- /dev/null +++ b/packages/components/modules/files/common/utils/chunkFile.ts @@ -0,0 +1,13 @@ +import { CHUNK_SIZE } from '../constants' + +export function chunkFile(file: File, chunkSize = CHUNK_SIZE): Blob[] { + const chunks: Blob[] = [] + let offset = 0 + + while (offset < file.size) { + chunks.push(file.slice(offset, offset + chunkSize)) + offset += chunkSize + } + + return chunks +} diff --git a/packages/components/modules/files/common/utils/index.ts b/packages/components/modules/files/common/utils/index.ts new file mode 100644 index 000000000..ebf7505e3 --- /dev/null +++ b/packages/components/modules/files/common/utils/index.ts @@ -0,0 +1,3 @@ +export * from './chunkFile' +export * from './uploadChunk' +export * from './uploadChunks' diff --git a/packages/components/modules/files/common/utils/uploadChunk.ts b/packages/components/modules/files/common/utils/uploadChunk.ts new file mode 100644 index 000000000..af9131529 --- /dev/null +++ b/packages/components/modules/files/common/utils/uploadChunk.ts @@ -0,0 +1,55 @@ +export async function uploadChunk( + chunk: Blob, + presignedUrl: string, + abortSignal?: AbortSignal, + onProgress?: (loaded: number, total: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + // Set up progress tracking + if (onProgress) { + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + onProgress(event.loaded, event.total) + } + }) + } + + // Handle successful completion + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + const etag = xhr.getResponseHeader('ETag')?.replace(/"/g, '') + if (!etag) { + reject(new Error('No ETag in response')) + } else { + resolve(etag) + } + } else { + reject(new Error(`Chunk upload failed: ${xhr.status} ${xhr.statusText}`)) + } + }) + + // Handle errors + xhr.addEventListener('error', () => { + reject(new Error('Chunk upload failed: Network error')) + }) + + // Handle abort + xhr.addEventListener('abort', () => { + reject(new Error('Chunk upload aborted')) + }) + + // Connect abort signal + if (abortSignal) { + abortSignal.addEventListener('abort', () => { + xhr.abort() + }) + } + + // Start the upload + xhr.open('PUT', presignedUrl) + xhr.setRequestHeader('Content-Type', 'application/octet-stream') + xhr.send(chunk) + }) +} diff --git a/packages/components/modules/files/common/utils/uploadChunks.ts b/packages/components/modules/files/common/utils/uploadChunks.ts new file mode 100644 index 000000000..6f84de0ba --- /dev/null +++ b/packages/components/modules/files/common/utils/uploadChunks.ts @@ -0,0 +1,94 @@ +import { MAX_CONCURRENT_CHUNKS, RETRY_ATTEMPTS, RETRY_DELAY } from '../constants' +import { uploadChunk } from './uploadChunk' + +interface UploadChunksOptions { + chunks: Blob[] + presignedUrls: string[] + abortSignal?: AbortSignal + onProgress?: (chunkIndex: number, loaded: number, total: number) => void + onChunkComplete?: (chunkIndex: number, etag: string) => void + maxConcurrent?: number + retryAttempts?: number + retryDelay?: number +} + +export async function uploadChunks({ + chunks, + presignedUrls, + abortSignal, + onProgress, + onChunkComplete, + maxConcurrent = MAX_CONCURRENT_CHUNKS, + retryAttempts = RETRY_ATTEMPTS, + retryDelay = RETRY_DELAY, +}: UploadChunksOptions): Promise { + if (chunks.length !== presignedUrls.length) { + throw new Error('Chunks and presigned URLs count mismatch') + } + + const etags: string[] = new Array(chunks.length) + const activeUploads = new Map, number>() + + const uploadChunkWithRetry = async ( + chunkIndex: number, + chunk: Blob, + url: string, + attempt = 0, + ): Promise => { + try { + const etag = await uploadChunk(chunk, url, abortSignal, (loaded, total) => { + onProgress?.(chunkIndex, loaded, total) + }) + + etags[chunkIndex] = etag + onChunkComplete?.(chunkIndex, etag) + return undefined + } catch (error) { + if (abortSignal?.aborted) { + throw error + } + + if (attempt < retryAttempts) { + await new Promise((resolve) => { + setTimeout(resolve, retryDelay * 2 ** attempt) + }) + return uploadChunkWithRetry(chunkIndex, chunk, url, attempt + 1) + } + + throw new Error( + `Failed to upload chunk ${chunkIndex} after ${retryAttempts} attempts: ${error}`, + ) + } + } + + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i] + const url = presignedUrls[i] + if (!chunk || !url) { + throw new Error(`Missing chunk or URL at index ${i}`) + } + + // Wait if we've hit the concurrency limit + while (activeUploads.size >= maxConcurrent) { + // eslint-disable-next-line no-await-in-loop + await Promise.race(activeUploads.keys()) + } + + const uploadPromise = uploadChunkWithRetry(i, chunk, url) + activeUploads.set(uploadPromise, i) + + // Remove from active uploads when complete + uploadPromise + .then(() => { + activeUploads.delete(uploadPromise) + }) + .catch(() => { + activeUploads.delete(uploadPromise) + }) + } + + // Wait for all remaining uploads to complete + await Promise.all(activeUploads.keys()) + + return etags +} diff --git a/packages/components/modules/files/native/index.ts b/packages/components/modules/files/native/index.ts new file mode 100644 index 000000000..887859e73 --- /dev/null +++ b/packages/components/modules/files/native/index.ts @@ -0,0 +1,2 @@ +// Placeholder for React Native implementation +// TODO: Implement React Native file upload components diff --git a/packages/components/modules/files/web/AttachedFileItem/index.tsx b/packages/components/modules/files/web/AttachedFileItem/index.tsx new file mode 100644 index 000000000..b58668739 --- /dev/null +++ b/packages/components/modules/files/web/AttachedFileItem/index.tsx @@ -0,0 +1,112 @@ +'use client' + +import type { FC } from 'react' + +import { + Delete as DeleteIcon, + Download as DownloadIcon, + InsertDriveFile as FileIcon, +} from '@mui/icons-material' +import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' +import { useFragment } from 'react-relay' + +import type { FileItem_file$key } from '../../../../__generated__/FileItem_file.graphql' +import { FileItemFragment } from '../../common/graphql/fragments/FileItem' + +interface AttachedFileItemProps { + file: FileItem_file$key +} + +const AttachedFileItem: FC = ({ file: fileRef }) => { + const file = useFragment(FileItemFragment, fileRef) + + const canChangeFile = file.hasPerm || false + + const formatFileSize = (bytes: number | null | undefined): string => { + if (!bytes) return '0 B' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` + } + + const formatDate = (dateString: string): string => { + const date = new Date(dateString) + return date.toLocaleDateString() + } + + const handleDownload = () => { + if (file.file) { + window.open(file.file, '_blank') + } + } + + const handleDelete = () => { + // TODO: Implement delete functionality + } + + const getFileIcon = () => { + const contentType = file.fileContentType || '' + + if (contentType.startsWith('image/')) { + return + } + if (contentType.startsWith('video/')) { + return + } + if (contentType === 'application/pdf') { + return + } + return + } + + return ( + + + + + + {getFileIcon()} + + + {file.fileName} + + + {formatFileSize(file.fileSize)} • {formatDate(file.created)} + + {file.createdBy && file.createdBy.fullName && ( + + Uploaded by {file.createdBy.fullName} + + )} + + + + + + + + + {canChangeFile && ( + + + + )} + + + + {file.uploadStatus && file.uploadStatus !== 'COMPLETED' && ( + + )} + + {file.description && ( + + {file.description} + + )} + + + + ) +} + +export default AttachedFileItem diff --git a/packages/components/modules/files/web/FileUpload/index.tsx b/packages/components/modules/files/web/FileUpload/index.tsx new file mode 100644 index 000000000..d70534cfb --- /dev/null +++ b/packages/components/modules/files/web/FileUpload/index.tsx @@ -0,0 +1,105 @@ +'use client' + +import type { FC } from 'react' +import { useCallback } from 'react' + +import { ConnectionHandler, useFragment } from 'react-relay' + +import { useFileUploadStore } from '../../common/context/FileUploadProvider' +import { useFileAttachToTargetMutation } from '../../common/graphql/mutations/FileAttachToTarget' +import { FilesListFragment } from '../../common/graphql/queries/FilesList' +import { useChunkedUpload } from '../../common/hooks/useChunkedUpload' +import FileUploadDropzone from '../FileUploadDropzone' +import FileUploadList from '../FileUploadList' +import type { FileUploadProps } from './types' + +const FileUpload: FC = ({ + target: targetRef, + maxFiles, + maxFileSize, + acceptedFileTypes, + disabled, + autoAttach = true, + onUploadComplete, + onAttachComplete, + onError, +}) => { + const target = useFragment(FilesListFragment, targetRef) + const { uploadFile } = useChunkedUpload() + const [attachFiles, isAttaching] = useFileAttachToTargetMutation() + const { clearCompleted } = useFileUploadStore() + + const handleFilesSelected = useCallback( + async (selectedFiles: File[]) => { + if (!target.id) { + onError?.(new Error('Target object ID is required')) + return + } + + try { + const uploadPromises = selectedFiles.map((file) => uploadFile(file)) + + const fileRelayIds = await Promise.all(uploadPromises) + + onUploadComplete?.(fileRelayIds) + + if (autoAttach && fileRelayIds.length > 0) { + const connectionID = ConnectionHandler.getConnectionID(target.id, 'FilesList_files') + + attachFiles({ + variables: { + input: { + fileRelayIds, + targetObjectId: target.id, + }, + connections: [connectionID], + }, + onCompleted: () => { + // Clear completed files from upload state after successful attachment + clearCompleted() + onAttachComplete?.() + }, + onError: (error) => { + onError?.(error) + }, + }) + } else if (!autoAttach) { + // If not auto-attaching, clear completed files after upload + clearCompleted() + } + } catch (error) { + onError?.(error instanceof Error ? error : new Error('Upload failed')) + } + }, + [ + uploadFile, + autoAttach, + target.id, + attachFiles, + onUploadComplete, + onAttachComplete, + onError, + clearCompleted, + ], + ) + + if (!target.isFilesEnabled) { + return null + } + + return ( +
+ + +
+ ) +} + +export default FileUpload +export type { FileUploadProps } from './types' diff --git a/packages/components/modules/files/web/FileUpload/types.ts b/packages/components/modules/files/web/FileUpload/types.ts new file mode 100644 index 000000000..5f643d6ff --- /dev/null +++ b/packages/components/modules/files/web/FileUpload/types.ts @@ -0,0 +1,15 @@ +import type { FilesList_target$key } from '../../../../__generated__/FilesList_target.graphql' + +type Accept = Record + +export interface FileUploadProps { + target: FilesList_target$key + maxFiles?: number + maxFileSize?: number // in bytes + acceptedFileTypes?: Accept + disabled?: boolean + autoAttach?: boolean + onUploadComplete?: (fileIds: string[]) => void + onAttachComplete?: () => void + onError?: (error: Error) => void +} diff --git a/packages/components/modules/files/web/FileUploadDropzone/index.tsx b/packages/components/modules/files/web/FileUploadDropzone/index.tsx new file mode 100644 index 000000000..f995179c9 --- /dev/null +++ b/packages/components/modules/files/web/FileUploadDropzone/index.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { FC } from 'react' +import { useCallback } from 'react' + +import { Dropzone } from '@baseapp-frontend/design-system/components/web/dropzones' + +import type { FileUploadDropzoneProps } from './types' + +const FileUploadDropzone: FC = ({ + onFilesSelected, + maxFiles = 10, + maxFileSize = 100 * 1024 * 1024, // 100MB default + acceptedFileTypes, + disabled, +}) => { + const handleSelect = useCallback( + (files: File | File[] | Blob | Blob[]) => { + const fileArray = Array.isArray(files) ? files : [files] + onFilesSelected(fileArray as File[]) + }, + [onFilesSelected], + ) + + return ( + {}} + multiple={maxFiles > 1} + maxFileSize={maxFileSize / (1024 * 1024)} // Convert bytes to MB + asBase64={false} // Keep as File objects for chunked upload + title="Upload Files" + subTitle={`Max ${maxFiles} files, ${Math.round(maxFileSize / (1024 * 1024))}MB each`} + includeActionButton={false} + DropzoneOptions={{ + disabled, + maxFiles, + }} + /> + ) +} + +export default FileUploadDropzone +export type { FileUploadDropzoneProps } from './types' diff --git a/packages/components/modules/files/web/FileUploadDropzone/types.ts b/packages/components/modules/files/web/FileUploadDropzone/types.ts new file mode 100644 index 000000000..fe395603d --- /dev/null +++ b/packages/components/modules/files/web/FileUploadDropzone/types.ts @@ -0,0 +1,9 @@ +type Accept = Record + +export interface FileUploadDropzoneProps { + onFilesSelected: (files: File[]) => void + maxFiles?: number + maxFileSize?: number // in bytes + acceptedFileTypes?: Accept + disabled?: boolean +} diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/FileProgress.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/FileProgress.tsx new file mode 100644 index 000000000..d14e0d6b8 --- /dev/null +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/FileProgress.tsx @@ -0,0 +1,66 @@ +'use client' + +import type { FC } from 'react' + +import { Box, LinearProgress, Typography } from '@mui/material' + +import { FileUploadStatus } from '../../../common/constants' + +interface FileProgressProps { + progress: number + status: FileUploadStatus +} + +const FileProgress: FC = ({ progress, status }) => { + const getColor = () => { + switch (status) { + case FileUploadStatus.UPLOADING: + return 'primary' + case FileUploadStatus.COMPLETED: + return 'success' + case FileUploadStatus.FAILED: + return 'error' + case FileUploadStatus.PAUSED: + return 'warning' + default: + return 'primary' + } + } + + const getStatusText = () => { + switch (status) { + case FileUploadStatus.PENDING: + return 'Pending...' + case FileUploadStatus.UPLOADING: + return `Uploading ${Math.round(progress)}%` + case FileUploadStatus.PAUSED: + return 'Paused' + case FileUploadStatus.COMPLETED: + return 'Completed' + case FileUploadStatus.FAILED: + return 'Failed' + case FileUploadStatus.ABORTED: + return 'Aborted' + default: + return '' + } + } + + return ( + + + + {getStatusText()} + + {status === FileUploadStatus.UPLOADING && ( + + {Math.round(progress)}% + + )} + + + + ) +} + +export default FileProgress diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx new file mode 100644 index 000000000..0e96302af --- /dev/null +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx @@ -0,0 +1,127 @@ +'use client' + +import type { FC } from 'react' + +import { + Close as CloseIcon, + Pause as PauseIcon, + PlayArrow as PlayArrowIcon, + Replay as ReplayIcon, +} from '@mui/icons-material' +import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' + +import { FileUploadStatus } from '../../../common/constants' +import { useFileUpload } from '../../../common/context/useFileUpload' +import { useChunkedUpload } from '../../../common/hooks/useChunkedUpload' +import type { FileUploadProgress } from '../../../common/types' +import FileProgress from './FileProgress' + +interface UploadingFileItemProps { + fileProgress: FileUploadProgress + allowRemove?: boolean + allowRetry?: boolean +} + +const UploadingFileItem: FC = ({ + fileProgress, + allowRemove = true, + allowRetry = true, +}) => { + const { removeFile, pauseFile, resumeFile } = useFileUpload() + const { resumeUpload, retryUpload } = useChunkedUpload() + + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` + } + + const getProgress = (): number => { + if (fileProgress.status === FileUploadStatus.COMPLETED) return 100 + if (fileProgress.fileSize === 0) return 0 + return (fileProgress.uploadedBytes / fileProgress.fileSize) * 100 + } + + const handleRemove = () => { + removeFile(fileProgress.id) + } + + const handleRetry = () => { + retryUpload(fileProgress.id) + } + + const handlePause = () => { + pauseFile(fileProgress.id) + } + + const handleResume = () => { + resumeFile(fileProgress.id) + resumeUpload(fileProgress.id) + } + + const canRemove = + allowRemove && + [ + FileUploadStatus.PENDING, + FileUploadStatus.FAILED, + FileUploadStatus.PAUSED, + FileUploadStatus.ABORTED, + ].includes(fileProgress.status) + + const canRetry = allowRetry && fileProgress.status === FileUploadStatus.FAILED + + const canPause = fileProgress.status === FileUploadStatus.UPLOADING + + const canResume = fileProgress.status === FileUploadStatus.PAUSED + + return ( + + + + + + + {fileProgress.fileName} + + + {formatFileSize(fileProgress.fileSize)} + + + + + {canPause && ( + + + + )} + + {canResume && ( + + + + )} + + {canRetry && ( + + + + )} + + {canRemove && ( + + + + )} + + + + + + {fileProgress.error && } + + + + ) +} + +export default UploadingFileItem diff --git a/packages/components/modules/files/web/FileUploadList/index.tsx b/packages/components/modules/files/web/FileUploadList/index.tsx new file mode 100644 index 000000000..a0a4f4fe5 --- /dev/null +++ b/packages/components/modules/files/web/FileUploadList/index.tsx @@ -0,0 +1,66 @@ +'use client' + +import type { FC } from 'react' + +import { Box, Typography } from '@mui/material' +import { useFragment } from 'react-relay' + +import { useFileUpload } from '../../common/context/useFileUpload' +import { FilesListFragment } from '../../common/graphql/queries/FilesList' +import AttachedFileItem from '../AttachedFileItem' +import UploadingFileItem from './UploadingFileItem' +import type { FileUploadListProps } from './types' + +const FileUploadList: FC = ({ + target: targetRef, + showUploadProgress = true, + allowRemove = true, + allowRetry = true, +}) => { + const target = useFragment(FilesListFragment, targetRef) + const { files } = useFileUpload() + + const uploadingFiles = Array.from(files.values()) + + const hasUploadingFiles = uploadingFiles.length > 0 + const hasAttachedFiles = (target.files?.edges?.length ?? 0) > 0 + + if (!hasUploadingFiles && !hasAttachedFiles) { + return null + } + + return ( + + {showUploadProgress && hasUploadingFiles && ( + + + Uploading ({uploadingFiles.length}) + + {uploadingFiles.map((file) => ( + + ))} + + )} + + {hasAttachedFiles && target.files && ( + + + Attached Files ({target.files.edges.length}) + + {target.files.edges.map((edge: any) => { + if (!edge?.node) return null + return + })} + + )} + + ) +} + +export default FileUploadList +export type { FileUploadListProps } from './types' diff --git a/packages/components/modules/files/web/FileUploadList/types.ts b/packages/components/modules/files/web/FileUploadList/types.ts new file mode 100644 index 000000000..bab661928 --- /dev/null +++ b/packages/components/modules/files/web/FileUploadList/types.ts @@ -0,0 +1,8 @@ +import type { FilesList_target$key } from '../../../../__generated__/FilesList_target.graphql' + +export interface FileUploadListProps { + target: FilesList_target$key + showUploadProgress?: boolean + allowRemove?: boolean + allowRetry?: boolean +} diff --git a/packages/components/modules/files/web/index.ts b/packages/components/modules/files/web/index.ts new file mode 100644 index 000000000..d47517eaf --- /dev/null +++ b/packages/components/modules/files/web/index.ts @@ -0,0 +1,10 @@ +export { default as FileUpload } from './FileUpload' +export type { FileUploadProps } from './FileUpload/types' + +export { default as FileUploadDropzone } from './FileUploadDropzone' +export type { FileUploadDropzoneProps } from './FileUploadDropzone/types' + +export { default as FileUploadList } from './FileUploadList' +export type { FileUploadListProps } from './FileUploadList/types' + +export { default as AttachedFileItem } from './AttachedFileItem' diff --git a/packages/components/package.json b/packages/components/package.json index cc46e81af..3403a4121 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -51,7 +51,10 @@ "./content-feed/native": "./modules/content-feed/native/index.ts", "./pages/common": "./modules/pages/common/index.ts", "./pages/web": "./modules/pages/web/index.ts", - "./pages/native": "./modules/pages/native/index.ts" + "./pages/native": "./modules/pages/native/index.ts", + "./files/common": "./modules/files/common/index.ts", + "./files/web": "./modules/files/web/index.ts", + "./files/native": "./modules/files/native/index.ts" }, "files": [ "modules", From 07cf2a96e6bbcef56a7723ad26c60180019af44b Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Thu, 1 Jan 2026 21:54:56 -0300 Subject: [PATCH 02/21] fix duplicated uploads --- .../components/modules/files/web/FileUpload/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/components/modules/files/web/FileUpload/index.tsx b/packages/components/modules/files/web/FileUpload/index.tsx index d70534cfb..54912f998 100644 --- a/packages/components/modules/files/web/FileUpload/index.tsx +++ b/packages/components/modules/files/web/FileUpload/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { ConnectionHandler, useFragment } from 'react-relay' @@ -28,6 +28,7 @@ const FileUpload: FC = ({ const { uploadFile } = useChunkedUpload() const [attachFiles, isAttaching] = useFileAttachToTargetMutation() const { clearCompleted } = useFileUploadStore() + const [dropzoneKey, setDropzoneKey] = useState(0) const handleFilesSelected = useCallback( async (selectedFiles: File[]) => { @@ -57,6 +58,8 @@ const FileUpload: FC = ({ onCompleted: () => { // Clear completed files from upload state after successful attachment clearCompleted() + // Reset dropzone by changing its key to force remount + setDropzoneKey((prev) => prev + 1) onAttachComplete?.() }, onError: (error) => { @@ -66,6 +69,8 @@ const FileUpload: FC = ({ } else if (!autoAttach) { // If not auto-attaching, clear completed files after upload clearCompleted() + // Reset dropzone by changing its key to force remount + setDropzoneKey((prev) => prev + 1) } } catch (error) { onError?.(error instanceof Error ? error : new Error('Upload failed')) @@ -90,6 +95,7 @@ const FileUpload: FC = ({ return (
Date: Thu, 1 Jan 2026 22:18:05 -0300 Subject: [PATCH 03/21] refactor thumbnail field --- .../common/graphql/queries/ActivityLogsFragment.ts | 4 +--- .../comments/common/graphql/queries/CommentItem.ts | 8 ++------ .../components/modules/comments/web/CommentItem/index.tsx | 2 +- .../modules/content-feed/web/PostImageSlide/index.tsx | 4 ++-- .../messages/common/graphql/fragments/MessagesList.ts | 4 +--- .../__storybook__/MessagesGroupWithQuery/index.tsx | 4 +--- .../common/graphql/queries/NotificationItem.ts | 4 +--- .../modules/pages/common/graphql/fragments/Metadata.ts | 4 +--- .../profiles/common/graphql/fragments/ProfileComponent.ts | 8 ++------ .../profiles/common/graphql/fragments/ProfileItem.ts | 4 +--- .../common/utils/getProfileDefaultValues/index.ts | 4 ++-- .../modules/profiles/native/ProfileComponent/index.tsx | 4 ++-- .../profiles/native/ProfileSettingsComponent/index.tsx | 4 ++-- .../profiles/web/ProfileMembers/MemberItem/index.tsx | 2 +- .../profiles/web/ProfileSettingsComponent/index.tsx | 4 ++-- .../ProfilesList/ProfileMenuItem/index.tsx | 2 +- .../profiles/web/profile-popover/ProfilesList/index.tsx | 6 +++--- 17 files changed, 26 insertions(+), 46 deletions(-) diff --git a/packages/components/modules/activity-log/common/graphql/queries/ActivityLogsFragment.ts b/packages/components/modules/activity-log/common/graphql/queries/ActivityLogsFragment.ts index 5f586c578..34e50e114 100644 --- a/packages/components/modules/activity-log/common/graphql/queries/ActivityLogsFragment.ts +++ b/packages/components/modules/activity-log/common/graphql/queries/ActivityLogsFragment.ts @@ -45,9 +45,7 @@ export const ActivityLogsFragmentQuery = graphql` id fullName email - avatar(width: 48, height: 48) { - url - } + avatar(width: 48, height: 48) } } } diff --git a/packages/components/modules/comments/common/graphql/queries/CommentItem.ts b/packages/components/modules/comments/common/graphql/queries/CommentItem.ts index 50c78fdc4..26b1e2e65 100644 --- a/packages/components/modules/comments/common/graphql/queries/CommentItem.ts +++ b/packages/components/modules/comments/common/graphql/queries/CommentItem.ts @@ -11,9 +11,7 @@ export const CommentItemFragmentQuery = graphql` profile { id name - image(width: 50, height: 50) { - url - } + image(width: 50, height: 50) urlPath { path } @@ -21,9 +19,7 @@ export const CommentItemFragmentQuery = graphql` user { id fullName - avatar(width: 50, height: 50) { - url - } + avatar(width: 50, height: 50) } created diff --git a/packages/components/modules/comments/web/CommentItem/index.tsx b/packages/components/modules/comments/web/CommentItem/index.tsx index b4226184e..2e7445773 100644 --- a/packages/components/modules/comments/web/CommentItem/index.tsx +++ b/packages/components/modules/comments/web/CommentItem/index.tsx @@ -161,7 +161,7 @@ const CommentItem: FC = ({ width={40} height={40} alt={comment.profile?.name ?? `Comment's user avatar`} - src={comment.profile?.image?.url} + src={comment.profile?.image || ''} onClick={() => router.push(profileUrl)} /> diff --git a/packages/components/modules/content-feed/web/PostImageSlide/index.tsx b/packages/components/modules/content-feed/web/PostImageSlide/index.tsx index c5fcc4bfc..b004f4038 100644 --- a/packages/components/modules/content-feed/web/PostImageSlide/index.tsx +++ b/packages/components/modules/content-feed/web/PostImageSlide/index.tsx @@ -11,9 +11,9 @@ import { PostImageSlideProps } from './types' const PostImageSlide: FC = ({ imagesRef }) => { const target = useFragment(ContentPostImageFragment, imagesRef) - if (!target?.image?.url) return null + if (!target?.image) return null - return + return } export default PostImageSlide diff --git a/packages/components/modules/messages/common/graphql/fragments/MessagesList.ts b/packages/components/modules/messages/common/graphql/fragments/MessagesList.ts index 7991a4048..1d78afa48 100644 --- a/packages/components/modules/messages/common/graphql/fragments/MessagesList.ts +++ b/packages/components/modules/messages/common/graphql/fragments/MessagesList.ts @@ -19,9 +19,7 @@ export const MessagesListFragment = graphql` profile { id name - image(height: 32, width: 32) { - url - } + image(height: 32, width: 32) } isRead messageType diff --git a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx index 9aad1fec6..d99f7358c 100644 --- a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx +++ b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx @@ -17,9 +17,7 @@ const MessagesGroupWithQuery = () => { profile { id name - image(width: 32, height: 32) { - url - } + image(width: 32, height: 32) } isRead ...MessageItemFragment diff --git a/packages/components/modules/notifications/common/graphql/queries/NotificationItem.ts b/packages/components/modules/notifications/common/graphql/queries/NotificationItem.ts index eef643d3c..8327e5afc 100644 --- a/packages/components/modules/notifications/common/graphql/queries/NotificationItem.ts +++ b/packages/components/modules/notifications/common/graphql/queries/NotificationItem.ts @@ -16,9 +16,7 @@ export const NotificationItemFragment = graphql` ... on Profile { id name - image(width: 48, height: 48) { - url - } + image(width: 48, height: 48) } } diff --git a/packages/components/modules/pages/common/graphql/fragments/Metadata.ts b/packages/components/modules/pages/common/graphql/fragments/Metadata.ts index c59e26e3d..a1aa1385b 100644 --- a/packages/components/modules/pages/common/graphql/fragments/Metadata.ts +++ b/packages/components/modules/pages/common/graphql/fragments/Metadata.ts @@ -7,8 +7,6 @@ export const MetadataFragment = graphql` metaDescription metaRobots metaOgType - metaOgImage(width: 1000, height: 1000) { - url - } + metaOgImage(width: 1000, height: 1000) } ` diff --git a/packages/components/modules/profiles/common/graphql/fragments/ProfileComponent.ts b/packages/components/modules/profiles/common/graphql/fragments/ProfileComponent.ts index d781587c2..884706e3d 100644 --- a/packages/components/modules/profiles/common/graphql/fragments/ProfileComponent.ts +++ b/packages/components/modules/profiles/common/graphql/fragments/ProfileComponent.ts @@ -6,12 +6,8 @@ export const ProfileComponentFragment = graphql` status name biography - image(height: 96, width: 96) { - url - } - bannerImage(height: 290, width: 868) { - url - } + image(height: 96, width: 96) + bannerImage(height: 290, width: 868) isFollowedByMe followersCount followingCount diff --git a/packages/components/modules/profiles/common/graphql/fragments/ProfileItem.ts b/packages/components/modules/profiles/common/graphql/fragments/ProfileItem.ts index 5ba5b2e43..29c128b00 100644 --- a/packages/components/modules/profiles/common/graphql/fragments/ProfileItem.ts +++ b/packages/components/modules/profiles/common/graphql/fragments/ProfileItem.ts @@ -5,9 +5,7 @@ export const ProfileItemFragment = graphql` @argumentDefinitions(avatarSize: { type: "Int", defaultValue: 100 }) { id name - image(width: $avatarSize, height: $avatarSize) { - url - } + image(width: $avatarSize, height: $avatarSize) urlPath { path } diff --git a/packages/components/modules/profiles/common/utils/getProfileDefaultValues/index.ts b/packages/components/modules/profiles/common/utils/getProfileDefaultValues/index.ts index cdf4953a3..6cc4b3b42 100644 --- a/packages/components/modules/profiles/common/utils/getProfileDefaultValues/index.ts +++ b/packages/components/modules/profiles/common/utils/getProfileDefaultValues/index.ts @@ -13,8 +13,8 @@ export const getProfileDefaultValues = ({ const formattedProfile = { ...profile, phoneNumber: profile?.owner?.phoneNumber ?? DEFAULT_PHONE_NUMBER_COUNTRY_CODE, - image: profile?.image?.url ?? '', - bannerImage: profile?.bannerImage?.url ?? '', + image: profile?.image ?? '', + bannerImage: profile?.bannerImage ?? '', urlPath: profile?.urlPath?.path ?? '', biography: profile?.biography ?? '', } diff --git a/packages/components/modules/profiles/native/ProfileComponent/index.tsx b/packages/components/modules/profiles/native/ProfileComponent/index.tsx index 3b745f11d..db8b15706 100644 --- a/packages/components/modules/profiles/native/ProfileComponent/index.tsx +++ b/packages/components/modules/profiles/native/ProfileComponent/index.tsx @@ -23,8 +23,8 @@ const ProfileComponent = ({ profile: profileRef }: ProfileComponentProps) => { return ( - - + + diff --git a/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx b/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx index d873cd7e8..9fc42e5c5 100644 --- a/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx +++ b/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx @@ -176,10 +176,10 @@ const ProfileSettingsComponent: FC = ({ profile: // id: profile.id, // name: profile.name ?? null, // urlPath: profile.urlPath?.path ?? null, - // image: profile.image?.url ?? null, + // image: profile?.image ?? null, // }) // } - // }, [profile?.id, profile?.name, profile?.urlPath?.path, profile?.image?.url]) + // }, [profile?.id, profile?.name, profile?.urlPath?.path, profile?.image]) const handleRemoveImage = (type: 'image' | 'bannerImage') => { clearErrors(type === 'image' ? 'image' : 'bannerImage') diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx index 58e2e832a..156169aad 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx @@ -187,7 +187,7 @@ const MemberItem: FC = ({ = ({ profile: id: profile.id, name: profile.name ?? null, urlPath: profile.urlPath?.path ?? null, - image: profile.image?.url ?? null, + image: profile?.image ?? null, } updateProfileIfActive(newProfile) } - }, [profile?.id, profile?.name, profile?.urlPath?.path, profile?.image?.url]) + }, [profile?.id, profile?.name, profile?.urlPath?.path, profile?.image]) const handleRemoveImage = (type: any) => { clearErrors(type) diff --git a/packages/components/modules/profiles/web/profile-popover/ProfilesList/ProfileMenuItem/index.tsx b/packages/components/modules/profiles/web/profile-popover/ProfilesList/ProfileMenuItem/index.tsx index 03840361f..510919020 100644 --- a/packages/components/modules/profiles/web/profile-popover/ProfilesList/ProfileMenuItem/index.tsx +++ b/packages/components/modules/profiles/web/profile-popover/ProfilesList/ProfileMenuItem/index.tsx @@ -39,7 +39,7 @@ const ProfileMenuItem: FC = ({ = ({ const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL?.replace('/v1', '') let absoluteImagePath = null if (profile.image) { - absoluteImagePath = profile.image.url.startsWith('http') - ? profile.image.url - : `${baseUrl}${profile.image.url}` + absoluteImagePath = profile.image.startsWith('http') + ? profile.image + : `${baseUrl}${profile.image}` } setCurrentProfile({ From 568b7e0703d063e871d5b5c8cd76a1116835832a Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Mon, 5 Jan 2026 10:05:24 -0300 Subject: [PATCH 04/21] add file delete and show FileUploadList idenpendently --- .../comments/web/CommentItem/index.tsx | 5 ++ .../common/graphql/fragments/FileItem.ts | 1 + .../common/graphql/mutations/FileDelete.ts | 61 +++++++++++++++++++ .../components/modules/files/common/index.ts | 1 + .../files/web/AttachedFileItem/index.tsx | 57 ++++++++++++++--- .../modules/files/web/FileUpload/index.tsx | 20 +++--- .../UploadingFileItem/index.tsx | 55 ++++++++++++++--- .../files/web/FileUploadList/index.tsx | 4 +- 8 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 packages/components/modules/files/common/graphql/mutations/FileDelete.ts diff --git a/packages/components/modules/comments/web/CommentItem/index.tsx b/packages/components/modules/comments/web/CommentItem/index.tsx index 2e7445773..622cd685a 100644 --- a/packages/components/modules/comments/web/CommentItem/index.tsx +++ b/packages/components/modules/comments/web/CommentItem/index.tsx @@ -13,6 +13,7 @@ import { useRefetchableFragment } from 'react-relay' import { CommentItemRefetchQuery } from '../../../../__generated__/CommentItemRefetchQuery.graphql' import { CommentItem_comment$key } from '../../../../__generated__/CommentItem_comment.graphql' import { ActionsOverlay, Timestamp as DefaultTimestamp } from '../../../__shared__/web' +import { FileUploadList } from '../../../files/web' import { CommentItemFragmentQuery, useCommentDeleteMutation, useCommentReply } from '../../common' import DefaultCommentUpdate from '../CommentUpdate' import DefaultCommentPinnedBadge from './CommentPinnedBadge' @@ -172,6 +173,10 @@ const CommentItem: FC = ({
{renderCommentContent()} + +
+ +
diff --git a/packages/components/modules/files/common/graphql/fragments/FileItem.ts b/packages/components/modules/files/common/graphql/fragments/FileItem.ts index 86489a3d8..4a2f67082 100644 --- a/packages/components/modules/files/common/graphql/fragments/FileItem.ts +++ b/packages/components/modules/files/common/graphql/fragments/FileItem.ts @@ -15,5 +15,6 @@ export const FileItemFragment = graphql` fullName } hasPerm(perm: "change_file") + thumbnail(width: 100, height: 100) } ` diff --git a/packages/components/modules/files/common/graphql/mutations/FileDelete.ts b/packages/components/modules/files/common/graphql/mutations/FileDelete.ts new file mode 100644 index 000000000..8c0204b74 --- /dev/null +++ b/packages/components/modules/files/common/graphql/mutations/FileDelete.ts @@ -0,0 +1,61 @@ +import { useNotification } from '@baseapp-frontend/utils' + +import { type Disposable, type UseMutationConfig, graphql, useMutation } from 'react-relay' + +import type { FileDeleteMutation } from '../../../../../__generated__/FileDeleteMutation.graphql' + +export const FileDeleteMutationQuery = graphql` + mutation FileDeleteMutation($input: FileDeleteInput!, $connections: [ID!]!) { + fileDelete(input: $input) { + deletedId @deleteEdge(connections: $connections) + parent { + filesCount + isFilesEnabled + } + errors { + field + messages + } + } + } +` + +export const useFileDeleteMutation = (): [ + (config: UseMutationConfig) => Disposable, + boolean, +] => { + const { sendToast } = useNotification() + const [commitMutation, isMutationInFlight] = + useMutation(FileDeleteMutationQuery) + + const commit = (config: UseMutationConfig) => + commitMutation({ + ...config, + onCompleted: (response, errors) => { + if (errors?.length) { + errors.forEach((error) => { + sendToast(error.message, { type: 'error' }) + }) + } + + const mutationErrors = response?.fileDelete?.errors + if (mutationErrors?.length) { + mutationErrors.forEach((error) => { + if (error?.messages) { + sendToast(error.messages.join(', '), { type: 'error' }) + } + }) + } else { + sendToast('File deleted successfully', { type: 'success' }) + } + + config?.onCompleted?.(response, errors) + }, + onError: (error: Error) => { + sendToast(error.message, { type: 'error' }) + config?.onError?.(error) + }, + }) + + return [commit, isMutationInFlight] +} diff --git a/packages/components/modules/files/common/index.ts b/packages/components/modules/files/common/index.ts index 53d37ed08..f210d2393 100644 --- a/packages/components/modules/files/common/index.ts +++ b/packages/components/modules/files/common/index.ts @@ -5,5 +5,6 @@ export * from './context/FileUploadProvider' export * from './context/useFileUpload' export * from './hooks/useChunkedUpload' export * from './graphql/mutations/FileAttachToTarget' +export * from './graphql/mutations/FileDelete' export * from './graphql/queries/FilesList' export * from './graphql/fragments/FileItem' diff --git a/packages/components/modules/files/web/AttachedFileItem/index.tsx b/packages/components/modules/files/web/AttachedFileItem/index.tsx index b58668739..d99f285eb 100644 --- a/packages/components/modules/files/web/AttachedFileItem/index.tsx +++ b/packages/components/modules/files/web/AttachedFileItem/index.tsx @@ -8,17 +8,20 @@ import { InsertDriveFile as FileIcon, } from '@mui/icons-material' import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' -import { useFragment } from 'react-relay' +import { ConnectionHandler, useFragment } from 'react-relay' import type { FileItem_file$key } from '../../../../__generated__/FileItem_file.graphql' import { FileItemFragment } from '../../common/graphql/fragments/FileItem' +import { useFileDeleteMutation } from '../../common/graphql/mutations/FileDelete' interface AttachedFileItemProps { file: FileItem_file$key + targetObjectId?: string } -const AttachedFileItem: FC = ({ file: fileRef }) => { +const AttachedFileItem: FC = ({ file: fileRef, targetObjectId }) => { const file = useFragment(FileItemFragment, fileRef) + const [deleteFile, isDeletingFile] = useFileDeleteMutation() const canChangeFile = file.hasPerm || false @@ -41,7 +44,21 @@ const AttachedFileItem: FC = ({ file: fileRef }) => { } const handleDelete = () => { - // TODO: Implement delete functionality + if (!targetObjectId) { + console.error('Target object ID is required for deleting file') + return + } + + const connectionID = ConnectionHandler.getConnectionID(targetObjectId, 'FilesList_files') + + deleteFile({ + variables: { + input: { + id: file.id, + }, + connections: [connectionID], + }, + }) } const getFileIcon = () => { @@ -59,14 +76,34 @@ const AttachedFileItem: FC = ({ file: fileRef }) => { return } + const isImage = file.fileContentType?.startsWith('image/') + return ( - - {getFileIcon()} - + + + {isImage && file.thumbnail ? ( + + ) : ( + getFileIcon() + )} + + {file.fileName} @@ -87,7 +124,13 @@ const AttachedFileItem: FC = ({ file: fileRef }) => { {canChangeFile && ( - + )} diff --git a/packages/components/modules/files/web/FileUpload/index.tsx b/packages/components/modules/files/web/FileUpload/index.tsx index 54912f998..f4a4de21e 100644 --- a/packages/components/modules/files/web/FileUpload/index.tsx +++ b/packages/components/modules/files/web/FileUpload/index.tsx @@ -10,7 +10,6 @@ import { useFileAttachToTargetMutation } from '../../common/graphql/mutations/Fi import { FilesListFragment } from '../../common/graphql/queries/FilesList' import { useChunkedUpload } from '../../common/hooks/useChunkedUpload' import FileUploadDropzone from '../FileUploadDropzone' -import FileUploadList from '../FileUploadList' import type { FileUploadProps } from './types' const FileUpload: FC = ({ @@ -93,17 +92,14 @@ const FileUpload: FC = ({ } return ( -
- - -
+ ) } diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx index 0e96302af..3ab49b245 100644 --- a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx @@ -1,9 +1,11 @@ 'use client' import type { FC } from 'react' +import { useEffect, useMemo } from 'react' import { Close as CloseIcon, + InsertDriveFile as FileIcon, Pause as PauseIcon, PlayArrow as PlayArrowIcon, Replay as ReplayIcon, @@ -74,18 +76,57 @@ const UploadingFileItem: FC = ({ const canResume = fileProgress.status === FileUploadStatus.PAUSED + const isImage = fileProgress.file?.type.startsWith('image/') + + const thumbnailUrl = useMemo(() => { + if (isImage && fileProgress.file) { + return URL.createObjectURL(fileProgress.file) + } + return null + }, [isImage, fileProgress.file]) + + useEffect( + () => () => { + if (thumbnailUrl) { + URL.revokeObjectURL(thumbnailUrl) + } + }, + [thumbnailUrl], + ) + return ( - - - {fileProgress.fileName} - - - {formatFileSize(fileProgress.fileSize)} - + + + {isImage && thumbnailUrl ? ( + + ) : ( + + )} + + + + {fileProgress.fileName} + + + {formatFileSize(fileProgress.fileSize)} + + diff --git a/packages/components/modules/files/web/FileUploadList/index.tsx b/packages/components/modules/files/web/FileUploadList/index.tsx index a0a4f4fe5..cde4a1a95 100644 --- a/packages/components/modules/files/web/FileUploadList/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/index.tsx @@ -54,7 +54,9 @@ const FileUploadList: FC = ({ {target.files.edges.map((edge: any) => { if (!edge?.node) return null - return + return ( + + ) })} )} From cd5b8d644da202589aa10375b06af6a1f9c4dc62 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Wed, 7 Jan 2026 12:15:40 -0300 Subject: [PATCH 05/21] restructure for code reusability --- packages/components/modules/files/README.md | 597 ++++++++++++++++++ .../common/hooks/useFileDeleteLogic/index.ts | 52 ++ .../common/hooks/useFileDeleteLogic/types.ts | 10 + .../hooks/useFileDownloadLogic/index.ts | 54 ++ .../hooks/useFileDownloadLogic/types.ts | 12 + .../common/hooks/useFileUploadLogic/index.ts | 86 +++ .../common/hooks/useFileUploadLogic/types.ts | 13 + .../components/modules/files/common/index.ts | 6 + .../modules/files/common/utils/formatters.ts | 46 ++ .../modules/files/common/utils/index.ts | 1 + .../files/web/AttachedFileItem/index.tsx | 77 +-- .../modules/files/web/FileUpload/index.tsx | 77 +-- .../UploadingFileItem/index.tsx | 12 +- 13 files changed, 915 insertions(+), 128 deletions(-) create mode 100644 packages/components/modules/files/README.md create mode 100644 packages/components/modules/files/common/hooks/useFileDeleteLogic/index.ts create mode 100644 packages/components/modules/files/common/hooks/useFileDeleteLogic/types.ts create mode 100644 packages/components/modules/files/common/hooks/useFileDownloadLogic/index.ts create mode 100644 packages/components/modules/files/common/hooks/useFileDownloadLogic/types.ts create mode 100644 packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts create mode 100644 packages/components/modules/files/common/hooks/useFileUploadLogic/types.ts create mode 100644 packages/components/modules/files/common/utils/formatters.ts diff --git a/packages/components/modules/files/README.md b/packages/components/modules/files/README.md new file mode 100644 index 000000000..eb6c38757 --- /dev/null +++ b/packages/components/modules/files/README.md @@ -0,0 +1,597 @@ +# Files Module + +A comprehensive file upload, management, and display system with support for chunked uploads, file attachments, and cross-platform compatibility. + +## Features + +- 📤 **Chunked File Upload** - Efficient upload of large files with progress tracking +- 📎 **File Attachment** - Attach files to any target object (comments, posts, etc.) +- 🖼️ **Image Thumbnails** - Automatic 100x100 thumbnails for image files +- 🗑️ **File Deletion** - Delete attached files with proper permissions +- ⬇️ **File Download** - Platform-agnostic download handling +- ⏸️ **Upload Control** - Pause, resume, and retry failed uploads +- 🎨 **Progress Tracking** - Real-time upload progress with visual feedback +- 📱 **Cross-Platform** - Shared logic for web and React Native + +## Architecture + +The module is organized into three main directories: + +``` +files/ +├── common/ # Platform-agnostic business logic +├── web/ # Web UI components (React + MUI) +└── native/ # React Native UI components +``` + +### Common + +Contains all platform-agnostic code that can be shared between web and mobile: + +- **Hooks** - Business logic hooks for upload, delete, and download operations +- **Utils** - Formatting and utility functions +- **GraphQL** - Queries, mutations, and fragments +- **Context** - React context for file upload state management +- **Types** - Shared TypeScript types + +### Web + +Web-specific UI components built with React and Material-UI: + +- `FileUpload` - Main file upload component with dropzone +- `FileUploadList` - Displays uploading and attached files +- `AttachedFileItem` - Individual attached file display +- `UploadingFileItem` - Individual uploading file with progress + +### Native + +React Native UI components (use the same common hooks and utilities). + +## Quick Start + +### Basic File Upload + +```typescript +import { FileUpload } from '@baseapp-frontend/components/modules/files/web' + +function MyComponent({ target }) { + return ( + console.log('Uploaded:', fileIds)} + onError={(error) => console.error(error)} + /> + ) +} +``` + +### Display File List + +```typescript +import { FileUploadList } from '@baseapp-frontend/components/modules/files/web' + +function MyComponent({ target }) { + return ( + + ) +} +``` + +## Common Hooks + +All hooks are available from the common module and can be used in both web and native platforms. + +### useFileUploadLogic + +Orchestrates file upload and attachment logic. + +```typescript +import { useFileUploadLogic } from '@baseapp-frontend/components/modules/files/common' + +const { + handleFilesSelected, // Function to handle file selection + isAttaching, // Boolean indicating if files are being attached + resetKey, // Key to reset UI components +} = useFileUploadLogic({ + targetObjectId: 'relay-id', + autoAttach: true, + onUploadComplete: (fileRelayIds) => { + console.log('Upload complete:', fileRelayIds) + }, + onAttachComplete: () => { + console.log('Files attached successfully') + }, + onError: (error) => { + console.error('Upload error:', error) + }, +}) + +// Use in your component + handleFilesSelected(Array.from(e.target.files))} +/> +``` + +**Parameters:** +- `targetObjectId?: string` - The Relay ID of the target object to attach files to +- `autoAttach?: boolean` - Automatically attach files after upload (default: true) +- `onUploadComplete?: (fileRelayIds: string[]) => void` - Called after upload succeeds +- `onAttachComplete?: () => void` - Called after files are attached +- `onError?: (error: Error) => void` - Called on errors + +### useFileDeleteLogic + +Handles file deletion with proper cache updates. + +```typescript +import { useFileDeleteLogic } from '@baseapp-frontend/components/modules/files/common' + +const { + handleDelete, // Function to delete a file + isDeletingFile, // Boolean indicating deletion in progress +} = useFileDeleteLogic({ + targetObjectId: 'relay-id', + onDeleteComplete: () => { + console.log('File deleted') + }, + onError: (error) => { + console.error('Delete error:', error) + }, +}) + +// Use in your component + +``` + +**Parameters:** +- `targetObjectId?: string` - The Relay ID of the target object owning the files +- `onDeleteComplete?: () => void` - Called after successful deletion +- `onError?: (error: Error) => void` - Called on errors + +### useFileDownloadLogic + +Platform-agnostic download logic with platform-specific handlers. + +```typescript +import { useFileDownloadLogic } from '@baseapp-frontend/components/modules/files/common' + +// Web implementation +const { handleDownload } = useFileDownloadLogic({ + downloadHandler: (url, fileName) => { + window.open(url, '_blank') + }, + onDownloadStart: () => console.log('Download started'), + onDownloadComplete: () => console.log('Download complete'), + onError: (error) => console.error(error), +}) + +// React Native implementation +import * as FileSystem from 'expo-file-system' + +const { handleDownload } = useFileDownloadLogic({ + downloadHandler: async (url, fileName) => { + const downloadPath = FileSystem.documentDirectory + fileName + await FileSystem.downloadAsync(url, downloadPath) + }, +}) + +// Use in your component + +``` + +**Parameters:** +- `downloadHandler: (fileUrl: string, fileName?: string) => void | Promise` - Platform-specific download implementation +- `onDownloadStart?: () => void` - Called when download starts +- `onDownloadComplete?: () => void` - Called when download completes +- `onError?: (error: Error) => void` - Called on errors + +## Utility Functions + +### Formatters + +```typescript +import { + formatFileSize, + formatDate, + getFileType, + calculateProgress, + isImageFile, +} from '@baseapp-frontend/components/modules/files/common' + +// Format file size +formatFileSize(1024) // "1.00 KB" +formatFileSize(1048576) // "1.00 MB" + +// Format date +formatDate('2024-01-05T10:00:00Z') // "1/5/2024" (locale-specific) + +// Get file type category +getFileType('image/png') // "image" +getFileType('application/pdf') // "pdf" +getFileType('video/mp4') // "video" +getFileType('text/plain') // "other" + +// Calculate upload progress +calculateProgress(512, 1024) // 50 + +// Check if file is an image +isImageFile('image/jpeg') // true +isImageFile('application/pdf') // false +``` + +## GraphQL Operations + +### Mutations + +#### FileAttachToTarget + +Attach uploaded files to a target object: + +```graphql +mutation FileAttachToTarget($input: FileAttachToTargetInput!, $connections: [ID!]!) { + fileAttachToTarget(input: $input) { + attachedFiles @prependEdge(connections: $connections) { + node { + ...FileItem_file + } + } + target { + filesCount + isFilesEnabled + } + } +} +``` + +#### FileDelete + +Delete a file: + +```graphql +mutation FileDelete($input: FileDeleteInput!, $connections: [ID!]!) { + fileDelete(input: $input) { + deletedId @deleteEdge(connections: $connections) + parent { + filesCount + isFilesEnabled + } + } +} +``` + +### Queries + +#### FilesList + +Fragment to fetch files list: + +```graphql +fragment FilesList_target on FilesInterface { + id + isFilesEnabled + filesCount + files(first: 20) @connection(key: "FilesList_files") { + edges { + node { + id + ...FileItem_file + } + } + } +} +``` + +### Fragments + +#### FileItem + +File information fragment: + +```graphql +fragment FileItem_file on File { + id + fileName + fileSize + fileContentType + uploadStatus + file + thumbnail + description + created + createdBy { + id + fullName + } + hasPerm(perm: "change_file") +} +``` + +## Component Props + +### FileUpload + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| target | `FilesInterface$key` | required | Relay fragment reference for the target object | +| maxFiles | `number` | - | Maximum number of files allowed | +| maxFileSize | `number` | - | Maximum file size in bytes | +| acceptedFileTypes | `string[]` | - | Accepted MIME types (e.g., `['image/*', 'application/pdf']`) | +| disabled | `boolean` | false | Disable file upload | +| autoAttach | `boolean` | true | Automatically attach files after upload | +| onUploadComplete | `(fileRelayIds: string[]) => void` | - | Callback when upload completes | +| onAttachComplete | `() => void` | - | Callback when files are attached | +| onError | `(error: Error) => void` | - | Error callback | + +### FileUploadList + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| target | `FilesInterface$key` | required | Relay fragment reference for the target object | +| showUploadProgress | `boolean` | true | Show uploading files section | +| allowRemove | `boolean` | true | Allow removing files during upload | +| allowRetry | `boolean` | true | Allow retrying failed uploads | + +### AttachedFileItem + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| file | `FileItem_file$key` | required | Relay fragment reference for the file | +| targetObjectId | `string` | - | Target object ID for deletion (enables delete button) | + +## Implementing React Native Components + +To create React Native file components, use the common hooks with native UI: + +```typescript +// native/AttachedFileItem/index.tsx +import React from 'react' +import { View, Text, Image, TouchableOpacity } from 'react-native' +import { useFragment } from 'react-relay' +import * as FileSystem from 'expo-file-system' + +import { + FileItemFragment, + useFileDeleteLogic, + useFileDownloadLogic, + formatFileSize, + formatDate, + isImageFile, +} from '../../common' + +export const AttachedFileItem = ({ file: fileRef, targetObjectId }) => { + const file = useFragment(FileItemFragment, fileRef) + + const { handleDelete, isDeletingFile } = useFileDeleteLogic({ targetObjectId }) + + const { handleDownload } = useFileDownloadLogic({ + downloadHandler: async (url, fileName) => { + const downloadPath = FileSystem.documentDirectory + fileName + await FileSystem.downloadAsync(url, downloadPath) + }, + }) + + const isImage = isImageFile(file.fileContentType) + + return ( + + {isImage && file.thumbnail && ( + + )} + {file.fileName} + {formatFileSize(file.fileSize)} + {formatDate(file.created)} + + handleDownload(file.file, file.fileName)}> + Download + + + {file.hasPerm && ( + handleDelete(file.id)} disabled={isDeletingFile}> + Delete + + )} + + ) +} +``` + +## Features in Detail + +### Chunked Upload + +Large files are automatically split into chunks for reliable upload: + +- Configurable chunk size (default from backend) +- Parallel chunk upload +- Automatic retry on failure +- Progress tracking per chunk +- S3 multipart upload support + +### Upload State Management + +The file upload state is managed through React Context: + +```typescript +import { FileUploadProvider, useFileUpload } from '@baseapp-frontend/components/modules/files/common' + +// Wrap your app with the provider + + + + +// Access upload state anywhere +const { files, removeFile, pauseFile, resumeFile } = useFileUpload() +``` + +### File Upload Status + +Files go through several states during upload: + +- `PENDING` - File queued for upload +- `UPLOADING` - Currently uploading +- `PAUSED` - Upload paused by user +- `COMPLETED` - Upload successful +- `FAILED` - Upload failed +- `ABORTED` - Upload cancelled + +### Permissions + +File operations respect backend permissions: + +- Delete button only shows if user has `change_file` permission +- Permission checked via `hasPerm` field in FileItem fragment + +## Best Practices + +1. **Always wrap with FileUploadProvider** + ```typescript + + + + ``` + +2. **Handle errors gracefully** + ```typescript + { + // Show user-friendly error message + toast.error(error.message) + }} + /> + ``` + +3. **Set appropriate file limits** + ```typescript + + ``` + +4. **Provide targetObjectId for deletion** + ```typescript + + ``` + +5. **Use platform-specific download handlers** + ```typescript + // Different implementations for web and native + const downloadHandler = Platform.select({ + web: (url) => window.open(url, '_blank'), + native: async (url, fileName) => { + await FileSystem.downloadAsync(url, FileSystem.documentDirectory + fileName) + }, + }) + ``` + +## Exports + +All common functionality is exported from the main index: + +```typescript +import { + // Hooks + useFileUploadLogic, + useFileDeleteLogic, + useFileDownloadLogic, + useChunkedUpload, + useFileUpload, + + // Utilities + formatFileSize, + formatDate, + getFileType, + calculateProgress, + isImageFile, + + // Context + FileUploadProvider, + useFileUploadStore, + + // GraphQL + FileItemFragment, + FilesListFragment, + useFileAttachToTargetMutation, + useFileDeleteMutation, + + // Constants + FileUploadStatus, + + // Types + FileUploadProgress, + UseFileUploadLogicParams, + UseFileDeleteLogicParams, + UseFileDownloadLogicParams, +} from '@baseapp-frontend/components/modules/files/common' +``` + +## TypeScript Support + +Full TypeScript support with exported types: + +```typescript +import type { + FileUploadProgress, + UseFileUploadLogicParams, + UseFileUploadLogicReturn, + UseFileDeleteLogicParams, + UseFileDeleteLogicReturn, + UseFileDownloadLogicParams, + UseFileDownloadLogicReturn, +} from '@baseapp-frontend/components/modules/files/common' +``` + +## Testing + +### Unit Testing Hooks + +```typescript +import { renderHook, act } from '@testing-library/react' +import { useFileUploadLogic } from '@baseapp-frontend/components/modules/files/common' + +test('handles file upload', async () => { + const { result } = renderHook(() => + useFileUploadLogic({ + targetObjectId: 'test-id', + autoAttach: true, + }) + ) + + await act(async () => { + await result.current.handleFilesSelected([mockFile]) + }) + + expect(result.current.isAttaching).toBe(false) +}) +``` + +### Testing Components + +```typescript +import { render, screen } from '@testing-library/react' +import { FileUpload } from '@baseapp-frontend/components/modules/files/web' + +test('renders file upload', () => { + render() + expect(screen.getByText(/drag and drop/i)).toBeInTheDocument() +}) +``` diff --git a/packages/components/modules/files/common/hooks/useFileDeleteLogic/index.ts b/packages/components/modules/files/common/hooks/useFileDeleteLogic/index.ts new file mode 100644 index 000000000..a391c6615 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileDeleteLogic/index.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react' + +import { ConnectionHandler } from 'react-relay' + +import { useFileDeleteMutation } from '../../graphql/mutations/FileDelete' +import type { UseFileDeleteLogicParams, UseFileDeleteLogicReturn } from './types' + +/** + * Hook that handles file deletion logic + * This hook is platform-agnostic and can be used in both web and native + */ +export const useFileDeleteLogic = ({ + targetObjectId, + onDeleteComplete, + onError, +}: UseFileDeleteLogicParams): UseFileDeleteLogicReturn => { + const [deleteFile, isDeletingFile] = useFileDeleteMutation() + + const handleDelete = useCallback( + (fileId: string) => { + if (!targetObjectId) { + const error = new Error('Target object ID is required for deleting file') + console.error(error.message) + onError?.(error) + return + } + + const connectionID = ConnectionHandler.getConnectionID(targetObjectId, 'FilesList_files') + + deleteFile({ + variables: { + input: { + id: fileId, + }, + connections: [connectionID], + }, + onCompleted: () => { + onDeleteComplete?.() + }, + onError: (error) => { + onError?.(error) + }, + }) + }, + [targetObjectId, deleteFile, onDeleteComplete, onError], + ) + + return { + handleDelete, + isDeletingFile, + } +} diff --git a/packages/components/modules/files/common/hooks/useFileDeleteLogic/types.ts b/packages/components/modules/files/common/hooks/useFileDeleteLogic/types.ts new file mode 100644 index 000000000..871c464de --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileDeleteLogic/types.ts @@ -0,0 +1,10 @@ +export interface UseFileDeleteLogicParams { + targetObjectId?: string + onDeleteComplete?: () => void + onError?: (error: Error) => void +} + +export interface UseFileDeleteLogicReturn { + handleDelete: (fileId: string) => void + isDeletingFile: boolean +} diff --git a/packages/components/modules/files/common/hooks/useFileDownloadLogic/index.ts b/packages/components/modules/files/common/hooks/useFileDownloadLogic/index.ts new file mode 100644 index 000000000..169e246af --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileDownloadLogic/index.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react' + +import type { UseFileDownloadLogicParams, UseFileDownloadLogicReturn } from './types' + +/** + * Hook that handles file download logic + * Platform-specific implementations should be provided via the downloadHandler parameter + * + * @example Web implementation: + * ```ts + * const { handleDownload } = useFileDownloadLogic({ + * downloadHandler: (url) => window.open(url, '_blank') + * }) + * ``` + * + * @example React Native implementation: + * ```ts + * const { handleDownload } = useFileDownloadLogic({ + * downloadHandler: async (url) => { + * await FileSystem.downloadAsync(url, FileSystem.documentDirectory + filename) + * } + * }) + * ``` + */ +export const useFileDownloadLogic = ({ + downloadHandler, + onDownloadStart, + onDownloadComplete, + onError, +}: UseFileDownloadLogicParams): UseFileDownloadLogicReturn => { + const handleDownload = useCallback( + async (fileUrl: string | null | undefined, fileName?: string) => { + if (!fileUrl) { + const error = new Error('File URL is required for downloading') + console.error(error.message) + onError?.(error) + return + } + + try { + onDownloadStart?.() + await downloadHandler(fileUrl, fileName) + onDownloadComplete?.() + } catch (error) { + onError?.(error instanceof Error ? error : new Error('Download failed')) + } + }, + [downloadHandler, onDownloadStart, onDownloadComplete, onError], + ) + + return { + handleDownload, + } +} diff --git a/packages/components/modules/files/common/hooks/useFileDownloadLogic/types.ts b/packages/components/modules/files/common/hooks/useFileDownloadLogic/types.ts new file mode 100644 index 000000000..825f4d7a5 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileDownloadLogic/types.ts @@ -0,0 +1,12 @@ +export type DownloadHandler = (fileUrl: string, fileName?: string) => void | Promise + +export interface UseFileDownloadLogicParams { + downloadHandler: DownloadHandler + onDownloadStart?: () => void + onDownloadComplete?: () => void + onError?: (error: Error) => void +} + +export interface UseFileDownloadLogicReturn { + handleDownload: (fileUrl: string | null | undefined, fileName?: string) => Promise +} diff --git a/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts b/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts new file mode 100644 index 000000000..7ed86a1c9 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react' + +import { ConnectionHandler } from 'react-relay' + +import { useFileUploadStore } from '../../context/FileUploadProvider' +import { useFileAttachToTargetMutation } from '../../graphql/mutations/FileAttachToTarget' +import { useChunkedUpload } from '../useChunkedUpload' +import type { UseFileUploadLogicParams, UseFileUploadLogicReturn } from './types' + +/** + * Hook that orchestrates the file upload and attach logic + * This hook is platform-agnostic and can be used in both web and native + */ +export const useFileUploadLogic = ({ + targetObjectId, + autoAttach = true, + onUploadComplete, + onAttachComplete, + onError, +}: UseFileUploadLogicParams): UseFileUploadLogicReturn => { + const { uploadFile } = useChunkedUpload() + const [attachFiles, isAttaching] = useFileAttachToTargetMutation() + const { clearCompleted } = useFileUploadStore() + const [resetKey, setResetKey] = useState(0) + + const handleFilesSelected = useCallback( + async (selectedFiles: File[]) => { + if (!targetObjectId) { + onError?.(new Error('Target object ID is required')) + return + } + + try { + // Upload all files in parallel + const uploadPromises = selectedFiles.map((file) => uploadFile(file)) + const fileRelayIds = await Promise.all(uploadPromises) + + onUploadComplete?.(fileRelayIds) + + // Auto-attach if enabled + if (autoAttach && fileRelayIds.length > 0) { + const connectionID = ConnectionHandler.getConnectionID(targetObjectId, 'FilesList_files') + + attachFiles({ + variables: { + input: { + fileRelayIds, + targetObjectId, + }, + connections: [connectionID], + }, + onCompleted: () => { + clearCompleted() + setResetKey((prev) => prev + 1) + onAttachComplete?.() + }, + onError: (error) => { + onError?.(error) + }, + }) + } else if (!autoAttach) { + clearCompleted() + setResetKey((prev) => prev + 1) + } + } catch (error) { + onError?.(error instanceof Error ? error : new Error('Upload failed')) + } + }, + [ + uploadFile, + autoAttach, + targetObjectId, + attachFiles, + onUploadComplete, + onAttachComplete, + onError, + clearCompleted, + ], + ) + + return { + handleFilesSelected, + isAttaching, + resetKey, + } +} diff --git a/packages/components/modules/files/common/hooks/useFileUploadLogic/types.ts b/packages/components/modules/files/common/hooks/useFileUploadLogic/types.ts new file mode 100644 index 000000000..8def6e9c1 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileUploadLogic/types.ts @@ -0,0 +1,13 @@ +export interface UseFileUploadLogicParams { + targetObjectId?: string + autoAttach?: boolean + onUploadComplete?: (fileRelayIds: string[]) => void + onAttachComplete?: () => void + onError?: (error: Error) => void +} + +export interface UseFileUploadLogicReturn { + handleFilesSelected: (selectedFiles: File[]) => Promise + isAttaching: boolean + resetKey: number +} diff --git a/packages/components/modules/files/common/index.ts b/packages/components/modules/files/common/index.ts index f210d2393..d988cb482 100644 --- a/packages/components/modules/files/common/index.ts +++ b/packages/components/modules/files/common/index.ts @@ -4,6 +4,12 @@ export * from './utils' export * from './context/FileUploadProvider' export * from './context/useFileUpload' export * from './hooks/useChunkedUpload' +export * from './hooks/useFileUploadLogic' +export type * from './hooks/useFileUploadLogic/types' +export * from './hooks/useFileDeleteLogic' +export type * from './hooks/useFileDeleteLogic/types' +export * from './hooks/useFileDownloadLogic' +export type * from './hooks/useFileDownloadLogic/types' export * from './graphql/mutations/FileAttachToTarget' export * from './graphql/mutations/FileDelete' export * from './graphql/queries/FilesList' diff --git a/packages/components/modules/files/common/utils/formatters.ts b/packages/components/modules/files/common/utils/formatters.ts new file mode 100644 index 000000000..83a9f3bdf --- /dev/null +++ b/packages/components/modules/files/common/utils/formatters.ts @@ -0,0 +1,46 @@ +/** + * Format bytes to human-readable file size + */ +export const formatFileSize = (bytes: number | null | undefined): string => { + if (!bytes) return '0 B' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +/** + * Format date string to localized date + */ +export const formatDate = (dateString: string): string => { + const date = new Date(dateString) + return date.toLocaleDateString() +} + +/** + * Get file type category from content type + */ +export const getFileType = ( + contentType: string | null | undefined, +): 'image' | 'video' | 'pdf' | 'other' => { + if (!contentType) return 'other' + + if (contentType.startsWith('image/')) return 'image' + if (contentType.startsWith('video/')) return 'video' + if (contentType === 'application/pdf') return 'pdf' + + return 'other' +} + +/** + * Calculate upload progress percentage + */ +export const calculateProgress = (uploadedBytes: number, totalBytes: number): number => { + if (totalBytes === 0) return 0 + return (uploadedBytes / totalBytes) * 100 +} + +/** + * Check if file is an image + */ +export const isImageFile = (contentType: string | null | undefined): boolean => + contentType?.startsWith('image/') ?? false diff --git a/packages/components/modules/files/common/utils/index.ts b/packages/components/modules/files/common/utils/index.ts index ebf7505e3..eea1a6c3e 100644 --- a/packages/components/modules/files/common/utils/index.ts +++ b/packages/components/modules/files/common/utils/index.ts @@ -1,3 +1,4 @@ export * from './chunkFile' export * from './uploadChunk' export * from './uploadChunks' +export * from './formatters' diff --git a/packages/components/modules/files/web/AttachedFileItem/index.tsx b/packages/components/modules/files/web/AttachedFileItem/index.tsx index d99f285eb..2b3328800 100644 --- a/packages/components/modules/files/web/AttachedFileItem/index.tsx +++ b/packages/components/modules/files/web/AttachedFileItem/index.tsx @@ -8,11 +8,13 @@ import { InsertDriveFile as FileIcon, } from '@mui/icons-material' import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' -import { ConnectionHandler, useFragment } from 'react-relay' +import { useFragment } from 'react-relay' import type { FileItem_file$key } from '../../../../__generated__/FileItem_file.graphql' import { FileItemFragment } from '../../common/graphql/fragments/FileItem' -import { useFileDeleteMutation } from '../../common/graphql/mutations/FileDelete' +import { useFileDeleteLogic } from '../../common/hooks/useFileDeleteLogic' +import { useFileDownloadLogic } from '../../common/hooks/useFileDownloadLogic' +import { formatDate, formatFileSize, getFileType, isImageFile } from '../../common/utils/formatters' interface AttachedFileItemProps { file: FileItem_file$key @@ -21,62 +23,33 @@ interface AttachedFileItemProps { const AttachedFileItem: FC = ({ file: fileRef, targetObjectId }) => { const file = useFragment(FileItemFragment, fileRef) - const [deleteFile, isDeletingFile] = useFileDeleteMutation() - const canChangeFile = file.hasPerm || false - - const formatFileSize = (bytes: number | null | undefined): string => { - if (!bytes) return '0 B' - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` - return `${(bytes / (1024 * 1024)).toFixed(2)} MB` - } - - const formatDate = (dateString: string): string => { - const date = new Date(dateString) - return date.toLocaleDateString() - } + const { handleDelete, isDeletingFile } = useFileDeleteLogic({ targetObjectId }) - const handleDownload = () => { - if (file.file) { - window.open(file.file, '_blank') - } - } + const { handleDownload } = useFileDownloadLogic({ + downloadHandler: (url) => { + window.open(url, '_blank') + }, + }) - const handleDelete = () => { - if (!targetObjectId) { - console.error('Target object ID is required for deleting file') - return - } - - const connectionID = ConnectionHandler.getConnectionID(targetObjectId, 'FilesList_files') - - deleteFile({ - variables: { - input: { - id: file.id, - }, - connections: [connectionID], - }, - }) - } + const canChangeFile = file.hasPerm || false const getFileIcon = () => { - const contentType = file.fileContentType || '' - - if (contentType.startsWith('image/')) { - return - } - if (contentType.startsWith('video/')) { - return - } - if (contentType === 'application/pdf') { - return + const fileType = getFileType(file.fileContentType) + + switch (fileType) { + case 'image': + return + case 'video': + return + case 'pdf': + return + default: + return } - return } - const isImage = file.fileContentType?.startsWith('image/') + const isImage = isImageFile(file.fileContentType) return ( @@ -119,14 +92,14 @@ const AttachedFileItem: FC = ({ file: fileRef, targetObje - + handleDownload(file.file)} title="Download"> {canChangeFile && ( handleDelete(file.id)} color="error" title="Delete" disabled={isDeletingFile} diff --git a/packages/components/modules/files/web/FileUpload/index.tsx b/packages/components/modules/files/web/FileUpload/index.tsx index f4a4de21e..18ec032d4 100644 --- a/packages/components/modules/files/web/FileUpload/index.tsx +++ b/packages/components/modules/files/web/FileUpload/index.tsx @@ -1,14 +1,11 @@ 'use client' import type { FC } from 'react' -import { useCallback, useState } from 'react' -import { ConnectionHandler, useFragment } from 'react-relay' +import { useFragment } from 'react-relay' -import { useFileUploadStore } from '../../common/context/FileUploadProvider' -import { useFileAttachToTargetMutation } from '../../common/graphql/mutations/FileAttachToTarget' import { FilesListFragment } from '../../common/graphql/queries/FilesList' -import { useChunkedUpload } from '../../common/hooks/useChunkedUpload' +import { useFileUploadLogic } from '../../common/hooks/useFileUploadLogic' import FileUploadDropzone from '../FileUploadDropzone' import type { FileUploadProps } from './types' @@ -24,68 +21,14 @@ const FileUpload: FC = ({ onError, }) => { const target = useFragment(FilesListFragment, targetRef) - const { uploadFile } = useChunkedUpload() - const [attachFiles, isAttaching] = useFileAttachToTargetMutation() - const { clearCompleted } = useFileUploadStore() - const [dropzoneKey, setDropzoneKey] = useState(0) - const handleFilesSelected = useCallback( - async (selectedFiles: File[]) => { - if (!target.id) { - onError?.(new Error('Target object ID is required')) - return - } - - try { - const uploadPromises = selectedFiles.map((file) => uploadFile(file)) - - const fileRelayIds = await Promise.all(uploadPromises) - - onUploadComplete?.(fileRelayIds) - - if (autoAttach && fileRelayIds.length > 0) { - const connectionID = ConnectionHandler.getConnectionID(target.id, 'FilesList_files') - - attachFiles({ - variables: { - input: { - fileRelayIds, - targetObjectId: target.id, - }, - connections: [connectionID], - }, - onCompleted: () => { - // Clear completed files from upload state after successful attachment - clearCompleted() - // Reset dropzone by changing its key to force remount - setDropzoneKey((prev) => prev + 1) - onAttachComplete?.() - }, - onError: (error) => { - onError?.(error) - }, - }) - } else if (!autoAttach) { - // If not auto-attaching, clear completed files after upload - clearCompleted() - // Reset dropzone by changing its key to force remount - setDropzoneKey((prev) => prev + 1) - } - } catch (error) { - onError?.(error instanceof Error ? error : new Error('Upload failed')) - } - }, - [ - uploadFile, - autoAttach, - target.id, - attachFiles, - onUploadComplete, - onAttachComplete, - onError, - clearCompleted, - ], - ) + const { handleFilesSelected, isAttaching, resetKey } = useFileUploadLogic({ + targetObjectId: target.id, + autoAttach, + onUploadComplete, + onAttachComplete, + onError, + }) if (!target.isFilesEnabled) { return null @@ -93,7 +36,7 @@ const FileUpload: FC = ({ return ( = ({ const { removeFile, pauseFile, resumeFile } = useFileUpload() const { resumeUpload, retryUpload } = useChunkedUpload() - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` - return `${(bytes / (1024 * 1024)).toFixed(2)} MB` - } - const getProgress = (): number => { if (fileProgress.status === FileUploadStatus.COMPLETED) return 100 - if (fileProgress.fileSize === 0) return 0 - return (fileProgress.uploadedBytes / fileProgress.fileSize) * 100 + return calculateProgress(fileProgress.uploadedBytes, fileProgress.fileSize) } const handleRemove = () => { @@ -76,7 +70,7 @@ const UploadingFileItem: FC = ({ const canResume = fileProgress.status === FileUploadStatus.PAUSED - const isImage = fileProgress.file?.type.startsWith('image/') + const isImage = isImageFile(fileProgress.file?.type) const thumbnailUrl = useMemo(() => { if (isImage && fileProgress.file) { From de7f69f798c97f0304ce08d4d77aaddda5ed6d39 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 27 Jun 2026 01:41:31 +0000 Subject: [PATCH 06/21] frontend: regenerate schema + adapt image fields to backend String contract - regenerate packages/components/schema.graphql against the deployed ported backend (image/avatar/bannerImage fields are now String scalars, not { url } objects) - update fragments to select image fields as scalars; drop { url } subselections - update components/utils to consume image fields as strings (coalesce null -> undefined where passed to string|undefined props) - regenerate Relay __generated__ artifacts - rebased onto origin/master Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XyuGcRJryi9hQiWRVXU7Js --- .../__storybook__/mockResolvers.ts | 5 +- .../LogGroups/__storybook__/stories.tsx | 8 +- .../ActivityLogComponent/LogGroups/index.tsx | 2 +- .../LogItem/__storybook__/stories.tsx | 4 +- .../__storybook__/mockResolvers.ts | 60 +-- .../__storybook__/mockResolvers.ts | 5 +- .../__storybook__/mockResolvers.ts | 5 +- .../Comments/__storybook__/mockResolvers.ts | 10 +- .../Comments/__tests__/__mocks__/constants.ts | 5 +- .../Comments/__tests__/__mocks__/requests.ts | 153 +++--- .../Comments/__tests__/__mocks__/resolvers.ts | 10 +- .../__storybook__/mockResolvers.ts | 10 +- .../graphql/fragments/ContentPostImage.ts | 4 +- .../content-feed/web/PostHeader/index.tsx | 2 +- .../content-feed/web/PostImageSlide/index.tsx | 2 +- .../common/graphql/fragments/RoomTitle.ts | 4 +- .../fragments/SingleChatDetailsFragment.ts | 8 +- .../modules/messages/common/utils.ts | 6 +- .../CreateGroupListItem/index.tsx | 2 +- .../CreateRoomListItem/index.tsx | 2 +- .../SelectedGroupMembers/index.tsx | 2 +- .../ChatRoomPage/ChatRoomHeader/index.tsx | 2 +- .../MessagesGroup/UserMessage/index.tsx | 2 +- .../native/GroupChatDetails/index.tsx | 2 +- .../GroupDetailsPage/GroupProfile/index.tsx | 2 +- .../Members/MemberItem/index.tsx | 2 +- .../ChatCard/ChatCardComponent/index.tsx | 2 +- .../ProfileSummary/index.tsx | 2 +- .../native/SingleChatDetailsPage/index.tsx | 2 +- .../AllChatRoomsList/ChatRoomItem/index.tsx | 2 +- .../web/ChatCreate/ChatRoomListItem/index.tsx | 2 +- .../web/ChatRoom/ChatRoomHeader/index.tsx | 2 +- .../web/GroupChatDetails/Body/index.tsx | 7 +- .../GroupChatDetails/ProfileCard/index.tsx | 2 +- .../messages/web/GroupChatDetails/index.tsx | 2 +- .../web/GroupChatEdit/AddMemberCard/index.tsx | 2 +- .../GroupChatEdit/AddedMemberCard/index.tsx | 2 +- .../MessagesGroup/UserMessage/index.tsx | 2 +- .../MessagesGroupWithQuery/index.tsx | 2 +- .../__storybook__/mockResolvers.ts | 4 +- .../MessagesGroup/__storybook__/stories.tsx | 2 +- .../__storybook__/mockResolvers.ts | 12 +- .../web/ProfileSummary/Body/index.tsx | 7 +- .../EditGroupTitleAndImage/index.tsx | 2 +- .../ProfileCard/index.tsx | 2 +- .../NotificationItem/CommentCreated/index.tsx | 2 +- .../NotificationItem/CommentReply/index.tsx | 2 +- .../ReactionCreated/index.tsx | 2 +- .../NotificationItem/CommentCreated/index.tsx | 2 +- .../NotificationItem/CommentReply/index.tsx | 2 +- .../ReactionCreated/index.tsx | 2 +- .../native/ProfileComponent/index.tsx | 7 +- .../profiles/web/ProfileComponent/index.tsx | 4 +- .../ProfileMembers/__tests__/Members.cy.tsx | 8 +- .../__tests__/__mocks__/requests.ts | 12 +- .../__tests__/__mocks__/requests.ts | 19 +- .../web/ProfileSettingsComponent/index.tsx | 4 +- packages/components/schema.graphql | 495 +++++++++++++----- 58 files changed, 535 insertions(+), 405 deletions(-) diff --git a/packages/components/modules/__shared__/web/ReactionButton/__storybook__/mockResolvers.ts b/packages/components/modules/__shared__/web/ReactionButton/__storybook__/mockResolvers.ts index eefda7043..0a8c7dd45 100644 --- a/packages/components/modules/__shared__/web/ReactionButton/__storybook__/mockResolvers.ts +++ b/packages/components/modules/__shared__/web/ReactionButton/__storybook__/mockResolvers.ts @@ -13,9 +13,8 @@ export const mockResolvers = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 2, diff --git a/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/__storybook__/stories.tsx b/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/__storybook__/stories.tsx index 776b443e0..7df95824e 100644 --- a/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/__storybook__/stories.tsx +++ b/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/__storybook__/stories.tsx @@ -37,9 +37,7 @@ export const DefaultLogItem: Story = { id: 'user-1', fullName: 'Alice Doe', email: 'alice@example.com', - avatar: { - url: 'https://placehold.co/48x48', - }, + avatar: 'https://placehold.co/48x48', }, }, { @@ -54,9 +52,7 @@ export const DefaultLogItem: Story = { id: 'user-2', fullName: 'Bob Smith', email: 'bob@example.com', - avatar: { - url: 'https://placehold.co/48x48?text=Bob', - }, + avatar: 'https://placehold.co/48x48?text=Bob', }, }, ], diff --git a/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/index.tsx b/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/index.tsx index e1cdc3770..037f3083d 100644 --- a/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/index.tsx +++ b/packages/components/modules/activity-log/web/ActivityLogComponent/LogGroups/index.tsx @@ -49,7 +49,7 @@ const LogGroups: FC = ({ width={40} height={40} className="self-start justify-self-center" - src={group.logs[0]?.user?.avatar?.url ?? ''} + src={group.logs[0]?.user?.avatar ?? ''} sx={{ border: 'none', marginBottom: '4px' }} /> ) diff --git a/packages/components/modules/activity-log/web/ActivityLogComponent/LogItem/__storybook__/stories.tsx b/packages/components/modules/activity-log/web/ActivityLogComponent/LogItem/__storybook__/stories.tsx index 94dc8b77a..156e3c20a 100644 --- a/packages/components/modules/activity-log/web/ActivityLogComponent/LogItem/__storybook__/stories.tsx +++ b/packages/components/modules/activity-log/web/ActivityLogComponent/LogItem/__storybook__/stories.tsx @@ -33,9 +33,7 @@ export const DefaultLogItem: Story = { id: 'user-1', fullName: 'Mock User', email: 'mock@tsl.io', - avatar: { - url: 'https://placehold.co/48x48', - }, + avatar: 'https://placehold.co/48x48', }, }, }, diff --git a/packages/components/modules/activity-log/web/ActivityLogComponent/__storybook__/mockResolvers.ts b/packages/components/modules/activity-log/web/ActivityLogComponent/__storybook__/mockResolvers.ts index 1db35e853..69ab4325e 100644 --- a/packages/components/modules/activity-log/web/ActivityLogComponent/__storybook__/mockResolvers.ts +++ b/packages/components/modules/activity-log/web/ActivityLogComponent/__storybook__/mockResolvers.ts @@ -12,9 +12,8 @@ export const mockData = { id: '1234', fullName: 'Activity User', email: 'test@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/2162b9f5-3c50-4b45-8d33-9fdb28f5dee4.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/2162b9f5-3c50-4b45-8d33-9fdb28f5dee4.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -30,9 +29,8 @@ export const mockData = { id: '5678', fullName: 'Activity User', email: 'test@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/2162b9f5-3c50-4b45-8d33-9fdb28f5dee4.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/2162b9f5-3c50-4b45-8d33-9fdb28f5dee4.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -48,9 +46,8 @@ export const mockData = { id: '91011', fullName: 'Activity User', email: 'test@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/2162b9f5-3c50-4b45-8d33-9fdb28f5dee4.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/2162b9f5-3c50-4b45-8d33-9fdb28f5dee4.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -66,9 +63,8 @@ export const mockData = { id: '1213', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -84,9 +80,8 @@ export const mockData = { id: '1415', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -102,9 +97,8 @@ export const mockData = { id: '1617', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -120,9 +114,8 @@ export const mockData = { id: '1819', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -138,9 +131,8 @@ export const mockData = { id: '2021', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -220,9 +212,8 @@ export const mockData = { id: '3031', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -238,9 +229,8 @@ export const mockData = { id: '3233', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -256,9 +246,8 @@ export const mockData = { id: '3435', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, @@ -274,9 +263,8 @@ export const mockData = { id: '3637', fullName: 'Test User', email: 'test2@tsl.io', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-db-staging/media/profile_images/7069d845-181f-45c8-ab45-ca10c0f7937f.JPG.48x48_q85.jpg', }, __typename: 'ActivityLog', }, diff --git a/packages/components/modules/comments/web/CommentItem/__storybook__/mockResolvers.ts b/packages/components/modules/comments/web/CommentItem/__storybook__/mockResolvers.ts index eefda7043..0a8c7dd45 100644 --- a/packages/components/modules/comments/web/CommentItem/__storybook__/mockResolvers.ts +++ b/packages/components/modules/comments/web/CommentItem/__storybook__/mockResolvers.ts @@ -13,9 +13,8 @@ export const mockResolvers = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 2, diff --git a/packages/components/modules/comments/web/CommentUpdate/__storybook__/mockResolvers.ts b/packages/components/modules/comments/web/CommentUpdate/__storybook__/mockResolvers.ts index eefda7043..0a8c7dd45 100644 --- a/packages/components/modules/comments/web/CommentUpdate/__storybook__/mockResolvers.ts +++ b/packages/components/modules/comments/web/CommentUpdate/__storybook__/mockResolvers.ts @@ -13,9 +13,8 @@ export const mockResolvers = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 2, diff --git a/packages/components/modules/comments/web/Comments/__storybook__/mockResolvers.ts b/packages/components/modules/comments/web/Comments/__storybook__/mockResolvers.ts index c72f24bc6..0b35afa6d 100644 --- a/packages/components/modules/comments/web/Comments/__storybook__/mockResolvers.ts +++ b/packages/components/modules/comments/web/Comments/__storybook__/mockResolvers.ts @@ -23,9 +23,8 @@ export const mockResolvers = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 2, @@ -48,9 +47,8 @@ export const mockResolvers = { user: { id: 'user-2', fullName: 'Jane Smith', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/c/9/19/resized/50/50/cb95449a94688af33f6e9bb090cf2936.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/c/9/19/resized/50/50/cb95449a94688af33f6e9bb090cf2936.png', }, reactionsCount: { total: 3, diff --git a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/constants.ts b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/constants.ts index 34869c027..45fbccc8a 100644 --- a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/constants.ts +++ b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/constants.ts @@ -2,9 +2,8 @@ export const MOCK_PROFILE = { id: 'UHJvZm1234567', pk: 74, name: 'Some Profile', - image: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + image: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', urlPath: { path: '/82323322', id: 'VVJMUGF11111111111', diff --git a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts index 75b5bd19e..f2e59ac56 100644 --- a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts @@ -17,9 +17,8 @@ export const replytoCommentMockData = { user: { id: 'user-2', fullName: 'Jane Smith', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/c/9/19/resized/50/50/cb95449a94688af33f6e9bb090cf2936.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/c/9/19/resized/50/50/cb95449a94688af33f6e9bb090cf2936.png', }, __isCommentsInterface: 'Comment', comments: { @@ -37,9 +36,8 @@ export const replytoCommentMockData = { user: { id: 'VXNlcjo0', fullName: 'Alexandre Anicio', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-21T23:02:53.693681+00:00', commentsCount: { @@ -163,9 +161,8 @@ export const commentEditMockData = { user: { id: 'VXNlcjo0', fullName: 'Alexandre Anicio', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-24T01:03:09.090046+00:00', commentsCount: { @@ -193,9 +190,8 @@ export const commentEditMockData = { user: { id: 'VXNlcjo0', fullName: 'Alexandre Anicio', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-21T23:02:53.693681+00:00', commentsCount: { @@ -273,9 +269,8 @@ export const commentsNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -303,9 +298,8 @@ export const commentsNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -333,9 +327,8 @@ export const commentsNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -363,9 +356,8 @@ export const commentsNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -393,9 +385,8 @@ export const commentsNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -422,9 +413,8 @@ export const replytoCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-04-17T19:53:45.601467+00:00', commentsCount: { @@ -449,9 +439,8 @@ export const replytoCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, ted: '2024-08-26T14:45:51.772548+00:00', commentsCount: { @@ -479,9 +468,8 @@ export const replytoCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-25T14:44:51.772548+00:00', commentsCount: { @@ -509,9 +497,8 @@ export const replytoCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-24T14:43:51.772548+00:00', commentsCount: { @@ -539,9 +526,8 @@ export const replytoCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-23T14:42:51.772548+00:00', commentsCount: { @@ -569,9 +555,8 @@ export const replytoCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-22T14:41:51.772548+00:00', commentsCount: { @@ -612,9 +597,8 @@ export const secondPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-04-17T19:53:45.601467+00:00', commentsCount: { @@ -639,9 +623,8 @@ export const secondPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-21T14:45:51.772548+00:00', commentsCount: { @@ -669,9 +652,8 @@ export const secondPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-20T14:45:51.772548+00:00', commentsCount: { @@ -699,9 +681,8 @@ export const secondPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-19T14:45:51.772548+00:00', commentsCount: { @@ -729,9 +710,8 @@ export const secondPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-18T14:45:51.772548+00:00', commentsCount: { @@ -759,9 +739,8 @@ export const secondPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-17T14:45:51.772548+00:00', commentsCount: { @@ -802,9 +781,8 @@ export const thirdPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-04-17T19:53:45.601467+00:00', commentsCount: { @@ -829,9 +807,8 @@ export const thirdPageOfRepliesCommentWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: '2024-08-16T14:45:51.772548+00:00', commentsCount: { @@ -887,9 +864,7 @@ export const commentsTestMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://cdn.example.com/avatar1.png', - }, + avatar: 'https://cdn.example.com/avatar1.png', }, reactionsCount: { total: 2 }, myReaction: null, @@ -912,9 +887,7 @@ export const commentsTestMockData = { user: { id: 'user-2', fullName: 'Jane Smith', - avatar: { - url: 'https://cdn.example.com/avatar2.png', - }, + avatar: 'https://cdn.example.com/avatar2.png', }, reactionsCount: { total: 3 }, myReaction: { @@ -960,9 +933,8 @@ export const commentsWithNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -986,9 +958,8 @@ export const commentsWithNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -1012,9 +983,8 @@ export const commentsWithNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -1038,9 +1008,8 @@ export const commentsWithNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -1064,9 +1033,8 @@ export const commentsWithNextPageMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 0, @@ -1110,9 +1078,8 @@ export const commentsWithElevenRepliesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, cursor: 'cursor-1', reactionsCount: { diff --git a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/resolvers.ts b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/resolvers.ts index 7d06239d7..1f816d9d6 100644 --- a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/resolvers.ts +++ b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/resolvers.ts @@ -31,9 +31,8 @@ export const commentCreateResolver: MockResolvers = { user: { id: 'VXNlcjo0', fullName: 'Alexandre Anicio', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: new Date().toISOString(), commentsCount: { @@ -85,9 +84,8 @@ export const commentReplyResolver: MockResolvers = { user: { id: 'VXNlcjo0', fullName: 'Alexandre Anicio', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, created: new Date().toISOString(), commentsCount: { diff --git a/packages/components/modules/comments/web/CommentsList/__storybook__/mockResolvers.ts b/packages/components/modules/comments/web/CommentsList/__storybook__/mockResolvers.ts index 49eebfa22..7ae188ee3 100644 --- a/packages/components/modules/comments/web/CommentsList/__storybook__/mockResolvers.ts +++ b/packages/components/modules/comments/web/CommentsList/__storybook__/mockResolvers.ts @@ -17,9 +17,8 @@ export const mockResolvers = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', }, reactionsCount: { total: 2, @@ -42,9 +41,8 @@ export const mockResolvers = { user: { id: 'user-2', fullName: 'Jane Smith', - avatar: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/c/9/19/resized/50/50/cb95449a94688af33f6e9bb090cf2936.png', - }, + avatar: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/c/9/19/resized/50/50/cb95449a94688af33f6e9bb090cf2936.png', }, reactionsCount: { total: 3, diff --git a/packages/components/modules/content-feed/common/graphql/fragments/ContentPostImage.ts b/packages/components/modules/content-feed/common/graphql/fragments/ContentPostImage.ts index bb3a52ca4..eb135f637 100644 --- a/packages/components/modules/content-feed/common/graphql/fragments/ContentPostImage.ts +++ b/packages/components/modules/content-feed/common/graphql/fragments/ContentPostImage.ts @@ -7,8 +7,6 @@ export const ContentPostImageFragment = graphql` height: { type: "Int", defaultValue: 0 } ) { id - image(width: $width, height: $height) { - url - } + image(width: $width, height: $height) } ` diff --git a/packages/components/modules/content-feed/web/PostHeader/index.tsx b/packages/components/modules/content-feed/web/PostHeader/index.tsx index 8d317f1ac..57178999b 100644 --- a/packages/components/modules/content-feed/web/PostHeader/index.tsx +++ b/packages/components/modules/content-feed/web/PostHeader/index.tsx @@ -19,7 +19,7 @@ const PostHeader: FC = ({ post }) => { return ( - + {name} diff --git a/packages/components/modules/content-feed/web/PostImageSlide/index.tsx b/packages/components/modules/content-feed/web/PostImageSlide/index.tsx index b004f4038..880eb3783 100644 --- a/packages/components/modules/content-feed/web/PostImageSlide/index.tsx +++ b/packages/components/modules/content-feed/web/PostImageSlide/index.tsx @@ -13,7 +13,7 @@ const PostImageSlide: FC = ({ imagesRef }) => { if (!target?.image) return null - return + return } export default PostImageSlide diff --git a/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts b/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts index 1f46f9bf1..fa174dbb2 100644 --- a/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts +++ b/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts @@ -4,9 +4,7 @@ export const RoomTitleFragment = graphql` fragment RoomTitleFragment on ChatRoom { isSoleAdmin id - image(width: 150, height: 150) { - url - } + image(width: 150, height: 150) title otherParticipant { profile { diff --git a/packages/components/modules/messages/common/graphql/fragments/SingleChatDetailsFragment.ts b/packages/components/modules/messages/common/graphql/fragments/SingleChatDetailsFragment.ts index 32f072e82..1a20ed60a 100644 --- a/packages/components/modules/messages/common/graphql/fragments/SingleChatDetailsFragment.ts +++ b/packages/components/modules/messages/common/graphql/fragments/SingleChatDetailsFragment.ts @@ -6,18 +6,14 @@ export const SingleChatDetailsFragment = graphql` isSoleAdmin isGroup title - image(width: 150, height: 150) { - url - } + image(width: 150, height: 150) otherParticipant { id role profile { id name - image(width: 150, height: 150) { - url - } + image(width: 150, height: 150) biography urlPath { path diff --git a/packages/components/modules/messages/common/utils.ts b/packages/components/modules/messages/common/utils.ts index 8c5061e60..ad628c506 100644 --- a/packages/components/modules/messages/common/utils.ts +++ b/packages/components/modules/messages/common/utils.ts @@ -14,7 +14,7 @@ export const useTitleAndImage = (titleRef: RoomTitleFragment$key | null | undefi const data = useFragment(RoomTitleFragment, titleRef ?? null) return { title: data?.title, - image: data?.image?.url, + image: data?.image ?? undefined, } } @@ -28,7 +28,7 @@ export const useSingleChatDetails = (chatRef: SingleChatDetailsFragment$key) => isGroup, isSoleAdmin, title, - image: image?.url, + image: image ?? undefined, } } if (!participant) { @@ -54,7 +54,7 @@ export const useSingleChatDetails = (chatRef: SingleChatDetailsFragment$key) => roomId: id, isGroup, title: participant?.profile?.name, - image: participant?.profile?.image?.url, + image: participant?.profile?.image ?? undefined, username: participant?.profile?.urlPath?.path, biography: participant?.profile?.biography, id: participant?.profile?.id, diff --git a/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateGroupListItem/index.tsx b/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateGroupListItem/index.tsx index 43bb5934e..fa19c6a15 100644 --- a/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateGroupListItem/index.tsx +++ b/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateGroupListItem/index.tsx @@ -40,7 +40,7 @@ const CreateGroupListItem = ({ profile: profileRef }: { profile: ProfileItemFrag - + {node?.name} diff --git a/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateRoomListItem/index.tsx b/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateRoomListItem/index.tsx index 721be279d..f7fa0d6fc 100644 --- a/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateRoomListItem/index.tsx +++ b/packages/components/modules/messages/native/ChatCreate/CreateRoomList/CreateRoomListItem/index.tsx @@ -58,7 +58,7 @@ const ChatRoomListItem = ({ profile: profileRef }: { profile: ProfileItemFragmen - + {node?.name} diff --git a/packages/components/modules/messages/native/ChatCreate/CreateRoomList/SelectedGroupMembers/index.tsx b/packages/components/modules/messages/native/ChatCreate/CreateRoomList/SelectedGroupMembers/index.tsx index 18f35d078..2a5044be0 100644 --- a/packages/components/modules/messages/native/ChatCreate/CreateRoomList/SelectedGroupMembers/index.tsx +++ b/packages/components/modules/messages/native/ChatCreate/CreateRoomList/SelectedGroupMembers/index.tsx @@ -59,7 +59,7 @@ const SelectedGroupMembers = () => { - + diff --git a/packages/components/modules/messages/native/ChatRoomPage/ChatRoomHeader/index.tsx b/packages/components/modules/messages/native/ChatRoomPage/ChatRoomHeader/index.tsx index 40ec7d928..3208556f7 100644 --- a/packages/components/modules/messages/native/ChatRoomPage/ChatRoomHeader/index.tsx +++ b/packages/components/modules/messages/native/ChatRoomPage/ChatRoomHeader/index.tsx @@ -16,7 +16,7 @@ const ChatRoomHeader: FC = ({ roomHeader, onChatDetailsClic return ( - + {title} diff --git a/packages/components/modules/messages/native/ChatRoomPage/MessagesList/MessagesGroup/UserMessage/index.tsx b/packages/components/modules/messages/native/ChatRoomPage/MessagesList/MessagesGroup/UserMessage/index.tsx index d9b5dcebe..52f6f3377 100644 --- a/packages/components/modules/messages/native/ChatRoomPage/MessagesList/MessagesGroup/UserMessage/index.tsx +++ b/packages/components/modules/messages/native/ChatRoomPage/MessagesList/MessagesGroup/UserMessage/index.tsx @@ -43,7 +43,7 @@ const UserMessage: FC = ({ {!isOwnMessage && ( {canShowAvatar && ( - + )} )} diff --git a/packages/components/modules/messages/native/GroupChatDetails/index.tsx b/packages/components/modules/messages/native/GroupChatDetails/index.tsx index 0e6cd640a..e0dfc6bdd 100644 --- a/packages/components/modules/messages/native/GroupChatDetails/index.tsx +++ b/packages/components/modules/messages/native/GroupChatDetails/index.tsx @@ -54,7 +54,7 @@ const GroupChatDetails = () => { {groups.image ? ( ) : ( diff --git a/packages/components/modules/messages/native/GroupDetailsPage/GroupProfile/index.tsx b/packages/components/modules/messages/native/GroupDetailsPage/GroupProfile/index.tsx index 3f4a2b4fb..c99bbbb12 100644 --- a/packages/components/modules/messages/native/GroupDetailsPage/GroupProfile/index.tsx +++ b/packages/components/modules/messages/native/GroupDetailsPage/GroupProfile/index.tsx @@ -20,7 +20,7 @@ const GroupProfile: FC = ({ return ( - + {title} diff --git a/packages/components/modules/messages/native/GroupDetailsPage/Members/MemberItem/index.tsx b/packages/components/modules/messages/native/GroupDetailsPage/Members/MemberItem/index.tsx index 570178279..2534d707f 100644 --- a/packages/components/modules/messages/native/GroupDetailsPage/Members/MemberItem/index.tsx +++ b/packages/components/modules/messages/native/GroupDetailsPage/Members/MemberItem/index.tsx @@ -101,7 +101,7 @@ const MemberItem: FC = ({ return ( - + diff --git a/packages/components/modules/messages/native/RoomsList/ChatCard/ChatCardComponent/index.tsx b/packages/components/modules/messages/native/RoomsList/ChatCard/ChatCardComponent/index.tsx index 86eccf5de..36c053a88 100644 --- a/packages/components/modules/messages/native/RoomsList/ChatCard/ChatCardComponent/index.tsx +++ b/packages/components/modules/messages/native/RoomsList/ChatCard/ChatCardComponent/index.tsx @@ -138,7 +138,7 @@ const ChatCardComponent: FC = ({ roomRef, isArchived }) onLongPress={onChatCardLongPress} > - + {title} {lastMessage && lastMessageTime ? ( diff --git a/packages/components/modules/messages/native/SingleChatDetailsPage/ProfileSummary/index.tsx b/packages/components/modules/messages/native/SingleChatDetailsPage/ProfileSummary/index.tsx index e5d33f724..d25ff17bf 100644 --- a/packages/components/modules/messages/native/SingleChatDetailsPage/ProfileSummary/index.tsx +++ b/packages/components/modules/messages/native/SingleChatDetailsPage/ProfileSummary/index.tsx @@ -14,7 +14,7 @@ const ProfileSummary: FC = ({ name, avatar, username }) => return ( - + {name} diff --git a/packages/components/modules/messages/native/SingleChatDetailsPage/index.tsx b/packages/components/modules/messages/native/SingleChatDetailsPage/index.tsx index bdaf50c16..5dc682b39 100644 --- a/packages/components/modules/messages/native/SingleChatDetailsPage/index.tsx +++ b/packages/components/modules/messages/native/SingleChatDetailsPage/index.tsx @@ -67,7 +67,7 @@ const SingleChatDetailsPage: FC = ({ roomId }) => { = ({ sx={{ alignSelf: 'center', justifySelf: 'center' }} width={48} height={48} - src={image} + src={image ?? undefined} /> {title} diff --git a/packages/components/modules/messages/web/ChatCreate/ChatRoomListItem/index.tsx b/packages/components/modules/messages/web/ChatCreate/ChatRoomListItem/index.tsx index 6e4d6dc36..bc2bded25 100644 --- a/packages/components/modules/messages/web/ChatCreate/ChatRoomListItem/index.tsx +++ b/packages/components/modules/messages/web/ChatCreate/ChatRoomListItem/index.tsx @@ -27,7 +27,7 @@ const ChatRoomListItem: FC = ({ profile: profileRef, onCh diff --git a/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx b/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx index 4f881b3aa..1f9e74de2 100644 --- a/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx +++ b/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx @@ -93,7 +93,7 @@ const ChatRoomHeader: FC = ({ className="self-start justify-self-center" width={32} height={32} - src={image} + src={image ?? undefined} sx={{ border: 'none', alignSelf: 'center' }} /> diff --git a/packages/components/modules/messages/web/GroupChatDetails/Body/index.tsx b/packages/components/modules/messages/web/GroupChatDetails/Body/index.tsx index 77dca307e..012dca198 100644 --- a/packages/components/modules/messages/web/GroupChatDetails/Body/index.tsx +++ b/packages/components/modules/messages/web/GroupChatDetails/Body/index.tsx @@ -25,7 +25,12 @@ const Body: FC = ({ return ( - + {title} diff --git a/packages/components/modules/messages/web/GroupChatDetails/ProfileCard/index.tsx b/packages/components/modules/messages/web/GroupChatDetails/ProfileCard/index.tsx index 487718e80..40678d33f 100644 --- a/packages/components/modules/messages/web/GroupChatDetails/ProfileCard/index.tsx +++ b/packages/components/modules/messages/web/GroupChatDetails/ProfileCard/index.tsx @@ -134,7 +134,7 @@ const ProfileCard: FC = ({ diff --git a/packages/components/modules/messages/web/GroupChatDetails/index.tsx b/packages/components/modules/messages/web/GroupChatDetails/index.tsx index 16ad78d73..8a07447cf 100644 --- a/packages/components/modules/messages/web/GroupChatDetails/index.tsx +++ b/packages/components/modules/messages/web/GroupChatDetails/index.tsx @@ -135,7 +135,7 @@ const GroupChatDetails: FC = ({ /> diff --git a/packages/components/modules/messages/web/GroupChatEdit/AddMemberCard/index.tsx b/packages/components/modules/messages/web/GroupChatEdit/AddMemberCard/index.tsx index 83dc10a47..7f9003ff8 100644 --- a/packages/components/modules/messages/web/GroupChatEdit/AddMemberCard/index.tsx +++ b/packages/components/modules/messages/web/GroupChatEdit/AddMemberCard/index.tsx @@ -48,7 +48,7 @@ const AddMemberCard: FC = ({ diff --git a/packages/components/modules/messages/web/GroupChatEdit/AddedMemberCard/index.tsx b/packages/components/modules/messages/web/GroupChatEdit/AddedMemberCard/index.tsx index 105f19109..55ad1311b 100644 --- a/packages/components/modules/messages/web/GroupChatEdit/AddedMemberCard/index.tsx +++ b/packages/components/modules/messages/web/GroupChatEdit/AddedMemberCard/index.tsx @@ -35,7 +35,7 @@ const AddedMemberCard: FC = ({ profile, handleRemoveMember diff --git a/packages/components/modules/messages/web/MessagesList/MessagesGroup/UserMessage/index.tsx b/packages/components/modules/messages/web/MessagesList/MessagesGroup/UserMessage/index.tsx index 4c82a71ae..243248197 100644 --- a/packages/components/modules/messages/web/MessagesList/MessagesGroup/UserMessage/index.tsx +++ b/packages/components/modules/messages/web/MessagesList/MessagesGroup/UserMessage/index.tsx @@ -77,7 +77,7 @@ const UserMessage: FC = ({ className="self-start justify-self-center" width={32} height={32} - src={message?.profile?.image?.url} + src={message?.profile?.image ?? undefined} sx={{ border: 'none' }} showDeletedUser={isProfileNullOrUndefined} /> diff --git a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx index d99f7358c..b56951ea7 100644 --- a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx +++ b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/MessagesGroupWithQuery/index.tsx @@ -40,7 +40,7 @@ const MessagesGroupWithQuery = () => { profile: { id: 'profile-456', name: 'Alice', - image: { url: '' }, + image: '', }, isRead: true, }, diff --git a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/mockResolvers.ts b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/mockResolvers.ts index 74adcdee1..ac56f60b1 100644 --- a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/mockResolvers.ts +++ b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/mockResolvers.ts @@ -12,9 +12,7 @@ export const mockResolvers = { profile: { id: 'profile-123', name: 'Você', - image: { - url: '', - }, + image: '', }, messageType: 'USER_MESSAGE', }), diff --git a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/stories.tsx b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/stories.tsx index 1ec49ecee..cecad9e36 100644 --- a/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/stories.tsx +++ b/packages/components/modules/messages/web/MessagesList/MessagesGroup/__storybook__/stories.tsx @@ -40,7 +40,7 @@ export const FromOtherUser: Story = { profile: { id: 'profile-456', name: 'Alice', - image: { url: '' }, + image: '', }, messageType: 'USER_MESSAGE', }), diff --git a/packages/components/modules/messages/web/MessagesList/__storybook__/mockResolvers.ts b/packages/components/modules/messages/web/MessagesList/__storybook__/mockResolvers.ts index cf5d92236..06d7f236a 100644 --- a/packages/components/modules/messages/web/MessagesList/__storybook__/mockResolvers.ts +++ b/packages/components/modules/messages/web/MessagesList/__storybook__/mockResolvers.ts @@ -16,9 +16,7 @@ export const mockResolvers = { profile: { id: 'profile-456', name: 'Alice', - image: { - url: '', - }, + image: '', }, isRead: false, messageType: 'USER_MESSAGE', @@ -36,9 +34,7 @@ export const mockResolvers = { profile: { id: 'profile-123', name: 'Você', - image: { - url: '', - }, + image: '', }, isRead: true, messageType: 'USER_MESSAGE', @@ -56,9 +52,7 @@ export const mockResolvers = { profile: { id: 'profile-456', name: 'Alice', - image: { - url: '', - }, + image: '', }, isRead: true, messageType: 'USER_MESSAGE', diff --git a/packages/components/modules/messages/web/ProfileSummary/Body/index.tsx b/packages/components/modules/messages/web/ProfileSummary/Body/index.tsx index 0335d4219..736a12e6d 100644 --- a/packages/components/modules/messages/web/ProfileSummary/Body/index.tsx +++ b/packages/components/modules/messages/web/ProfileSummary/Body/index.tsx @@ -29,7 +29,12 @@ const Body: FC = ({ avatarSize = 144, chatRoomRef }) => { return ( - + {title} diff --git a/packages/components/modules/messages/web/__shared__/EditGroupTitleAndImage/index.tsx b/packages/components/modules/messages/web/__shared__/EditGroupTitleAndImage/index.tsx index a2e9d65e9..4885401f4 100644 --- a/packages/components/modules/messages/web/__shared__/EditGroupTitleAndImage/index.tsx +++ b/packages/components/modules/messages/web/__shared__/EditGroupTitleAndImage/index.tsx @@ -52,7 +52,7 @@ const EditGroupTitleAndImage: FC = ({ = ({ diff --git a/packages/components/modules/notifications/native/NotificationsList/NotificationItem/CommentCreated/index.tsx b/packages/components/modules/notifications/native/NotificationsList/NotificationItem/CommentCreated/index.tsx index f48238816..4250f35f6 100644 --- a/packages/components/modules/notifications/native/NotificationsList/NotificationItem/CommentCreated/index.tsx +++ b/packages/components/modules/notifications/native/NotificationsList/NotificationItem/CommentCreated/index.tsx @@ -11,7 +11,7 @@ const CommentCreated: FC = ({ notification }) => { return ( - + = ({ notification }) => { return ( - + = ({ notification }) => { return ( - + = ({ return ( diff --git a/packages/components/modules/notifications/web/NotificationsList/NotificationItem/CommentReply/index.tsx b/packages/components/modules/notifications/web/NotificationsList/NotificationItem/CommentReply/index.tsx index ca6abca5c..0d693723c 100644 --- a/packages/components/modules/notifications/web/NotificationsList/NotificationItem/CommentReply/index.tsx +++ b/packages/components/modules/notifications/web/NotificationsList/NotificationItem/CommentReply/index.tsx @@ -28,7 +28,7 @@ const CommentReply: FC = ({ return ( diff --git a/packages/components/modules/notifications/web/NotificationsList/NotificationItem/ReactionCreated/index.tsx b/packages/components/modules/notifications/web/NotificationsList/NotificationItem/ReactionCreated/index.tsx index 7239243ed..45506d0e4 100644 --- a/packages/components/modules/notifications/web/NotificationsList/NotificationItem/ReactionCreated/index.tsx +++ b/packages/components/modules/notifications/web/NotificationsList/NotificationItem/ReactionCreated/index.tsx @@ -25,7 +25,7 @@ const ReactionCreated: FC = ({ return ( diff --git a/packages/components/modules/profiles/native/ProfileComponent/index.tsx b/packages/components/modules/profiles/native/ProfileComponent/index.tsx index db8b15706..878aafc74 100644 --- a/packages/components/modules/profiles/native/ProfileComponent/index.tsx +++ b/packages/components/modules/profiles/native/ProfileComponent/index.tsx @@ -23,8 +23,11 @@ const ProfileComponent = ({ profile: profileRef }: ProfileComponentProps) => { return ( - - + + diff --git a/packages/components/modules/profiles/web/ProfileComponent/index.tsx b/packages/components/modules/profiles/web/ProfileComponent/index.tsx index 3d21eac11..14f557165 100644 --- a/packages/components/modules/profiles/web/ProfileComponent/index.tsx +++ b/packages/components/modules/profiles/web/ProfileComponent/index.tsx @@ -108,7 +108,7 @@ const ProfileComponent: FC = ({ ), ].filter(Boolean) - const bannerSrc = profile?.bannerImage?.url || bannerFallback + const bannerSrc = profile?.bannerImage || bannerFallback return (
@@ -130,7 +130,7 @@ const ProfileComponent: FC = ({ className="self-start justify-self-center" width={96} height={96} - src={profile?.image?.url} + src={profile?.image ?? undefined} /> diff --git a/packages/components/modules/profiles/web/ProfileMembers/__tests__/Members.cy.tsx b/packages/components/modules/profiles/web/ProfileMembers/__tests__/Members.cy.tsx index 9c7fb111a..f4d643d53 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/__tests__/Members.cy.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/__tests__/Members.cy.tsx @@ -31,9 +31,7 @@ describe('Members', () => { canChangeRole: true, id: 'UHJvZmlsZTox', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, }, }, @@ -132,9 +130,7 @@ describe('Members', () => { canChangeRole: true, id: 'UHJvZmlsZTox', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, }, }, diff --git a/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts b/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts index 595eaf796..b58925dde 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts @@ -72,9 +72,7 @@ export const allRolesMembersListMockData = { canChangeRole: true, id: 'UHJvZmlsZTox', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, members: { totalCount: 3, @@ -102,9 +100,7 @@ export const fullMembersListMockData = { canChangeRole: true, id: 'owner-id', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, members: { totalCount: 6, @@ -132,9 +128,7 @@ export const fullMembersListNextPageMockData = { canChangeRole: true, id: 'owner-id', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, members: { totalCount: 9, diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts b/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts index 64f3b5891..0f40ae5c2 100644 --- a/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts @@ -60,9 +60,8 @@ export const profileSettingsImageUpdateData = { status: 'ACTIVE', name: 'John Doe', biography: 'John Doe is a software engineer at Google.', - image: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + image: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', bannerImage: { url: null, }, @@ -121,9 +120,8 @@ export const profileSettingsBannerUpdateData = { image: { url: null, }, - bannerImage: { - url: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - }, + bannerImage: + 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', isFollowedByMe: false, followersCount: 0, followingCount: 0, @@ -176,12 +174,9 @@ export const mockDataWithBanner = { status: 'ACTIVE', name: 'John Doe', biography: 'John Doe is a software engineer at Google.', - image: { - url: 'http://localhost:8000/media/profile_images/5dfe5729-1730-4fe6-b22a-a0f15f65a754.png.96x96_q85.png', - }, - bannerImage: { - url: 'http://localhost:8000/media/banner_images/test-banner.png', - }, + image: + 'http://localhost:8000/media/profile_images/5dfe5729-1730-4fe6-b22a-a0f15f65a754.png.96x96_q85.png', + bannerImage: 'http://localhost:8000/media/banner_images/test-banner.png', isFollowedByMe: false, followersCount: 0, followingCount: 0, diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx b/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx index 58152f845..9a493e168 100644 --- a/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx @@ -161,7 +161,7 @@ const ProfileSettingsComponent: FC = ({ profile: )} = ({ profile: Date: Sat, 11 Jul 2026 01:44:42 +0000 Subject: [PATCH 07/21] profiles invitations: adapt image fields to String contract Master's InviteMemberDialog queried image { url }; on this branch Profile.image is a String scalar, so select it directly and use it as avatarUrl. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../profiles/common/graphql/fragments/InviteMembersSearch.ts | 4 +--- .../ProfileMembers/InviteMemberDialog/MemberSearch/index.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/components/modules/profiles/common/graphql/fragments/InviteMembersSearch.ts b/packages/components/modules/profiles/common/graphql/fragments/InviteMembersSearch.ts index 8351a833f..3078ecbb5 100644 --- a/packages/components/modules/profiles/common/graphql/fragments/InviteMembersSearch.ts +++ b/packages/components/modules/profiles/common/graphql/fragments/InviteMembersSearch.ts @@ -17,9 +17,7 @@ export const InviteMembersSearchFragment = graphql` node { id name - image(width: 100, height: 100) { - url - } + image(width: 100, height: 100) urlPath { path } diff --git a/packages/components/modules/profiles/web/ProfileMembers/InviteMemberDialog/MemberSearch/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/InviteMemberDialog/MemberSearch/index.tsx index 3e9a0e515..e873487de 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/InviteMemberDialog/MemberSearch/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/InviteMemberDialog/MemberSearch/index.tsx @@ -68,7 +68,7 @@ const MemberSearch: FC = ({ selected, onAdd, onRemove }) => { userId, name: node.name ?? node.urlPath?.path ?? '', handle: node.urlPath?.path ?? undefined, - avatarUrl: node.image?.url ?? undefined, + avatarUrl: node.image ?? undefined, }) } return options From c9c694cc91227e54a4e6dca9f92fffc9522e7218 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 11 Jul 2026 02:01:09 +0000 Subject: [PATCH 08/21] files: document zustand store correctly, drop any cast in FileUploadList README claimed a FileUploadProvider JSX wrapper; the store is a module-level zustand singleton, so no provider exists. Documented the shared-state implication for multiple targets and fixed the edges.map any cast. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- packages/components/modules/files/README.md | 33 ++++++++----------- .../files/web/FileUploadList/index.tsx | 2 +- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/components/modules/files/README.md b/packages/components/modules/files/README.md index eb6c38757..0e4f7ad75 100644 --- a/packages/components/modules/files/README.md +++ b/packages/components/modules/files/README.md @@ -422,20 +422,21 @@ Large files are automatically split into chunks for reliable upload: ### Upload State Management -The file upload state is managed through React Context: +The file upload state is managed by a global zustand store — no provider wrapper is +needed. Call the hook from any component: ```typescript -import { FileUploadProvider, useFileUpload } from '@baseapp-frontend/components/modules/files/common' - -// Wrap your app with the provider - - - +import { useFileUpload } from '@baseapp-frontend/components/modules/files/common' // Access upload state anywhere const { files, removeFile, pauseFile, resumeFile } = useFileUpload() ``` +Because the store is a module-level singleton, upload state is shared across every +mounted `FileUpload`/`FileUploadList`. When rendering multiple upload targets on the +same screen, in-progress items from all targets appear in each list's "Uploading" +section until they complete and attach. + ### File Upload Status Files go through several states during upload: @@ -456,14 +457,7 @@ File operations respect backend permissions: ## Best Practices -1. **Always wrap with FileUploadProvider** - ```typescript - - - - ``` - -2. **Handle errors gracefully** +1. **Handle errors gracefully** ```typescript ``` -3. **Set appropriate file limits** +2. **Set appropriate file limits** ```typescript ``` -4. **Provide targetObjectId for deletion** +3. **Provide targetObjectId for deletion** ```typescript ``` -5. **Use platform-specific download handlers** +4. **Use platform-specific download handlers** ```typescript // Different implementations for web and native const downloadHandler = Platform.select({ @@ -523,8 +517,7 @@ import { calculateProgress, isImageFile, - // Context - FileUploadProvider, + // Store useFileUploadStore, // GraphQL diff --git a/packages/components/modules/files/web/FileUploadList/index.tsx b/packages/components/modules/files/web/FileUploadList/index.tsx index cde4a1a95..ab40abb2b 100644 --- a/packages/components/modules/files/web/FileUploadList/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/index.tsx @@ -52,7 +52,7 @@ const FileUploadList: FC = ({ Attached Files ({target.files.edges.length}) - {target.files.edges.map((edge: any) => { + {target.files.edges.map((edge) => { if (!edge?.node) return null return ( From 7529c87b0eb4b926b3a8b673f544cb342e385c7e Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 11 Jul 2026 02:13:16 +0000 Subject: [PATCH 09/21] files: fix chunk bookkeeping and harden pause/resume in useChunkedUpload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Record ETags per chunk index as chunks complete (new recordChunkEtag store action); completedChunks was chunkIndex+1, which under 3-way parallel uploads mis-counted on out-of-order completion, and etags were only stored at the very end — a paused upload resumed with wrong slices and empty parts. - Resume now uploads exactly the chunks with missing ETags (non-contiguous safe) against their original presigned URLs. - Track initiatedAt/expiresIn from the initiate response; resuming past expiry aborts the stale upload (best effort) and transparently re-initiates. - Extract shared startUpload used by upload/retry/expired-resume. - Drop parentContentType/parentObjectId from initiate payloads — the API accepts parent_id only, so the fields were silently ignored; attaching is done via fileAttachToTarget. - Select id on FilesList file nodes (was masked by the removed any cast); remove leftover merge-conflict marker in ProfileMembers MemberItem. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../modules/files/common/constants.ts | 1 + .../context/FileUploadProvider/index.tsx | 20 ++ .../context/FileUploadProvider/types.ts | 1 + .../files/common/graphql/queries/FilesList.ts | 1 + .../common/hooks/useChunkedUpload/index.ts | 277 ++++++++---------- .../common/hooks/useChunkedUpload/types.ts | 5 - .../components/modules/files/common/types.ts | 7 +- .../web/ProfileMembers/MemberItem/index.tsx | 1 - 8 files changed, 150 insertions(+), 163 deletions(-) diff --git a/packages/components/modules/files/common/constants.ts b/packages/components/modules/files/common/constants.ts index e362893a6..095f94e62 100644 --- a/packages/components/modules/files/common/constants.ts +++ b/packages/components/modules/files/common/constants.ts @@ -2,6 +2,7 @@ export const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB - Backend minimum for multipart export const MAX_CONCURRENT_CHUNKS = 3 // Limit concurrent uploads to avoid overwhelming browser export const RETRY_ATTEMPTS = 3 export const RETRY_DELAY = 1000 // ms +export const URL_EXPIRY_SAFETY_MARGIN_MS = 60 * 1000 // Treat URLs expiring within 1min as expired export enum FileUploadStatus { PENDING = 'pending', diff --git a/packages/components/modules/files/common/context/FileUploadProvider/index.tsx b/packages/components/modules/files/common/context/FileUploadProvider/index.tsx index 0dc6abeae..c8527bcf2 100644 --- a/packages/components/modules/files/common/context/FileUploadProvider/index.tsx +++ b/packages/components/modules/files/common/context/FileUploadProvider/index.tsx @@ -69,6 +69,26 @@ export const useFileUploadStore = create((set, get) => ({ }) }, + recordChunkEtag: (fileId: string, chunkIndex: number, etag: string) => { + set((state) => { + const newFiles = new Map(state.files) + const existing = newFiles.get(fileId) + + if (existing) { + const newEtags = [...existing.etags] + newEtags[chunkIndex] = etag + + newFiles.set(fileId, { + ...existing, + etags: newEtags, + completedChunks: newEtags.filter(Boolean).length, + }) + } + + return { files: newFiles } + }) + }, + removeFile: (id: string) => { set((state) => { const newFiles = new Map(state.files) diff --git a/packages/components/modules/files/common/context/FileUploadProvider/types.ts b/packages/components/modules/files/common/context/FileUploadProvider/types.ts index 9cf3f8534..ff679144e 100644 --- a/packages/components/modules/files/common/context/FileUploadProvider/types.ts +++ b/packages/components/modules/files/common/context/FileUploadProvider/types.ts @@ -5,6 +5,7 @@ export interface FileUploadState { addFile: (file: File) => string updateFileProgress: (id: string, progress: Partial) => void updateChunkProgress: (fileId: string, chunkIndex: number, loaded: number, total: number) => void + recordChunkEtag: (fileId: string, chunkIndex: number, etag: string) => void removeFile: (id: string) => void pauseFile: (id: string) => void resumeFile: (id: string) => void diff --git a/packages/components/modules/files/common/graphql/queries/FilesList.ts b/packages/components/modules/files/common/graphql/queries/FilesList.ts index 8895990cd..17242388e 100644 --- a/packages/components/modules/files/common/graphql/queries/FilesList.ts +++ b/packages/components/modules/files/common/graphql/queries/FilesList.ts @@ -12,6 +12,7 @@ export const FilesListFragment = graphql` files(first: $first, after: $after) @connection(key: "FilesList_files") { edges { node { + id ...FileItem_file } } diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts index a83eab500..8366e996d 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts @@ -2,80 +2,103 @@ import { useCallback } from 'react' import { axios } from '@baseapp-frontend/utils' -import { CHUNK_SIZE, FileUploadStatus } from '../../constants' +import { CHUNK_SIZE, FileUploadStatus, URL_EXPIRY_SAFETY_MARGIN_MS } from '../../constants' import { useFileUploadStore } from '../../context/FileUploadProvider' import { useFileUpload } from '../../context/useFileUpload' import type { CompleteUploadPart, InitiateUploadResponse } from '../../types' import { chunkFile, uploadChunks } from '../../utils' -import type { UploadFileOptions, UseChunkedUploadOptions } from './types' +import type { UseChunkedUploadOptions } from './types' + +const areUrlsExpired = (initiatedAt?: number, expiresIn?: number): boolean => { + if (!initiatedAt || !expiresIn) return true + return Date.now() >= initiatedAt + expiresIn * 1000 - URL_EXPIRY_SAFETY_MARGIN_MS +} + +const buildParts = (etags: (string | undefined)[]): CompleteUploadPart[] => + etags.map((etag, idx) => { + if (!etag) { + throw new Error(`Missing ETag for part ${idx + 1}`) + } + return { partNumber: idx + 1, etag } + }) export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { const { addFile, updateFileProgress, updateChunkProgress } = useFileUpload() + /** + * Initiate a fresh multipart upload for `file` and upload every chunk. + * Shared by uploadFile, retryUpload and resumeUpload (when the original + * presigned URLs have expired). + */ + const startUpload = useCallback( + async (fileId: string, file: File) => { + const { recordChunkEtag } = useFileUploadStore.getState() + const chunks = chunkFile(file) + const abortController = new AbortController() + + updateFileProgress(fileId, { + status: FileUploadStatus.PENDING, + totalChunks: chunks.length, + completedChunks: 0, + uploadedBytes: 0, + chunkProgress: new Map(), + etags: [], + error: undefined, + abortController, + }) + + const data: InitiateUploadResponse = await axios.post('files/uploads', { + fileName: file.name, + fileSize: file.size, + fileContentType: file.type, + numParts: chunks.length, + partSize: CHUNK_SIZE, + }) + + updateFileProgress(fileId, { + status: FileUploadStatus.UPLOADING, + uploadId: data.uploadId, + backendId: data.id, + fileRelayId: data.relayId, + presignedUrls: data.presignedUrls, + initiatedAt: Date.now(), + expiresIn: data.expiresIn, + }) + + const etags = await uploadChunks({ + chunks, + presignedUrls: data.presignedUrls.map((p) => p.url), + abortSignal: abortController.signal, + onProgress: (chunkIndex, loaded, total) => { + updateChunkProgress(fileId, chunkIndex, loaded, total) + }, + onChunkComplete: (chunkIndex, etag) => { + recordChunkEtag(fileId, chunkIndex, etag) + }, + }) + + await axios.post(`files/uploads/${data.id}/complete`, { + parts: buildParts(etags), + }) + + updateFileProgress(fileId, { + status: FileUploadStatus.COMPLETED, + etags, + }) + + return data.relayId + }, + [updateFileProgress, updateChunkProgress], + ) + const uploadFile = useCallback( - async (file: File, uploadOptions?: UploadFileOptions) => { + async (file: File) => { const fileId = addFile(file) try { - const chunks = chunkFile(file) - const abortController = new AbortController() - - updateFileProgress(fileId, { - totalChunks: chunks.length, - abortController, - }) - - const data: InitiateUploadResponse = await axios.post('files/uploads', { - fileName: file.name, - fileSize: file.size, - fileContentType: file.type, - numParts: chunks.length, - partSize: CHUNK_SIZE, - ...(uploadOptions?.targetObjectId && - uploadOptions?.parentContentType && { - parentContentType: uploadOptions.parentContentType, - parentObjectId: uploadOptions.targetObjectId, - }), - }) - - updateFileProgress(fileId, { - status: FileUploadStatus.UPLOADING, - uploadId: data.uploadId, - fileRelayId: data.relayId, - presignedUrls: data.presignedUrls, - }) - - const etags = await uploadChunks({ - chunks, - presignedUrls: data.presignedUrls.map((p: { url: string }) => p.url), - abortSignal: abortController.signal, - onProgress: (chunkIndex, loaded, total) => { - updateChunkProgress(fileId, chunkIndex, loaded, total) - }, - onChunkComplete: (chunkIndex) => { - updateFileProgress(fileId, { - completedChunks: chunkIndex + 1, - }) - }, - }) - - const parts: CompleteUploadPart[] = etags.map((etag, idx) => ({ - partNumber: idx + 1, - etag, - })) - - await axios.post(`files/uploads/${data.id}/complete`, { - parts, - }) - - updateFileProgress(fileId, { - status: FileUploadStatus.COMPLETED, - etags, - }) - - options?.onUploadComplete?.(fileId, data.relayId) - - return data.relayId + const relayId = await startUpload(fileId, file) + options?.onUploadComplete?.(fileId, relayId) + return relayId } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Upload failed' @@ -89,7 +112,7 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { throw error } }, - [addFile, updateFileProgress, updateChunkProgress, options], + [addFile, startUpload, updateFileProgress, options], ) const pauseUpload = useCallback((fileId: string) => { @@ -99,8 +122,7 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { const resumeUpload = useCallback( async (fileId: string) => { - const { resumeFile } = useFileUploadStore.getState() - const { files } = useFileUploadStore.getState() + const { files, resumeFile, recordChunkEtag } = useFileUploadStore.getState() const fileProgress = files.get(fileId) if (!fileProgress || fileProgress.status !== FileUploadStatus.PAUSED) { @@ -110,6 +132,17 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { resumeFile(fileId) try { + // The original presigned URLs expire; past the deadline the only safe + // path is aborting the stale upload and starting a fresh one. + if (areUrlsExpired(fileProgress.initiatedAt, fileProgress.expiresIn)) { + if (fileProgress.backendId) { + await axios.delete(`files/uploads/${fileProgress.backendId}`).catch(() => {}) + } + const relayId = await startUpload(fileId, fileProgress.file) + options?.onUploadComplete?.(fileId, relayId) + return + } + const chunks = chunkFile(fileProgress.file) const abortController = new AbortController() @@ -118,35 +151,32 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { abortController, }) - const { completedChunks } = fileProgress - const remainingChunks = chunks.slice(completedChunks) - const remainingUrls = - fileProgress.presignedUrls?.slice(completedChunks).map((p: { url: string }) => p.url) || - [] + // Chunks complete out of order (parallel uploads), so resume from the + // per-index ETag record rather than assuming a contiguous prefix. + const presignedUrls = fileProgress.presignedUrls ?? [] + const pendingIndexes = chunks + .map((_, index) => index) + .filter((index) => !fileProgress.etags[index]) const etags = await uploadChunks({ - chunks: remainingChunks, - presignedUrls: remainingUrls, + chunks: pendingIndexes.map((index) => chunks[index]!), + presignedUrls: pendingIndexes.map((index) => presignedUrls[index]!.url), abortSignal: abortController.signal, onProgress: (chunkIndex, loaded, total) => { - updateChunkProgress(fileId, completedChunks + chunkIndex, loaded, total) + updateChunkProgress(fileId, pendingIndexes[chunkIndex]!, loaded, total) }, - onChunkComplete: (chunkIndex) => { - updateFileProgress(fileId, { - completedChunks: completedChunks + chunkIndex + 1, - }) + onChunkComplete: (chunkIndex, etag) => { + recordChunkEtag(fileId, pendingIndexes[chunkIndex]!, etag) }, }) - const allEtags = [...fileProgress.etags, ...etags] - - const parts: CompleteUploadPart[] = allEtags.map((etag, idx) => ({ - partNumber: idx + 1, - etag, - })) + const allEtags = [...fileProgress.etags] + pendingIndexes.forEach((originalIndex, i) => { + allEtags[originalIndex] = etags[i] + }) - await axios.post(`files/uploads/${fileProgress.id}/complete`, { - parts, + await axios.post(`files/uploads/${fileProgress.backendId}/complete`, { + parts: buildParts(allEtags), }) updateFileProgress(fileId, { @@ -168,11 +198,11 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { throw error } }, - [updateFileProgress, updateChunkProgress, options], + [startUpload, updateFileProgress, updateChunkProgress, options], ) const retryUpload = useCallback( - async (fileId: string, uploadOptions?: UploadFileOptions) => { + async (fileId: string) => { const { files } = useFileUploadStore.getState() const fileProgress = files.get(fileId) @@ -180,73 +210,10 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { return undefined } - const { file } = fileProgress - try { - const chunks = chunkFile(file) - const abortController = new AbortController() - - updateFileProgress(fileId, { - status: FileUploadStatus.UPLOADING, - totalChunks: chunks.length, - completedChunks: 0, - uploadedBytes: 0, - chunkProgress: new Map(), - etags: [], - error: undefined, - abortController, - }) - - const data: InitiateUploadResponse = await axios.post('files/uploads', { - fileName: file.name, - fileSize: file.size, - fileContentType: file.type, - numParts: chunks.length, - partSize: CHUNK_SIZE, - ...(uploadOptions?.targetObjectId && - uploadOptions?.parentContentType && { - parentContentType: uploadOptions.parentContentType, - parentObjectId: uploadOptions.targetObjectId, - }), - }) - - updateFileProgress(fileId, { - uploadId: data.uploadId, - fileRelayId: data.relayId, - presignedUrls: data.presignedUrls, - }) - - const etags = await uploadChunks({ - chunks, - presignedUrls: data.presignedUrls.map((p: { url: string }) => p.url), - abortSignal: abortController.signal, - onProgress: (chunkIndex, loaded, total) => { - updateChunkProgress(fileId, chunkIndex, loaded, total) - }, - onChunkComplete: (chunkIndex) => { - updateFileProgress(fileId, { - completedChunks: chunkIndex + 1, - }) - }, - }) - - const parts: CompleteUploadPart[] = etags.map((etag, idx) => ({ - partNumber: idx + 1, - etag, - })) - - await axios.post(`files/uploads/${data.id}/complete`, { - parts, - }) - - updateFileProgress(fileId, { - status: FileUploadStatus.COMPLETED, - etags, - }) - - options?.onUploadComplete?.(fileId, data.relayId) - - return data.relayId + const relayId = await startUpload(fileId, fileProgress.file) + options?.onUploadComplete?.(fileId, relayId) + return relayId } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Retry failed' @@ -260,10 +227,10 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { throw error } }, - [updateFileProgress, updateChunkProgress, options], + [startUpload, updateFileProgress, options], ) return { uploadFile, pauseUpload, resumeUpload, retryUpload } } -export type { UseChunkedUploadOptions, UploadFileOptions } from './types' +export type { UseChunkedUploadOptions } from './types' diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts index c9843fbc7..b842d774f 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/types.ts @@ -3,8 +3,3 @@ export interface UseChunkedUploadOptions { onUploadError?: (fileId: string, error: Error) => void onUploadProgress?: (fileId: string, progress: number) => void } - -export interface UploadFileOptions { - targetObjectId?: string - parentContentType?: string -} diff --git a/packages/components/modules/files/common/types.ts b/packages/components/modules/files/common/types.ts index 2ffd144d1..d1d1cb327 100644 --- a/packages/components/modules/files/common/types.ts +++ b/packages/components/modules/files/common/types.ts @@ -22,14 +22,17 @@ export interface FileUploadProgress { chunkProgress: Map error?: string uploadId?: string // Backend upload ID + backendId?: string // Backend file id (public_id) for upload endpoints fileRelayId?: string // GraphQL relay ID after completion - etags: string[] // ETags from S3 + etags: (string | undefined)[] // ETags from S3, sparse-indexed by chunk presignedUrls?: PresignedUrl[] + initiatedAt?: number // Epoch ms when the upload was initiated + expiresIn?: number // Presigned URL lifetime in seconds abortController?: AbortController // For pausing/aborting uploads } export interface InitiateUploadResponse { - id: number + id: string relayId: string uploadId: string uploadStatus: string diff --git a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx index 1aa5cf293..15d22e35a 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/MemberItem/index.tsx @@ -329,7 +329,6 @@ const MemberItem: FC = ({ {renderRoleButton()} ->>>>>>> origin/master ) } From e3b6b4c9ed1134e5d62486179f841bd29110f5e0 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 11 Jul 2026 02:16:25 +0000 Subject: [PATCH 10/21] files: jest coverage for chunking, upload retries, store, and resume paths chunkFile slicing, uploadChunks (out-of-order ETag indexing, retry/backoff, abort short-circuit), the upload store (recordChunkEtag sparse counting, pause/resume/retry transitions), and useChunkedUpload (initiate/complete payloads, sparse resume, expired-URL re-initiation, failed-state handling). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../context/__tests__/fileUploadStore.test.ts | 110 ++++++++++ .../__tests__/useChunkedUpload.test.ts | 189 ++++++++++++++++++ .../common/utils/__tests__/chunkFile.test.ts | 27 +++ .../utils/__tests__/uploadChunks.test.ts | 108 ++++++++++ 4 files changed, 434 insertions(+) create mode 100644 packages/components/modules/files/common/context/__tests__/fileUploadStore.test.ts create mode 100644 packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts create mode 100644 packages/components/modules/files/common/utils/__tests__/chunkFile.test.ts create mode 100644 packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts diff --git a/packages/components/modules/files/common/context/__tests__/fileUploadStore.test.ts b/packages/components/modules/files/common/context/__tests__/fileUploadStore.test.ts new file mode 100644 index 000000000..76831c1a3 --- /dev/null +++ b/packages/components/modules/files/common/context/__tests__/fileUploadStore.test.ts @@ -0,0 +1,110 @@ +import { FileUploadStatus } from '../../constants' +import { useFileUploadStore } from '../FileUploadProvider' + +const makeFile = () => new File(['content'], 'test.txt') + +const resetStore = () => { + useFileUploadStore.setState({ files: new Map() }) +} + +describe('useFileUploadStore', () => { + beforeEach(resetStore) + + it('addFile registers a pending file and returns its id', () => { + const id = useFileUploadStore.getState().addFile(makeFile()) + const progress = useFileUploadStore.getState().files.get(id) + + expect(progress).toMatchObject({ + status: FileUploadStatus.PENDING, + completedChunks: 0, + etags: [], + }) + }) + + it('recordChunkEtag stores ETags sparsely and derives completedChunks', () => { + const { addFile, recordChunkEtag } = useFileUploadStore.getState() + const id = addFile(makeFile()) + + // Chunks complete out of order. + recordChunkEtag(id, 2, 'etag-2') + recordChunkEtag(id, 0, 'etag-0') + + const progress = useFileUploadStore.getState().files.get(id)! + expect(progress.etags).toEqual(['etag-0', undefined, 'etag-2']) + expect(progress.completedChunks).toBe(2) + + recordChunkEtag(id, 1, 'etag-1') + expect(useFileUploadStore.getState().files.get(id)!.completedChunks).toBe(3) + }) + + it('pauseFile aborts the in-flight controller and only pauses uploading files', () => { + const { addFile, updateFileProgress, pauseFile } = useFileUploadStore.getState() + const id = addFile(makeFile()) + const abortController = new AbortController() + + pauseFile(id) // PENDING — no-op + expect(useFileUploadStore.getState().files.get(id)!.status).toBe(FileUploadStatus.PENDING) + + updateFileProgress(id, { status: FileUploadStatus.UPLOADING, abortController }) + pauseFile(id) + + const progress = useFileUploadStore.getState().files.get(id)! + expect(progress.status).toBe(FileUploadStatus.PAUSED) + expect(progress.abortController).toBeUndefined() + expect(abortController.signal.aborted).toBe(true) + }) + + it('resumeFile moves paused files back to pending', () => { + const { addFile, updateFileProgress, resumeFile } = useFileUploadStore.getState() + const id = addFile(makeFile()) + + updateFileProgress(id, { status: FileUploadStatus.PAUSED }) + resumeFile(id) + + expect(useFileUploadStore.getState().files.get(id)!.status).toBe(FileUploadStatus.PENDING) + }) + + it('retryFile resets progress for failed files only', () => { + const { addFile, updateFileProgress, retryFile } = useFileUploadStore.getState() + const id = addFile(makeFile()) + + updateFileProgress(id, { + status: FileUploadStatus.FAILED, + completedChunks: 2, + etags: ['a', 'b'], + error: 'boom', + }) + retryFile(id) + + const progress = useFileUploadStore.getState().files.get(id)! + expect(progress).toMatchObject({ + status: FileUploadStatus.PENDING, + completedChunks: 0, + etags: [], + error: undefined, + }) + }) + + it('clearCompleted removes only completed files', () => { + const { addFile, updateFileProgress, clearCompleted } = useFileUploadStore.getState() + const done = addFile(makeFile()) + const uploading = addFile(makeFile()) + + updateFileProgress(done, { status: FileUploadStatus.COMPLETED }) + updateFileProgress(uploading, { status: FileUploadStatus.UPLOADING }) + clearCompleted() + + const { files } = useFileUploadStore.getState() + expect(files.has(done)).toBe(false) + expect(files.has(uploading)).toBe(true) + }) + + it('getCompletedFileIds returns relay ids of completed uploads', () => { + const { addFile, updateFileProgress, getCompletedFileIds } = useFileUploadStore.getState() + const id = addFile(makeFile()) + + updateFileProgress(id, { status: FileUploadStatus.COMPLETED, fileRelayId: 'relay-1' }) + + expect(getCompletedFileIds()).toEqual(['relay-1']) + }) +}) diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts new file mode 100644 index 000000000..f12c6c7ef --- /dev/null +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts @@ -0,0 +1,189 @@ +import { axios } from '@baseapp-frontend/utils' + +import { renderHook } from '@testing-library/react' + +import { FileUploadStatus } from '../../../constants' +import { useFileUploadStore } from '../../../context/FileUploadProvider' +import { uploadChunks } from '../../../utils' +import { useChunkedUpload } from '../index' + +jest.mock('@baseapp-frontend/utils', () => ({ + axios: { + post: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock('../../../utils', () => ({ + ...jest.requireActual('../../../utils'), + uploadChunks: jest.fn(), +})) + +const mockAxiosPost = axios.post as jest.MockedFunction +const mockAxiosDelete = axios.delete as jest.MockedFunction +const mockUploadChunks = uploadChunks as jest.MockedFunction + +const CHUNK_SIZE = 5 * 1024 * 1024 +const makeFile = (chunks: number) => + new File([new Uint8Array(chunks * CHUNK_SIZE)], 'video.mp4', { type: 'video/mp4' }) + +const initiateResponse = (numParts: number, expiresIn = 3600) => ({ + id: 'file-public-id', + relayId: 'RmlsZTox', + uploadId: 'upload-1', + uploadStatus: 'uploading', + expiresIn, + presignedUrls: Array.from({ length: numParts }, (_, i) => ({ + partNumber: i + 1, + url: `https://s3/part-${i}`, + })), +}) + +describe('useChunkedUpload', () => { + beforeEach(() => { + jest.clearAllMocks() + useFileUploadStore.setState({ files: new Map() }) + }) + + it('uploadFile initiates, uploads every chunk and completes', async () => { + mockAxiosPost + .mockResolvedValueOnce(initiateResponse(2) as never) + .mockResolvedValueOnce({} as never) + mockUploadChunks.mockResolvedValue(['etag-0', 'etag-1']) + + const { result } = renderHook(() => useChunkedUpload()) + const relayId = await result.current.uploadFile(makeFile(2)) + + expect(relayId).toBe('RmlsZTox') + expect(mockAxiosPost).toHaveBeenNthCalledWith( + 1, + 'files/uploads', + expect.objectContaining({ numParts: 2, partSize: CHUNK_SIZE }), + ) + expect(mockAxiosPost).toHaveBeenNthCalledWith(2, 'files/uploads/file-public-id/complete', { + parts: [ + { partNumber: 1, etag: 'etag-0' }, + { partNumber: 2, etag: 'etag-1' }, + ], + }) + + const progress = Array.from(useFileUploadStore.getState().files.values())[0]! + expect(progress.status).toBe(FileUploadStatus.COMPLETED) + }) + + it('uploadFile marks the file failed and rethrows on error', async () => { + mockAxiosPost.mockRejectedValue(new Error('initiate failed')) + + const { result } = renderHook(() => useChunkedUpload()) + await expect(result.current.uploadFile(makeFile(1))).rejects.toThrow('initiate failed') + + const progress = Array.from(useFileUploadStore.getState().files.values())[0]! + expect(progress.status).toBe(FileUploadStatus.FAILED) + expect(progress.error).toBe('initiate failed') + }) + + it('resumeUpload uploads only the chunks without recorded ETags', async () => { + const file = makeFile(3) + const { addFile, updateFileProgress } = useFileUploadStore.getState() + const fileId = addFile(file) + + // Paused upload: chunks 0 and 2 already uploaded (out-of-order), 1 missing. + updateFileProgress(fileId, { + status: FileUploadStatus.PAUSED, + backendId: 'file-public-id', + fileRelayId: 'RmlsZTox', + totalChunks: 3, + etags: ['etag-0', undefined, 'etag-2'], + completedChunks: 2, + presignedUrls: initiateResponse(3).presignedUrls, + initiatedAt: Date.now(), + expiresIn: 3600, + }) + + mockUploadChunks.mockResolvedValue(['etag-1']) + mockAxiosPost.mockResolvedValue({} as never) + + const { result } = renderHook(() => useChunkedUpload()) + await result.current.resumeUpload(fileId) + + // Only the missing chunk was uploaded, against its original URL. + const call = mockUploadChunks.mock.calls[0]![0] + expect(call.chunks).toHaveLength(1) + expect(call.presignedUrls).toEqual(['https://s3/part-1']) + + expect(mockAxiosPost).toHaveBeenCalledWith('files/uploads/file-public-id/complete', { + parts: [ + { partNumber: 1, etag: 'etag-0' }, + { partNumber: 2, etag: 'etag-1' }, + { partNumber: 3, etag: 'etag-2' }, + ], + }) + expect(useFileUploadStore.getState().files.get(fileId)!.status).toBe(FileUploadStatus.COMPLETED) + }) + + it('resumeUpload re-initiates from scratch when the presigned URLs expired', async () => { + const file = makeFile(2) + const { addFile, updateFileProgress } = useFileUploadStore.getState() + const fileId = addFile(file) + + updateFileProgress(fileId, { + status: FileUploadStatus.PAUSED, + backendId: 'stale-id', + totalChunks: 2, + etags: ['etag-0'], + completedChunks: 1, + presignedUrls: initiateResponse(2).presignedUrls, + initiatedAt: Date.now() - 2 * 3600 * 1000, // initiated two hours ago + expiresIn: 3600, + }) + + mockAxiosDelete.mockResolvedValue({} as never) + mockAxiosPost + .mockResolvedValueOnce(initiateResponse(2) as never) + .mockResolvedValueOnce({} as never) + mockUploadChunks.mockResolvedValue(['fresh-0', 'fresh-1']) + + const { result } = renderHook(() => useChunkedUpload()) + await result.current.resumeUpload(fileId) + + // Stale upload aborted server-side, then a fresh initiate + full upload. + expect(mockAxiosDelete).toHaveBeenCalledWith('files/uploads/stale-id') + expect(mockAxiosPost).toHaveBeenNthCalledWith(1, 'files/uploads', expect.any(Object)) + expect(mockUploadChunks.mock.calls[0]![0].chunks).toHaveLength(2) + expect(mockAxiosPost).toHaveBeenNthCalledWith(2, 'files/uploads/file-public-id/complete', { + parts: [ + { partNumber: 1, etag: 'fresh-0' }, + { partNumber: 2, etag: 'fresh-1' }, + ], + }) + }) + + it('resumeUpload ignores files that are not paused', async () => { + const { addFile } = useFileUploadStore.getState() + const fileId = addFile(makeFile(1)) + + const { result } = renderHook(() => useChunkedUpload()) + await result.current.resumeUpload(fileId) + + expect(mockAxiosPost).not.toHaveBeenCalled() + expect(mockUploadChunks).not.toHaveBeenCalled() + }) + + it('retryUpload restarts a failed upload from scratch', async () => { + const file = makeFile(1) + const { addFile, updateFileProgress } = useFileUploadStore.getState() + const fileId = addFile(file) + updateFileProgress(fileId, { status: FileUploadStatus.FAILED, error: 'boom' }) + + mockAxiosPost + .mockResolvedValueOnce(initiateResponse(1) as never) + .mockResolvedValueOnce({} as never) + mockUploadChunks.mockResolvedValue(['etag-0']) + + const { result } = renderHook(() => useChunkedUpload()) + const relayId = await result.current.retryUpload(fileId) + + expect(relayId).toBe('RmlsZTox') + expect(useFileUploadStore.getState().files.get(fileId)!.status).toBe(FileUploadStatus.COMPLETED) + }) +}) diff --git a/packages/components/modules/files/common/utils/__tests__/chunkFile.test.ts b/packages/components/modules/files/common/utils/__tests__/chunkFile.test.ts new file mode 100644 index 000000000..d3294711a --- /dev/null +++ b/packages/components/modules/files/common/utils/__tests__/chunkFile.test.ts @@ -0,0 +1,27 @@ +import { chunkFile } from '../chunkFile' + +const makeFile = (size: number) => new File([new Uint8Array(size)], 'test.bin') + +describe('chunkFile', () => { + it('returns a single chunk for files smaller than the chunk size', () => { + const chunks = chunkFile(makeFile(10), 100) + expect(chunks).toHaveLength(1) + expect(chunks[0]!.size).toBe(10) + }) + + it('splits files into chunkSize pieces with a smaller trailing chunk', () => { + const chunks = chunkFile(makeFile(250), 100) + expect(chunks).toHaveLength(3) + expect(chunks.map((c) => c.size)).toEqual([100, 100, 50]) + }) + + it('splits exact multiples without an empty trailing chunk', () => { + const chunks = chunkFile(makeFile(200), 100) + expect(chunks).toHaveLength(2) + expect(chunks.map((c) => c.size)).toEqual([100, 100]) + }) + + it('returns no chunks for an empty file', () => { + expect(chunkFile(makeFile(0), 100)).toHaveLength(0) + }) +}) diff --git a/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts b/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts new file mode 100644 index 000000000..f6d833855 --- /dev/null +++ b/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts @@ -0,0 +1,108 @@ +import { uploadChunk } from '../uploadChunk' +import { uploadChunks } from '../uploadChunks' + +jest.mock('../uploadChunk') + +const mockUploadChunk = uploadChunk as jest.MockedFunction + +const makeChunks = (amount: number) => Array.from({ length: amount }, () => new Blob(['x'])) +const makeUrls = (amount: number) => + Array.from({ length: amount }, (_, i) => `https://s3/part-${i}`) + +describe('uploadChunks', () => { + beforeEach(() => { + mockUploadChunk.mockReset() + }) + + it('throws when chunks and URLs counts differ', async () => { + await expect( + uploadChunks({ chunks: makeChunks(2), presignedUrls: makeUrls(3) }), + ).rejects.toThrow('Chunks and presigned URLs count mismatch') + }) + + it('returns ETags indexed by chunk even when chunks complete out of order', async () => { + mockUploadChunk.mockImplementation((_chunk, url) => { + const index = Number(url.split('-').pop()) + // Later chunks resolve sooner to force out-of-order completion. + return new Promise((resolve) => { + setTimeout(() => resolve(`etag-${index}`), (5 - index) * 5) + }) + }) + + const etags = await uploadChunks({ + chunks: makeChunks(5), + presignedUrls: makeUrls(5), + }) + + expect(etags).toEqual(['etag-0', 'etag-1', 'etag-2', 'etag-3', 'etag-4']) + }) + + it('reports each completed chunk with its original index and ETag', async () => { + mockUploadChunk.mockImplementation((_chunk, url) => + Promise.resolve(`etag-${url.split('-').pop()}`), + ) + const onChunkComplete = jest.fn() + + await uploadChunks({ + chunks: makeChunks(3), + presignedUrls: makeUrls(3), + onChunkComplete, + }) + + expect(onChunkComplete).toHaveBeenCalledTimes(3) + expect(onChunkComplete).toHaveBeenCalledWith(0, 'etag-0') + expect(onChunkComplete).toHaveBeenCalledWith(1, 'etag-1') + expect(onChunkComplete).toHaveBeenCalledWith(2, 'etag-2') + }) + + it('retries failed chunks with backoff before succeeding', async () => { + mockUploadChunk + .mockRejectedValueOnce(new Error('network')) + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue('etag-ok') + + const etags = await uploadChunks({ + chunks: makeChunks(1), + presignedUrls: makeUrls(1), + retryDelay: 1, + }) + + expect(etags).toEqual(['etag-ok']) + expect(mockUploadChunk).toHaveBeenCalledTimes(3) + }) + + it('fails after exhausting retry attempts', async () => { + mockUploadChunk.mockRejectedValue(new Error('network')) + + await expect( + uploadChunks({ + chunks: makeChunks(1), + presignedUrls: makeUrls(1), + retryAttempts: 2, + retryDelay: 1, + }), + ).rejects.toThrow('Failed to upload chunk 0 after 2 attempts') + + // 1 initial + 2 retries + expect(mockUploadChunk).toHaveBeenCalledTimes(3) + }) + + it('does not retry when the upload was aborted', async () => { + const abortController = new AbortController() + mockUploadChunk.mockImplementation(() => { + abortController.abort() + return Promise.reject(new Error('aborted')) + }) + + await expect( + uploadChunks({ + chunks: makeChunks(1), + presignedUrls: makeUrls(1), + abortSignal: abortController.signal, + retryDelay: 1, + }), + ).rejects.toThrow('aborted') + + expect(mockUploadChunk).toHaveBeenCalledTimes(1) + }) +}) From 97ff9d54baa4171d7c31e69921bec5f0af14c43f Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 11 Jul 2026 02:22:17 +0000 Subject: [PATCH 11/21] sync schema.graphql: ContentPost implements CommentsInterface Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- packages/components/schema.graphql | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/components/schema.graphql b/packages/components/schema.graphql index 3f149aa7e..e95bb9119 100644 --- a/packages/components/schema.graphql +++ b/packages/components/schema.graphql @@ -513,7 +513,7 @@ type CommentUpdatePayload { clientMutationId: String } -type ContentPost implements Node & MentionsInterface & ReactionsInterface { +type ContentPost implements Node & MentionsInterface & ReactionsInterface & CommentsInterface { created: DateTime! modified: DateTime! profile: Profile @@ -530,6 +530,19 @@ type ContentPost implements Node & MentionsInterface & ReactionsInterface { reactions(offset: Int, before: String, after: String, first: Int, last: Int, id: ID): ReactionConnection isReactionsEnabled: Boolean! myReaction(profileId: ID): Reaction + commentsCount: CommentsCount! + comments( + first: Int + last: Int + offset: Int + after: String + before: String + q: String + + """Ordering""" + orderBy: String + ): CommentConnection + isCommentsEnabled: Boolean! pk: Int } From 46c9ce0841ad2ab7df873fc54ed98d4fa4cf5e22 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 11 Jul 2026 12:28:34 +0000 Subject: [PATCH 12/21] =?UTF-8?q?files:=20fix=20pause/resume=20=E2=80=94?= =?UTF-8?q?=20pause=20no=20longer=20marks=20upload=20FAILED,=20UI=20resume?= =?UTF-8?q?=20works?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs made in-session pause/resume unreachable from the UI (the jest tests exercised resumeUpload directly on a hand-crafted PAUSED state, masking both): - The catch in upload/resume/retry set status=FAILED on any thrown error, including the AbortController rejection a pause triggers — clobbering the PAUSED the store just set and firing onUploadError for a deliberate pause. A shared handleUploadError now leaves PAUSED/ABORTED untouched. - UploadingFileItem.handleResume called the store's resumeFile (PAUSED→PENDING) before resumeUpload, whose guard requires PAUSED — so resume no-op'd and the file stranded in PENDING. Removed the redundant call; the hook flips it. - resumeUpload now computes pending chunks before treating URL expiry as fatal: if every chunk already uploaded, it only re-issues the (URL-free) complete call instead of discarding the whole upload and re-uploading from byte zero. Added regression tests for both fixed behaviors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../__tests__/useChunkedUpload.test.ts | 55 ++++++++ .../common/hooks/useChunkedUpload/index.ts | 122 ++++++++++-------- .../UploadingFileItem/index.tsx | 6 +- 3 files changed, 124 insertions(+), 59 deletions(-) diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts index f12c6c7ef..d8f71903b 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts @@ -169,6 +169,61 @@ describe('useChunkedUpload', () => { expect(mockUploadChunks).not.toHaveBeenCalled() }) + it('pausing mid-upload leaves the file PAUSED, not FAILED, so resume is possible', async () => { + // Simulate a pause during chunk upload: pauseFile flips UPLOADING→PAUSED and + // aborts, so uploadChunks rejects. The catch must NOT clobber PAUSED. + mockAxiosPost.mockResolvedValueOnce(initiateResponse(2) as never) + mockUploadChunks.mockImplementation(async () => { + const [id] = Array.from(useFileUploadStore.getState().files.keys()) + useFileUploadStore.getState().pauseFile(id!) + throw new Error('Chunk upload aborted') + }) + const onUploadError = jest.fn() + + const { result } = renderHook(() => useChunkedUpload({ onUploadError })) + await expect(result.current.uploadFile(makeFile(2))).rejects.toThrow('Chunk upload aborted') + + const progress = Array.from(useFileUploadStore.getState().files.values())[0]! + expect(progress.status).toBe(FileUploadStatus.PAUSED) + expect(onUploadError).not.toHaveBeenCalled() + }) + + it('resumeUpload with every chunk already uploaded only completes, even if URLs expired', async () => { + const file = makeFile(2) + const { addFile, updateFileProgress } = useFileUploadStore.getState() + const fileId = addFile(file) + + // All ETags present, but the presigned URLs are long expired. + updateFileProgress(fileId, { + status: FileUploadStatus.PAUSED, + backendId: 'file-public-id', + fileRelayId: 'RmlsZTox', + totalChunks: 2, + etags: ['etag-0', 'etag-1'], + completedChunks: 2, + presignedUrls: initiateResponse(2).presignedUrls, + initiatedAt: Date.now() - 2 * 3600 * 1000, + expiresIn: 3600, + }) + + mockAxiosPost.mockResolvedValue({} as never) + + const { result } = renderHook(() => useChunkedUpload()) + await result.current.resumeUpload(fileId) + + // No re-initiate, no re-upload, no DELETE — just the complete call. + expect(mockUploadChunks).not.toHaveBeenCalled() + expect(mockAxiosDelete).not.toHaveBeenCalled() + expect(mockAxiosPost).toHaveBeenCalledTimes(1) + expect(mockAxiosPost).toHaveBeenCalledWith('files/uploads/file-public-id/complete', { + parts: [ + { partNumber: 1, etag: 'etag-0' }, + { partNumber: 2, etag: 'etag-1' }, + ], + }) + expect(useFileUploadStore.getState().files.get(fileId)!.status).toBe(FileUploadStatus.COMPLETED) + }) + it('retryUpload restarts a failed upload from scratch', async () => { const file = makeFile(1) const { addFile, updateFileProgress } = useFileUploadStore.getState() diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts index 8366e996d..94da848b3 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts @@ -25,6 +25,32 @@ const buildParts = (etags: (string | undefined)[]): CompleteUploadPart[] => export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { const { addFile, updateFileProgress, updateChunkProgress } = useFileUpload() + /** + * Translate a thrown upload error into store state. A pause aborts the + * in-flight request, which rejects here — but pauseFile has already set the + * status to PAUSED (and abortFile to ABORTED), so those must not be clobbered + * to FAILED or resume becomes impossible. + */ + const handleUploadError = useCallback( + (fileId: string, error: unknown, fallbackMessage: string) => { + const current = useFileUploadStore.getState().files.get(fileId) + if ( + current?.status === FileUploadStatus.PAUSED || + current?.status === FileUploadStatus.ABORTED + ) { + return + } + + const errorMessage = error instanceof Error ? error.message : fallbackMessage + updateFileProgress(fileId, { + status: FileUploadStatus.FAILED, + error: errorMessage, + }) + options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) + }, + [updateFileProgress, options], + ) + /** * Initiate a fresh multipart upload for `file` and upload every chunk. * Shared by uploadFile, retryUpload and resumeUpload (when the original @@ -100,19 +126,11 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { options?.onUploadComplete?.(fileId, relayId) return relayId } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Upload failed' - - updateFileProgress(fileId, { - status: FileUploadStatus.FAILED, - error: errorMessage, - }) - - options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) - + handleUploadError(fileId, error, 'Upload failed') throw error } }, - [addFile, startUpload, updateFileProgress, options], + [addFile, startUpload, handleUploadError, options], ) const pauseUpload = useCallback((fileId: string) => { @@ -132,9 +150,22 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { resumeFile(fileId) try { - // The original presigned URLs expire; past the deadline the only safe - // path is aborting the stale upload and starting a fresh one. - if (areUrlsExpired(fileProgress.initiatedAt, fileProgress.expiresIn)) { + const chunks = chunkFile(fileProgress.file) + // Chunks complete out of order (parallel uploads), so resume from the + // per-index ETag record rather than assuming a contiguous prefix. + const pendingIndexes = chunks + .map((_, index) => index) + .filter((index) => !fileProgress.etags[index]) + + // Every chunk already uploaded: only the (URL-free) complete call is + // left, so URL expiry is irrelevant — go straight to complete instead + // of discarding the whole upload. + if ( + pendingIndexes.length > 0 && + areUrlsExpired(fileProgress.initiatedAt, fileProgress.expiresIn) + ) { + // The presigned part URLs have expired; abort the stale upload and + // start fresh, since we cannot re-sign individual parts. if (fileProgress.backendId) { await axios.delete(`files/uploads/${fileProgress.backendId}`).catch(() => {}) } @@ -143,37 +174,30 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { return } - const chunks = chunkFile(fileProgress.file) const abortController = new AbortController() - updateFileProgress(fileId, { status: FileUploadStatus.UPLOADING, abortController, }) - // Chunks complete out of order (parallel uploads), so resume from the - // per-index ETag record rather than assuming a contiguous prefix. - const presignedUrls = fileProgress.presignedUrls ?? [] - const pendingIndexes = chunks - .map((_, index) => index) - .filter((index) => !fileProgress.etags[index]) - - const etags = await uploadChunks({ - chunks: pendingIndexes.map((index) => chunks[index]!), - presignedUrls: pendingIndexes.map((index) => presignedUrls[index]!.url), - abortSignal: abortController.signal, - onProgress: (chunkIndex, loaded, total) => { - updateChunkProgress(fileId, pendingIndexes[chunkIndex]!, loaded, total) - }, - onChunkComplete: (chunkIndex, etag) => { - recordChunkEtag(fileId, pendingIndexes[chunkIndex]!, etag) - }, - }) - const allEtags = [...fileProgress.etags] - pendingIndexes.forEach((originalIndex, i) => { - allEtags[originalIndex] = etags[i] - }) + if (pendingIndexes.length > 0) { + const presignedUrls = fileProgress.presignedUrls ?? [] + const etags = await uploadChunks({ + chunks: pendingIndexes.map((index) => chunks[index]!), + presignedUrls: pendingIndexes.map((index) => presignedUrls[index]!.url), + abortSignal: abortController.signal, + onProgress: (chunkIndex, loaded, total) => { + updateChunkProgress(fileId, pendingIndexes[chunkIndex]!, loaded, total) + }, + onChunkComplete: (chunkIndex, etag) => { + recordChunkEtag(fileId, pendingIndexes[chunkIndex]!, etag) + }, + }) + pendingIndexes.forEach((originalIndex, i) => { + allEtags[originalIndex] = etags[i] + }) + } await axios.post(`files/uploads/${fileProgress.backendId}/complete`, { parts: buildParts(allEtags), @@ -186,19 +210,11 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { options?.onUploadComplete?.(fileId, fileProgress.fileRelayId!) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Resume failed' - - updateFileProgress(fileId, { - status: FileUploadStatus.FAILED, - error: errorMessage, - }) - - options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) - + handleUploadError(fileId, error, 'Resume failed') throw error } }, - [startUpload, updateFileProgress, updateChunkProgress, options], + [startUpload, updateFileProgress, updateChunkProgress, handleUploadError, options], ) const retryUpload = useCallback( @@ -215,19 +231,11 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { options?.onUploadComplete?.(fileId, relayId) return relayId } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Retry failed' - - updateFileProgress(fileId, { - status: FileUploadStatus.FAILED, - error: errorMessage, - }) - - options?.onUploadError?.(fileId, error instanceof Error ? error : new Error(errorMessage)) - + handleUploadError(fileId, error, 'Retry failed') throw error } }, - [startUpload, updateFileProgress, options], + [startUpload, handleUploadError, options], ) return { uploadFile, pauseUpload, resumeUpload, retryUpload } diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx index 9d7432fa6..2221a5f41 100644 --- a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx @@ -30,7 +30,7 @@ const UploadingFileItem: FC = ({ allowRemove = true, allowRetry = true, }) => { - const { removeFile, pauseFile, resumeFile } = useFileUpload() + const { removeFile, pauseFile } = useFileUpload() const { resumeUpload, retryUpload } = useChunkedUpload() const getProgress = (): number => { @@ -51,7 +51,9 @@ const UploadingFileItem: FC = ({ } const handleResume = () => { - resumeFile(fileProgress.id) + // resumeUpload reads the PAUSED status itself and flips it via the store; + // calling resumeFile here first would move it to PENDING and make the + // hook's PAUSED guard skip the resume. resumeUpload(fileProgress.id) } From 580208018cf887dcf88f79d00290007cb9a8e71e Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sun, 12 Jul 2026 02:43:12 +0000 Subject: [PATCH 13/21] files: split web components into composable trigger + chip list (BaseApp design) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple the three concerns so the pieces can be mounted to match the Figma comment design while keeping today's behaviour as defaults: - useFileSelect: headless hidden-input select (accept/size/count validation), so the trigger is pluggable. - FileUploadTrigger: owns the upload logic and renders as a dropzone (default) or a compact paperclip button (as="button"). FileUpload stays as a back-compat dropzone wrapper. - FileThumbnail + FileChip: presentational 40x40 thumbnail (image or type-icon tile) and the compact bordered chip from the design. - UploadingFileItem / AttachedFileItem gain variant='chip'; AttachedFileItem gains editable (× remove on files the user can change). - FileUploadList gains variant (cards|chips), layout (stack|horizontal), and editable — cards/stack remain the defaults so nothing changes for existing callers. Wire comments to the design: CommentItem renders the chip list (editable while editing); CommentUpdate renders the paperclip trigger button. Tests: useFileSelect unit coverage; full components suite green (37). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../comments/web/CommentItem/index.tsx | 7 +- .../comments/web/CommentUpdate/index.tsx | 6 +- .../__tests__/useFileSelect.test.ts | 73 ++++++++++++ .../files/common/hooks/useFileSelect/index.ts | 59 ++++++++++ .../files/common/hooks/useFileSelect/types.ts | 28 +++++ .../components/modules/files/common/index.ts | 1 + .../files/web/AttachedFileItem/index.tsx | 61 +++++++++- .../files/web/AttachedFileItem/types.ts | 9 ++ .../modules/files/web/FileChip/index.tsx | 43 +++++++ .../modules/files/web/FileChip/types.ts | 13 +++ .../modules/files/web/FileThumbnail/index.tsx | 72 ++++++++++++ .../modules/files/web/FileThumbnail/types.ts | 9 ++ .../modules/files/web/FileUpload/index.tsx | 48 ++------ .../UploadingFileItem/index.tsx | 105 ++++++++++++------ .../FileUploadList/UploadingFileItem/types.ts | 8 ++ .../files/web/FileUploadList/index.tsx | 70 +++++++++++- .../modules/files/web/FileUploadList/types.ts | 6 + .../files/web/FileUploadTrigger/index.tsx | 87 +++++++++++++++ .../files/web/FileUploadTrigger/types.ts | 23 ++++ .../components/modules/files/web/index.ts | 10 ++ 20 files changed, 651 insertions(+), 87 deletions(-) create mode 100644 packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts create mode 100644 packages/components/modules/files/common/hooks/useFileSelect/index.ts create mode 100644 packages/components/modules/files/common/hooks/useFileSelect/types.ts create mode 100644 packages/components/modules/files/web/AttachedFileItem/types.ts create mode 100644 packages/components/modules/files/web/FileChip/index.tsx create mode 100644 packages/components/modules/files/web/FileChip/types.ts create mode 100644 packages/components/modules/files/web/FileThumbnail/index.tsx create mode 100644 packages/components/modules/files/web/FileThumbnail/types.ts create mode 100644 packages/components/modules/files/web/FileUploadList/UploadingFileItem/types.ts create mode 100644 packages/components/modules/files/web/FileUploadTrigger/index.tsx create mode 100644 packages/components/modules/files/web/FileUploadTrigger/types.ts diff --git a/packages/components/modules/comments/web/CommentItem/index.tsx b/packages/components/modules/comments/web/CommentItem/index.tsx index 622cd685a..db37ee261 100644 --- a/packages/components/modules/comments/web/CommentItem/index.tsx +++ b/packages/components/modules/comments/web/CommentItem/index.tsx @@ -175,7 +175,12 @@ const CommentItem: FC = ({ {renderCommentContent()}
- +
diff --git a/packages/components/modules/comments/web/CommentUpdate/index.tsx b/packages/components/modules/comments/web/CommentUpdate/index.tsx index 45d30001c..d4b6a41d3 100644 --- a/packages/components/modules/comments/web/CommentUpdate/index.tsx +++ b/packages/components/modules/comments/web/CommentUpdate/index.tsx @@ -2,7 +2,7 @@ import { FC, useEffect, useMemo, useRef } from 'react' -import { FileUpload } from '@baseapp-frontend/components/files/web' +import { FileUploadTrigger } from '@baseapp-frontend/components/files/web' import { setFormRelayErrors } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -173,8 +173,9 @@ const CommentUpdate: FC = ({ }} {...mergedSocialInputProps} /> - = ({ 'application/pdf': ['.pdf'], }} autoAttach - onUploadComplete={(fileIds) => console.log('Uploaded:', fileIds)} /> ) diff --git a/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts b/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts new file mode 100644 index 000000000..659f93745 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react' + +import { useFileSelect } from '../index' + +const makeFile = (name: string, size: number) => { + const file = new File(['x'], name) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +const fireChange = ( + onChange: (event: { target: { files: File[]; value: string } }) => void, + files: File[], +) => { + const target = { files, value: 'C:\\fakepath\\x' } + onChange({ target } as never) + return target +} + +describe('useFileSelect', () => { + it('builds the accept attribute from mime types and extensions', () => { + const { result } = renderHook(() => + useFileSelect({ + onFilesSelected: jest.fn(), + acceptedFileTypes: { 'image/*': ['.png', '.jpg'], 'application/pdf': ['.pdf'] }, + }), + ) + expect(result.current.getInputProps().accept).toBe('image/*,.png,.jpg,application/pdf,.pdf') + }) + + it('sets multiple only when maxFiles > 1', () => { + const single = renderHook(() => useFileSelect({ onFilesSelected: jest.fn(), maxFiles: 1 })) + const many = renderHook(() => useFileSelect({ onFilesSelected: jest.fn(), maxFiles: 5 })) + expect(single.result.current.getInputProps().multiple).toBe(false) + expect(many.result.current.getInputProps().multiple).toBe(true) + }) + + it('filters out files over maxFileSize and caps at maxFiles', () => { + const onFilesSelected = jest.fn() + const { result } = renderHook(() => + useFileSelect({ onFilesSelected, maxFiles: 2, maxFileSize: 1000 }), + ) + + const ok1 = makeFile('a.txt', 500) + const tooBig = makeFile('big.txt', 5000) + const ok2 = makeFile('b.txt', 800) + const ok3 = makeFile('c.txt', 900) + + fireChange(result.current.getInputProps().onChange, [ok1, tooBig, ok2, ok3]) + + expect(onFilesSelected).toHaveBeenCalledTimes(1) + // tooBig filtered out, then capped to 2 → [ok1, ok2] + expect(onFilesSelected).toHaveBeenCalledWith([ok1, ok2]) + }) + + it('does not fire the callback when nothing valid was selected, and resets the input', () => { + const onFilesSelected = jest.fn() + const { result } = renderHook(() => useFileSelect({ onFilesSelected, maxFileSize: 100 })) + + const target = fireChange(result.current.getInputProps().onChange, [makeFile('big', 999)]) + + expect(onFilesSelected).not.toHaveBeenCalled() + expect(target.value).toBe('') + }) + + it('open() does not throw when no input is mounted and respects disabled', () => { + const { result } = renderHook(() => + useFileSelect({ onFilesSelected: jest.fn(), disabled: true }), + ) + expect(() => result.current.open()).not.toThrow() + expect(result.current.getInputProps().disabled).toBe(true) + }) +}) diff --git a/packages/components/modules/files/common/hooks/useFileSelect/index.ts b/packages/components/modules/files/common/hooks/useFileSelect/index.ts new file mode 100644 index 000000000..ec5e0efbe --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileSelect/index.ts @@ -0,0 +1,59 @@ +import { useCallback, useRef } from 'react' + +import type { Accept, UseFileSelectParams, UseFileSelectReturn } from './types' + +const buildAcceptAttr = (acceptedFileTypes?: Accept): string | undefined => { + if (!acceptedFileTypes) return undefined + const parts = Object.entries(acceptedFileTypes).flatMap(([mime, exts]) => [mime, ...exts]) + return parts.length ? parts.join(',') : undefined +} + +/** + * Headless file selection: owns a hidden and the + * accept/size/count validation, decoupled from how the trigger looks. Both the + * dropzone and the button triggers use it so selection behaves identically. + */ +export const useFileSelect = ({ + onFilesSelected, + maxFiles = 10, + maxFileSize = 100 * 1024 * 1024, + acceptedFileTypes, + disabled = false, +}: UseFileSelectParams): UseFileSelectReturn => { + const inputRef = useRef(null) + + const open = useCallback(() => { + if (!disabled) { + inputRef.current?.click() + } + }, [disabled]) + + const getInputProps = useCallback( + () => ({ + ref: (node: HTMLInputElement | null) => { + inputRef.current = node + }, + type: 'file', + multiple: maxFiles > 1, + accept: buildAcceptAttr(acceptedFileTypes), + disabled, + style: { display: 'none' }, + onChange: (event) => { + const { target } = event + const selected = Array.from(target.files ?? []) + .filter((file) => file.size <= maxFileSize) + .slice(0, maxFiles) + // Reset so selecting the same file again re-triggers onChange. + target.value = '' + if (selected.length) { + onFilesSelected(selected) + } + }, + }), + [acceptedFileTypes, disabled, maxFileSize, maxFiles, onFilesSelected], + ) + + return { open, getInputProps } +} + +export type { UseFileSelectParams, UseFileSelectReturn } from './types' diff --git a/packages/components/modules/files/common/hooks/useFileSelect/types.ts b/packages/components/modules/files/common/hooks/useFileSelect/types.ts new file mode 100644 index 000000000..63c449805 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useFileSelect/types.ts @@ -0,0 +1,28 @@ +import type { ChangeEventHandler } from 'react' + +export type Accept = Record + +export interface UseFileSelectParams { + onFilesSelected: (files: File[]) => void + maxFiles?: number + maxFileSize?: number // in bytes + acceptedFileTypes?: Accept + disabled?: boolean +} + +export interface FileSelectInputProps { + ref: (node: HTMLInputElement | null) => void + type: 'file' + multiple: boolean + accept?: string + disabled?: boolean + style: { display: 'none' } + onChange: ChangeEventHandler +} + +export interface UseFileSelectReturn { + /** Programmatically open the OS file picker. */ + open: () => void + /** Spread onto a hidden the trigger renders. */ + getInputProps: () => FileSelectInputProps +} diff --git a/packages/components/modules/files/common/index.ts b/packages/components/modules/files/common/index.ts index d988cb482..2c8627fe3 100644 --- a/packages/components/modules/files/common/index.ts +++ b/packages/components/modules/files/common/index.ts @@ -4,6 +4,7 @@ export * from './utils' export * from './context/FileUploadProvider' export * from './context/useFileUpload' export * from './hooks/useChunkedUpload' +export * from './hooks/useFileSelect' export * from './hooks/useFileUploadLogic' export type * from './hooks/useFileUploadLogic/types' export * from './hooks/useFileDeleteLogic' diff --git a/packages/components/modules/files/web/AttachedFileItem/index.tsx b/packages/components/modules/files/web/AttachedFileItem/index.tsx index 2b3328800..b07db0554 100644 --- a/packages/components/modules/files/web/AttachedFileItem/index.tsx +++ b/packages/components/modules/files/web/AttachedFileItem/index.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import { + Close as CloseIcon, Delete as DeleteIcon, Download as DownloadIcon, InsertDriveFile as FileIcon, @@ -10,18 +11,25 @@ import { import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' import { useFragment } from 'react-relay' -import type { FileItem_file$key } from '../../../../__generated__/FileItem_file.graphql' import { FileItemFragment } from '../../common/graphql/fragments/FileItem' import { useFileDeleteLogic } from '../../common/hooks/useFileDeleteLogic' import { useFileDownloadLogic } from '../../common/hooks/useFileDownloadLogic' import { formatDate, formatFileSize, getFileType, isImageFile } from '../../common/utils/formatters' +import FileChip from '../FileChip' +import FileThumbnail from '../FileThumbnail' +import type { AttachedFileItemProps } from './types' -interface AttachedFileItemProps { - file: FileItem_file$key - targetObjectId?: string +const getTypeLabel = (fileName?: string | null, contentType?: string | null): string => { + const ext = fileName && fileName.includes('.') ? fileName.split('.').pop() : undefined + return (ext || getFileType(contentType) || 'file').toUpperCase() } -const AttachedFileItem: FC = ({ file: fileRef, targetObjectId }) => { +const AttachedFileItem: FC = ({ + file: fileRef, + targetObjectId, + variant = 'card', + editable = false, +}) => { const file = useFragment(FileItemFragment, fileRef) const { handleDelete, isDeletingFile } = useFileDeleteLogic({ targetObjectId }) @@ -51,6 +59,48 @@ const AttachedFileItem: FC = ({ file: fileRef, targetObje const isImage = isImageFile(file.fileContentType) + if (variant === 'chip') { + return ( + + } + name={file.fileName} + subtitle={ + + {getTypeLabel(file.fileName, file.fileContentType)} + + } + action={ + editable && canChangeFile ? ( + handleDelete(file.id)} + disabled={isDeletingFile} + title="Remove" + aria-label="Remove" + > + + + ) : ( + handleDownload(file.file)} + title="Download" + aria-label="Download" + > + + + ) + } + /> + ) + } + return ( @@ -126,3 +176,4 @@ const AttachedFileItem: FC = ({ file: fileRef, targetObje } export default AttachedFileItem +export type { AttachedFileItemProps } from './types' diff --git a/packages/components/modules/files/web/AttachedFileItem/types.ts b/packages/components/modules/files/web/AttachedFileItem/types.ts new file mode 100644 index 000000000..a2a5e0147 --- /dev/null +++ b/packages/components/modules/files/web/AttachedFileItem/types.ts @@ -0,0 +1,9 @@ +import type { FileItem_file$key } from '../../../../__generated__/FileItem_file.graphql' + +export interface AttachedFileItemProps { + file: FileItem_file$key + targetObjectId?: string + variant?: 'card' | 'chip' + /** When true (edit mode), show a remove control on attached files the user can change. */ + editable?: boolean +} diff --git a/packages/components/modules/files/web/FileChip/index.tsx b/packages/components/modules/files/web/FileChip/index.tsx new file mode 100644 index 000000000..f730d8c13 --- /dev/null +++ b/packages/components/modules/files/web/FileChip/index.tsx @@ -0,0 +1,43 @@ +'use client' + +import type { FC } from 'react' + +import { Box, Stack, Typography } from '@mui/material' + +import type { FileChipProps } from './types' + +/** + * Presentational attachment chip matching the Figma comment design: a compact + * bordered card with a 40×40 thumbnail, the file name, a secondary line + * (type label or progress), and a trailing action slot. Data-agnostic — the + * uploading and attached items feed it their own thumbnail/subtitle/action. + */ +const FileChip: FC = ({ thumbnail, name, subtitle, action, fixedWidth = false }) => ( + + {thumbnail} + + + {name} + + {subtitle} + + {action} + +) + +export default FileChip +export type { FileChipProps } from './types' diff --git a/packages/components/modules/files/web/FileChip/types.ts b/packages/components/modules/files/web/FileChip/types.ts new file mode 100644 index 000000000..916ab1a26 --- /dev/null +++ b/packages/components/modules/files/web/FileChip/types.ts @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +export interface FileChipProps { + /** 40×40 thumbnail node (image or type-icon tile). */ + thumbnail: ReactNode + name?: string | null + /** Secondary line: an uppercase type label or a progress bar. */ + subtitle?: ReactNode + /** Trailing action(s): remove, download, menu, retry… */ + action?: ReactNode + /** Fixed width (uploading chips are fixed; attached chips size to content up to a max). */ + fixedWidth?: boolean +} diff --git a/packages/components/modules/files/web/FileThumbnail/index.tsx b/packages/components/modules/files/web/FileThumbnail/index.tsx new file mode 100644 index 000000000..1b83c696a --- /dev/null +++ b/packages/components/modules/files/web/FileThumbnail/index.tsx @@ -0,0 +1,72 @@ +'use client' + +import type { FC } from 'react' + +import { + InsertDriveFile as FileIcon, + Image as ImageIcon, + PictureAsPdf as PdfIcon, + Videocam as VideoIcon, +} from '@mui/icons-material' +import { Box } from '@mui/material' + +import { getFileType, isImageFile } from '../../common/utils/formatters' +import type { FileThumbnailProps } from './types' + +const getTypeIcon = (contentType?: string | null) => { + switch (getFileType(contentType)) { + case 'image': + return + case 'video': + return + case 'pdf': + return + default: + return + } +} + +/** + * 40×40 (default) file thumbnail matching the Figma attachment chip: an image + * preview when available, otherwise a primary-colored tile with a type icon. + */ +const FileThumbnail: FC = ({ src, contentType, alt, size = 40 }) => { + const showImage = Boolean(src) && isImageFile(contentType) + + if (showImage) { + return ( + + ) + } + + return ( + + {getTypeIcon(contentType)} + + ) +} + +export default FileThumbnail +export type { FileThumbnailProps } from './types' diff --git a/packages/components/modules/files/web/FileThumbnail/types.ts b/packages/components/modules/files/web/FileThumbnail/types.ts new file mode 100644 index 000000000..554b28e34 --- /dev/null +++ b/packages/components/modules/files/web/FileThumbnail/types.ts @@ -0,0 +1,9 @@ +export interface FileThumbnailProps { + /** Image preview URL; when present (and the file is an image) it is shown cover-fit. */ + src?: string | null + /** MIME type, used to pick the fallback icon tile. */ + contentType?: string | null + alt?: string + /** Square size in px (Figma chip = 40). */ + size?: number +} diff --git a/packages/components/modules/files/web/FileUpload/index.tsx b/packages/components/modules/files/web/FileUpload/index.tsx index 18ec032d4..57d87b9f7 100644 --- a/packages/components/modules/files/web/FileUpload/index.tsx +++ b/packages/components/modules/files/web/FileUpload/index.tsx @@ -2,49 +2,15 @@ import type { FC } from 'react' -import { useFragment } from 'react-relay' - -import { FilesListFragment } from '../../common/graphql/queries/FilesList' -import { useFileUploadLogic } from '../../common/hooks/useFileUploadLogic' -import FileUploadDropzone from '../FileUploadDropzone' +import FileUploadTrigger from '../FileUploadTrigger' import type { FileUploadProps } from './types' -const FileUpload: FC = ({ - target: targetRef, - maxFiles, - maxFileSize, - acceptedFileTypes, - disabled, - autoAttach = true, - onUploadComplete, - onAttachComplete, - onError, -}) => { - const target = useFragment(FilesListFragment, targetRef) - - const { handleFilesSelected, isAttaching, resetKey } = useFileUploadLogic({ - targetObjectId: target.id, - autoAttach, - onUploadComplete, - onAttachComplete, - onError, - }) - - if (!target.isFilesEnabled) { - return null - } - - return ( - - ) -} +/** + * Backwards-compatible dropzone trigger. Thin wrapper over FileUploadTrigger so + * existing `` usages keep rendering the drop area. + * New code can use FileUploadTrigger with `as="button"` for the compact trigger. + */ +const FileUpload: FC = (props) => export default FileUpload export type { FileUploadProps } from './types' diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx index 2221a5f41..46165cf9e 100644 --- a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx @@ -10,25 +10,31 @@ import { PlayArrow as PlayArrowIcon, Replay as ReplayIcon, } from '@mui/icons-material' -import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' +import { + Box, + Card, + CardContent, + Chip, + IconButton, + LinearProgress, + Stack, + Typography, +} from '@mui/material' import { FileUploadStatus } from '../../../common/constants' import { useFileUpload } from '../../../common/context/useFileUpload' import { useChunkedUpload } from '../../../common/hooks/useChunkedUpload' -import type { FileUploadProgress } from '../../../common/types' import { calculateProgress, formatFileSize, isImageFile } from '../../../common/utils/formatters' +import FileChip from '../../FileChip' +import FileThumbnail from '../../FileThumbnail' import FileProgress from './FileProgress' - -interface UploadingFileItemProps { - fileProgress: FileUploadProgress - allowRemove?: boolean - allowRetry?: boolean -} +import type { UploadingFileItemProps } from './types' const UploadingFileItem: FC = ({ fileProgress, allowRemove = true, allowRetry = true, + variant = 'card', }) => { const { removeFile, pauseFile } = useFileUpload() const { resumeUpload, retryUpload } = useChunkedUpload() @@ -90,6 +96,61 @@ const UploadingFileItem: FC = ({ [thumbnailUrl], ) + const actions = ( + + {canPause && ( + + + + )} + {canResume && ( + + + + )} + {canRetry && ( + + + + )} + {canRemove && ( + + + + )} + + ) + + if (variant === 'chip') { + return ( + + } + name={fileProgress.fileName} + subtitle={ + fileProgress.error ? ( + + {fileProgress.error} + + ) : ( + + ) + } + action={actions} + /> + ) + } + return ( @@ -124,32 +185,7 @@ const UploadingFileItem: FC = ({ - - - {canPause && ( - - - - )} - - {canResume && ( - - - - )} - - {canRetry && ( - - - - )} - - {canRemove && ( - - - - )} - + {actions} @@ -162,3 +198,4 @@ const UploadingFileItem: FC = ({ } export default UploadingFileItem +export type { UploadingFileItemProps } from './types' diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/types.ts b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/types.ts new file mode 100644 index 000000000..98ffccd0a --- /dev/null +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/types.ts @@ -0,0 +1,8 @@ +import type { FileUploadProgress } from '../../../common/types' + +export interface UploadingFileItemProps { + fileProgress: FileUploadProgress + allowRemove?: boolean + allowRetry?: boolean + variant?: 'card' | 'chip' +} diff --git a/packages/components/modules/files/web/FileUploadList/index.tsx b/packages/components/modules/files/web/FileUploadList/index.tsx index ab40abb2b..e565cd7fb 100644 --- a/packages/components/modules/files/web/FileUploadList/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/index.tsx @@ -1,8 +1,8 @@ 'use client' -import type { FC } from 'react' +import type { FC, ReactNode } from 'react' -import { Box, Typography } from '@mui/material' +import { Box, Stack, Typography } from '@mui/material' import { useFragment } from 'react-relay' import { useFileUpload } from '../../common/context/useFileUpload' @@ -16,6 +16,9 @@ const FileUploadList: FC = ({ showUploadProgress = true, allowRemove = true, allowRetry = true, + variant = 'cards', + layout = 'stack', + editable = false, }) => { const target = useFragment(FilesListFragment, targetRef) const { files } = useFileUpload() @@ -29,6 +32,60 @@ const FileUploadList: FC = ({ return null } + const itemVariant = variant === 'chips' ? 'chip' : 'card' + const isHorizontal = layout === 'horizontal' + + // Chips: uploading + attached share one wrapping/scrolling row (Figma). + // Cards: keep the two labelled sections (today's behaviour). + const wrap = (children: ReactNode) => + isHorizontal ? ( + + {children} + + ) : ( + {children} + ) + + if (variant === 'chips') { + return ( + + {wrap( + <> + {showUploadProgress && + uploadingFiles.map((file) => ( + + ))} + {target.files?.edges?.map((edge) => { + if (!edge?.node) return null + return ( + + ) + })} + , + )} + + ) + } + return ( {showUploadProgress && hasUploadingFiles && ( @@ -42,6 +99,7 @@ const FileUploadList: FC = ({ fileProgress={file} allowRemove={allowRemove} allowRetry={allowRetry} + variant={itemVariant} /> ))} @@ -55,7 +113,13 @@ const FileUploadList: FC = ({ {target.files.edges.map((edge) => { if (!edge?.node) return null return ( - + ) })} diff --git a/packages/components/modules/files/web/FileUploadList/types.ts b/packages/components/modules/files/web/FileUploadList/types.ts index bab661928..e7e15198a 100644 --- a/packages/components/modules/files/web/FileUploadList/types.ts +++ b/packages/components/modules/files/web/FileUploadList/types.ts @@ -5,4 +5,10 @@ export interface FileUploadListProps { showUploadProgress?: boolean allowRemove?: boolean allowRetry?: boolean + /** `cards` (default, stacked detailed cards) or `chips` (compact Figma chips). */ + variant?: 'cards' | 'chips' + /** `stack` (default, vertical) or `horizontal` (wrapping/scrolling row — pairs with chips). */ + layout?: 'stack' | 'horizontal' + /** Edit mode: show a remove control on attached files the user can change. */ + editable?: boolean } diff --git a/packages/components/modules/files/web/FileUploadTrigger/index.tsx b/packages/components/modules/files/web/FileUploadTrigger/index.tsx new file mode 100644 index 000000000..ecaf4f453 --- /dev/null +++ b/packages/components/modules/files/web/FileUploadTrigger/index.tsx @@ -0,0 +1,87 @@ +'use client' + +import type { FC } from 'react' + +import { AttachFile as AttachFileIcon } from '@mui/icons-material' +import { IconButton, Tooltip } from '@mui/material' +import { useFragment } from 'react-relay' + +import { FilesListFragment } from '../../common/graphql/queries/FilesList' +import { useFileSelect } from '../../common/hooks/useFileSelect' +import { useFileUploadLogic } from '../../common/hooks/useFileUploadLogic' +import FileUploadDropzone from '../FileUploadDropzone' +import type { FileUploadTriggerProps } from './types' + +/** + * Owns the upload orchestration (useFileUploadLogic) and renders a pluggable + * trigger: the full drop area (default) or a compact button. The button variant + * uses the headless useFileSelect so selection/validation matches the dropzone. + */ +const FileUploadTrigger: FC = ({ + target: targetRef, + as = 'dropzone', + icon, + label = 'Attach files', + maxFiles, + maxFileSize, + acceptedFileTypes, + disabled, + autoAttach = true, + onUploadComplete, + onAttachComplete, + onError, +}) => { + const target = useFragment(FilesListFragment, targetRef) + + const { handleFilesSelected, isAttaching, resetKey } = useFileUploadLogic({ + targetObjectId: target.id, + autoAttach, + onUploadComplete, + onAttachComplete, + onError, + }) + + const isDisabled = disabled || isAttaching + + const { open, getInputProps } = useFileSelect({ + onFilesSelected: handleFilesSelected, + maxFiles, + maxFileSize, + acceptedFileTypes, + disabled: isDisabled, + }) + + if (!target.isFilesEnabled) { + return null + } + + if (as === 'button') { + return ( + <> + {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + + + {icon ?? } + + + + + ) + } + + return ( + + ) +} + +export default FileUploadTrigger +export type { FileUploadTriggerProps } from './types' diff --git a/packages/components/modules/files/web/FileUploadTrigger/types.ts b/packages/components/modules/files/web/FileUploadTrigger/types.ts new file mode 100644 index 000000000..4360893d2 --- /dev/null +++ b/packages/components/modules/files/web/FileUploadTrigger/types.ts @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' + +import type { FilesList_target$key } from '../../../../__generated__/FilesList_target.graphql' + +type Accept = Record + +export interface FileUploadTriggerProps { + target: FilesList_target$key + /** How the trigger is rendered: the full drop area (default) or a compact icon/label button. */ + as?: 'dropzone' | 'button' + /** Button-variant only: icon shown inside the button (defaults to a paperclip). */ + icon?: ReactNode + /** Button-variant only: accessible label / tooltip (defaults to "Attach files"). */ + label?: string + maxFiles?: number + maxFileSize?: number // in bytes + acceptedFileTypes?: Accept + disabled?: boolean + autoAttach?: boolean + onUploadComplete?: (fileIds: string[]) => void + onAttachComplete?: () => void + onError?: (error: Error) => void +} diff --git a/packages/components/modules/files/web/index.ts b/packages/components/modules/files/web/index.ts index d47517eaf..f08f711e0 100644 --- a/packages/components/modules/files/web/index.ts +++ b/packages/components/modules/files/web/index.ts @@ -1,6 +1,9 @@ export { default as FileUpload } from './FileUpload' export type { FileUploadProps } from './FileUpload/types' +export { default as FileUploadTrigger } from './FileUploadTrigger' +export type { FileUploadTriggerProps } from './FileUploadTrigger/types' + export { default as FileUploadDropzone } from './FileUploadDropzone' export type { FileUploadDropzoneProps } from './FileUploadDropzone/types' @@ -8,3 +11,10 @@ export { default as FileUploadList } from './FileUploadList' export type { FileUploadListProps } from './FileUploadList/types' export { default as AttachedFileItem } from './AttachedFileItem' +export type { AttachedFileItemProps } from './AttachedFileItem/types' + +export { default as FileChip } from './FileChip' +export type { FileChipProps } from './FileChip/types' + +export { default as FileThumbnail } from './FileThumbnail' +export type { FileThumbnailProps } from './FileThumbnail/types' From 60ad1a5d9d20db46553f4b686839d27ab2557cea Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sun, 12 Jul 2026 23:52:55 +0000 Subject: [PATCH 14/21] files: loading state on remove/delete + attach trigger in comment action bar - AttachedFileItem and FileUploadTrigger now use the design-system IconButton (which renders a spinner via isLoading) instead of raw MUI: the remove/delete action shows isLoading={isDeletingFile}, the attach button isLoading while attaching. - Wire the comment editor's attach icon: SocialInput forwards a new optional SocialUpsertActionsProps to its SocialUpsertActions slot, and CommentUpdate passes a module-level CommentFilesUpsertActions (target-bound) whose AttachmentIcon is the real FileUploadTrigger. Removes the standalone attach button that used to sit below the input. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../__shared__/web/SocialInput/index.tsx | 3 +- .../__shared__/web/SocialInput/types.ts | 3 +- .../CommentFilesUpsertActions/index.tsx | 40 ++++++++++++++++ .../CommentFilesUpsertActions/types.ts | 6 +++ .../comments/web/CommentUpdate/index.tsx | 47 +++++++------------ .../files/web/AttachedFileItem/index.tsx | 6 ++- .../files/web/FileUploadTrigger/index.tsx | 12 ++++- 7 files changed, 83 insertions(+), 34 deletions(-) create mode 100644 packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/index.tsx create mode 100644 packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/types.ts diff --git a/packages/components/modules/__shared__/web/SocialInput/index.tsx b/packages/components/modules/__shared__/web/SocialInput/index.tsx index dbcb41ad9..54d995e7d 100644 --- a/packages/components/modules/__shared__/web/SocialInput/index.tsx +++ b/packages/components/modules/__shared__/web/SocialInput/index.tsx @@ -67,6 +67,7 @@ const SocialInput = forwardRef( SocialTextField = DefaultSocialTextField, SocialTextFieldProps = {}, SocialUpsertActions = DefaultSocialUpsertActions, + SocialUpsertActionsProps = {}, SubmitActions = DefaultSubmitActions, SubmitActionsProps = {}, Form = DefaultForm, @@ -113,7 +114,7 @@ const SocialInput = forwardRef( onCancelReply={onCancelReply} {...SocialTextFieldProps} > - + SocialTextFieldProps?: Partial - SocialUpsertActions?: FC + SocialUpsertActions?: FC + SocialUpsertActionsProps?: Record SubmitActions?: FC SubmitActionsProps?: Partial Form?: ComponentType> diff --git a/packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/index.tsx b/packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/index.tsx new file mode 100644 index 000000000..f4ac9bec9 --- /dev/null +++ b/packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/index.tsx @@ -0,0 +1,40 @@ +'use client' + +import type { FC } from 'react' + +import { FileUploadTrigger } from '@baseapp-frontend/components/files/web' +import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' +import { AttachmentIcon, MentionIcon } from '@baseapp-frontend/design-system/components/web/icons' + +import type { CommentFilesUpsertActionsProps } from './types' + +const MAX_FILES = 5 +const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB +const ACCEPTED_FILE_TYPES = { + 'image/*': ['.png', '.jpg'], + 'application/pdf': ['.pdf'], +} + +/** + * SocialInput upsert-action bar for the comment editor: the attach icon is the + * real file upload trigger; the mention icon stays a placeholder until wired. + */ +const CommentFilesUpsertActions: FC = ({ target }) => ( +
+ } + maxFiles={MAX_FILES} + maxFileSize={MAX_FILE_SIZE} + acceptedFileTypes={ACCEPTED_FILE_TYPES} + autoAttach + /> + + + +
+) + +export default CommentFilesUpsertActions +export type { CommentFilesUpsertActionsProps } from './types' diff --git a/packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/types.ts b/packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/types.ts new file mode 100644 index 000000000..57724d485 --- /dev/null +++ b/packages/components/modules/comments/web/CommentUpdate/CommentFilesUpsertActions/types.ts @@ -0,0 +1,6 @@ +import type { FileUploadTriggerProps } from '@baseapp-frontend/components/files/web' + +export interface CommentFilesUpsertActionsProps { + /** The comment (a FilesInterface implementer) files attach to. */ + target: FileUploadTriggerProps['target'] +} diff --git a/packages/components/modules/comments/web/CommentUpdate/index.tsx b/packages/components/modules/comments/web/CommentUpdate/index.tsx index d4b6a41d3..8ce2eb83e 100644 --- a/packages/components/modules/comments/web/CommentUpdate/index.tsx +++ b/packages/components/modules/comments/web/CommentUpdate/index.tsx @@ -2,7 +2,6 @@ import { FC, useEffect, useMemo, useRef } from 'react' -import { FileUploadTrigger } from '@baseapp-frontend/components/files/web' import { setFormRelayErrors } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -16,6 +15,7 @@ import { withMentionsInSocialInputProps, } from '../../../__shared__/web' import { useCommentUpdateMutation } from '../../common' +import CommentFilesUpsertActions from './CommentFilesUpsertActions' import { CommentUpdateProps } from './types' /** @@ -157,34 +157,23 @@ const CommentUpdate: FC = ({ }, [inputRef]) return ( - <> - - - + ) } diff --git a/packages/components/modules/files/web/AttachedFileItem/index.tsx b/packages/components/modules/files/web/AttachedFileItem/index.tsx index b07db0554..5eaebabca 100644 --- a/packages/components/modules/files/web/AttachedFileItem/index.tsx +++ b/packages/components/modules/files/web/AttachedFileItem/index.tsx @@ -2,13 +2,15 @@ import type { FC } from 'react' +import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' + import { Close as CloseIcon, Delete as DeleteIcon, Download as DownloadIcon, InsertDriveFile as FileIcon, } from '@mui/icons-material' -import { Box, Card, CardContent, Chip, IconButton, Stack, Typography } from '@mui/material' +import { Box, Card, CardContent, Chip, Stack, Typography } from '@mui/material' import { useFragment } from 'react-relay' import { FileItemFragment } from '../../common/graphql/fragments/FileItem' @@ -81,6 +83,7 @@ const AttachedFileItem: FC = ({ size="small" onClick={() => handleDelete(file.id)} disabled={isDeletingFile} + isLoading={isDeletingFile} title="Remove" aria-label="Remove" > @@ -153,6 +156,7 @@ const AttachedFileItem: FC = ({ color="error" title="Delete" disabled={isDeletingFile} + isLoading={isDeletingFile} > diff --git a/packages/components/modules/files/web/FileUploadTrigger/index.tsx b/packages/components/modules/files/web/FileUploadTrigger/index.tsx index ecaf4f453..ca606bbc5 100644 --- a/packages/components/modules/files/web/FileUploadTrigger/index.tsx +++ b/packages/components/modules/files/web/FileUploadTrigger/index.tsx @@ -2,8 +2,10 @@ import type { FC } from 'react' +import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' + import { AttachFile as AttachFileIcon } from '@mui/icons-material' -import { IconButton, Tooltip } from '@mui/material' +import { Tooltip } from '@mui/material' import { useFragment } from 'react-relay' import { FilesListFragment } from '../../common/graphql/queries/FilesList' @@ -62,7 +64,13 @@ const FileUploadTrigger: FC = ({ - + {icon ?? } From d773ac9e76c7ab9643320a6f6dcc3a780388745a Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Mon, 13 Jul 2026 00:15:43 +0000 Subject: [PATCH 15/21] files: fix broken image preview + chip max-width - The attached-file preview used file.thumbnail, which the backend leaves null when easy_thumbnails can't generate one, and downloads used the raw `file` field (a host-less /media path the frontend origin can't serve). Query the absolute `url` field and use it: preview falls back to thumbnail || url so images always render, and download opens `url`. - FileChip max-width 264 -> 232px to match the design. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../modules/files/common/graphql/fragments/FileItem.ts | 1 + .../modules/files/web/AttachedFileItem/index.tsx | 10 +++++----- .../components/modules/files/web/FileChip/index.tsx | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/components/modules/files/common/graphql/fragments/FileItem.ts b/packages/components/modules/files/common/graphql/fragments/FileItem.ts index 4a2f67082..5b94d4a82 100644 --- a/packages/components/modules/files/common/graphql/fragments/FileItem.ts +++ b/packages/components/modules/files/common/graphql/fragments/FileItem.ts @@ -8,6 +8,7 @@ export const FileItemFragment = graphql` fileContentType uploadStatus file + url description created createdBy { diff --git a/packages/components/modules/files/web/AttachedFileItem/index.tsx b/packages/components/modules/files/web/AttachedFileItem/index.tsx index 5eaebabca..745f76f37 100644 --- a/packages/components/modules/files/web/AttachedFileItem/index.tsx +++ b/packages/components/modules/files/web/AttachedFileItem/index.tsx @@ -66,7 +66,7 @@ const AttachedFileItem: FC = ({ @@ -92,7 +92,7 @@ const AttachedFileItem: FC = ({ ) : ( handleDownload(file.file)} + onClick={() => handleDownload(file.url)} title="Download" aria-label="Download" > @@ -111,10 +111,10 @@ const AttachedFileItem: FC = ({ - {isImage && file.thumbnail ? ( + {isImage && (file.thumbnail || file.url) ? ( = ({ - handleDownload(file.file)} title="Download"> + handleDownload(file.url)} title="Download"> diff --git a/packages/components/modules/files/web/FileChip/index.tsx b/packages/components/modules/files/web/FileChip/index.tsx index f730d8c13..ef8cca1a4 100644 --- a/packages/components/modules/files/web/FileChip/index.tsx +++ b/packages/components/modules/files/web/FileChip/index.tsx @@ -24,7 +24,7 @@ const FileChip: FC = ({ thumbnail, name, subtitle, action, fixedW borderColor: 'divider', bgcolor: 'grey.100', width: fixedWidth ? 196 : 'auto', - maxWidth: 264, + maxWidth: 232, flexShrink: 0, }} > From 3da3ba63b68677305cbfa91033600c18ecd92c9c Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Mon, 13 Jul 2026 00:55:05 +0000 Subject: [PATCH 16/21] files: drop source preview while uploading, show generic icon Previously the uploading item decoded the source File (via createObjectURL) into an image preview. Even for a 40px tile the browser decodes the full-res bitmap, which is wasteful for large images. Show a generic type icon during upload; the real thumbnail arrives when FileAttachToTarget prependEdges the committed File node. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../UploadingFileItem/index.tsx | 45 +++---------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx index 46165cf9e..8c7671b20 100644 --- a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' -import { useEffect, useMemo } from 'react' import { Close as CloseIcon, @@ -24,7 +23,7 @@ import { import { FileUploadStatus } from '../../../common/constants' import { useFileUpload } from '../../../common/context/useFileUpload' import { useChunkedUpload } from '../../../common/hooks/useChunkedUpload' -import { calculateProgress, formatFileSize, isImageFile } from '../../../common/utils/formatters' +import { calculateProgress, formatFileSize } from '../../../common/utils/formatters' import FileChip from '../../FileChip' import FileThumbnail from '../../FileThumbnail' import FileProgress from './FileProgress' @@ -78,24 +77,10 @@ const UploadingFileItem: FC = ({ const canResume = fileProgress.status === FileUploadStatus.PAUSED - const isImage = isImageFile(fileProgress.file?.type) - - const thumbnailUrl = useMemo(() => { - if (isImage && fileProgress.file) { - return URL.createObjectURL(fileProgress.file) - } - return null - }, [isImage, fileProgress.file]) - - useEffect( - () => () => { - if (thumbnailUrl) { - URL.revokeObjectURL(thumbnailUrl) - } - }, - [thumbnailUrl], - ) - + // While uploading we show a generic type icon rather than decoding the source + // file into a preview (a full-res image would decode into a large bitmap just + // to paint a tile). The real thumbnail lands once the attach mutation + // prependEdges the committed File node. const actions = ( {canPause && ( @@ -127,7 +112,7 @@ const UploadingFileItem: FC = ({ fixedWidth thumbnail={ @@ -158,23 +143,7 @@ const UploadingFileItem: FC = ({ - {isImage && thumbnailUrl ? ( - - ) : ( - - )} + From f3c626fe876905f8af0b765231996a9697c96506 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Mon, 13 Jul 2026 01:10:36 +0000 Subject: [PATCH 17/21] comments: attach files on create via deferred upload Adds file attachment to CommentCreate, mirroring CommentUpdate. Because the comment does not exist at selection time, files upload immediately and their relay ids are held by a new useDeferredFileAttachments hook; on successful create they are attached to the new comment in one FileAttachToTarget mutation (the comment's FilesList_files connection already exists via the CommentItem response, so the prependEdge lands). The attach icon lives in a create-specific SocialUpsertActions bar and shows a loading state while uploads are in flight. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../CommentFilesUpsertActions/index.tsx | 52 ++++++++++++ .../CommentFilesUpsertActions/types.ts | 11 +++ .../comments/web/CommentCreate/index.tsx | 22 ++++++ .../hooks/useDeferredFileAttachments/index.ts | 79 +++++++++++++++++++ .../hooks/useDeferredFileAttachments/types.ts | 14 ++++ .../components/modules/files/common/index.ts | 2 + 6 files changed, 180 insertions(+) create mode 100644 packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/index.tsx create mode 100644 packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/types.ts create mode 100644 packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts create mode 100644 packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts diff --git a/packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/index.tsx b/packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/index.tsx new file mode 100644 index 000000000..21148a841 --- /dev/null +++ b/packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/index.tsx @@ -0,0 +1,52 @@ +'use client' + +import type { FC } from 'react' + +import { useFileSelect } from '@baseapp-frontend/components/files/common' +import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' +import { AttachmentIcon, MentionIcon } from '@baseapp-frontend/design-system/components/web/icons' + +import type { CommentFilesUpsertActionsProps } from './types' + +/** + * SocialInput upsert-action bar for the comment composer: the attach icon + * uploads files right away; they are attached to the comment once it is created + * (see CommentCreate). The mention icon stays a placeholder until wired. + */ +const CommentFilesUpsertActions: FC = ({ + onFilesSelected, + isUploading = false, + maxFiles, + maxFileSize, + acceptedFileTypes, +}) => { + const { open, getInputProps } = useFileSelect({ + onFilesSelected, + maxFiles, + maxFileSize, + acceptedFileTypes, + disabled: isUploading, + }) + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */} + + + + + + + +
+ ) +} + +export default CommentFilesUpsertActions +export type { CommentFilesUpsertActionsProps } from './types' diff --git a/packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/types.ts b/packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/types.ts new file mode 100644 index 000000000..fc53be94f --- /dev/null +++ b/packages/components/modules/comments/web/CommentCreate/CommentFilesUpsertActions/types.ts @@ -0,0 +1,11 @@ +type Accept = Record + +export interface CommentFilesUpsertActionsProps { + /** Uploads the picked files (deferred attach happens after the comment is created). */ + onFilesSelected: (files: File[]) => void + /** Shows the attach button in a loading state while uploads are in flight. */ + isUploading?: boolean + maxFiles?: number + maxFileSize?: number + acceptedFileTypes?: Accept +} diff --git a/packages/components/modules/comments/web/CommentCreate/index.tsx b/packages/components/modules/comments/web/CommentCreate/index.tsx index fc6b30a15..904247435 100644 --- a/packages/components/modules/comments/web/CommentCreate/index.tsx +++ b/packages/components/modules/comments/web/CommentCreate/index.tsx @@ -3,6 +3,7 @@ import { forwardRef, useMemo } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' +import { useDeferredFileAttachments } from '@baseapp-frontend/components/files/common' import { setFormRelayErrors } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -20,10 +21,18 @@ import { withMentionsInSocialInputProps, } from '../../../__shared__/web' import { useCommentCreateMutation, useCommentReply } from '../../common' +import CommentFilesUpsertActions from './CommentFilesUpsertActions' import { CommentCreateProps } from './types' let nextClientMutationId = 0 +const MAX_FILES = 5 +const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB +const ACCEPTED_FILE_TYPES = { + 'image/*': ['.png', '.jpg'], + 'application/pdf': ['.pdf'], +} + /** * ### CommentCreate Component * @@ -103,6 +112,7 @@ const CommentCreate = forwardRef( }) const { setValue } = form const [commitMutation, isMutationInFlight] = useCommentCreateMutation() + const { handleFilesSelected, attachTo, isUploading } = useDeferredFileAttachments() const { mentions, isMentionsActive } = useFormMentions({ setValue, @@ -149,6 +159,10 @@ const CommentCreate = forwardRef( setFormRelayErrors(form, mutationErrors) if (!mutationErrors?.length) { + const newCommentId = response?.commentCreate?.comment?.node?.id + if (newCommentId) { + attachTo(newCommentId) + } commentReply.resetCommentReply() form.reset() if (commentReply.commentItemRef?.current) { @@ -182,6 +196,14 @@ const CommentCreate = forwardRef( ariaLabel: 'create comment', }} {...mergedSocialInputProps} + SocialUpsertActions={CommentFilesUpsertActions} + SocialUpsertActionsProps={{ + onFilesSelected: handleFilesSelected, + isUploading, + maxFiles: MAX_FILES, + maxFileSize: MAX_FILE_SIZE, + acceptedFileTypes: ACCEPTED_FILE_TYPES, + }} /> ) }, diff --git a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts new file mode 100644 index 000000000..f391ea320 --- /dev/null +++ b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts @@ -0,0 +1,79 @@ +import { useCallback, useRef, useState } from 'react' + +import { ConnectionHandler } from 'react-relay' + +import { useFileUploadStore } from '../../context/FileUploadProvider' +import { useFileAttachToTargetMutation } from '../../graphql/mutations/FileAttachToTarget' +import { useChunkedUpload } from '../useChunkedUpload' +import type { UseDeferredFileAttachmentsReturn } from './types' + +/** + * Upload files now, attach them later — for a target that does not exist yet + * (e.g. a new comment). Files upload immediately via the chunked uploader; the + * resulting File relay ids are held until attachTo(targetObjectId) runs once the + * target has been created, then attached in a single mutation. + */ +export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn => { + const { uploadFile } = useChunkedUpload() + const [attachFiles, isAttaching] = useFileAttachToTargetMutation() + const { clearCompleted } = useFileUploadStore() + const uploadsRef = useRef[]>([]) + const inFlightRef = useRef(0) + const [isUploading, setIsUploading] = useState(false) + + const handleFilesSelected = useCallback( + async (files: File[]) => { + if (!files.length) return + + const promises = files.map((file) => uploadFile(file)) + uploadsRef.current.push(...promises) + inFlightRef.current += 1 + setIsUploading(true) + try { + await Promise.allSettled(promises) + } finally { + inFlightRef.current -= 1 + if (inFlightRef.current === 0) setIsUploading(false) + } + }, + [uploadFile], + ) + + const attachTo = useCallback( + async (targetObjectId: string, onDone?: () => void) => { + const results = await Promise.allSettled(uploadsRef.current) + const fileRelayIds = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled' && !!r.value) + .map((r) => r.value) + uploadsRef.current = [] + + if (!fileRelayIds.length) { + onDone?.() + return + } + + const connectionID = ConnectionHandler.getConnectionID(targetObjectId, 'FilesList_files') + attachFiles({ + variables: { + input: { fileRelayIds, targetObjectId }, + connections: [connectionID], + }, + onCompleted: () => { + clearCompleted() + onDone?.() + }, + onError: () => { + onDone?.() + }, + }) + }, + [attachFiles, clearCompleted], + ) + + const reset = useCallback(() => { + uploadsRef.current = [] + clearCompleted() + }, [clearCompleted]) + + return { handleFilesSelected, attachTo, reset, isUploading, isAttaching } +} diff --git a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts new file mode 100644 index 000000000..60406acac --- /dev/null +++ b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts @@ -0,0 +1,14 @@ +export interface UseDeferredFileAttachmentsReturn { + /** Upload the selected files immediately; their ids are held for a later attachTo. */ + handleFilesSelected: (files: File[]) => Promise + /** + * Attach every successfully uploaded file to a target that now exists (e.g. a + * freshly created comment). Waits for any in-flight uploads first, so a fast + * submit still attaches them. onDone runs after the attach settles. + */ + attachTo: (targetObjectId: string, onDone?: () => void) => Promise + /** Discard the held uploads without attaching them. */ + reset: () => void + isUploading: boolean + isAttaching: boolean +} diff --git a/packages/components/modules/files/common/index.ts b/packages/components/modules/files/common/index.ts index 2c8627fe3..dcb06eb83 100644 --- a/packages/components/modules/files/common/index.ts +++ b/packages/components/modules/files/common/index.ts @@ -7,6 +7,8 @@ export * from './hooks/useChunkedUpload' export * from './hooks/useFileSelect' export * from './hooks/useFileUploadLogic' export type * from './hooks/useFileUploadLogic/types' +export * from './hooks/useDeferredFileAttachments' +export type * from './hooks/useDeferredFileAttachments/types' export * from './hooks/useFileDeleteLogic' export type * from './hooks/useFileDeleteLogic/types' export * from './hooks/useFileDownloadLogic' From fad5a548747d904567b6c8906f7b9038a1408b8d Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Mon, 13 Jul 2026 01:26:08 +0000 Subject: [PATCH 18/21] files: scope uploads so progress shows only on its own composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload store is a global singleton, so every FileUploadList (one per comment) was rendering all in-flight uploads — a file attached during comment creation appeared on existing comments. Tag each upload with a scope: the auto-attach path scopes to its target id; the deferred (create) path scopes to a per-composer id. FileUploadList now shows only uploads matching its target, and CommentCreate renders a new target-less UploadingFilesList for its own scope. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../comments/web/CommentCreate/index.tsx | 59 +++++++++++-------- .../context/FileUploadProvider/index.tsx | 3 +- .../context/FileUploadProvider/types.ts | 2 +- .../common/hooks/useChunkedUpload/index.ts | 4 +- .../hooks/useDeferredFileAttachments/index.ts | 7 ++- .../hooks/useDeferredFileAttachments/types.ts | 2 + .../common/hooks/useFileUploadLogic/index.ts | 5 +- .../components/modules/files/common/types.ts | 2 + .../files/web/FileUploadList/index.tsx | 6 +- .../modules/files/web/FileUploadList/types.ts | 2 + .../files/web/UploadingFilesList/index.tsx | 56 ++++++++++++++++++ .../files/web/UploadingFilesList/types.ts | 10 ++++ .../components/modules/files/web/index.ts | 3 + 13 files changed, 127 insertions(+), 34 deletions(-) create mode 100644 packages/components/modules/files/web/UploadingFilesList/index.tsx create mode 100644 packages/components/modules/files/web/UploadingFilesList/types.ts diff --git a/packages/components/modules/comments/web/CommentCreate/index.tsx b/packages/components/modules/comments/web/CommentCreate/index.tsx index 904247435..0fe360c62 100644 --- a/packages/components/modules/comments/web/CommentCreate/index.tsx +++ b/packages/components/modules/comments/web/CommentCreate/index.tsx @@ -4,6 +4,7 @@ import { forwardRef, useMemo } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' import { useDeferredFileAttachments } from '@baseapp-frontend/components/files/common' +import { UploadingFilesList } from '@baseapp-frontend/components/files/web' import { setFormRelayErrors } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' @@ -112,7 +113,12 @@ const CommentCreate = forwardRef( }) const { setValue } = form const [commitMutation, isMutationInFlight] = useCommentCreateMutation() - const { handleFilesSelected, attachTo, isUploading } = useDeferredFileAttachments() + const { + handleFilesSelected, + attachTo, + isUploading, + scope: filesScope, + } = useDeferredFileAttachments() const { mentions, isMentionsActive } = useFormMentions({ setValue, @@ -181,30 +187,33 @@ const CommentCreate = forwardRef( } return ( - + <> + + + ) }, ) diff --git a/packages/components/modules/files/common/context/FileUploadProvider/index.tsx b/packages/components/modules/files/common/context/FileUploadProvider/index.tsx index c8527bcf2..3174a3c04 100644 --- a/packages/components/modules/files/common/context/FileUploadProvider/index.tsx +++ b/packages/components/modules/files/common/context/FileUploadProvider/index.tsx @@ -7,13 +7,14 @@ import type { FileUploadState } from './types' export const useFileUploadStore = create((set, get) => ({ files: new Map(), - addFile: (file: File) => { + addFile: (file: File, scope?: string) => { const id = `${Date.now()}-${file.name}-${Math.random()}` const fileProgress: FileUploadProgress = { id, file, fileName: file.name, fileSize: file.size, + scope, status: FileUploadStatus.PENDING, uploadedBytes: 0, totalChunks: 0, diff --git a/packages/components/modules/files/common/context/FileUploadProvider/types.ts b/packages/components/modules/files/common/context/FileUploadProvider/types.ts index ff679144e..60878349d 100644 --- a/packages/components/modules/files/common/context/FileUploadProvider/types.ts +++ b/packages/components/modules/files/common/context/FileUploadProvider/types.ts @@ -2,7 +2,7 @@ import type { FileUploadProgress } from '../../types' export interface FileUploadState { files: Map - addFile: (file: File) => string + addFile: (file: File, scope?: string) => string updateFileProgress: (id: string, progress: Partial) => void updateChunkProgress: (fileId: string, chunkIndex: number, loaded: number, total: number) => void recordChunkEtag: (fileId: string, chunkIndex: number, etag: string) => void diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts index 94da848b3..7b8e8adf9 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts @@ -118,8 +118,8 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { ) const uploadFile = useCallback( - async (file: File) => { - const fileId = addFile(file) + async (file: File, scope?: string) => { + const fileId = addFile(file, scope) try { const relayId = await startUpload(fileId, file) diff --git a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts index f391ea320..f4d74d8f5 100644 --- a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts +++ b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts @@ -19,13 +19,16 @@ export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn = const { clearCompleted } = useFileUploadStore() const uploadsRef = useRef[]>([]) const inFlightRef = useRef(0) + // Stable per-instance scope so this composer's uploads are shown here and not + // on unrelated file lists (they share one global upload store). + const scopeRef = useRef(`deferred-${Math.random().toString(36).slice(2)}`) const [isUploading, setIsUploading] = useState(false) const handleFilesSelected = useCallback( async (files: File[]) => { if (!files.length) return - const promises = files.map((file) => uploadFile(file)) + const promises = files.map((file) => uploadFile(file, scopeRef.current)) uploadsRef.current.push(...promises) inFlightRef.current += 1 setIsUploading(true) @@ -75,5 +78,5 @@ export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn = clearCompleted() }, [clearCompleted]) - return { handleFilesSelected, attachTo, reset, isUploading, isAttaching } + return { handleFilesSelected, attachTo, reset, isUploading, isAttaching, scope: scopeRef.current } } diff --git a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts index 60406acac..6086de4cb 100644 --- a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts +++ b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/types.ts @@ -11,4 +11,6 @@ export interface UseDeferredFileAttachmentsReturn { reset: () => void isUploading: boolean isAttaching: boolean + /** Upload scope for this composer — pass to UploadingFilesList to show its progress. */ + scope: string } diff --git a/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts b/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts index 7ed86a1c9..d9811902f 100644 --- a/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts +++ b/packages/components/modules/files/common/hooks/useFileUploadLogic/index.ts @@ -31,8 +31,9 @@ export const useFileUploadLogic = ({ } try { - // Upload all files in parallel - const uploadPromises = selectedFiles.map((file) => uploadFile(file)) + // Upload all files in parallel, scoped to this target so its list shows + // only its own uploads. + const uploadPromises = selectedFiles.map((file) => uploadFile(file, targetObjectId)) const fileRelayIds = await Promise.all(uploadPromises) onUploadComplete?.(fileRelayIds) diff --git a/packages/components/modules/files/common/types.ts b/packages/components/modules/files/common/types.ts index d1d1cb327..2fd5b2533 100644 --- a/packages/components/modules/files/common/types.ts +++ b/packages/components/modules/files/common/types.ts @@ -15,6 +15,8 @@ export interface FileUploadProgress { file: File // Original File object fileName: string fileSize: number + /** Owner of this upload (e.g. a target id, or a composer instance) — lets each list show only its own uploads. */ + scope?: string status: FileUploadStatus uploadedBytes: number totalChunks: number diff --git a/packages/components/modules/files/web/FileUploadList/index.tsx b/packages/components/modules/files/web/FileUploadList/index.tsx index e565cd7fb..e584bd7c9 100644 --- a/packages/components/modules/files/web/FileUploadList/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/index.tsx @@ -19,11 +19,15 @@ const FileUploadList: FC = ({ variant = 'cards', layout = 'stack', editable = false, + scope, }) => { const target = useFragment(FilesListFragment, targetRef) const { files } = useFileUpload() - const uploadingFiles = Array.from(files.values()) + // The upload store is global; show only uploads owned by this list (its + // target by default) so another composer's uploads don't leak in here. + const activeScope = scope ?? target.id + const uploadingFiles = Array.from(files.values()).filter((file) => file.scope === activeScope) const hasUploadingFiles = uploadingFiles.length > 0 const hasAttachedFiles = (target.files?.edges?.length ?? 0) > 0 diff --git a/packages/components/modules/files/web/FileUploadList/types.ts b/packages/components/modules/files/web/FileUploadList/types.ts index e7e15198a..5fabc2f31 100644 --- a/packages/components/modules/files/web/FileUploadList/types.ts +++ b/packages/components/modules/files/web/FileUploadList/types.ts @@ -11,4 +11,6 @@ export interface FileUploadListProps { layout?: 'stack' | 'horizontal' /** Edit mode: show a remove control on attached files the user can change. */ editable?: boolean + /** Which uploads to show from the shared store. Defaults to the target's id. */ + scope?: string } diff --git a/packages/components/modules/files/web/UploadingFilesList/index.tsx b/packages/components/modules/files/web/UploadingFilesList/index.tsx new file mode 100644 index 000000000..1c9897790 --- /dev/null +++ b/packages/components/modules/files/web/UploadingFilesList/index.tsx @@ -0,0 +1,56 @@ +'use client' + +import type { FC } from 'react' + +import { Box, Stack } from '@mui/material' + +import { useFileUpload } from '../../common/context/useFileUpload' +import UploadingFileItem from '../FileUploadList/UploadingFileItem' +import type { UploadingFilesListProps } from './types' + +/** + * Renders in-progress uploads for a single scope from the shared upload store, + * without needing an attached-files target. Used by composers (e.g. comment + * create) that upload before their target exists. + */ +const UploadingFilesList: FC = ({ + scope, + allowRemove = true, + allowRetry = true, + variant = 'chips', + layout = 'horizontal', +}) => { + const { files } = useFileUpload() + + const uploadingFiles = Array.from(files.values()).filter((file) => file.scope === scope) + + if (!uploadingFiles.length) { + return null + } + + const itemVariant = variant === 'chips' ? 'chip' : 'card' + const isHorizontal = layout === 'horizontal' + + const items = uploadingFiles.map((file) => ( + + )) + + return ( + + {isHorizontal ? ( + {items} + ) : ( + {items} + )} + + ) +} + +export default UploadingFilesList +export type { UploadingFilesListProps } from './types' diff --git a/packages/components/modules/files/web/UploadingFilesList/types.ts b/packages/components/modules/files/web/UploadingFilesList/types.ts new file mode 100644 index 000000000..f89be656e --- /dev/null +++ b/packages/components/modules/files/web/UploadingFilesList/types.ts @@ -0,0 +1,10 @@ +export interface UploadingFilesListProps { + /** Show only uploads owned by this scope (from the shared upload store). */ + scope: string + allowRemove?: boolean + allowRetry?: boolean + /** `chips` (default, compact) or `cards` (detailed). */ + variant?: 'cards' | 'chips' + /** `horizontal` (default, wrapping row) or `stack` (vertical). */ + layout?: 'stack' | 'horizontal' +} diff --git a/packages/components/modules/files/web/index.ts b/packages/components/modules/files/web/index.ts index f08f711e0..f7bac3c16 100644 --- a/packages/components/modules/files/web/index.ts +++ b/packages/components/modules/files/web/index.ts @@ -10,6 +10,9 @@ export type { FileUploadDropzoneProps } from './FileUploadDropzone/types' export { default as FileUploadList } from './FileUploadList' export type { FileUploadListProps } from './FileUploadList/types' +export { default as UploadingFilesList } from './UploadingFilesList' +export type { UploadingFilesListProps } from './UploadingFilesList/types' + export { default as AttachedFileItem } from './AttachedFileItem' export type { AttachedFileItemProps } from './AttachedFileItem/types' From dc955388fad66a8c1dc78279e9c945f7a82c587b Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Tue, 14 Jul 2026 02:13:44 +0000 Subject: [PATCH 19/21] files: on completed upload hide progress bar, allow remove In a composer (e.g. new comment) an uploaded-but-not-yet-attached file kept showing its progress bar and a pause control, with no way to undo it. Once COMPLETED the chip now shows the file size instead of the bar (card hides FileProgress) and offers a remove control. Removing works correctly because the deferred hook now reads the completed uploads for its scope straight from the store at attach time (fileRelayId is persisted on completion), so a removed file is excluded from the attach; cleanup is scoped to the composer instead of clearing all completed uploads globally. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0146oeiGsELS6QVpVUQHDQXd --- .../common/hooks/useChunkedUpload/index.ts | 1 + .../hooks/useDeferredFileAttachments/index.ts | 30 +++++++++++++------ .../UploadingFileItem/index.tsx | 11 ++++++- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts index 7b8e8adf9..d17c1e6ef 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/index.ts @@ -110,6 +110,7 @@ export const useChunkedUpload = (options?: UseChunkedUploadOptions) => { updateFileProgress(fileId, { status: FileUploadStatus.COMPLETED, etags, + fileRelayId: data.relayId, }) return data.relayId diff --git a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts index f4d74d8f5..8351e51ab 100644 --- a/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts +++ b/packages/components/modules/files/common/hooks/useDeferredFileAttachments/index.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react' import { ConnectionHandler } from 'react-relay' +import { FileUploadStatus } from '../../constants' import { useFileUploadStore } from '../../context/FileUploadProvider' import { useFileAttachToTargetMutation } from '../../graphql/mutations/FileAttachToTarget' import { useChunkedUpload } from '../useChunkedUpload' @@ -16,7 +17,6 @@ import type { UseDeferredFileAttachmentsReturn } from './types' export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn => { const { uploadFile } = useChunkedUpload() const [attachFiles, isAttaching] = useFileAttachToTargetMutation() - const { clearCompleted } = useFileUploadStore() const uploadsRef = useRef[]>([]) const inFlightRef = useRef(0) // Stable per-instance scope so this composer's uploads are shown here and not @@ -44,12 +44,21 @@ export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn = const attachTo = useCallback( async (targetObjectId: string, onDone?: () => void) => { - const results = await Promise.allSettled(uploadsRef.current) - const fileRelayIds = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled' && !!r.value) - .map((r) => r.value) + // Wait for any in-flight uploads so a fast submit still catches them. + await Promise.allSettled(uploadsRef.current) uploadsRef.current = [] + // Read the completed uploads for this composer straight from the store, so + // any files the user removed before submitting are naturally excluded. + const store = useFileUploadStore.getState() + const scoped = Array.from(store.files.values()).filter( + (file) => + file.scope === scopeRef.current && + file.status === FileUploadStatus.COMPLETED && + !!file.fileRelayId, + ) + const fileRelayIds = scoped.map((file) => file.fileRelayId as string) + if (!fileRelayIds.length) { onDone?.() return @@ -62,7 +71,7 @@ export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn = connections: [connectionID], }, onCompleted: () => { - clearCompleted() + scoped.forEach((file) => store.removeFile(file.id)) onDone?.() }, onError: () => { @@ -70,13 +79,16 @@ export const useDeferredFileAttachments = (): UseDeferredFileAttachmentsReturn = }, }) }, - [attachFiles, clearCompleted], + [attachFiles], ) const reset = useCallback(() => { uploadsRef.current = [] - clearCompleted() - }, [clearCompleted]) + const store = useFileUploadStore.getState() + Array.from(store.files.values()) + .filter((file) => file.scope === scopeRef.current) + .forEach((file) => store.removeFile(file.id)) + }, []) return { handleFilesSelected, attachTo, reset, isUploading, isAttaching, scope: scopeRef.current } } diff --git a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx index 8c7671b20..a8821097b 100644 --- a/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx +++ b/packages/components/modules/files/web/FileUploadList/UploadingFileItem/index.tsx @@ -62,6 +62,8 @@ const UploadingFileItem: FC = ({ resumeUpload(fileProgress.id) } + const isCompleted = fileProgress.status === FileUploadStatus.COMPLETED + const canRemove = allowRemove && [ @@ -69,6 +71,8 @@ const UploadingFileItem: FC = ({ FileUploadStatus.FAILED, FileUploadStatus.PAUSED, FileUploadStatus.ABORTED, + // Uploaded but not yet attached (e.g. a new comment) — let the user undo it. + FileUploadStatus.COMPLETED, ].includes(fileProgress.status) const canRetry = allowRetry && fileProgress.status === FileUploadStatus.FAILED @@ -119,10 +123,15 @@ const UploadingFileItem: FC = ({ } name={fileProgress.fileName} subtitle={ + // eslint-disable-next-line no-nested-ternary fileProgress.error ? ( {fileProgress.error} + ) : isCompleted ? ( + + {formatFileSize(fileProgress.fileSize)} + ) : ( = ({ {actions}
- + {!isCompleted && } {fileProgress.error && }
From ceeff1b3bc64c8f88bd21f622e65956ba8299523 Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 3 Oct 2026 15:05:31 +0000 Subject: [PATCH 20/21] test: mock GraphQL image fields as URL strings #413 moved image, bannerImage and avatar to plain String URLs, but mocks it didn't touch, or that master added after it branched (GroupChatEdit, Members.story), still returned { url } objects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019P1oyApi4BLS8iLfMEGm8b --- .../Comments/__tests__/__mocks__/requests.ts | 8 ++--- .../__tests__/__mocks__/requests.ts | 4 +-- .../__storybook__/mockResolvers.ts | 12 ++----- .../__tests__/__mocks__/profiles.ts | 36 +++++-------------- .../__tests__/__mocks__/requests.ts | 12 ++----- .../__tests__/__utils__/Members.story.tsx | 2 +- .../__tests__/__mocks__/requests.ts | 32 +++++------------ 7 files changed, 28 insertions(+), 78 deletions(-) diff --git a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts index b65d1c694..eef60a4ef 100644 --- a/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/comments/web/Comments/__tests__/__mocks__/requests.ts @@ -1126,9 +1126,7 @@ export const commentsWithMultiLineBodiesMockData = { user: { id: 'user-1', fullName: 'John Doe', - avatar: { - url: 'https://cdn.example.com/avatar1.png', - }, + avatar: 'https://cdn.example.com/avatar1.png', }, reactionsCount: { total: 0 }, myReaction: null, @@ -1151,9 +1149,7 @@ export const commentsWithMultiLineBodiesMockData = { user: { id: 'user-2', fullName: 'Jane Smith', - avatar: { - url: 'https://cdn.example.com/avatar2.png', - }, + avatar: 'https://cdn.example.com/avatar2.png', }, reactionsCount: { total: 0 }, myReaction: null, diff --git a/packages/components/modules/messages/web/GroupChatEdit/__tests__/__mocks__/requests.ts b/packages/components/modules/messages/web/GroupChatEdit/__tests__/__mocks__/requests.ts index dc3a85f0e..4fec4ca33 100644 --- a/packages/components/modules/messages/web/GroupChatEdit/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/messages/web/GroupChatEdit/__tests__/__mocks__/requests.ts @@ -28,7 +28,7 @@ const profileNode = (id: string, name: string, path: string) => ({ __typename: 'Profile', id, name, - image: { url: PROFILE_IMAGE_URL }, + image: PROFILE_IMAGE_URL, urlPath: { __typename: 'URLPath', id: `${id}-url-path`, path }, }) @@ -234,7 +234,7 @@ const updatedRoomNode = (participants: ReturnType[]) => isSoleAdmin: true, isArchived: false, title: GROUP_TITLE, - image: { url: GROUP_IMAGE_URL }, + image: GROUP_IMAGE_URL, otherParticipant: null, lastMessageTime: '2026-01-01T00:00:00.000Z', lastMessage: { __typename: 'Message', id: 'TWVzc2FnZTox', content: 'Welcome' }, diff --git a/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__storybook__/mockResolvers.ts b/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__storybook__/mockResolvers.ts index d40c4eedb..ff505650b 100644 --- a/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__storybook__/mockResolvers.ts +++ b/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__storybook__/mockResolvers.ts @@ -8,9 +8,7 @@ export const mockResolvers = { profile: { id: 'profile-1', name: 'Profile 1', - image: { - url: Profile1Img, - }, + image: Profile1Img, urlPath: { path: '/profile/1', }, @@ -19,9 +17,7 @@ export const mockResolvers = { { id: 'profile-2', name: 'Profile 2', - image: { - url: Profile2Img, - }, + image: Profile2Img, urlPath: { path: '/profile/2', }, @@ -29,9 +25,7 @@ export const mockResolvers = { { id: 'profile-3', name: 'Profile 3', - image: { - url: Profile3Img, - }, + image: Profile3Img, urlPath: { path: '/profile/3', }, diff --git a/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__tests__/__mocks__/profiles.ts b/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__tests__/__mocks__/profiles.ts index 7a4847ab7..7e6ab7da3 100644 --- a/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__tests__/__mocks__/profiles.ts +++ b/packages/components/modules/navigations/web/Header/AccountMenu/AccountPopover/__tests__/__mocks__/profiles.ts @@ -23,9 +23,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: userProfile.id, name: userProfile.name, - image: { - url: userProfile.image, - }, + image: userProfile.image, urlPath: { path: userProfile.urlPath, }, @@ -37,9 +35,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZTo0', name: 'Second Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'second-profile', id: 'VVJMUGF0aDoz', @@ -52,9 +48,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZToxOA==', name: 'Third Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'third-profile', id: 'VVJMUGF0aDoxNg==', @@ -67,9 +61,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZToxMQ==', name: 'Fourth Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'fourth-profile', id: 'VVJMUGF0aDoxMA==', @@ -82,9 +74,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZTo2', name: 'Fifth Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'fifth-profile', id: 'VVJMUGF0aDo1', @@ -97,9 +87,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZTo3', name: 'Sixth Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'sixth-profile', id: 'VVJMUGF0aDo2', @@ -112,9 +100,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZTo4', name: 'Seventh Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'seventh-profile', id: 'VVJMUGF0aDo3', @@ -127,9 +113,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZTo5', name: 'Eighth Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'eighth-profile', id: 'VVJMUGF0aDo4', @@ -155,9 +139,7 @@ export const mockProfilesListFactory = (userProfile: MinimalProfile) => { node: { id: 'UHJvZmlsZTox', name: 'Tenth Profile', - image: { - url: faker.image.avatar(), - }, + image: faker.image.avatar(), urlPath: { path: 'tenth-profile', id: 'VVJMUGF0aDoxMQ==', diff --git a/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts b/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts index 56793adc5..fa11b2147 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/profiles/web/ProfileMembers/__tests__/__mocks__/requests.ts @@ -88,9 +88,7 @@ export const allRolesMembersListMockData = { profile: { id: 'owner-profile-id', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, }, }, @@ -127,9 +125,7 @@ export const fullMembersListMockData = { profile: { id: 'owner-profile-id', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, }, }, @@ -166,9 +162,7 @@ export const fullMembersListNextPageMockData = { profile: { id: 'owner-profile-id', name: 'Owner Profile', - image: { - url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', - }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, }, }, diff --git a/packages/components/modules/profiles/web/ProfileMembers/__tests__/__utils__/Members.story.tsx b/packages/components/modules/profiles/web/ProfileMembers/__tests__/__utils__/Members.story.tsx index 56ed1fb2e..2620da2f9 100644 --- a/packages/components/modules/profiles/web/ProfileMembers/__tests__/__utils__/Members.story.tsx +++ b/packages/components/modules/profiles/web/ProfileMembers/__tests__/__utils__/Members.story.tsx @@ -42,7 +42,7 @@ const ownerContext = { canChangeRole: true, id: 'UHJvZmlsZTox', name: 'Owner Profile', - image: { url: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg' }, + image: 'https://cdn.jsdelivr.net/npm/simple-icons@3.0.1/icons/react.svg', urlPath: null, }, }, diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts b/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts index 0f40ae5c2..27b671abc 100644 --- a/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/__tests__/__mocks__/requests.ts @@ -31,12 +31,8 @@ export const profileSettingsTextUpdateData = { status: 'ACTIVE', name: 'Jane Smith', biography: 'Jane Smith is a software engineer at Microsoft.', - image: { - url: null, - }, - bannerImage: { - url: null, - }, + image: null, + bannerImage: null, isFollowedByMe: false, followersCount: 0, followingCount: 0, @@ -62,9 +58,7 @@ export const profileSettingsImageUpdateData = { biography: 'John Doe is a software engineer at Google.', image: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', - bannerImage: { - url: null, - }, + bannerImage: null, isFollowedByMe: false, followersCount: 0, followingCount: 0, @@ -88,12 +82,8 @@ export const profileSettingsImageRemoveData = { status: 'ACTIVE', name: 'John Doe', biography: 'John Doe is a software engineer at Google.', - image: { - url: null, - }, - bannerImage: { - url: null, - }, + image: null, + bannerImage: null, isFollowedByMe: false, followersCount: 0, followingCount: 0, @@ -117,9 +107,7 @@ export const profileSettingsBannerUpdateData = { status: 'ACTIVE', name: 'John Doe', biography: 'John Doe is a software engineer at Google.', - image: { - url: null, - }, + image: null, bannerImage: 'https://nyc3.digitaloceanspaces.com/baseapp-production-storage/media/user-avatars/5/6/4/resized/50/50/185a04dfdaa512d218cf9b7a5097e3c9.png', isFollowedByMe: false, @@ -145,12 +133,8 @@ export const profileSettingsBannerRemoveData = { status: 'ACTIVE', name: 'John Doe', biography: 'John Doe is a software engineer at Google.', - image: { - url: null, - }, - bannerImage: { - url: null, - }, + image: null, + bannerImage: null, isFollowedByMe: false, followersCount: 0, followingCount: 0, From c297fac7605741a10025f12921f6b8730078355d Mon Sep 17 00:00:00 2001 From: Alisson Patricio Date: Sat, 3 Oct 2026 15:09:44 +0000 Subject: [PATCH 21/21] test(files): port the files module tests from Jest to Vitest #413 was written before master moved to Vitest. The jest = vi shim covers jest.fn(), but Vitest only hoists vi.mock(), so the jest.mock() calls never replaced axios or uploadChunks and 14 tests failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019P1oyApi4BLS8iLfMEGm8b --- .../__tests__/useChunkedUpload.test.ts | 23 ++++++++++--------- .../__tests__/useFileSelect.test.ts | 15 ++++++------ .../utils/__tests__/uploadChunks.test.ts | 8 ++++--- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts b/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts index d8f71903b..09b245954 100644 --- a/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts +++ b/packages/components/modules/files/common/hooks/useChunkedUpload/__tests__/useChunkedUpload.test.ts @@ -1,27 +1,28 @@ import { axios } from '@baseapp-frontend/utils' import { renderHook } from '@testing-library/react' +import { vi } from 'vitest' import { FileUploadStatus } from '../../../constants' import { useFileUploadStore } from '../../../context/FileUploadProvider' import { uploadChunks } from '../../../utils' import { useChunkedUpload } from '../index' -jest.mock('@baseapp-frontend/utils', () => ({ +vi.mock('@baseapp-frontend/utils', () => ({ axios: { - post: jest.fn(), - delete: jest.fn(), + post: vi.fn(), + delete: vi.fn(), }, })) -jest.mock('../../../utils', () => ({ - ...jest.requireActual('../../../utils'), - uploadChunks: jest.fn(), +vi.mock('../../../utils', async () => ({ + ...(await vi.importActual('../../../utils')), + uploadChunks: vi.fn(), })) -const mockAxiosPost = axios.post as jest.MockedFunction -const mockAxiosDelete = axios.delete as jest.MockedFunction -const mockUploadChunks = uploadChunks as jest.MockedFunction +const mockAxiosPost = vi.mocked(axios.post) +const mockAxiosDelete = vi.mocked(axios.delete) +const mockUploadChunks = vi.mocked(uploadChunks) const CHUNK_SIZE = 5 * 1024 * 1024 const makeFile = (chunks: number) => @@ -41,7 +42,7 @@ const initiateResponse = (numParts: number, expiresIn = 3600) => ({ describe('useChunkedUpload', () => { beforeEach(() => { - jest.clearAllMocks() + vi.clearAllMocks() useFileUploadStore.setState({ files: new Map() }) }) @@ -178,7 +179,7 @@ describe('useChunkedUpload', () => { useFileUploadStore.getState().pauseFile(id!) throw new Error('Chunk upload aborted') }) - const onUploadError = jest.fn() + const onUploadError = vi.fn() const { result } = renderHook(() => useChunkedUpload({ onUploadError })) await expect(result.current.uploadFile(makeFile(2))).rejects.toThrow('Chunk upload aborted') diff --git a/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts b/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts index 659f93745..98b40810a 100644 --- a/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts +++ b/packages/components/modules/files/common/hooks/useFileSelect/__tests__/useFileSelect.test.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react' +import { vi } from 'vitest' import { useFileSelect } from '../index' @@ -21,7 +22,7 @@ describe('useFileSelect', () => { it('builds the accept attribute from mime types and extensions', () => { const { result } = renderHook(() => useFileSelect({ - onFilesSelected: jest.fn(), + onFilesSelected: vi.fn(), acceptedFileTypes: { 'image/*': ['.png', '.jpg'], 'application/pdf': ['.pdf'] }, }), ) @@ -29,14 +30,14 @@ describe('useFileSelect', () => { }) it('sets multiple only when maxFiles > 1', () => { - const single = renderHook(() => useFileSelect({ onFilesSelected: jest.fn(), maxFiles: 1 })) - const many = renderHook(() => useFileSelect({ onFilesSelected: jest.fn(), maxFiles: 5 })) + const single = renderHook(() => useFileSelect({ onFilesSelected: vi.fn(), maxFiles: 1 })) + const many = renderHook(() => useFileSelect({ onFilesSelected: vi.fn(), maxFiles: 5 })) expect(single.result.current.getInputProps().multiple).toBe(false) expect(many.result.current.getInputProps().multiple).toBe(true) }) it('filters out files over maxFileSize and caps at maxFiles', () => { - const onFilesSelected = jest.fn() + const onFilesSelected = vi.fn() const { result } = renderHook(() => useFileSelect({ onFilesSelected, maxFiles: 2, maxFileSize: 1000 }), ) @@ -54,7 +55,7 @@ describe('useFileSelect', () => { }) it('does not fire the callback when nothing valid was selected, and resets the input', () => { - const onFilesSelected = jest.fn() + const onFilesSelected = vi.fn() const { result } = renderHook(() => useFileSelect({ onFilesSelected, maxFileSize: 100 })) const target = fireChange(result.current.getInputProps().onChange, [makeFile('big', 999)]) @@ -64,9 +65,7 @@ describe('useFileSelect', () => { }) it('open() does not throw when no input is mounted and respects disabled', () => { - const { result } = renderHook(() => - useFileSelect({ onFilesSelected: jest.fn(), disabled: true }), - ) + const { result } = renderHook(() => useFileSelect({ onFilesSelected: vi.fn(), disabled: true })) expect(() => result.current.open()).not.toThrow() expect(result.current.getInputProps().disabled).toBe(true) }) diff --git a/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts b/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts index f6d833855..74b3eac57 100644 --- a/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts +++ b/packages/components/modules/files/common/utils/__tests__/uploadChunks.test.ts @@ -1,9 +1,11 @@ +import { vi } from 'vitest' + import { uploadChunk } from '../uploadChunk' import { uploadChunks } from '../uploadChunks' -jest.mock('../uploadChunk') +vi.mock('../uploadChunk') -const mockUploadChunk = uploadChunk as jest.MockedFunction +const mockUploadChunk = vi.mocked(uploadChunk) const makeChunks = (amount: number) => Array.from({ length: amount }, () => new Blob(['x'])) const makeUrls = (amount: number) => @@ -41,7 +43,7 @@ describe('uploadChunks', () => { mockUploadChunk.mockImplementation((_chunk, url) => Promise.resolve(`etag-${url.split('-').pop()}`), ) - const onChunkComplete = jest.fn() + const onChunkComplete = vi.fn() await uploadChunks({ chunks: makeChunks(3),