[PUI] Attachment table (#5525)

* Basic AttachmentTable

* Add form for editing an attachment

* Fix columns for InvenTreeTable component

* Update part attachment table

* Add dropzone to attachments table

* Handle file upload with Dropzone

* Add header for panelgroup

* Improve rendering of attachment files

* Allow various attachment list API endpoints to be searched

* Determine available attachment actions based on user permissions

* Reload attachment table after upload

* Delete attachments via table

* ts fix

* Clip width of actions column

* More updates

- Add manual buttons for adding link or file
- Edit link or file

* Add tooltip for row actions

* Adds a custom hook for refreshing tables

- So much cleaner :)

* Change export type

* Disable row action column when checkbox selection is active

* Fix(?) for custom hook

* Badge tweak
This commit is contained in:
Oliver 2023-09-12 11:45:23 +10:00 committed by GitHub
parent f11a9e97d2
commit 7e753523d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 554 additions and 64 deletions

View File

@ -228,6 +228,12 @@ class AttachmentMixin:
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'attachment',
'comment',
'link',
]
def perform_create(self, serializer):
"""Save the user information when a file is uploaded."""
attachment = serializer.save()

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 133
INVENTREE_API_VERSION = 134
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v134 -> 2023-09-11 : https://github.com/inventree/InvenTree/pull/5525
- Allow "Attachment" list endpoints to be searched by attachment, link and comment fields
v133 -> 2023-09-08 : https://github.com/inventree/InvenTree/pull/5518
- Add extra optional fields which can be used for StockAdjustment endpoints

View File

@ -583,10 +583,6 @@ class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = BuildOrderAttachment.objects.all()
serializer_class = build.serializers.BuildAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'build',
]

View File

@ -4,7 +4,6 @@ from django.db.models import Q
from django.urls import include, path, re_path
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
import part.models
from InvenTree.api import (AttachmentMixin, ListCreateDestroyAPIView,
@ -89,10 +88,6 @@ class CompanyAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = CompanyAttachment.objects.all()
serializer_class = CompanyAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'company',
]
@ -246,10 +241,6 @@ class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'manufacturer_part',
]

View File

@ -583,10 +583,6 @@ class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.SalesOrderAttachment.objects.all()
serializer_class = serializers.SalesOrderAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
]
filterset_fields = [
'order',
]
@ -1079,10 +1075,6 @@ class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.PurchaseOrderAttachment.objects.all()
serializer_class = serializers.PurchaseOrderAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
]
filterset_fields = [
'order',
]
@ -1359,10 +1351,6 @@ class ReturnOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = models.ReturnOrderAttachment.objects.all()
serializer_class = serializers.ReturnOrderAttachmentSerializer
filter_backends = [
rest_filters.DjangoFilterBackend,
]
filterset_fields = [
'order',
]

View File

@ -326,10 +326,6 @@ class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = PartAttachment.objects.all()
serializer_class = part_serializers.PartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filterset_fields = [
'part',
]

View File

@ -1051,8 +1051,6 @@ class StockAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
queryset = StockItemAttachment.objects.all()
serializer_class = StockSerializers.StockItemAttachmentSerializer
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = [
'stock_item',
]

View File

@ -0,0 +1,63 @@
import { Group, Text } from '@mantine/core';
import { IconFileTypeJpg, IconPhoto } from '@tabler/icons-react';
import {
IconFile,
IconFileTypeCsv,
IconFileTypeDoc,
IconFileTypePdf,
IconFileTypeXls,
IconFileTypeZip
} from '@tabler/icons-react';
import { ReactNode } from 'react';
/**
* Return an icon based on the provided filename
*/
export function attachmentIcon(attachment: string): ReactNode {
const sz = 18;
let suffix = attachment.split('.').pop()?.toLowerCase() ?? '';
switch (suffix) {
case 'pdf':
return <IconFileTypePdf size={sz} />;
case 'csv':
return <IconFileTypeCsv size={sz} />;
case 'xls':
case 'xlsx':
return <IconFileTypeXls size={sz} />;
case 'doc':
case 'docx':
return <IconFileTypeDoc size={sz} />;
case 'zip':
case 'tar':
case 'gz':
case '7z':
return <IconFileTypeZip size={sz} />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'bmp':
case 'tif':
case 'webp':
return <IconPhoto size={sz} />;
default:
return <IconFile size={sz} />;
}
}
/**
* Render a link to a file attachment, with icon and text
* @param attachment : string - The attachment filename
*/
export function AttachmentLink({
attachment
}: {
attachment: string;
}): ReactNode {
return (
<Group position="left" spacing="sm">
{attachmentIcon(attachment)}
<Text>{attachment.split('/').pop()}</Text>
</Group>
);
}

