Skip to content

Commit

Permalink
display files and button to download them in case detail page
Browse files Browse the repository at this point in the history
fix
  • Loading branch information
Pascal-Delange committed Dec 19, 2023
1 parent cadb40c commit 7e0f800
Show file tree
Hide file tree
Showing 4 changed files with 251 additions and 0 deletions.
9 changes: 9 additions & 0 deletions packages/app-builder/public/locales/en/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
"case.tags": "Tags",
"case.contributors": "Contributors",
"case.decisions": "Decisions",
"case.files": "Files",
"case.file.name": "File name",
"case.file.extension": "Extension",
"case.file.added_date": "Added on",
"case.file.download": "Download",
"case.file.downloading": "Downloading...",
"case.file.errors.downloading_decisions_link": "An unknown error as occured while generating the download link. Please try again later.",
"case.inbox": "Inbox",
"case.inboxes": "Inboxes",
"case.status.open": "open",
Expand All @@ -18,6 +25,8 @@
"case_detail.informations": "Informations",
"case_detail.decisions_count_one": "{{count}} decision",
"case_detail.decisions_count_other": "{{count}} decisions",
"case_detail.files_count_one": "{{count}} files",
"case_detail.files_count_other": "{{count}} files",
"case_detail.history": "History",
"case_detail.unknown_user": "unknown user",
"case_detail.unknown_tag": "unknown tag",
Expand Down
142 changes: 142 additions & 0 deletions packages/app-builder/src/components/Cases/CaseFiles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
AlreadyDownloadingError,
useDownloadCaseFiles,
} from '@app-builder/services/DownloadCaseFilesService';
import { formatDateTime } from '@app-builder/utils/format';
import { type ColumnDef, getCoreRowModel } from '@tanstack/react-table';
import { type CaseFile } from 'marble-api';
import { useMemo } from 'react';
import toast from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { last } from 'remeda';
import { ClientOnly } from 'remix-utils';
import { Button, Collapsible, Table, useVirtualTable } from 'ui-design-system';

import { casesI18n } from './cases-i18n';

export function CaseFiles({ files }: { files: CaseFile[] }) {
const { t } = useTranslation(casesI18n);

return (
<Collapsible.Container className="bg-grey-00">
<Collapsible.Title>
<div className="flex flex-1 items-center justify-between">
<span className="text-grey-100 text-m font-bold capitalize">
{t('cases:case.files')}
</span>
<span className="text-grey-25 text-xs font-normal capitalize">
{t('cases:case_detail.files_count', {
count: files.length,
})}
</span>
</div>
</Collapsible.Title>
<Collapsible.Content>
<FilesList files={files} />
</Collapsible.Content>
</Collapsible.Container>
);
}

function FilesList({ files }: { files: CaseFile[] }) {
const {
t,
i18n: { language },
} = useTranslation(casesI18n);

const columns = useMemo<ColumnDef<CaseFile>[]>(
() => [
{
id: 'file_name',
accessorKey: 'file_name',
header: t('cases:case.file.name'),
size: 120,
},
{
id: 'extension',
size: 40,
header: t('cases:case.file.extension'),
cell: ({ cell }) => {
return last(cell.row.original.file_name.split('.'))?.toUpperCase();
},
},
{
id: 'created_at',
header: t('cases:case.file.added_date'),
size: 40,
cell: ({ cell }) => {
return formatDateTime(cell.row.original.created_at, {
language,
timeStyle: undefined,
});
},
},
{
id: 'link',
accessorKey: 'file_name',
header: t('cases:case.file.download'),
size: 40,
cell: ({ cell }) => {
return <FileLink caseFileId={cell.row.original.id} />;
},
},
],
[language, t],
);

const { table, getBodyProps, rows, getContainerProps } = useVirtualTable({
data: files,
columns,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
enableSorting: false,
});

// useDownloadCaseFiles()

return (
<Table.Container {...getContainerProps()} className="bg-grey-00">
<Table.Header headerGroups={table.getHeaderGroups()} />
<Table.Body {...getBodyProps()}>
{rows.map((row) => {
return <Table.Row key={row.id} tabIndex={0} row={row} />;
})}
</Table.Body>
</Table.Container>
);
}

