1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

feat(docker): migrate files table to react [EE-4663] (#8916)

This commit is contained in:
Chaim Lev-Ari 2023-07-16 10:59:58 +03:00 committed by GitHub
parent 146681e1c7
commit 09f60c3277
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 529 additions and 229 deletions

View file

@ -16,7 +16,6 @@ export function NameCell({
row: { original: container },
}: CellContext<ContainerGroup, string>) {
const name = getValue();
return (
<Link
to="azure.containerinstances.container"

View file

@ -12,6 +12,7 @@ import {
getFacetedMinMaxValues,
getExpandedRowModel,
TableOptions,
TableMeta,
} from '@tanstack/react-table';
import { ReactNode, useMemo } from 'react';
import clsx from 'clsx';
@ -30,10 +31,11 @@ import { BasicTableSettings } from './types';
import { DatatableContent } from './DatatableContent';
import { createSelectColumn } from './select-column';
import { TableRow } from './TableRow';
import { type TableState as GlobalTableState } from './useTableState';
export interface Props<
D extends Record<string, unknown>,
TSettings extends BasicTableSettings = BasicTableSettings
TMeta extends TableMeta<D> = TableMeta<D>
> extends AutomationTestingProps {
dataset: D[];
columns: TableOptions<D>['columns'];
@ -53,16 +55,17 @@ export interface Props<
highlightedItemId?: string;
onPageChange?(page: number): void;
settingsManager: TSettings & {
search: string;
setSearch: (value: string) => void;
};
settingsManager: GlobalTableState<BasicTableSettings>;
renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode;
getRowCanExpand?(row: Row<D>): boolean;
noWidget?: boolean;
meta?: TMeta;
}
export function Datatable<D extends Record<string, unknown>>({
export function Datatable<
D extends Record<string, unknown>,
TMeta extends TableMeta<D> = TableMeta<D>
>({
columns,
dataset,
renderTableSettings = () => null,
@ -85,7 +88,8 @@ export function Datatable<D extends Record<string, unknown>>({
noWidget,
getRowCanExpand,
'data-cy': dataCy,
}: Props<D>) {
meta,
}: Props<D, TMeta>) {
const isServerSidePagination = typeof pageCount !== 'undefined';
const enableRowSelection = getIsSelectionEnabled(
disableSelect,
@ -127,6 +131,7 @@ export function Datatable<D extends Record<string, unknown>>({
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand,
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
meta,
});
const tableState = tableInstance.getState();

View file

@ -4,6 +4,11 @@ import { useStore } from 'zustand';
import { useSearchBarState } from './SearchBar';
import { BasicTableSettings, CreatePersistedStoreReturn } from './types';
export type TableState<TSettings extends BasicTableSettings> = TSettings & {
setSearch: (search: string) => void;
search: string;
};
export function useTableState<
TSettings extends BasicTableSettings = BasicTableSettings
>(store: CreatePersistedStoreReturn<TSettings>, storageKey: string) {

View file

@ -0,0 +1,123 @@
import { CornerLeftUp, File as FileIcon, Upload } from 'lucide-react';
import { useState } from 'react';
import { Authorized } from '@/react/hooks/useUser';
import { Datatable } from '@@/datatables';
import { BasicTableSettings } from '@@/datatables/types';
import { Button } from '@@/buttons';
import { TableState } from '@@/datatables/useTableState';
import { FileData, FilesTableMeta } from './types';
import { columns } from './columns';
interface Props {
title: string;
dataset: FileData[];
tableState: TableState<BasicTableSettings>;
isRoot: boolean;
onGoToParent: () => void;
onBrowse: (folderName: string) => void;
onRename: (oldName: string, newName: string) => void;
onDownload: (fileName: string) => void;
onDelete: (fileName: string) => void;
isUploadAllowed: boolean;
onFileSelectedForUpload: (file: File) => void;
}
function goToParent(onClick: () => void): FileData {
return {
custom: (
<Button
onClick={onClick}
color="link"
icon={CornerLeftUp}
className="!m-0 !p-0"
>
Go to parent
</Button>
),
Dir: true,
Name: '..',
Size: 0,
ModTime: 0,
};
}
export function FilesTable({
isRoot,
title,
dataset,
tableState,
onGoToParent,
onRename,
onBrowse,
onDelete,
onDownload,
isUploadAllowed,
onFileSelectedForUpload,
}: Props) {
const [isEditState, setIsEditState] = useState(
Object.fromEntries(dataset.map((f) => [f.Name, false]))
);
function isEdit(name: string) {
return isEditState[name];
}
function setIsEdit(name: string, value: boolean) {
setIsEditState((editState) => ({ ...editState, [name]: value }));
}
return (
<Datatable<FileData, FilesTableMeta>
title={title}
titleIcon={FileIcon}
dataset={isRoot ? dataset : [goToParent(onGoToParent), ...dataset]}
settingsManager={tableState}
columns={columns}
getRowId={(row) => row.Name}
meta={{
table: 'files',
isEdit,
setIsEdit,
onRename,
onBrowse,
onDownload,
onDelete,
}}
initialTableState={{
columnVisibility: {
Dir: false,
},
}}
disableSelect
renderTableActions={() => {
if (!isUploadAllowed) {
return null;
}
return (
<Authorized authorizations="DockerAgentBrowsePut">
<div className="flex flex-row items-center">
<Button color="light" icon={Upload} as="label">
<input
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onFileSelectedForUpload(file);
}
}}
/>
</Button>
</div>
</Authorized>
);
}}
/>
);
}

View file

@ -0,0 +1,58 @@
import { CellContext } from '@tanstack/react-table';
import { Download, Edit, Trash2 } from 'lucide-react';
import { Authorized } from '@/react/hooks/useUser';
import { Button } from '@@/buttons';
import { FileData, isFilesTableMeta } from '../types';
export function ActionsCell({
row: { original: item },
table,
}: CellContext<FileData, unknown>) {
const { meta } = table.options;
if (!isFilesTableMeta(meta)) {
throw new Error('Invalid table meta');
}
return (
<div className="flex gap-2">
{!item.Dir && (
<Authorized authorizations="DockerAgentBrowseGet">
<Button
color="secondary"
size="xsmall"
onClick={() => meta.onDownload(item.Name)}
icon={Download}
className="!m-0"
>
Download
</Button>
</Authorized>
)}
<Authorized authorizations="DockerAgentBrowseRename">
<Button
color="secondary"
size="xsmall"
icon={Edit}
onClick={() => meta.setIsEdit(item.Name, true)}
className="!m-0"
>
Rename
</Button>
</Authorized>
<Authorized authorizations="DockerAgentBrowseDelete">
<Button
color="dangerlight"
size="xsmall"
icon={Trash2}
onClick={() => meta.onDelete(item.Name)}
className="!m-0"
>
Delete
</Button>
</Authorized>
</div>
);
}

View file

@ -0,0 +1,98 @@
import { CellContext } from '@tanstack/react-table';
import { Check, File as FileIcon, Folder, X } from 'lucide-react';
import { Form, Formik } from 'formik';
import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';
import { Input } from '@@/form-components/Input';
import { FileData, isFilesTableMeta } from '../types';
export function NameCell({
getValue,
row: { original: item },
table,
}: CellContext<FileData, string>) {
const name = getValue();
const { meta } = table.options;
if (!isFilesTableMeta(meta)) {
throw new Error('Invalid table meta');
}
const isEdit = meta.isEdit(name);
if (item.custom) {
return item.custom;
}
if (isEdit) {
return (
<EditForm
originalName={name}
onSave={handleRename}
onClose={() => meta.setIsEdit(name, false)}
/>
);
}
return (
<>
{item.Dir ? (
<Button
color="link"
className="!ml-0 p-0"
onClick={() => meta.onBrowse(name)}
icon={Folder}
>
{name}
</Button>
) : (
<span className="vertical-center">
<Icon icon={FileIcon} />
{name}
</span>
)}
</>
);
function handleRename(name: string) {
if (!isFilesTableMeta(meta)) {
throw new Error('Invalid table meta');
}
meta.onRename(item.Name, name);
meta.setIsEdit(name, false);
}
}
function EditForm({
originalName,
onSave,
onClose,
}: {
originalName: string;
onSave: (name: string) => void;
onClose: () => void;
}) {
return (
<Formik
initialValues={{ name: originalName }}
onSubmit={({ name }) => onSave(name)}
onReset={onClose}
>
{({ values, setFieldValue }) => (
<Form className="flex items-center">
<Input
name="name"
value={values.name}
onChange={(e) => setFieldValue('name', e.target.value)}
className="input-sm w-auto"
/>
<Button color="none" type="reset" icon={X} />
<Button color="none" type="submit" icon={Check} />
</Form>
)}
</Formik>
);
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { FileData } from '../types';
export const columnHelper = createColumnHelper<FileData>();

View file

@ -0,0 +1,44 @@
import {
CellContext,
ColumnDef,
ColumnDefTemplate,
} from '@tanstack/react-table';
import { humanize, isoDateFromTimestamp } from '@/portainer/filters/filters';
import { FileData } from '../types';
import { columnHelper } from './helper';
import { NameCell } from './NameCell';
import { ActionsCell } from './ActionsCell';
export const columns = [
columnHelper.accessor('Name', {
header: 'Name',
cell: NameCell,
}),
columnHelper.accessor('Size', {
header: 'Size',
cell: hideIfCustom(({ getValue }) => humanize(getValue())),
}),
columnHelper.accessor('ModTime', {
header: 'Last modification',
cell: hideIfCustom(({ getValue }) => isoDateFromTimestamp(getValue())),
}),
columnHelper.display({
header: 'Actions',
cell: hideIfCustom(ActionsCell),
}),
columnHelper.accessor('Dir', {}), // workaround, to enable sorting by Dir (put directory first)
] as ColumnDef<FileData>[];
function hideIfCustom<TValue>(
template: ColumnDefTemplate<CellContext<FileData, TValue>>
): ColumnDefTemplate<CellContext<FileData, TValue>> {
return (props) => {
if (props.row.original.custom) {
return null;
}
return typeof template === 'string' ? template : template(props);
};
}

View file

@ -0,0 +1 @@
export { FilesTable } from './FilesTable';

View file

@ -0,0 +1,26 @@
import { TableMeta } from '@tanstack/react-table';
import { ReactNode } from 'react';
export type FileData = {
Name: string;
Dir: boolean;
Size: number;
ModTime: number;
custom: ReactNode;
};
export type FilesTableMeta = TableMeta<FileData> & {
table: 'files';
isEdit(rowId: string): boolean;
setIsEdit(rowId: string, isEdit: boolean): void;
onRename: (oldName: string, newName: string) => void;
onBrowse: (name: string) => void;
onDownload: (fileName: string) => void;
onDelete: (fileName: string) => void;
};
export function isFilesTableMeta(
meta?: TableMeta<FileData>
): meta is FilesTableMeta {
return !!meta && meta.table === 'files';
}

View file

@ -0,0 +1,51 @@
import { ComponentProps } from 'react';
import { FilesTable } from '@/react/docker/components/FilesTable';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
const tableKey = 'host-browser';
const settingsStore = createPersistedStore(tableKey, {
desc: true,
id: 'Dir',
});
interface Props
extends Omit<
ComponentProps<typeof FilesTable>,
'isUploadAllowed' | 'tableState' | 'title'
> {
relativePath: string;
}
export function AgentHostBrowser({
relativePath,
dataset,
isRoot,
onBrowse,
onDelete,
onDownload,
onFileSelectedForUpload,
onGoToParent,
onRename,
}: Props) {
const tableState = useTableState(settingsStore, tableKey);
return (
<FilesTable
tableState={tableState}
dataset={dataset}
title={`Host browser - ${relativePath}`}
isRoot={isRoot}
onRename={onRename}
onBrowse={onBrowse}
onDownload={onDownload}
onDelete={onDelete}
isUploadAllowed
onFileSelectedForUpload={onFileSelectedForUpload}
onGoToParent={onGoToParent}
/>
);
}

View file

@ -0,0 +1,49 @@
import { ComponentProps } from 'react';
import { FilesTable } from '@/react/docker/components/FilesTable';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
const tableKey = 'host-browser';
const settingsStore = createPersistedStore(tableKey, 'Name');
interface Props
extends Omit<
ComponentProps<typeof FilesTable>,
'onSearchChange' | 'tableState' | 'title'
> {
relativePath: string;
}
export function AgentVolumeBrowser({
relativePath,
dataset,
isRoot,
onBrowse,
onDelete,
onDownload,
onFileSelectedForUpload,
onGoToParent,
onRename,
isUploadAllowed,
}: Props) {
const tableState = useTableState(settingsStore, tableKey);
return (
<FilesTable
tableState={tableState}
dataset={dataset}
title={`Volume browser - ${relativePath}`}
isRoot={isRoot}
onRename={onRename}
onBrowse={onBrowse}
onDownload={onDownload}
onDelete={onDelete}
isUploadAllowed={isUploadAllowed}
onFileSelectedForUpload={onFileSelectedForUpload}
onGoToParent={onGoToParent}
/>
);
}