View File

@ -1,4 +1,4 @@
import { Tabs } from '@mantine/core';
import { Divider, Paper, Stack, Tabs, Text } from '@mantine/core';
import { ReactNode } from 'react';
import { useEffect, useState } from 'react';
@ -78,7 +78,13 @@ export function PanelGroup({
(panel, idx) =>
!panel.hidden && (
<Tabs.Panel key={idx} value={panel.name}>
<Paper p="md" radius="xs">
<Stack spacing="md">
<Text size="xl">{panel.label}</Text>
<Divider />
{panel.content}
</Stack>
</Paper>
</Tabs.Panel>
)
)}

View File

@ -0,0 +1,248 @@
import { t } from '@lingui/macro';
import { Badge, Group, Stack, Text, Tooltip } from '@mantine/core';
import { ActionIcon } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useId } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconExternalLink, IconFileUpload } from '@tabler/icons-react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import {
addAttachment,
deleteAttachment,
editAttachment
} from '../../functions/forms/AttachmentForms';
import { useTableRefresh } from '../../hooks/TableRefresh';
import { AttachmentLink } from '../items/AttachmentLink';
import { TableColumn } from './Column';
import { InvenTreeTable } from './InvenTreeTable';
import { RowAction } from './RowActions';
/**
* Define set of columns to display for the attachment table
*/
function attachmentTableColumns(): TableColumn[] {
return [
{
accessor: 'attachment',
title: t`Attachment`,
sortable: false,
switchable: false,
noWrap: true,
render: function (record: any) {
if (record.attachment) {
return <AttachmentLink attachment={record.attachment} />;
} else if (record.link) {
// TODO: Custom renderer for links
return record.link;
} else {
return '-';
}
}
},
{
accessor: 'comment',
title: t`Comment`,
sortable: false,
switchable: true,
render: function (record: any) {
return record.comment;
}
},
{
accessor: 'uploaded',
title: t`Uploaded`,
sortable: false,
switchable: true,
render: function (record: any) {
return (
<Group position="apart">
<Text>{record.upload_date}</Text>
{record.user_detail && (
<Badge size="xs">{record.user_detail.username}</Badge>
)}
</Group>
);
}
}
];
}
/**
* Construct a table for displaying uploaded attachments
*/
export function AttachmentTable({
url,
model,
pk
}: {
url: string;
pk: number;
model: string;
}): ReactNode {
const tableId = useId();
const { refreshId, refreshTable } = useTableRefresh();
const tableColumns = useMemo(() => attachmentTableColumns(), []);
const [allowEdit, setAllowEdit] = useState<boolean>(false);
const [allowDelete, setAllowDelete] = useState<boolean>(false);
// Determine which permissions are available for this URL
useEffect(() => {
api
.options(url)
.then((response) => {
let actions: any = response.data?.actions ?? {};
setAllowEdit('POST' in actions);
setAllowDelete('DELETE' in actions);
return response;
})
.catch((error) => {
return error;
});
}, []);
// Construct row actions for the attachment table
function rowActions(record: any): RowAction[] {
let actions: RowAction[] = [];
if (allowEdit) {
actions.push({
title: t`Edit`,
onClick: () => {
editAttachment({
url: url,
model: model,
pk: record.pk,
attachmentType: record.attachment ? 'file' : 'link',
callback: refreshTable
});
}
});
}
if (allowDelete) {
actions.push({
title: t`Delete`,
color: 'red',
onClick: () => {
deleteAttachment({
url: url,
pk: record.pk,
callback: refreshTable
});
}
});
}
return actions;
}
// Callback to upload file attachment(s)
function uploadFiles(files: File[]) {
files.forEach((file) => {
let formData = new FormData();
formData.append('attachment', file);
formData.append(model, pk.toString());
api
.post(url, formData)
.then((response) => {
notifications.show({
title: t`File uploaded`,
message: t`File ${file.name} uploaded successfully`,
color: 'green'
});
refreshTable();
return response;
})
.catch((error) => {
console.error('error uploading attachment:', file, '->', error);
notifications.show({
title: t`Upload Error`,
message: t`File could not be uploaded`,
color: 'red'
});
return error;
});
});
}
function customActionGroups(): ReactNode[] {
let actions = [];
if (allowEdit) {
actions.push(
<Tooltip label={t`Add attachment`}>
<ActionIcon
radius="sm"
onClick={() => {
addAttachment({
url: url,
model: model,
pk: pk,
attachmentType: 'file',
callback: refreshTable
});
}}
>
<IconFileUpload />
</ActionIcon>
</Tooltip>
);
actions.push(
<Tooltip label={t`Add external link`}>
<ActionIcon
radius="sm"
onClick={() => {
addAttachment({
url: url,
model: model,
pk: pk,
attachmentType: 'link',
callback: refreshTable
});
}}
>
<IconExternalLink />
</ActionIcon>
</Tooltip>
);
}
return actions;
}
return (
<Stack spacing="xs">
<InvenTreeTable
url={url}
tableKey={tableId}
refreshId={refreshId}
params={{
[model]: pk
}}
customActionGroups={customActionGroups()}
columns={tableColumns}
rowActions={allowEdit && allowDelete ? rowActions : undefined}
/>
{allowEdit && (
<Dropzone onDrop={uploadFiles}>
<Dropzone.Idle>
<Group position="center">
<IconFileUpload size={24} />
<Text size="sm">{t`Upload attachment`}</Text>
</Group>
</Dropzone.Idle>
</Dropzone>
)}
</Stack>
);
}

