diff --git a/docs/docs/report/index.md b/docs/docs/report/index.md index 37124e13d6..fd863e20f8 100644 --- a/docs/docs/report/index.md +++ b/docs/docs/report/index.md @@ -53,20 +53,20 @@ To read more about the capabilities of the report templating engine, and how to ## Creating Templates -Report and label templates can be created (and edited) via the [admin interface](../settings/admin.md), under the *Report* section. +Report and label templates are managed from the [Admin Center](../settings/admin.md#admin-center), which provides dedicated panels (under the *Reporting* group) for each template type: -Select the type of template you are wanting to create (a *Report Template* or *Label Template*) and press the *Add* button in the top right corner: +- **Label Templates** - Create and edit [label templates](./labels.md) +- **Report Templates** - Create and edit [report templates](./report.md) +- **Report Snippets** - Manage reusable [snippet](#report-snippets) files +- **Report Assets** - Manage uploaded [asset](#report-assets) files -{{ image("report/report_template_admin.png", "Report template admin") }} +Label and report templates are created and edited using the built-in [template editor](./template_editor.md), which allows templates to be written directly within the browser, with a live preview of the rendered output. !!! tip "Staff Access Only" - Only users with staff access can upload or edit report template files. + Only users with staff access can create, upload or edit templates, snippets and assets. -!!! info "Editing Reports" - Existing reports can be edited from the admin interface, in the same location as described above. To change the contents of the template, re-upload a template file, to override the existing template data. - -!!! tip "Template Editor" - InvenTree also provides a powerful [template editor](./template_editor.md) which allows for the creation and editing of report templates directly within the browser. +!!! info "Backend Admin Interface" + Templates can also be managed at a lower level via the [backend admin interface](../settings/admin.md#backend-admin-interface), under the *Report* section. This is recommended for advanced users only. ### Name and Description @@ -147,7 +147,9 @@ Setting the *Debug Mode* option renders the template as raw HTML instead of PDF, ## Report Assets -User can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header. Asset files are uploaded via the admin interface. +Users can upload asset files (e.g. images) which can be used when generating reports. For example, you may wish to generate a report with your company logo in the header. + +Asset files are managed from the [Admin Center](../settings/admin.md#admin-center), via the *Report Assets* panel. Staff users can upload new asset files, and remove assets which are no longer required. Asset files can be rendered directly into the template as follows @@ -181,7 +183,7 @@ Asset files can be rendered directly into the template as follows If the requested asset name does not match the name of an uploaded asset, the template will continue without loading the image. !!! info "Assets location" - Upload new assets via the [admin interface](../settings/admin.md) to ensure they are uploaded to the correct location on the server. + Upload new assets via the *Report Assets* panel in the [Admin Center](../settings/admin.md#admin-center) to ensure they are uploaded to the correct location on the server. ## Report Snippets @@ -190,7 +192,9 @@ A powerful feature provided by the django / WeasyPrint templating framework is t To support this, InvenTree provides report "snippets" - short (or not so short) template files which cannot be rendered by themselves, but can be called from other templates. -Similar to assets files, snippet template files are uploaded via the admin interface. +Snippet files are managed from the [Admin Center](../settings/admin.md#admin-center), via the *Report Snippets* panel. Staff users can upload new snippet files, and edit or remove existing snippets. + +Additionally, the content of an existing snippet can be modified directly within the browser - simply select a snippet from the table to open it in the built-in code editor. Snippets are included in a template as follows: @@ -243,7 +247,7 @@ When WeasyPrint renders a template to PDF it can make outbound requests to load |---|---| | `data:` URIs | Always permitted — self-contained, no network access | | `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint | -| `http` / `https` | Disabled by default, but can be blocked - see *Remote URL Fetching* below | +| `http` / `https` | Disabled by default, but can be enabled - see *Remote URL Fetching* below | | Any other scheme | Always blocked | HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address. diff --git a/docs/docs/report/report.md b/docs/docs/report/report.md index 935e977af9..716b481063 100644 --- a/docs/docs/report/report.md +++ b/docs/docs/report/report.md @@ -47,7 +47,7 @@ For example, rendering the name of a part (which is available in the particular ## Merging Reports -When rendering reports for multiple items, the default behaviour is that each item is rendered as a separate report. The chosen templeate is rendered multiple times, once for each item selected, and expects a single item in the context variable. +When rendering reports for multiple items, the default behaviour is that each item is rendered as a separate report. The chosen template is rendered multiple times, once for each item selected, and expects a single item in the context variable. However, it is possible to merge multiple items into a single report document. This is achieved by enabling the `merge` attribute of the report template: diff --git a/docs/docs/report/template_editor.md b/docs/docs/report/template_editor.md index b4fbddbf04..3008434944 100644 --- a/docs/docs/report/template_editor.md +++ b/docs/docs/report/template_editor.md @@ -4,11 +4,11 @@ title: Template editor ## Template editor -The Template Editor is integrated into the [Admin Center](../settings//admin.md#admin-center) of the Web UI. It allows users to create and edit label and report templates directly with a side by side preview for a more productive workflow. +The Template Editor is integrated into the [Admin Center](../settings/admin.md#admin-center) of the Web UI. It allows users to create and edit label and report templates directly with a side by side preview for a more productive workflow. ![Template Table](../assets/images/report/template-table.png) -On the left side (1) are all possible possible template types for labels and reports listed. With the "+" button (2), above the template table (3), new templates for the selected type can be created. To switch to the template editor click on a template. +On the left side (1) are all possible template types for labels and reports listed. With the "+" button (2), above the template table (3), new templates for the selected type can be created. To switch to the template editor click on a template. ### Editing Templates @@ -31,3 +31,10 @@ If you don't want to override the template, but just render a preview for a temp #### Edit template metadata Editing metadata such as name, description, filters and even width/height for labels and orientation/page size for reports can be done from the edit modal accessible when clicking on the three dots (4) and select "Edit" in the dropdown menu. + +### Editing Snippets + +[Report snippets](./index.md#report-snippets) can also be edited directly within the browser, from the *Report Snippets* panel in the Admin Center. Selecting a snippet from the table opens it in the same code editor as used for report and label templates. + +!!! info "No Preview" + As snippets cannot be rendered by themselves (they must be included in a report or label template), no preview area is available when editing a snippet. 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..454225c5b5 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, @@ -12,6 +16,7 @@ import { IconListDetails, IconMail, IconPackages, + IconPhoto, IconPlugConnected, IconQrcode, IconReport, @@ -21,10 +26,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 +104,14 @@ const LocationTypesTable = Loadable( lazy(() => import('../../../../tables/stock/LocationTypesTable')) ); +const SnippetTable = Loadable( + lazy(() => import('../../../../tables/settings/SnippetTable')) +); + +const AssetTable = Loadable( + lazy(() => import('../../../../tables/settings/AssetTable')) +); + export default function AdminCenter() { const user = useUserState(); @@ -221,6 +230,18 @@ export default function AdminCenter() { icon: , content: }, + { + name: 'snippets', + label: t`Report Snippets`, + icon: , + content: + }, + { + name: 'assets', + label: t`Report Assets`, + icon: , + content: + }, { name: 'location-types', label: t`Location Types`, @@ -273,7 +294,7 @@ export default function AdminCenter() { { id: 'reporting', label: t`Reporting`, - panelIDs: ['labels', 'reports'] + panelIDs: ['labels', 'reports', 'snippets', 'assets'] }, { id: 'plm', diff --git a/src/frontend/src/tables/settings/AssetTable.tsx b/src/frontend/src/tables/settings/AssetTable.tsx new file mode 100644 index 0000000000..a04ac10af0 --- /dev/null +++ b/src/frontend/src/tables/settings/AssetTable.tsx @@ -0,0 +1,124 @@ +import { AddItemButton } from '@lib/components/AddItemButton'; +import { type RowAction, RowDeleteAction } from '@lib/components/RowActions'; +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 { Alert, Text } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { AttachmentLink } from '../../components/items/AttachmentLink'; +import { + useCreateApiFormModal, + useDeleteApiFormModal +} from '../../hooks/UseForm'; +import { useUserState } from '../../states/UserState'; +import { DescriptionColumn } from '../ColumnRenderers'; +import { InvenTreeTable } from '../InvenTreeTable'; + +export type AssetI = { + pk: number; + asset: string; + description: string; +}; + +export default function AssetTable() { + const table = useTable('report-asset'); + const user = useUserState(); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'asset', + title: t`Asset`, + sortable: false, + switchable: false, + render: (record: AssetI) => { + if (!record.asset) { + return '-'; + } + + return ; + }, + noContext: true + }, + DescriptionColumn({ + accessor: 'description', + sortable: false, + switchable: false + }) + ]; + }, []); + + const [selectedAsset, setSelectedAsset] = useState(-1); + + const rowActions = useCallback( + (record: AssetI): RowAction[] => { + return [ + RowDeleteAction({ + hidden: !user.isStaff(), + onClick: () => { + setSelectedAsset(record.pk); + deleteAsset.open(); + } + }) + ]; + }, + [user] + ); + + const newAssetFields: ApiFormFieldSet = useMemo(() => { + return { + asset: {}, + description: {} + }; + }, []); + + const deleteAsset = useDeleteApiFormModal({ + url: ApiEndpoints.report_asset, + pk: selectedAsset, + title: t`Delete Asset`, + table: table + }); + + const newAsset = useCreateApiFormModal({ + url: ApiEndpoints.report_asset, + title: t`Add Asset`, + fields: newAssetFields, + table: table + }); + + const tableActions: ReactNode[] = useMemo(() => { + return [ + newAsset.open()} + tooltip={t`Add asset`} + hidden={!user.isStaff()} + /> + ]; + }, [user]); + + return ( + <> + {newAsset.modal} + {deleteAsset.modal} + } title={t`Assets`}> + {t`Assets are files (such as images) which can be used when rendering reports and labels.`} + + + + ); +} 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) + }} + /> + + ); +} diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index 71042ba5fa..dbd478c60d 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -254,6 +254,37 @@ test('Settings - Admin', async ({ browser }) => { await loadTab(page, 'Category Parameters'); await loadTab(page, 'Label Templates'); await loadTab(page, 'Report Templates'); + + // Check the "report snippets" panel + await loadTab(page, 'Report Snippets'); + await page + .getByText( + 'Snippets are reusable pieces of HTML content that can be inserted into reports and labels.' + ) + .waitFor(); + + // Launch the dialog to upload a new snippet + await page.getByLabel('action-button-add-snippet').click(); + await page.getByText('Add Snippet', { exact: true }).waitFor(); + await page.locator('input[type="file"]').waitFor({ state: 'attached' }); + await page.getByLabel('text-field-description').waitFor(); + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Check the "report assets" panel + await loadTab(page, 'Report Assets'); + await page + .getByText( + 'Assets are files (such as images) which can be used when rendering reports and labels.' + ) + .waitFor(); + + // Launch the dialog to upload a new asset + await page.getByLabel('action-button-add-asset').click(); + await page.getByText('Add Asset', { exact: true }).waitFor(); + await page.locator('input[type="file"]').waitFor({ state: 'attached' }); + await page.getByLabel('text-field-description').waitFor(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await loadTab(page, 'Plugins'); // Adjust some "location type" items