mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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:
		
							
								
								
									
										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}> | ||||
|               {panel.content} | ||||
|               <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"> | ||||
|             <IconDots /> | ||||
|           </ActionIcon> | ||||
|           <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} | ||||
|             > | ||||
|               {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} /> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user