function FileLink({ caseFileId }: { caseFileId: string }) {
const { downloadCaseFile, downloadingCaseFile } = useDownloadCaseFiles(
caseFileId,
{
onError: (e) => {
if (e instanceof AlreadyDownloadingError) {
// Already downloading, do nothing
return;
}
toast.error(t('cases:case.file.errors.downloading_decisions_link'));
},
},
);
const { t } = useTranslation(casesI18n);

return (
<ClientOnly>
{() => (
<Button
variant="secondary"
onClick={() => {
void downloadCaseFile();
}}
name="download"
disabled={downloadingCaseFile}
>
{downloadingCaseFile
? t('cases:case.file.downloading')
: t('cases:case.file.download')}
</Button>
)}
</ClientOnly>
);
}
2 changes: 2 additions & 0 deletions packages/app-builder/src/routes/__builder/cases/$caseId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CaseInformation,
casesI18n,
} from '@app-builder/components/Cases';
import { CaseFiles } from '@app-builder/components/Cases/CaseFiles';
import { isForbiddenHttpError, isNotFoundHttpError } from '@app-builder/models';
import { AddComment } from '@app-builder/routes/ressources/cases/add-comment';
import { EditCaseStatus } from '@app-builder/routes/ressources/cases/edit-status';
Expand Down Expand Up @@ -83,6 +84,7 @@ export default function CasePage() {
<div className="flex flex-col gap-4 lg:gap-8">
<CaseInformation caseDetail={caseDetail} inbox={inbox} />
<CaseDecisions decisions={caseDetail.decisions} />
<CaseFiles files={caseDetail.files} />
<CaseEvents events={caseDetail.events} />
</div>
<div className="flex flex-col gap-4 lg:gap-8"></div>
Expand Down
98 changes: 98 additions & 0 deletions packages/app-builder/src/services/DownloadCaseFilesService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { DownloadError } from '@app-builder/utils/download-blob';
import { UnknownError } from '@app-builder/utils/unknown-error';
import { useState } from 'react';
import { z } from 'zod';

import { useBackendInfo } from './auth/auth.client';
import { clientServices } from './init.client';

export class AlreadyDownloadingError extends Error {}
export class FetchLinkError extends Error {}
type DownloadFileError =
| AlreadyDownloadingError
| FetchLinkError
| DownloadError
| UnknownError;

const fileDownloadUrlSchema = z.object({
url: z.string(),
});

export function useDownloadCaseFiles(
caseFileId: string,
{ onError }: { onError?: (error: DownloadFileError) => void } = {},
) {
const [downloading, setDownloading] = useState(false);
const { backendUrl, accessToken } = useBackendInfo(
clientServices.authenticationClientService,
);

const downloadCaseFile = async () => {
try {
if (downloading) {
throw new AlreadyDownloadingError(
'Internal error: Already downloading',
);
}
setDownloading(true);

const downloadLink = `${backendUrl}/cases/files/${encodeURIComponent(
caseFileId,
)}/download_link`;
const response = await fetch(downloadLink, {
method: 'GET',
headers: {
Authorization: `Bearer ${await accessToken()}`,
},
});

if (!response.ok) {
throw new FetchLinkError(
'Internal error: Failed to download file: ' + response.statusText,
);
}
const { url } = fileDownloadUrlSchema.parse(await response.json());
await openFileLink(url);
} catch (error) {
if (
error instanceof AlreadyDownloadingError ||
error instanceof FetchLinkError ||
error instanceof DownloadError
) {
onError?.(error);
} else {
onError?.(new UnknownError(error));
}
} finally {
setDownloading(false);
}
};

return {
downloadCaseFile,
downloadingCaseFile: downloading,
};
}

const TIME_TO_OPEN_DOWNLOAD_MODALE = 150;

async function openFileLink(url: string) {
return new Promise<void>((resolve, reject) => {
try {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';

const clickHandler = () => {
setTimeout(() => {
resolve();
}, TIME_TO_OPEN_DOWNLOAD_MODALE);
};

a.addEventListener('click', clickHandler);
a.click();
} catch (error) {
reject(new DownloadError(error));
}
});
}

0 comments on commit 7e0f800

Please sign in to comment.