Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feat: File Browser Page #46

Merged
merged 1 commit into from
Oct 4, 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
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