Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Chore/add file download #113

Merged
merged 3 commits into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class ResourceBlobServiceImpl implements ResourceBlobService {
actualname: encodeURIComponent(resourceName),
},
ContentType: contentType,
ContentDisposition: `attachment; filename=${resourceName}`,
},
});

Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/@/components/ui/loadingSpinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
Expand Down
140 changes: 140 additions & 0 deletions apps/frontend/src/modules/common/hooks/useErrorHandledMutation.tsx
Original file line number Diff line number Diff line change
@@ -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> = TPayload & IErrorHandlingPayload;

type Override<T1, T2> = Omit<T1, keyof T2> & T2;

type UseErrorHandledMutation<TResponseBody, TError, TPayload> = Override<
UseMutationResult<TResponseBody, TError, ExtendedTPayload<TPayload>>,
{
mutate: UseMutateFunction<
TResponseBody,
TError,
ExtendedTPayload<TPayload>,
unknown
>;
mutateAsync: UseMutateAsyncFunction<
TResponseBody,
TError,
ExtendedTPayload<TPayload>,
unknown
>;
}
>;

export interface UseErrorHandledMutationOptions<TResponseBody, TError, TPayload>
extends UseMutationOptions<
TResponseBody,
TError,
ExtendedTPayload<TPayload>
> {}

export function useErrorHandledMutation<TResponseBody, TError, TPayload>(
options: UseErrorHandledMutationOptions<
TResponseBody,
TError,
ExtendedTPayload<TPayload>
>
): UseErrorHandledMutation<TResponseBody, TError, TPayload> {
const { toast } = useToast();

const onError = async (
error: unknown,
{ errorHandling }: ExtendedTPayload<TPayload>
): Promise<TResponseBody | undefined> => {
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<TResponseBody, TError, ExtendedTPayload<TPayload>>(
{
...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,
};
}
27 changes: 27 additions & 0 deletions apps/frontend/src/modules/common/mapper/errorCodeMessageMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export class ErrorCodeMessageMapper {
private readonly defaults: Record<number, string> = {
400: 'Invalid payload.',
401: 'Access denied.',
403: 'Action forbidden.',
500: 'Internal server error.',
};

private errorMap: Record<number, string>;

public constructor(errorMap: Record<number, string>) {
this.errorMap = {
...this.defaults,
...errorMap,
};
}

public map(code: number): string {
const message = this.errorMap[code];

if (!message) {
return `Unknown error.`;
}

return message;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ export class HttpService {

public static async post<T = unknown>(payload: RequestPayload): Promise<HttpResponse<T>> {
const { url, headers, body, type = 'json', signal } = payload;

let requestBody: unknown;

let contentType = '';

if (type === 'json') {
Expand All @@ -95,7 +93,6 @@ export class HttpService {
}

requestBody = formData;

contentType = '';
}

Expand All @@ -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 = {};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ExportResourcesBody, ExportResourcesPathParams } from '@common/contracts';
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;
}

type DownloadResourcesResponseBody = Blob;

export const useDownloadResourcesMutation = (
opts: UseMutationOptions<DownloadResourcesResponseBody, BaseApiError, DownloadResourcesPayload>,
) => {
const mapper = new ErrorCodeMessageMapper({});
const download = async (payload: DownloadResourcesPayload) => {
const response = await HttpService.post<DownloadResourcesResponseBody>({
url: `/buckets/${payload.bucketName}/resources/export`,
body: {
ids: payload.ids,
},
headers: {
Authorization: `Bearer ${payload.accessToken}`,
Accept: 'application/octet-stream',
},
});

if (!response.success) {
throw new ApiError("DownloadException", {
apiResponseError: response.body.context,
message: mapper.map(response.statusCode),
statusCode: response.statusCode
})
}

return response.body;
};

return useErrorHandledMutation({
mutationFn: download,
...opts,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -113,7 +114,9 @@ export const imageTableColumns: ColumnDef<Resource>[] = [
{
header: () => <Header>Actions</Header>,
accessorKey: 'actions',
cell: (): JSX.Element => {
cell: ({ row }): JSX.Element => {
const { download } = useResourceDownload();

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
Expand All @@ -130,7 +133,10 @@ export const imageTableColumns: ColumnDef<Resource>[] = [
className="bg-primary-foreground"
>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText('Test me up')}>Download</DropdownMenuItem>
<DropdownMenuItem onClick={() => download({
src: row.original.url,
name: row.original.name
})}>Download</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Delete</DropdownMenuItem>
<DropdownMenuItem>Change name</DropdownMenuItem>
Expand Down
28 changes: 28 additions & 0 deletions apps/frontend/src/modules/resource/hooks/useResourceDownload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
interface DownloadProps {
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');

document.body.appendChild(aElement);
aElement.click();

window.URL.revokeObjectURL(aElement.href);
document.body.removeChild(aElement);
}

return {
download
}
}
Loading
Loading