Skip to content

Commit

Permalink
Feat: File Browser Page (#46)
Browse files Browse the repository at this point in the history
  • Loading branch information
williamputraintan authored Oct 4, 2024
1 parent 8dd32fe commit 2990ae5
Show file tree
Hide file tree
Showing 14 changed files with 222 additions and 97 deletions.
2 changes: 1 addition & 1 deletion src/components/common/dropdowns/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const Dropdown: FC<DropdownProps> = ({
{floatingLabel && (
<label
className={classNames(
'absolute text-sm text-gray-500 -translate-y-4 scale-75 top-2 z-10 origin-[0] bg-white px-2 start-1',
'absolute text-sm text-gray-500 -translate-y-4 scale-75 top-2 z-2 origin-[0] bg-white px-2 start-1',
floatingLabelClassName ? floatingLabelClassName : ''
)}
>
Expand Down
25 changes: 25 additions & 0 deletions src/components/layouts/files/FilesLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { PropsWithChildren, Suspense } from 'react';
import { Outlet } from 'react-router-dom';

import LocationBreadcrumb from '@/components/navigation/breadcrumbs';
import { Card } from '@/components/common/cards';
import { SpinnerWithText } from '@/components/common/spinner';
import { DetailedErrorBoundary } from '@/components/common/error';
import MainArea from '../MainArea';

const FilesLayout = ({ children }: PropsWithChildren) => {
return (
<MainArea>
<LocationBreadcrumb />
<Card>
<Suspense fallback={<SpinnerWithText text='Loading ...' />}>
<DetailedErrorBoundary errorTitle='Unable to load files page'>
{children || <Outlet />}
</DetailedErrorBoundary>
</Suspense>
</Card>
</MainArea>
);
};

export default FilesLayout;
3 changes: 3 additions & 0 deletions src/components/navigation/header/TokenDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const TokenDialog = ({ onClose }: Props) => {
if (!exp) throw new Error('Cannot get JWT expiration time!');

if (cancel) return;

console.log(' dayjs.unix(parseInt(exp))', dayjs.unix(parseInt(exp)).format('DD/MM/YYYY'));
setJWTData({
token: token.toString(),
expires: dayjs.unix(parseInt(exp)).format('llll Z'),
Expand All @@ -40,6 +42,7 @@ export const TokenDialog = ({ onClose }: Props) => {
navigator.clipboard.writeText(jwtData.token);
toaster.success({ title: 'Copied!', message: 'JWT copied to clipboard' });
};

return (
<Dialog
open={true}
Expand Down
4 changes: 2 additions & 2 deletions src/components/tables/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ const Table: FC<TableProps> = ({
inCard ? 'overflow-hidden border-2 border-black border-opacity-5 sm:rounded-lg' : ''
)}
>
<table className='min-w-full divide-y divide-gray-300'>
{/* Experiment additional <thead /> to group column description */}
<table className='min-w-full'>
{/* Experiment additional <thead /> to group column w/ description */}
<thead>
{columns.find((c) => !!c.headerGroup) && (
<tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,30 @@ import { Bars3Icon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import dayjs from 'dayjs';
import { IconDropdown } from '@/components/common/dropdowns';
import toaster from '@/components/common/toaster';
import { getFilenameFromKey } from '@/utils/commonUtils';
import { FilePreviewDrawer } from './FilePreviewDrawer';
import { Dialog } from '@/components/dialogs';
import { JsonToTable } from '@/components/common/json-to-table';
import { FileDownloadButton } from './FileDownloadButton';
import { DOWNLOADABLE_FILETYPE_LIST } from '@/components/files';
import { DEFAULT_PAGE_SIZE } from '@/utils/constant';
import { getFilenameFromKey } from '@/utils/commonUtils';

export const FileTable = ({ portalRunId }: { portalRunId: string }) => {
export const FileAPITable = ({
additionalQueryParam,
tableColumn = getTableColumn({}),
portalRunId,
}: {
tableColumn?: Column[];
portalRunId?: string;
additionalQueryParam?: Record<string, string>;
}) => {
const [page, setPage] = useState<number>(1);
const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_PAGE_SIZE);
const [searchBox, setSearchBox] = useState<string>('');
const [dataQueryParams, setDataQueryParams] = useState<Record<string, string>>({});

const data = useFileObject({
params: {
query: {
...dataQueryParams,
...additionalQueryParam,
page: page,
rowsPerPage: rowsPerPage,
currentState: true,
Expand All @@ -37,44 +43,6 @@ export const FileTable = ({ portalRunId }: { portalRunId: string }) => {
return (
<Table
columns={tableColumn}
tableHeader={
<div className='flex flex-col md:flex-row'>
<div className='flex flex-1 items-center justify-start pt-2'>
<div className='w-full'>
<label htmlFor='search' className='sr-only'>
Search
</label>
<div className='relative'>
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
<MagnifyingGlassIcon className='h-5 w-5 text-gray-400' aria-hidden='true' />
</div>
<input
onBlur={() => {
setDataQueryParams({ key: searchBox });
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setDataQueryParams({ key: searchBox });
}
}}
onChange={(e) => {
setSearchBox(e.target.value.trim());
if (!e.target.value) {
setDataQueryParams({});
}
}}
value={searchBox}
id='search'
name='search'
className='block w-full rounded-md border-0 bg-white py-1.5 pl-10 pr-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6'
placeholder='Search (Object key)'
type='search'
/>
</div>
</div>
</div>
</div>
}
tableData={data.results.map((item) => ({
lastModifiedDate: item.lastModifiedDate,
size: item.size,
Expand All @@ -96,6 +64,55 @@ export const FileTable = ({ portalRunId }: { portalRunId: string }) => {
);
};

export const SearchBox = ({
placeholder = 'Search',
onSearch,
}: {
placeholder?: string;
onSearch: (s: string) => void;
}) => {
const [searchBox, setSearchBox] = useState<string>('');

return (
<div className='flex flex-col md:flex-row'>
<div className='flex flex-1 items-center justify-start pt-2'>
<div className='w-full'>
<label htmlFor='search' className='sr-only'>
Search
</label>
<div className='relative'>
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
<MagnifyingGlassIcon className='h-5 w-5 text-gray-400' aria-hidden='true' />
</div>
<input
onBlur={() => {
onSearch(searchBox);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSearch(searchBox);
}
}}
onChange={(e) => {
setSearchBox(e.target.value.trim());
if (!e.target.value) {
onSearch('');
}
}}
value={searchBox}
id='search'
name='search'
className='block w-full rounded-md border-0 bg-white py-1.5 pl-10 pr-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6'
placeholder={placeholder}
type='search'
/>
</div>
</div>
</div>
</div>
);
};

/**
* Convert bytes to human readable file size
* @param bytes
Expand Down Expand Up @@ -123,57 +140,84 @@ const humanFileSize = (bytes: number): string => {

/**
* Table Columns Properties
* @param isHideKeyPrefix Show filename as one of the column, else it shows the S3 key
* @returns
*/
const tableColumn: Column[] = [
{
header: 'Filename',
accessor: 'fileRecord',
cell: (data: unknown) => {
const { key } = data as { key: string };
return <div>{getFilenameFromKey(key)}</div>;
},
},
{
header: '',
accessor: 'fileRecord',
cell: (data: unknown) => {
const { key: s3Key } = data as S3Record;

const splitPath = s3Key.split('.');
const filetype = splitPath[splitPath.length - 1].toLowerCase();
const isDownloadable = DOWNLOADABLE_FILETYPE_LIST.includes(filetype);

return (
<div className='flex flex-row justify-end'>
{isDownloadable && <FileDownloadButton s3Record={data as S3Record} />}
<FilePreviewDrawer s3Record={data as S3Record} />
</div>
);
export const getTableColumn = ({
isHideKeyPrefix = true,
}: {
isHideKeyPrefix?: boolean;
}): Column[] => {
const col = [
{
header: '',
accessor: 'fileRecord',
cell: (data: unknown) => {
const { key: s3Key } = data as S3Record;

const splitPath = s3Key.split('.');
const filetype = splitPath[splitPath.length - 1].toLowerCase();
const isDownloadable = DOWNLOADABLE_FILETYPE_LIST.includes(filetype);

return (
<div className='flex flex-row justify-end'>
{isDownloadable && <FileDownloadButton s3Record={data as S3Record} />}
<FilePreviewDrawer s3Record={data as S3Record} />
</div>
);
},
},
},
{
header: 'Action button',
accessor: 'fileRecord',
cell: (data: unknown) => {
return <DataActionButton fileRecord={data as S3Record} />;
{
header: 'Action button',
accessor: 'fileRecord',
cell: (data: unknown) => {
return <DataActionButton fileRecord={data as S3Record} />;
},
},
},
{
header: 'Size',
accessor: 'size',
cell: (data: unknown) => {
return <div>{data ? humanFileSize(data as number) : '-'}</div>;
{
header: 'Size',
accessor: 'size',
cell: (data: unknown) => {
return <div>{data ? humanFileSize(data as number) : '-'}</div>;
},
},
},
{
header: 'Last Modified Date ',
accessor: 'lastModifiedDate',
cell: (data: unknown) => {
return <div className=''>{data ? dayjs(data as string).format('lll Z') : '-'}</div>;
{
header: 'Last Modified Date ',
accessor: 'lastModifiedDate',
cell: (data: unknown) => {
return <div className=''>{data ? dayjs(data as string).format('lll Z') : '-'}</div>;
},
},
},
];
];

if (isHideKeyPrefix) {
col.unshift({
header: 'Filename',
accessor: 'fileRecord',
cell: (data: unknown) => {
const { key } = data as { key: string };
return <div>{getFilenameFromKey(key)}</div>;
},
});
} else {
col.unshift({
header: 'Key',
accessor: 'fileRecord',
cell: (data: unknown) => {
const { key } = data as { key: string };
return <div>{key}</div>;
},
});
}

return col;
};

/**
* The action button for each row in the table
* @param param0
* @returns
*/
const DataActionButton = ({ fileRecord }: { fileRecord: S3Record }) => {
const { key: s3Key, bucket, s3ObjectId } = fileRecord;

Expand Down
28 changes: 28 additions & 0 deletions src/modules/files/pages/files.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SpinnerWithText } from '@/components/common/spinner';
import { Suspense, useState } from 'react';
import { FileAPITable, getTableColumn, SearchBox } from '../components/FileAPITable';

export default function FilesPage() {
const [queryParams, setQueryParams] = useState<Record<string, string>>({});

return (
<>
<SearchBox
placeholder='Object key search (support wildcard)'
onSearch={(s) => {
setQueryParams((p) => ({ ...p, key: s }));
}}
/>

{/* Only show the table if the key filter exist! */}
{!!queryParams?.key && (
<Suspense fallback={<SpinnerWithText className='mt-4' text='Fetching related files ...' />}>
<FileAPITable
additionalQueryParam={queryParams}
tableColumn={getTableColumn({ isHideKeyPrefix: false })}
/>
</Suspense>
)}
</>
);
}
14 changes: 14 additions & 0 deletions src/modules/files/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { lazy } from 'react';
import { RouteObject } from 'react-router-dom';
import FilesLayout from '@/components/layouts/files/FilesLayout';

const FilesPage = lazy(() => import('@/modules/files/pages/files'));

export const Router: RouteObject = {
path: 'files',
element: (
<FilesLayout>
<FilesPage />
</FilesLayout>
),
};
7 changes: 5 additions & 2 deletions src/modules/lab/pages/library/LibraryWorkflow.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { Suspense } from 'react';
import { PortalRunIdDropdown } from '../../components/library/PortalRunIdDropdown';
import { useParams } from 'react-router-dom';
import { FileTable } from '../../components/file/FileTable';
import { SpinnerWithText } from '@/components/common/spinner';
import { WorkflowVersion } from '../../components/library/WorkflowVersion';
import { FileAPITable, getTableColumn } from '@/modules/files/components/FileAPITable';

export default function LibraryWorkflowPage() {
const { libraryId, portalRunId, workflowType } = useParams();
Expand All @@ -29,7 +29,10 @@ export default function LibraryWorkflowPage() {
{/* Body */}
{portalRunId && (
<Suspense fallback={<SpinnerWithText text='Fetching related files ...' />}>
<FileTable portalRunId={portalRunId} />
<FileAPITable
portalRunId={portalRunId}
tableColumn={getTableColumn({ isHideKeyPrefix: true })}
/>
</Suspense>
)}
</div>
Expand Down
Loading

0 comments on commit 2990ae5

Please sign in to comment.