mirror of
https://github.com/inventree/InvenTree.git
synced 2026-05-22 01:06:50 +00:00
acc2786e44
* Implementation * Update API and CHANGELOG * Annotate response type * Simplify attachment renaming - Use the existing API endpoint * Capture the actual saved path * Tweak attachment table fields * Use built-in validation * Update docs * Unit testing * Ignore some lines from coverage * Check if file exists before deleting
423 lines
11 KiB
TypeScript
423 lines
11 KiB
TypeScript
import { t } from '@lingui/core/macro';
|
|
import { Badge, Group, Paper, Stack, Text } from '@mantine/core';
|
|
import { Dropzone } from '@mantine/dropzone';
|
|
import { notifications } from '@mantine/notifications';
|
|
import {
|
|
IconCircleCheck,
|
|
IconExclamationCircle,
|
|
IconExternalLink,
|
|
IconFileUpload,
|
|
IconUpload,
|
|
IconX
|
|
} from '@tabler/icons-react';
|
|
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
|
|
|
import { ActionButton } from '@lib/components/ActionButton';
|
|
import { ProgressBar } from '@lib/components/ProgressBar';
|
|
import {
|
|
type RowAction,
|
|
RowDeleteAction,
|
|
RowEditAction
|
|
} from '@lib/components/RowActions';
|
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
|
import type { ModelType } from '@lib/enums/ModelType';
|
|
import { apiUrl } from '@lib/functions/Api';
|
|
import useTable from '@lib/hooks/UseTable';
|
|
import type { TableFilter } from '@lib/types/Filters';
|
|
import type { ApiFormFieldSet } from '@lib/types/Forms';
|
|
import type { TableColumn } from '@lib/types/Tables';
|
|
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
|
import { useApi } from '../../contexts/ApiContext';
|
|
import { formatFileSize } from '../../defaults/formatters';
|
|
import {
|
|
useCreateApiFormModal,
|
|
useDeleteApiFormModal,
|
|
useEditApiFormModal
|
|
} from '../../hooks/UseForm';
|
|
import { useUserState } from '../../states/UserState';
|
|
import { InvenTreeTable } from '../InvenTreeTable';
|
|
|
|
/**
|
|
* Define set of columns to display for the attachment table
|
|
*/
|
|
function attachmentTableColumns(): TableColumn[] {
|
|
return [
|
|
{
|
|
accessor: 'attachment',
|
|
sortable: false,
|
|
switchable: false,
|
|
noWrap: true,
|
|
render: (record: any) => {
|
|
if (record.attachment) {
|
|
return <AttachmentLink attachment={record.attachment} />;
|
|
} else if (record.link) {
|
|
return <AttachmentLink attachment={record.link} external />;
|
|
} else {
|
|
return '-';
|
|
}
|
|
},
|
|
noContext: true
|
|
},
|
|
{
|
|
accessor: 'comment',
|
|
sortable: false,
|
|
|
|
render: (record: any) => {
|
|
return record.comment;
|
|
}
|
|
},
|
|
{
|
|
accessor: 'upload_date',
|
|
sortable: true,
|
|
|
|
render: (record: any) => {
|
|
return (
|
|
<Group justify='space-between'>
|
|
<Text>{record.upload_date}</Text>
|
|
{record.user_detail && (
|
|
<Badge size='xs'>{record.user_detail.username}</Badge>
|
|
)}
|
|
</Group>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
accessor: 'file_size',
|
|
sortable: true,
|
|
switchable: true,
|
|
render: (record: any) => {
|
|
if (!record.attachment) {
|
|
return '-';
|
|
} else {
|
|
return formatFileSize(record.file_size);
|
|
}
|
|
}
|
|
}
|
|
];
|
|
}
|
|
|
|
function UploadProgress({
|
|
filename,
|
|
progress
|
|
}: {
|
|
filename: string;
|
|
progress: number;
|
|
}) {
|
|
return (
|
|
<Stack gap='xs'>
|
|
<Text size='sm'>{t`Uploading file ${filename}`}</Text>
|
|
<ProgressBar value={progress} progressLabel={false} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Construct a table for displaying uploaded attachments
|
|
*/
|
|
export function AttachmentTable({
|
|
model_type,
|
|
model_id
|
|
}: Readonly<{
|
|
model_type: ModelType;
|
|
model_id: number;
|
|
}>): ReactNode {
|
|
const api = useApi();
|
|
const user = useUserState();
|
|
const table = useTable(`${model_type}-attachments`);
|
|
|
|
const tableColumns = useMemo(() => attachmentTableColumns(), []);
|
|
|
|
const url = apiUrl(ApiEndpoints.attachment_list);
|
|
|
|
const validPk = useMemo(() => model_id > 0, [model_id]);
|
|
|
|
const canDelete = useMemo(
|
|
() => user.hasDeletePermission(model_type),
|
|
[user, model_type]
|
|
);
|
|
|
|
const [isUploading, setIsUploading] = useState<boolean>(false);
|
|
|
|
const allowDragAndDrop: boolean = useMemo(() => {
|
|
return user.hasAddPermission(model_type);
|
|
}, [user, model_type]);
|
|
|
|
// Callback to upload file attachment(s)
|
|
function uploadFiles(files: File[]) {
|
|
files.forEach((file) => {
|
|
const formData = new FormData();
|
|
formData.append('attachment', file);
|
|
formData.append('model_type', model_type);
|
|
formData.append('model_id', model_id.toString());
|
|
|
|
setIsUploading(true);
|
|
|
|
const name: string = file.name;
|
|
const id: string = `attachment-upload-${model_type}-${model_id}-${file.name}`;
|
|
|
|
notifications.show({
|
|
id: id,
|
|
title: t`Uploading File`,
|
|
message: <UploadProgress filename={name} progress={0} />,
|
|
color: 'blue',
|
|
loading: true,
|
|
autoClose: false
|
|
});
|
|
|
|
api
|
|
.post(url, formData, {
|
|
timeout: 30 * 1000,
|
|
onUploadProgress: (progressEvent) => {
|
|
const progress = 100 * (progressEvent?.progress ?? 0);
|
|
notifications.update({
|
|
id: id,
|
|
title: t`Uploading File`,
|
|
color: 'blue',
|
|
loading: true,
|
|
autoClose: false,
|
|
message: <UploadProgress filename={name} progress={progress} />
|
|
});
|
|
}
|
|
})
|
|
.then((response) => {
|
|
notifications.update({
|
|
id: id,
|
|
title: t`File Uploaded`,
|
|
message: t`File ${name} uploaded successfully`,
|
|
color: 'green',
|
|
autoClose: 3500,
|
|
icon: <IconCircleCheck />,
|
|
loading: false
|
|
});
|
|
|
|
table.refreshTable();
|
|
|
|
return response;
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error uploading attachment:', file, '->', error);
|
|
notifications.update({
|
|
id: id,
|
|
title: t`Upload Error`,
|
|
message: t`File could not be uploaded`,
|
|
color: 'red',
|
|
autoClose: 5000,
|
|
icon: <IconExclamationCircle />,
|
|
loading: false
|
|
});
|
|
return error;
|
|
})
|
|
.finally(() => {
|
|
setIsUploading(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
const [attachmentType, setAttachmentType] = useState<'attachment' | 'link'>(
|
|
'attachment'
|
|
);
|
|
|
|
const [selectedAttachment, setSelectedAttachment] = useState<
|
|
number | undefined
|
|
>(undefined);
|
|
|
|
const uploadFields: ApiFormFieldSet = useMemo(() => {
|
|
const fields: ApiFormFieldSet = {
|
|
model_type: {
|
|
value: model_type,
|
|
hidden: true
|
|
},
|
|
model_id: {
|
|
value: model_id,
|
|
hidden: true
|
|
},
|
|
attachment: {},
|
|
filename: {},
|
|
link: {},
|
|
comment: {}
|
|
};
|
|
|
|
if (attachmentType != 'link') {
|
|
delete fields['link'];
|
|
} else {
|
|
delete fields['attachment'];
|
|
delete fields['filename'];
|
|
}
|
|
|
|
// Remove the 'attachment' field if we are editing an existing attachment, or uploading a link
|
|
if (attachmentType != 'attachment' || !!selectedAttachment) {
|
|
delete fields['attachment'];
|
|
}
|
|
|
|
if (!selectedAttachment) {
|
|
// Cannot edit the filename during creation
|
|
delete fields['filename'];
|
|
}
|
|
|
|
return fields;
|
|
}, [model_type, model_id, attachmentType, selectedAttachment]);
|
|
|
|
const uploadAttachment = useCreateApiFormModal({
|
|
url: url,
|
|
title: t`Upload Attachment`,
|
|
fields: uploadFields,
|
|
onFormSuccess: () => {
|
|
table.refreshTable();
|
|
}
|
|
});
|
|
|
|
const editAttachment = useEditApiFormModal({
|
|
url: url,
|
|
pk: selectedAttachment,
|
|
title: t`Edit Attachment`,
|
|
fields: uploadFields,
|
|
onFormSuccess: (record: any) => {
|
|
if (record.pk) {
|
|
table.updateRecord(record);
|
|
} else {
|
|
table.refreshTable();
|
|
}
|
|
}
|
|
});
|
|
|
|
const deleteAttachment = useDeleteApiFormModal({
|
|
url: url,
|
|
pk: selectedAttachment,
|
|
title: t`Delete Attachment`,
|
|
onFormSuccess: () => {
|
|
table.refreshTable();
|
|
}
|
|
});
|
|
|
|
const tableFilters: TableFilter[] = useMemo(() => {
|
|
return [
|
|
{
|
|
name: 'is_link',
|
|
label: t`Is Link`,
|
|
description: t`Show link attachments`
|
|
},
|
|
{
|
|
name: 'is_file',
|
|
label: t`Is File`,
|
|
description: t`Show file attachments`
|
|
}
|
|
];
|
|
}, []);
|
|
|
|
const tableActions: ReactNode[] = useMemo(() => {
|
|
return [
|
|
<ActionButton
|
|
key='add-attachment'
|
|
tooltip={t`Add attachment`}
|
|
hidden={!user.hasAddPermission(model_type)}
|
|
icon={<IconFileUpload />}
|
|
onClick={() => {
|
|
setAttachmentType('attachment');
|
|
setSelectedAttachment(undefined);
|
|
uploadAttachment.open();
|
|
}}
|
|
/>,
|
|
<ActionButton
|
|
key='add-external-link'
|
|
tooltip={t`Add external link`}
|
|
hidden={!user.hasAddPermission(model_type)}
|
|
icon={<IconExternalLink />}
|
|
onClick={() => {
|
|
setAttachmentType('link');
|
|
setSelectedAttachment(undefined);
|
|
uploadAttachment.open();
|
|
}}
|
|
/>
|
|
];
|
|
}, [user, model_type]);
|
|
|
|
// Construct row actions for the attachment table
|
|
const rowActions = useCallback(
|
|
(record: any): RowAction[] => {
|
|
return [
|
|
RowEditAction({
|
|
hidden: !user.hasChangePermission(model_type),
|
|
onClick: () => {
|
|
if (record.attachment) {
|
|
setAttachmentType('attachment');
|
|
} else {
|
|
setAttachmentType('link');
|
|
}
|
|
setSelectedAttachment(record.pk);
|
|
editAttachment.open();
|
|
}
|
|
}),
|
|
RowDeleteAction({
|
|
hidden: !canDelete,
|
|
onClick: () => {
|
|
setSelectedAttachment(record.pk);
|
|
deleteAttachment.open();
|
|
}
|
|
})
|
|
];
|
|
},
|
|
[user, model_type]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{uploadAttachment.modal}
|
|
{editAttachment.modal}
|
|
{deleteAttachment.modal}
|
|
<Stack gap='xs'>
|
|
{validPk && (
|
|
<InvenTreeTable
|
|
key='attachment-table'
|
|
url={url}
|
|
tableState={table}
|
|
columns={tableColumns}
|
|
props={{
|
|
noRecordsText: t`No attachments found`,
|
|
enableSelection: canDelete,
|
|
enableBulkDelete: canDelete,
|
|
tableActions: tableActions,
|
|
tableFilters: tableFilters,
|
|
rowActions: rowActions,
|
|
params: {
|
|
model_type: model_type,
|
|
model_id: model_id
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
{allowDragAndDrop && validPk && (
|
|
<Paper p='md' shadow='xs' radius='md'>
|
|
<Dropzone
|
|
onDrop={uploadFiles}
|
|
loading={isUploading}
|
|
key='attachment-dropzone'
|
|
>
|
|
<Group justify='center' gap='lg' mih={100}>
|
|
<Dropzone.Accept>
|
|
<IconUpload
|
|
style={{ color: 'var(--mantine-color-blue-6)' }}
|
|
stroke={1.5}
|
|
/>
|
|
</Dropzone.Accept>
|
|
<Dropzone.Reject>
|
|
<IconX
|
|
style={{ color: 'var(--mantine-color-red-6)' }}
|
|
stroke={1.5}
|
|
/>
|
|
</Dropzone.Reject>
|
|
<Dropzone.Idle>
|
|
<IconUpload
|
|
style={{ color: 'var(--mantine-color-dimmed)' }}
|
|
stroke={1.5}
|
|
/>
|
|
</Dropzone.Idle>
|
|
<Text size='sm'>{t`Drag attachment file here to upload`}</Text>
|
|
</Group>
|
|
</Dropzone>
|
|
</Paper>
|
|
)}
|
|
</Stack>
|
|
</>
|
|
);
|
|
}
|