diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index c0b10d673e..35792444e9 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -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() diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 91f695b72c..7859a8a3db 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 3575f32ed7..acadb88a16 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -583,10 +583,6 @@ class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = BuildOrderAttachment.objects.all() serializer_class = build.serializers.BuildAttachmentSerializer - filter_backends = [ - DjangoFilterBackend, - ] - filterset_fields = [ 'build', ] diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 05638e1398..d2268cb961 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -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', ] diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 99f04c5b2a..f11414f202 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -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', ] diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 60f371af56..7553112c34 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -326,10 +326,6 @@ class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): queryset = PartAttachment.objects.all() serializer_class = part_serializers.PartAttachmentSerializer - filter_backends = [ - DjangoFilterBackend, - ] - filterset_fields = [ 'part', ] diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 961ac7149a..519735a98f 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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', ] diff --git a/src/frontend/src/components/items/AttachmentLink.tsx b/src/frontend/src/components/items/AttachmentLink.tsx new file mode 100644 index 0000000000..8a27a29f2f --- /dev/null +++ b/src/frontend/src/components/items/AttachmentLink.tsx @@ -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 ; + case 'csv': + return ; + case 'xls': + case 'xlsx': + return ; + case 'doc': + case 'docx': + return ; + case 'zip': + case 'tar': + case 'gz': + case '7z': + return ; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'bmp': + case 'tif': + case 'webp': + return ; + default: + return ; + } +} + +/** + * 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 ( + + {attachmentIcon(attachment)} + {attachment.split('/').pop()} + + ); +} diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index 57106a2c62..3c1bc560b6 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -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 && ( - {panel.content} + + + {panel.label} + + {panel.content} + + ) )} diff --git a/src/frontend/src/components/tables/AttachmentTable.tsx b/src/frontend/src/components/tables/AttachmentTable.tsx new file mode 100644 index 0000000000..d60a92de5e --- /dev/null +++ b/src/frontend/src/components/tables/AttachmentTable.tsx @@ -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 ; + } 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 ( + + {record.upload_date} + {record.user_detail && ( + {record.user_detail.username} + )} + + ); + } + } + ]; +} + +/** + * 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(false); + const [allowDelete, setAllowDelete] = useState(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( + + { + addAttachment({ + url: url, + model: model, + pk: pk, + attachmentType: 'file', + callback: refreshTable + }); + }} + > + + + + ); + + actions.push( + + { + addAttachment({ + url: url, + model: model, + pk: pk, + attachmentType: 'link', + callback: refreshTable + }); + }} + > + + + + ); + } + + return actions; + } + + return ( + + + {allowEdit && ( + + + + + {t`Upload attachment`} + + + + )} + + ); +} diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 06355fc1bb..6fd4c4f10b 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -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(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([]); + + 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 ; + return ( + 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(noRecordsText); - // Data selection - const [selectedRecords, setSelectedRecords] = useState([]); - - 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 ( <> 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 && ( - + - - - + + + + + {title || t`Actions`} @@ -40,7 +45,9 @@ export function RowActions({ icon={action.icon} title={action.tooltip || action.title} > - {action.title} + + {action.title} + ))} diff --git a/src/frontend/src/functions/forms/AttachmentForms.tsx b/src/frontend/src/functions/forms/AttachmentForms.tsx new file mode 100644 index 0000000000..5be6ca3e0f --- /dev/null +++ b/src/frontend/src/functions/forms/AttachmentForms.tsx @@ -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: ( + {t`Are you sure you want to delete this attachment?`} + ) + }); +} diff --git a/src/frontend/src/hooks/TableRefresh.tsx b/src/frontend/src/hooks/TableRefresh.tsx new file mode 100644 index 0000000000..b45e1b4544 --- /dev/null +++ b/src/frontend/src/hooks/TableRefresh.tsx @@ -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
+ * + * @returns [refreshId, refreshTable] + * + * To use this hook: + * const [refreshId, refreshTable] = useTableRefresh(); + * + * Then, pass the refreshId to the InvenTreeTable component: + * + */ +export function useTableRefresh() { + const [refreshId, setRefreshId] = useState(randomId()); + + // Generate a new ID to refresh the table + const refreshTable = useCallback(function () { + setRefreshId(randomId()); + }, []); + + return { refreshId, refreshTable }; +} diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 184e6e68c1..ca989027e2 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -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: , - content: part attachments go here + content: partAttachmentsTab() }, { name: 'notes', @@ -156,6 +157,16 @@ export default function PartDetail() { }); }); + function partAttachmentsTab(): React.ReactNode { + return ( + + ); + } + function partStockTab(): React.ReactNode { return ( - Edit + Edit Part