View File

@ -5,7 +5,7 @@ import { IconFilter, IconRefresh } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ButtonMenu } from '../items/ButtonMenu';
@ -98,7 +98,8 @@ export function InvenTreeTable({
barcodeActions = [],
customActionGroups = [],
customFilters = [],
rowActions
rowActions,
refreshId
}: {
url: string;
params: any;
@ -118,10 +119,8 @@ export function InvenTreeTable({
customActionGroups?: any[];
customFilters?: TableFilter[];
rowActions?: (record: any) => RowAction[];
refreshId?: string;
}) {
// Data columns
const [dataColumns, setDataColumns] = useState<any[]>(columns);
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns = columns.some(
(col: TableColumn) => col.switchable
@ -132,10 +131,17 @@ export function InvenTreeTable({
loadHiddenColumns(tableKey)
);
// Data selection
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
function onSelectedRecordsChange(records: any[]) {
setSelectedRecords(records);
}
// Update column visibility when hiddenColumns change
useEffect(() => {
let cols = dataColumns.map((col) => {
let hidden: boolean = col.hidden;
const dataColumns: any = useMemo(() => {
let cols = columns.map((col) => {
let hidden: boolean = col.hidden ?? false;
if (col.switchable) {
hidden = hiddenColumns.includes(col.accessor);
@ -154,14 +160,20 @@ export function InvenTreeTable({
title: '',
hidden: false,
switchable: false,
width: 48,
render: function (record: any) {
return <RowActions actions={rowActions(record)} />;
return (
<RowActions
actions={rowActions(record)}
disabled={selectedRecords.length > 0}
/>
);
}
});
}
setDataColumns(cols);
}, [columns, hiddenColumns, rowActions]);
return cols;
}, [columns, hiddenColumns, rowActions, enableSelection, selectedRecords]);
// Callback when column visibility is toggled
function toggleColumn(columnName: string) {
@ -309,7 +321,7 @@ export function InvenTreeTable({
// Find matching column:
// If column provides custom ordering term, use that
let column = dataColumns.find((col) => col.accessor == key);
let column = dataColumns.find((col: any) => col.accessor == key);
return column?.ordering || key;
}
@ -317,13 +329,6 @@ export function InvenTreeTable({
const [missingRecordsText, setMissingRecordsText] =
useState<string>(noRecordsText);
// Data selection
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
function onSelectedRecordsChange(records: any[]) {
setSelectedRecords(records);
}
const handleSortStatusChange = (status: DataTableSortStatus) => {
setPage(1);
setSortStatus(status);
@ -386,6 +391,18 @@ export function InvenTreeTable({
}
);
/*
* Reload the table whenever the refetch changes
* this allows us to programmatically refresh the table
*
* Implement this using the custom useTableRefresh hook
*/
useEffect(() => {
if (refreshId) {
refetch();
}
}, [refreshId]);
return (
<>
<FilterSelectModal

View File

@ -1,12 +1,13 @@
import { t } from '@lingui/macro';
import { ActionIcon } from '@mantine/core';
import { Menu } from '@mantine/core';
import { ActionIcon, Tooltip } from '@mantine/core';
import { Menu, Text } from '@mantine/core';
import { IconDots } from '@tabler/icons-react';
import { ReactNode } from 'react';
// Type definition for a table row action
export type RowAction = {
title: string;
color?: string;
onClick: () => void;
tooltip?: string;
icon?: ReactNode;
@ -18,18 +19,22 @@ export type RowAction = {
*/
export function RowActions({
title,
actions
actions,
disabled = false
}: {
title?: string;
disabled?: boolean;
actions: RowAction[];
}): ReactNode {
return (
actions.length > 0 && (
<Menu withinPortal={true}>
<Menu withinPortal={true} disabled={disabled}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<Tooltip label={title || t`Actions`}>
<ActionIcon disabled={disabled} variant="subtle" color="gray">
<IconDots />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{title || t`Actions`}</Menu.Label>
@ -40,7 +45,9 @@ export function RowActions({
icon={action.icon}
title={action.tooltip || action.title}
>
<Text size="sm" color={action.color}>
{action.title}
</Text>
</Menu.Item>
))}
</Menu.Dropdown>

View File

@ -0,0 +1,135 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import {
openCreateApiForm,
openDeleteApiForm,
openEditApiForm
} from '../forms';
export function attachmentFields(editing: boolean): ApiFormFieldSet {
let fields: ApiFormFieldSet = {
attachment: {},
comment: {}
};
if (editing) {
delete fields['attachment'];
}
return fields;
}
/**
* Add a new attachment (either a file or a link)
*/
export function addAttachment({
url,
model,
pk,
attachmentType,
callback
}: {
url: string;
model: string;
pk: number;
attachmentType: 'file' | 'link';
callback?: () => void;
}) {
let formFields: ApiFormFieldSet = {
attachment: {},
link: {},
comment: {}
};
if (attachmentType === 'link') {
delete formFields['attachment'];
} else {
delete formFields['link'];
}
formFields[model] = {
value: pk,
hidden: true
};
let title = attachmentType === 'file' ? t`Add File` : t`Add Link`;
let message = attachmentType === 'file' ? t`File added` : t`Link added`;
openCreateApiForm({
name: 'attachment-add',
title: title,
url: url,
successMessage: message,
fields: formFields,
onFormSuccess: callback
});
}
/**
* Edit an existing attachment (either a file or a link)
*/
export function editAttachment({
url,
model,
pk,
attachmentType,
callback
}: {
url: string;
model: string;
pk: number;
attachmentType: 'file' | 'link';
callback?: () => void;
}) {
let formFields: ApiFormFieldSet = {
link: {},
comment: {}
};
if (attachmentType === 'file') {
delete formFields['link'];
}
formFields[model] = {
value: pk,
hidden: true
};
let title = attachmentType === 'file' ? t`Edit File` : t`Edit Link`;
let message = attachmentType === 'file' ? t`File updated` : t`Link updated`;
openEditApiForm({
name: 'attachment-edit',
title: title,
url: url,
pk: pk,
successMessage: message,
fields: formFields,
onFormSuccess: callback
});
}
export function deleteAttachment({
url,
pk,
callback
}: {
url: string;
pk: number;
callback: () => void;
}) {
openDeleteApiForm({
url: url,
pk: pk,
name: 'attachment-edit',
title: t`Delete Attachment`,
successMessage: t`Attachment deleted`,
onFormSuccess: callback,
fields: {},
preFormContent: (
<Text>{t`Are you sure you want to delete this attachment?`}</Text>
)
});
}

View File

@ -0,0 +1,25 @@
import { randomId } from '@mantine/hooks';
import { useCallback, useState } from 'react';
/**
* Custom hook for refreshing an InvenTreeTable externally
* Returns a unique ID for the table, which can be updated to trigger a refresh of the <table className=""></table>
*
* @returns [refreshId, refreshTable]
*
* To use this hook:
* const [refreshId, refreshTable] = useTableRefresh();
*
* Then, pass the refreshId to the InvenTreeTable component:
* <InvenTreeTable refreshId={refreshId} ... />
*/
export function useTableRefresh() {
const [refreshId, setRefreshId] = useState<string>(randomId());
// Generate a new ID to refresh the table
const refreshTable = useCallback(function () {
setRefreshId(randomId());
}, []);
return { refreshId, refreshTable };
}

View File

@ -27,12 +27,13 @@ import {
IconVersions
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import React, { useState } from 'react';
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { editPart } from '../../functions/forms/PartForms';
@ -129,7 +130,7 @@ export default function PartDetail() {
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip size="18" />,
content: <Text>part attachments go here</Text>
content: partAttachmentsTab()
},
{
name: 'notes',
@ -156,6 +157,16 @@ export default function PartDetail() {
});
});
function partAttachmentsTab(): React.ReactNode {
return (
<AttachmentTable
url="/part/attachment/"
model="part"
pk={part.pk ?? -1}
/>
);
}
function partStockTab(): React.ReactNode {
return (
<StockItemTable
@ -190,7 +201,7 @@ export default function PartDetail() {
})
}
>
Edit
Edit Part
</Button>
</Group>
<PanelGroup panels={partPanels} />