mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-04 14:10:52 +00:00
[UI] Snippet editor (#12299)
* Implement admin editor for report snippets * Report asset management * Add playwright test for report snippets * Add playwright test for "Report Assets" panel * Updated docs
This commit is contained in:
+17
-13
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -33,7 +33,7 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
|
||||
}
|
||||
|
||||
let preview = await api.post(
|
||||
printingUrl,
|
||||
printingUrl!,
|
||||
{
|
||||
items: [previewItem],
|
||||
template: template.pk
|
||||
|
||||
@@ -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<TemplateEditorProps>) {
|
||||
const { templateUrl, editors, previewAreas, template } = props;
|
||||
const {
|
||||
templateUrl,
|
||||
editors,
|
||||
previewAreas,
|
||||
template,
|
||||
fileField = 'template'
|
||||
} = props;
|
||||
const editorRef = useRef<EditorRef | null>(null);
|
||||
const previewRef = useRef<PreviewAreaRef | null>(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<string>('');
|
||||
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
||||
const [previewValue, setPreviewValue] = useState<null | string>(
|
||||
previewAreas[0].key
|
||||
previewAreas[0]?.key ?? null
|
||||
);
|
||||
|
||||
const codeRef = useRef<string | undefined>(undefined);
|
||||
@@ -131,12 +143,12 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
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<TemplateEditorProps>) {
|
||||
})
|
||||
.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<TemplateEditorProps>) {
|
||||
[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<TemplateEditorProps>) {
|
||||
}, [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 (
|
||||
<Boundary label='TemplateEditor'>
|
||||
<Stack style={{ height: '100%', flex: '1' }}>
|
||||
<Split visible style={{ flex: 1 }}>
|
||||
<Split visible={hasPreview} style={{ flex: 1 }}>
|
||||
<Tabs
|
||||
value={editorValue}
|
||||
onChange={async (v) => {
|
||||
@@ -282,7 +335,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
keepMounted={false}
|
||||
style={{
|
||||
minWidth: '300px',
|
||||
width: '50%',
|
||||
width: hasPreview ? '50%' : '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
@@ -301,6 +354,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
})}
|
||||
|
||||
<Group justify='right' style={{ flex: '1' }} wrap='nowrap'>
|
||||
{hasPreview ? (
|
||||
<SplitButton
|
||||
loading={isPreviewLoading}
|
||||
defaultSelected='preview_save'
|
||||
@@ -324,6 +378,15 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
}
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
leftSection={<IconDeviceFloppy size={18} />}
|
||||
loading={isSaving}
|
||||
onClick={() => saveTemplate()}
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -342,6 +405,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{hasPreview && (
|
||||
<Tabs
|
||||
value={previewValue}
|
||||
onChange={setPreviewValue}
|
||||
@@ -436,6 +500,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</Split>
|
||||
</Stack>
|
||||
</Boundary>
|
||||
|
||||
@@ -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: <IconReport />,
|
||||
content: <ReportTemplatePanel />
|
||||
},
|
||||
{
|
||||
name: 'snippets',
|
||||
label: t`Report Snippets`,
|
||||
icon: <IconFileCode />,
|
||||
content: <SnippetTable />
|
||||
},
|
||||
{
|
||||
name: 'assets',
|
||||
label: t`Report Assets`,
|
||||
icon: <IconPhoto />,
|
||||
content: <AssetTable />
|
||||
},
|
||||
{
|
||||
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',
|
||||
|
||||
@@ -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<AssetI>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'asset',
|
||||
title: t`Asset`,
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
render: (record: AssetI) => {
|
||||
if (!record.asset) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return <AttachmentLink attachment={record.asset} />;
|
||||
},
|
||||
noContext: true
|
||||
},
|
||||
DescriptionColumn({
|
||||
accessor: 'description',
|
||||
sortable: false,
|
||||
switchable: false
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedAsset, setSelectedAsset] = useState<number>(-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 [
|
||||
<AddItemButton
|
||||
key='add-asset'
|
||||
onClick={() => newAsset.open()}
|
||||
tooltip={t`Add asset`}
|
||||
hidden={!user.isStaff()}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newAsset.modal}
|
||||
{deleteAsset.modal}
|
||||
<Alert icon={<IconInfoCircle />} title={t`Assets`}>
|
||||
<Text>{t`Assets are files (such as images) which can be used when rendering reports and labels.`}</Text>
|
||||
</Alert>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.report_asset)}
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
enableSearch: false,
|
||||
enableFilters: false
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<SnippetI>({
|
||||
endpoint: ApiEndpoints.report_snippet,
|
||||
hasPrimaryKey: true,
|
||||
pk: id
|
||||
});
|
||||
|
||||
const filename = useMemo(
|
||||
() => snippet?.snippet?.split('/').pop() ?? '',
|
||||
[snippet]
|
||||
);
|
||||
|
||||
if (isFetching) {
|
||||
return <LoadingOverlay visible={true} />;
|
||||
}
|
||||
|
||||
if (error || !snippet) {
|
||||
return (
|
||||
<Text>
|
||||
{(error as any)?.response?.status === 404 ? (
|
||||
<Trans>Snippet not found</Trans>
|
||||
) : (
|
||||
<Trans>An error occurred while fetching snippet details</Trans>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap='xs' style={{ display: 'flex', flex: '1' }}>
|
||||
<Group justify='left'>
|
||||
<Title order={4}>{filename}</Title>
|
||||
</Group>
|
||||
|
||||
<TemplateEditor
|
||||
templateUrl={apiUrl(ApiEndpoints.report_snippet, id)}
|
||||
template={snippet as unknown as TemplateI}
|
||||
fileField='snippet'
|
||||
editors={[CodeEditor]}
|
||||
previewAreas={[]}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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<SnippetI>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'snippet',
|
||||
title: t`Snippet`,
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
render: (record: SnippetI) => {
|
||||
if (!record.snippet) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return <AttachmentLink attachment={record.snippet} />;
|
||||
},
|
||||
noContext: true
|
||||
},
|
||||
DescriptionColumn({
|
||||
accessor: 'description',
|
||||
sortable: false,
|
||||
switchable: false
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<number>(-1);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: SnippetI): RowAction[] => {
|
||||
return [
|
||||
{
|
||||
title: t`Modify`,
|
||||
tooltip: t`Modify snippet file`,
|
||||
icon: <IconFileCode />,
|
||||
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 [
|
||||
<AddItemButton
|
||||
key='add-snippet'
|
||||
onClick={() => newSnippet.open()}
|
||||
tooltip={t`Add snippet`}
|
||||
hidden={!user.isStaff()}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newSnippet.modal}
|
||||
{editSnippet.modal}
|
||||
{deleteSnippet.modal}
|
||||
<DetailDrawer
|
||||
title={t`Edit Snippet`}
|
||||
size={'90%'}
|
||||
closeOnEscape={false}
|
||||
renderContent={(id) => {
|
||||
return <SnippetDrawer id={id ?? ''} />;
|
||||
}}
|
||||
/>
|
||||
<Alert icon={<IconInfoCircle />} title={t`Snippets`}>
|
||||
<Text>{t`Snippets are reusable pieces of HTML content that can be inserted into reports and labels.`}</Text>
|
||||
</Alert>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.report_snippet)}
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
enableSearch: false,
|
||||
enableFilters: false,
|
||||
onRowClick: (record) => openDetailDrawer(record.pk)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user