mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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:
parent
f11a9e97d2
commit
7e753523d1
@ -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()
|
||||
|
@ -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
|
||||
|
||||
|
@ -583,10 +583,6 @@ class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
queryset = BuildOrderAttachment.objects.all()
|
||||
serializer_class = build.serializers.BuildAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'build',
|
||||
]
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -326,10 +326,6 @@ class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
queryset = PartAttachment.objects.all()
|
||||
serializer_class = part_serializers.PartAttachmentSerializer
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'part',
|
||||
]
|
||||
|
@ -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',
|
||||
]
|
||||
|
63
src/frontend/src/components/items/AttachmentLink.tsx
Normal file
63
src/frontend/src/components/items/AttachmentLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)
|
||||
)}
|
||||
|
248
src/frontend/src/components/tables/AttachmentTable.tsx
Normal file
248
src/frontend/src/components/tables/AttachmentTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
|
135
src/frontend/src/functions/forms/AttachmentForms.tsx
Normal file
135
src/frontend/src/functions/forms/AttachmentForms.tsx
Normal 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>
|
||||
)
|
||||
});
|
||||
}
|
25
src/frontend/src/hooks/TableRefresh.tsx
Normal file
25
src/frontend/src/hooks/TableRefresh.tsx
Normal 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 };
|
||||
}
|
@ -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} />
|
||||
|
Loading…
Reference in New Issue
Block a user