From 35105c2baf9a518c23d8fa11504e33214cface32 Mon Sep 17 00:00:00 2001 From: Jakub Barczewski Date: Tue, 3 Sep 2024 21:19:58 +0200 Subject: [PATCH 1/3] chore: add single file download --- .../resourceBlobServiceImpl.ts | 1 + .../imageTableColumns/imageTableColumns.tsx | 10 ++++++++-- .../resource/hooks/useResourceDownload.tsx | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx diff --git a/apps/backend/src/modules/resourceModule/infrastructure/services/resourceBlobService/resourceBlobServiceImpl.ts b/apps/backend/src/modules/resourceModule/infrastructure/services/resourceBlobService/resourceBlobServiceImpl.ts index 9dcbd2b..ba0857a 100644 --- a/apps/backend/src/modules/resourceModule/infrastructure/services/resourceBlobService/resourceBlobServiceImpl.ts +++ b/apps/backend/src/modules/resourceModule/infrastructure/services/resourceBlobService/resourceBlobServiceImpl.ts @@ -43,6 +43,7 @@ export class ResourceBlobServiceImpl implements ResourceBlobService { actualname: encodeURIComponent(resourceName), }, ContentType: contentType, + ContentDisposition: `attachment; filename=${resourceName}`, }, }); diff --git a/apps/frontend/src/modules/resource/components/imageTableColumns/imageTableColumns.tsx b/apps/frontend/src/modules/resource/components/imageTableColumns/imageTableColumns.tsx index 4e9b38b..e698f29 100644 --- a/apps/frontend/src/modules/resource/components/imageTableColumns/imageTableColumns.tsx +++ b/apps/frontend/src/modules/resource/components/imageTableColumns/imageTableColumns.tsx @@ -18,6 +18,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useRowsContext } from '../../../common/components/dataTable/rowsContext'; +import { useResourceDownload } from '../../hooks/useResourceDownload'; interface HeaderProps { className?: string | undefined; @@ -113,7 +114,9 @@ export const imageTableColumns: ColumnDef[] = [ { header: () =>
Actions
, accessorKey: 'actions', - cell: (): JSX.Element => { + cell: ({ row }): JSX.Element => { + const { download } = useResourceDownload(); + return ( @@ -130,7 +133,10 @@ export const imageTableColumns: ColumnDef[] = [ className="bg-primary-foreground" > Actions - navigator.clipboard.writeText('Test me up')}>Download + download({ + src: row.original.url, + name: row.original.name + })}>Download Delete Change name diff --git a/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx b/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx new file mode 100644 index 0000000..9174145 --- /dev/null +++ b/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx @@ -0,0 +1,20 @@ +interface DownloadProps { + src: string; + name: string; +} + +export const useResourceDownload = () => { + const download = ({ src, name }: DownloadProps) => { + const aElement = document.createElement('a'); + aElement.setAttribute('download', name ?? 'file'); + aElement.href = src; + + document.body.appendChild(aElement); + aElement.click(); + document.body.removeChild(aElement); + } + + return { + download + } +} From 65b22ba0a05b6e9859f6639ad855f29b0550f5f1 Mon Sep 17 00:00:00 2001 From: Jakub Barczewski Date: Tue, 3 Sep 2024 22:28:49 +0200 Subject: [PATCH 2/3] chore: add all files download --- .../@/components/ui/loadingSpinner.tsx | 6 +-- .../services/httpService/httpService.ts | 15 ++++--- .../mutations/downloadResourcesMutation.ts | 38 ++++++++++++++++ .../resource/hooks/useResourceDownload.tsx | 12 +++++- apps/frontend/src/routes/dashboard/index.tsx | 43 ++++++++++++++++--- 5 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts diff --git a/apps/frontend/@/components/ui/loadingSpinner.tsx b/apps/frontend/@/components/ui/loadingSpinner.tsx index 7f54dba..ea4eeea 100644 --- a/apps/frontend/@/components/ui/loadingSpinner.tsx +++ b/apps/frontend/@/components/ui/loadingSpinner.tsx @@ -2,11 +2,11 @@ import { type FC } from 'react'; import { cn } from '../../lib/utils'; -export const LoadingSpinner: FC<{ className?: string }> = ({ className }) => ( +export const LoadingSpinner: FC<{ className?: string; size?: number; }> = ({ className, size = 24 }) => ( (payload: RequestPayload): Promise> { const { url, headers, body, type = 'json', signal } = payload; - let requestBody: unknown; - let contentType = ''; if (type === 'json') { @@ -95,7 +93,6 @@ export class HttpService { } requestBody = formData; - contentType = ''; } @@ -114,12 +111,18 @@ export class HttpService { signal, }); - const responseBodyText = await response.text(); - let responseBody = {}; + if (headers && headers['Accept'] === 'application/octet-stream') { + responseBody = await response.blob(); + } + try { - responseBody = JSON.parse(responseBodyText); + if (headers && !headers['Accept']) { + const responseBodyText = await response.text(); + + responseBody = JSON.parse(responseBodyText); + } } catch (error) { responseBody = {}; } diff --git a/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts b/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts new file mode 100644 index 0000000..137f82b --- /dev/null +++ b/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts @@ -0,0 +1,38 @@ +import { ExportResourcesBody, ExportResourcesPathParams } from '@common/contracts'; +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { BaseApiError } from '../../../../common/services/httpService/types/baseApiError'; +import { HttpService } from '../../../../common/services/httpService/httpService'; + +interface DownloadResourcesPayload extends ExportResourcesBody, ExportResourcesPathParams { + accessToken: string; +} + +type DownloadResourcesResponseBody = Blob; + +export const useDownloadResourcesMutation = ( + opts: UseMutationOptions, +) => { + const download = async (payload: DownloadResourcesPayload) => { + const response = await HttpService.post({ + url: `/buckets/${payload.bucketName}/resources/export`, + body: { + ids: payload.ids, + }, + headers: { + Authorization: `Bearer ${payload.accessToken}`, + Accept: 'application/octet-stream', + }, + }); + + if (!response.success) { + throw new Error(response.body.message); + } + + return response.body; + }; + + return useMutation({ + mutationFn: download, + ...opts, + }); +}; diff --git a/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx b/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx index 9174145..ada11f3 100644 --- a/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx +++ b/apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx @@ -1,16 +1,24 @@ interface DownloadProps { - src: string; + src: string | Blob; name: string; } export const useResourceDownload = () => { const download = ({ src, name }: DownloadProps) => { const aElement = document.createElement('a'); + + if (typeof src === 'string') { + aElement.href = src; + } else { + const url = window.URL.createObjectURL(src); + aElement.href = url; + } aElement.setAttribute('download', name ?? 'file'); - aElement.href = src; document.body.appendChild(aElement); aElement.click(); + + window.URL.revokeObjectURL(aElement.href); document.body.removeChild(aElement); } diff --git a/apps/frontend/src/routes/dashboard/index.tsx b/apps/frontend/src/routes/dashboard/index.tsx index 6f4efc2..6cc4638 100644 --- a/apps/frontend/src/routes/dashboard/index.tsx +++ b/apps/frontend/src/routes/dashboard/index.tsx @@ -13,6 +13,10 @@ import { useUserTokensStore } from '../../modules/core/stores/userTokens/userTok import { findBucketResourcesQueryOptions } from '../../modules/resource/api/user/queries/findBucketResources/findBucketResourcesQueryOptions'; import { CreateResourceModal } from '../../modules/resource/components/createResourceModal/createResourceModal'; import { imageTableColumns } from '../../modules/resource/components/imageTableColumns/imageTableColumns'; +import { Button } from '../../../@/components/ui/button'; +import { useDownloadResourcesMutation } from '../../modules/resource/api/user/mutations/downloadResourcesMutation'; +import { ArrowDownTrayIcon } from '@heroicons/react/24/solid'; +import { useResourceDownload } from '../../modules/resource/hooks/useResourceDownload'; const searchSchema = z.object({ page: z.number().default(0), @@ -31,22 +35,23 @@ export const Route = createFileRoute('/dashboard/')({ function Dashboard(): JSX.Element { const accessToken = useUserTokensStore((state) => state.accessToken); - const { bucketName, page } = Route.useSearch({}); + const userId = useUserStore((state) => state.user.id); + + const { download } = useResourceDownload(); const navigate = useNavigate(); - const userId = useUserStore((state) => state.user.id); + const { mutateAsync: downloadAll, isPending: isDownloading } = useDownloadResourcesMutation({}); const bucketsQuery = findBucketsQueryOptions({ accessToken: accessToken as string, userId: userId as string, }); - - const { data: bucketsData, isFetched: isBucketsFetched } = useQuery(bucketsQuery); - + const [pageSize] = useState(10); + const { data: bucketsData, isFetched: isBucketsFetched } = useQuery(bucketsQuery); const { data: resourcesData, isFetched: isResourcesFetched } = useQuery({ ...findBucketResourcesQueryOptions({ accessToken: accessToken as string, @@ -74,7 +79,6 @@ function Dashboard(): JSX.Element { search: (prev) => ({ ...prev, page: page + 1 }), }); }; - const onPreviousPage = (): void => { navigate({ search: (prev) => ({ ...prev, page: page - 1 }), @@ -85,6 +89,21 @@ function Dashboard(): JSX.Element { return
Loading ...
; } + const onDownloadAll = async () => { + const ids = resourcesData?.data.map((r) => r.id); + + const response = await downloadAll({ + accessToken: accessToken as string, + bucketName: bucketName ?? '', + ids, + }) + + download({ + name: "resources.zip", + src: response, + }) + } + return (
@@ -106,6 +125,18 @@ function Dashboard(): JSX.Element { ))} +
{isBucketsFetched && !isResourcesFetched && bucketName && } {isBucketsFetched && isResourcesFetched && ( From 70c40751a4ef700dadaff6b4ea3e075a8b5ac3c5 Mon Sep 17 00:00:00 2001 From: Jakub Barczewski Date: Tue, 3 Sep 2024 22:48:13 +0200 Subject: [PATCH 3/3] chore: add better error handling --- .../common/hooks/useErrorHandledMutation.tsx | 140 ++++++++++++++++++ .../common/mapper/errorCodeMessageMapper.ts | 27 ++++ .../mutations/downloadResourcesMutation.ts | 14 +- 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/modules/common/hooks/useErrorHandledMutation.tsx create mode 100644 apps/frontend/src/modules/common/mapper/errorCodeMessageMapper.ts diff --git a/apps/frontend/src/modules/common/hooks/useErrorHandledMutation.tsx b/apps/frontend/src/modules/common/hooks/useErrorHandledMutation.tsx new file mode 100644 index 0000000..f5426da --- /dev/null +++ b/apps/frontend/src/modules/common/hooks/useErrorHandledMutation.tsx @@ -0,0 +1,140 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationOptions, + UseMutationResult, +} from '@tanstack/react-query'; +import { ApiError } from '../errors/apiError'; +import { useToast } from '../../../../@/components/ui/use-toast'; + +interface IErrorHandlingPayload { + errorHandling?: { + title?: string; + description?: string; + }; +} + +export type ExtendedTPayload = TPayload & IErrorHandlingPayload; + +type Override = Omit & T2; + +type UseErrorHandledMutation = Override< + UseMutationResult>, + { + mutate: UseMutateFunction< + TResponseBody, + TError, + ExtendedTPayload, + unknown + >; + mutateAsync: UseMutateAsyncFunction< + TResponseBody, + TError, + ExtendedTPayload, + unknown + >; + } +>; + +export interface UseErrorHandledMutationOptions + extends UseMutationOptions< + TResponseBody, + TError, + ExtendedTPayload + > {} + +export function useErrorHandledMutation( + options: UseErrorHandledMutationOptions< + TResponseBody, + TError, + ExtendedTPayload + > +): UseErrorHandledMutation { + const { toast } = useToast(); + + const onError = async ( + error: unknown, + { errorHandling }: ExtendedTPayload + ): Promise => { + let descriptionValue = ''; + + if (errorHandling?.description) { + descriptionValue = errorHandling.description; + } + + if (!errorHandling?.description && error instanceof ApiError) { + descriptionValue = (error as ApiError)?.context?.message; + } + + if ( + !errorHandling?.description && + !(error instanceof ApiError) && + error instanceof Error + ) { + descriptionValue = (error as Error)?.message; + } + + if (error instanceof ApiError) { + toast({ + title: errorHandling?.title || 'An exception occurred', + description: descriptionValue, + variant: 'destructive', + }); + + return; + } + + if (error instanceof Error) { + toast({ + title: errorHandling?.title || 'An exception occurred', + description: descriptionValue, + variant: 'destructive', + }); + + return; + } + + toast({ + title: 'An exception occurred', + description: descriptionValue, + variant: 'destructive', + }); + }; + + const result = useMutation>( + { + ...options, + mutationFn: (vars) => { + const varsNoErrorHandling = Object.entries(vars).reduce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (acc: any, [key, val]) => { + if (key === 'errorHandling') { + return acc; + } + acc[key] = val; + + return acc; + }, + {} as TPayload + ); + + if (!options.mutationFn) { + throw new Error('No mutation function provided.'); + } + + return options.mutationFn(varsNoErrorHandling); + }, + onError: (error, variables, context) => { + if (options.onError) { + options.onError(error, variables, context); + } + onError(error, variables); + }, + } + ); + + return { + ...result, + }; +} diff --git a/apps/frontend/src/modules/common/mapper/errorCodeMessageMapper.ts b/apps/frontend/src/modules/common/mapper/errorCodeMessageMapper.ts new file mode 100644 index 0000000..f7f4636 --- /dev/null +++ b/apps/frontend/src/modules/common/mapper/errorCodeMessageMapper.ts @@ -0,0 +1,27 @@ +export class ErrorCodeMessageMapper { + private readonly defaults: Record = { + 400: 'Invalid payload.', + 401: 'Access denied.', + 403: 'Action forbidden.', + 500: 'Internal server error.', + }; + + private errorMap: Record; + + public constructor(errorMap: Record) { + this.errorMap = { + ...this.defaults, + ...errorMap, + }; + } + + public map(code: number): string { + const message = this.errorMap[code]; + + if (!message) { + return `Unknown error.`; + } + + return message; + } +} diff --git a/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts b/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts index 137f82b..10c71d2 100644 --- a/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts +++ b/apps/frontend/src/modules/resource/api/user/mutations/downloadResourcesMutation.ts @@ -1,7 +1,10 @@ import { ExportResourcesBody, ExportResourcesPathParams } from '@common/contracts'; -import { useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { UseMutationOptions } from '@tanstack/react-query'; import { BaseApiError } from '../../../../common/services/httpService/types/baseApiError'; import { HttpService } from '../../../../common/services/httpService/httpService'; +import { useErrorHandledMutation } from '../../../../common/hooks/useErrorHandledMutation'; +import { ErrorCodeMessageMapper } from '../../../../common/mapper/errorCodeMessageMapper'; +import { ApiError } from '../../../../common/errors/apiError'; interface DownloadResourcesPayload extends ExportResourcesBody, ExportResourcesPathParams { accessToken: string; @@ -12,6 +15,7 @@ type DownloadResourcesResponseBody = Blob; export const useDownloadResourcesMutation = ( opts: UseMutationOptions, ) => { + const mapper = new ErrorCodeMessageMapper({}); const download = async (payload: DownloadResourcesPayload) => { const response = await HttpService.post({ url: `/buckets/${payload.bucketName}/resources/export`, @@ -25,13 +29,17 @@ export const useDownloadResourcesMutation = ( }); if (!response.success) { - throw new Error(response.body.message); + throw new ApiError("DownloadException", { + apiResponseError: response.body.context, + message: mapper.map(response.statusCode), + statusCode: response.statusCode + }) } return response.body; }; - return useMutation({ + return useErrorHandledMutation({ mutationFn: download, ...opts, });