diff --git a/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx b/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx index 5a0fea7cc2..3536f92c2e 100644 --- a/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx +++ b/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx @@ -33,7 +33,7 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef( } let preview = await api.post( - printingUrl, + printingUrl!, { items: [previewItem], template: template.pk diff --git a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx index 6aafc01f54..ef5a0e4587 100644 --- a/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx +++ b/src/frontend/src/components/editors/TemplateEditor/TemplateEditor.tsx @@ -1,6 +1,11 @@ +import { Boundary } from '@lib/components/Boundary'; +import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; import { t } from '@lingui/core/macro'; import { Alert, + Button, CloseButton, Group, List, @@ -24,11 +29,6 @@ import { import Split from '@uiw/react-split'; import type React from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { Boundary } from '@lib/components/Boundary'; -import { ModelInformationDict } from '@lib/enums/ModelInformation'; -import { ModelType } from '@lib/enums/ModelType'; -import { apiUrl } from '@lib/functions/Api'; import { api } from '../../../App'; import type { TemplateI } from '../../../tables/settings/TemplateTable'; import { SplitButton } from '../../buttons/SplitButton'; @@ -78,26 +78,38 @@ export type PreviewArea = { export type TemplateEditorProps = { templateUrl: string; - printingUrl: string; + printingUrl?: string; editors: Editor[]; previewAreas: PreviewArea[]; template: TemplateI; + // Name of the file field on the template model (e.g. 'template' or 'snippet') + fileField?: string; }; export function TemplateEditor(props: Readonly) { - const { templateUrl, editors, previewAreas, template } = props; + const { + templateUrl, + editors, + previewAreas, + template, + fileField = 'template' + } = props; const editorRef = useRef(null); const previewRef = useRef(null); + // If no preview areas are provided, the template is saved directly + const hasPreview = previewAreas.length > 0; + const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false); const [previewItem, setPreviewItem] = useState(''); const [renderingErrors, setRenderingErrors] = useState(null); const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); const [editorValue, setEditorValue] = useState(editors[0].key); const [previewValue, setPreviewValue] = useState( - previewAreas[0].key + previewAreas[0]?.key ?? null ); const codeRef = useRef(undefined); @@ -131,12 +143,12 @@ export function TemplateEditor(props: Readonly) { if (!templateUrl) return; api.get(templateUrl).then((response: any) => { - if (response.data?.template) { + if (response.data?.[fileField]) { // Fetch the raw template file from the server // Request that it is provided without any caching, // to ensure that we always get the latest version api - .get(response.data.template, { + .get(response.data[fileField], { headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache', @@ -149,7 +161,7 @@ export function TemplateEditor(props: Readonly) { }) .catch(() => { console.error( - `ERR: Could not load template from ${response.data.template}` + `ERR: Could not load template from ${response.data[fileField]}` ); codeRef.current = undefined; hideNotification('template-load-error'); @@ -244,6 +256,45 @@ export function TemplateEditor(props: Readonly) { [previewItem] ); + // Save the template file directly to the server, without any preview + const saveTemplate = useCallback(async () => { + const code = await getCodeFromEditor(); + + if (code === undefined) return; + + const filename = + (template as any)[fileField]?.split('/').pop() ?? 'template.html'; + + const formData = new FormData(); + formData.append(fileField, new File([code], filename)); + + setIsSaving(true); + + api + .patch(templateUrl, formData) + .then(() => { + hideNotification('template-save'); + showNotification({ + id: 'template-save', + title: t`Saved`, + message: t`Template file has been updated`, + color: 'green' + }); + }) + .catch(() => { + hideNotification('template-save'); + showNotification({ + id: 'template-save', + title: t`Error`, + message: t`Could not save the template to the server.`, + color: 'red' + }); + }) + .finally(() => { + setIsSaving(false); + }); + }, [getCodeFromEditor, template, templateUrl, fileField]); + const previewApiUrl = useMemo( () => ModelInformationDict[template.model_type ?? ModelType.stockitem] @@ -261,18 +312,20 @@ export function TemplateEditor(props: Readonly) { }, [template]); useEffect(() => { + if (!hasPreview) return; + api .get(apiUrl(previewApiUrl), { params: { limit: 1, ...templateFilters } }) .then((res) => { if (res.data.results.length === 0) return; setPreviewItem(res.data.results[0].pk); }); - }, [previewApiUrl, templateFilters]); + }, [hasPreview, previewApiUrl, templateFilters]); return ( - + { @@ -282,7 +335,7 @@ export function TemplateEditor(props: Readonly) { keepMounted={false} style={{ minWidth: '300px', - width: '50%', + width: hasPreview ? '50%' : '100%', display: 'flex', flexDirection: 'column' }} @@ -301,29 +354,39 @@ export function TemplateEditor(props: Readonly) { })} - updatePreview(true, false), - disabled: !previewItem || isPreviewLoading - }, - { - key: 'preview_save', - name: t`Save & Reload Preview`, - tooltip: t`Save the current template and reload the preview`, - icon: IconDeviceFloppy, - onClick: () => updatePreview(hasSaveConfirmed), - disabled: !previewItem || isPreviewLoading - } - ]} - /> + {hasPreview ? ( + updatePreview(true, false), + disabled: !previewItem || isPreviewLoading + }, + { + key: 'preview_save', + name: t`Save & Reload Preview`, + tooltip: t`Save the current template and reload the preview`, + icon: IconDeviceFloppy, + onClick: () => updatePreview(hasSaveConfirmed), + disabled: !previewItem || isPreviewLoading + } + ]} + /> + ) : ( + + )} @@ -342,100 +405,102 @@ export function TemplateEditor(props: Readonly) { ))} - - - {previewAreas.map((PreviewArea) => ( - - {PreviewArea.name} - - ))} - - -
- setPreviewItem(value) - }} - /> -
+ + {previewAreas.map((PreviewArea) => ( + + {PreviewArea.name} + + ))} + - {previewAreas.map((PreviewArea) => ( - -
setPreviewItem(value) + }} + /> +
+ + {previewAreas.map((PreviewArea) => ( + - {/* @ts-ignore-next-line */} - +
+ {/* @ts-ignore-next-line */} + - {renderingErrors && ( - - setRenderingErrors(null)} - style={{ - position: 'absolute', - top: '10px', - right: '10px', - color: '#fff' - }} - variant='filled' - /> - } - title={t`Error rendering template`} - mx='10px' - > - - {renderingErrors.map((error, index) => ( - {error} - ))} - - - - )} -
-
- ))} -
+ {renderingErrors && ( + + setRenderingErrors(null)} + style={{ + position: 'absolute', + top: '10px', + right: '10px', + color: '#fff' + }} + variant='filled' + /> + } + title={t`Error rendering template`} + mx='10px' + > + + {renderingErrors.map((error, index) => ( + {error} + ))} + + + + )} + + + ))} + + )}
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index ecbdddfae4..881a6bdeba 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -1,3 +1,6 @@ +import { PluginPanelKey } from '@lib/enums/ModelType'; +import { UserRoles } from '@lib/enums/Roles'; +import type { PanelGroupType, PanelType } from '@lib/types/Panel'; import { t } from '@lingui/core/macro'; import { Stack } from '@mantine/core'; import { @@ -5,6 +8,7 @@ import { IconCpu, IconDevicesPc, IconExclamationCircle, + IconFileCode, IconFileDownload, IconFileUpload, IconHome, @@ -21,10 +25,6 @@ import { IconUsersGroup } from '@tabler/icons-react'; import { lazy, useMemo } from 'react'; - -import { PluginPanelKey } from '@lib/enums/ModelType'; -import { UserRoles } from '@lib/enums/Roles'; -import type { PanelGroupType, PanelType } from '@lib/types/Panel'; import PermissionDenied from '../../../../components/errors/PermissionDenied'; import PageTitle from '../../../../components/nav/PageTitle'; import { SettingsHeader } from '../../../../components/nav/SettingsHeader'; @@ -103,6 +103,10 @@ const LocationTypesTable = Loadable( lazy(() => import('../../../../tables/stock/LocationTypesTable')) ); +const SnippetTable = Loadable( + lazy(() => import('../../../../tables/settings/SnippetTable')) +); + export default function AdminCenter() { const user = useUserState(); @@ -221,6 +225,12 @@ export default function AdminCenter() { icon: , content: }, + { + name: 'snippets', + label: t`Report Snippets`, + icon: , + content: + }, { name: 'location-types', label: t`Location Types`, @@ -273,7 +283,7 @@ export default function AdminCenter() { { id: 'reporting', label: t`Reporting`, - panelIDs: ['labels', 'reports'] + panelIDs: ['labels', 'reports', 'snippets'] }, { id: 'plm', diff --git a/src/frontend/src/tables/settings/SnippetTable.tsx b/src/frontend/src/tables/settings/SnippetTable.tsx new file mode 100644 index 0000000000..1abb3e2b13 --- /dev/null +++ b/src/frontend/src/tables/settings/SnippetTable.tsx @@ -0,0 +1,241 @@ +import { AddItemButton } from '@lib/components/AddItemButton'; +import { + type RowAction, + RowDeleteAction, + RowEditAction +} from '@lib/components/RowActions'; +import { DetailDrawer } from '@lib/components/nav/DetailDrawer'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import useTable from '@lib/hooks/UseTable'; +import type { ApiFormFieldSet } from '@lib/types/Forms'; +import type { TableColumn } from '@lib/types/Tables'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { + Alert, + Group, + LoadingOverlay, + Stack, + Text, + Title +} from '@mantine/core'; +import { IconFileCode, IconInfoCircle } from '@tabler/icons-react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + CodeEditor, + TemplateEditor +} from '../../components/editors/TemplateEditor'; +import { AttachmentLink } from '../../components/items/AttachmentLink'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useInstance } from '../../hooks/UseInstance'; +import { useUserState } from '../../states/UserState'; +import { DescriptionColumn } from '../ColumnRenderers'; +import { InvenTreeTable } from '../InvenTreeTable'; +import type { TemplateI } from './TemplateTable'; + +export type SnippetI = { + pk: number; + snippet: string; + description: string; +}; + +export function SnippetDrawer({ + id +}: Readonly<{ + id: string | number; +}>) { + const { + instance: snippet, + instanceQuery: { isFetching, error } + } = useInstance({ + endpoint: ApiEndpoints.report_snippet, + hasPrimaryKey: true, + pk: id + }); + + const filename = useMemo( + () => snippet?.snippet?.split('/').pop() ?? '', + [snippet] + ); + + if (isFetching) { + return ; + } + + if (error || !snippet) { + return ( + + {(error as any)?.response?.status === 404 ? ( + Snippet not found + ) : ( + An error occurred while fetching snippet details + )} + + ); + } + + return ( + + + {filename} + + + + + ); +} + +export default function SnippetTable() { + const table = useTable('report-snippet'); + const navigate = useNavigate(); + const user = useUserState(); + + const openDetailDrawer = useCallback((pk: number) => navigate(`${pk}/`), []); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'snippet', + title: t`Snippet`, + sortable: false, + switchable: false, + render: (record: SnippetI) => { + if (!record.snippet) { + return '-'; + } + + return ; + }, + noContext: true + }, + DescriptionColumn({ + accessor: 'description', + sortable: false, + switchable: false + }) + ]; + }, []); + + const [selectedSnippet, setSelectedSnippet] = useState(-1); + + const rowActions = useCallback( + (record: SnippetI): RowAction[] => { + return [ + { + title: t`Modify`, + tooltip: t`Modify snippet file`, + icon: , + onClick: () => openDetailDrawer(record.pk), + hidden: !user.isStaff() + }, + RowEditAction({ + hidden: !user.isStaff(), + onClick: () => { + setSelectedSnippet(record.pk); + editSnippet.open(); + } + }), + RowDeleteAction({ + hidden: !user.isStaff(), + onClick: () => { + setSelectedSnippet(record.pk); + deleteSnippet.open(); + } + }) + ]; + }, + [user] + ); + + const editSnippetFields: ApiFormFieldSet = useMemo(() => { + return { + description: {} + }; + }, []); + + const newSnippetFields: ApiFormFieldSet = useMemo(() => { + return { + snippet: {}, + description: {} + }; + }, []); + + const editSnippet = useEditApiFormModal({ + url: ApiEndpoints.report_snippet, + pk: selectedSnippet, + title: t`Edit Snippet`, + fields: editSnippetFields, + onFormSuccess: (record: any) => table.updateRecord(record) + }); + + const deleteSnippet = useDeleteApiFormModal({ + url: ApiEndpoints.report_snippet, + pk: selectedSnippet, + title: t`Delete Snippet`, + table: table + }); + + const newSnippet = useCreateApiFormModal({ + url: ApiEndpoints.report_snippet, + title: t`Add Snippet`, + fields: newSnippetFields, + onFormSuccess: (data) => { + table.refreshTable(); + openDetailDrawer(data.pk); + } + }); + + const tableActions: ReactNode[] = useMemo(() => { + return [ + newSnippet.open()} + tooltip={t`Add snippet`} + hidden={!user.isStaff()} + /> + ]; + }, [user]); + + return ( + <> + {newSnippet.modal} + {editSnippet.modal} + {deleteSnippet.modal} + { + return ; + }} + /> + } title={t`Snippets`}> + {t`Snippets are reusable pieces of HTML content that can be inserted into reports and labels.`} + + openDetailDrawer(record.pk) + }} + /> + + ); +}