mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-04 06:00:38 +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
|
## 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"
|
!!! 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"
|
!!! info "Backend Admin Interface"
|
||||||
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.
|
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.
|
||||||
|
|
||||||
!!! 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.
|
|
||||||
|
|
||||||
### Name and Description
|
### Name and Description
|
||||||
|
|
||||||
@@ -147,7 +147,9 @@ Setting the *Debug Mode* option renders the template as raw HTML instead of PDF,
|
|||||||
|
|
||||||
## Report Assets
|
## 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
|
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.
|
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"
|
!!! 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
|
## 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.
|
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:
|
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 |
|
| `data:` URIs | Always permitted — self-contained, no network access |
|
||||||
| `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint |
|
| `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 |
|
| Any other scheme | Always blocked |
|
||||||
|
|
||||||
HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address.
|
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
|
## 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:
|
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
|
## 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
|
### 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
|
#### 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 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(
|
let preview = await api.post(
|
||||||
printingUrl,
|
printingUrl!,
|
||||||
{
|
{
|
||||||
items: [previewItem],
|
items: [previewItem],
|
||||||
template: template.pk
|
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 { t } from '@lingui/core/macro';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Button,
|
||||||
CloseButton,
|
CloseButton,
|
||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
@@ -24,11 +29,6 @@ import {
|
|||||||
import Split from '@uiw/react-split';
|
import Split from '@uiw/react-split';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } 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 { api } from '../../../App';
|
||||||
import type { TemplateI } from '../../../tables/settings/TemplateTable';
|
import type { TemplateI } from '../../../tables/settings/TemplateTable';
|
||||||
import { SplitButton } from '../../buttons/SplitButton';
|
import { SplitButton } from '../../buttons/SplitButton';
|
||||||
@@ -78,26 +78,38 @@ export type PreviewArea = {
|
|||||||
|
|
||||||
export type TemplateEditorProps = {
|
export type TemplateEditorProps = {
|
||||||
templateUrl: string;
|
templateUrl: string;
|
||||||
printingUrl: string;
|
printingUrl?: string;
|
||||||
editors: Editor[];
|
editors: Editor[];
|
||||||
previewAreas: PreviewArea[];
|
previewAreas: PreviewArea[];
|
||||||
template: TemplateI;
|
template: TemplateI;
|
||||||
|
// Name of the file field on the template model (e.g. 'template' or 'snippet')
|
||||||
|
fileField?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
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 editorRef = useRef<EditorRef | null>(null);
|
||||||
const previewRef = useRef<PreviewAreaRef | 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 [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
|
||||||
|
|
||||||
const [previewItem, setPreviewItem] = useState<string>('');
|
const [previewItem, setPreviewItem] = useState<string>('');
|
||||||
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
|
const [renderingErrors, setRenderingErrors] = useState<string[] | null>(null);
|
||||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
|
||||||
const [previewValue, setPreviewValue] = useState<null | string>(
|
const [previewValue, setPreviewValue] = useState<null | string>(
|
||||||
previewAreas[0].key
|
previewAreas[0]?.key ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
const codeRef = useRef<string | undefined>(undefined);
|
const codeRef = useRef<string | undefined>(undefined);
|
||||||
@@ -131,12 +143,12 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
if (!templateUrl) return;
|
if (!templateUrl) return;
|
||||||
|
|
||||||
api.get(templateUrl).then((response: any) => {
|
api.get(templateUrl).then((response: any) => {
|
||||||
if (response.data?.template) {
|
if (response.data?.[fileField]) {
|
||||||
// Fetch the raw template file from the server
|
// Fetch the raw template file from the server
|
||||||
// Request that it is provided without any caching,
|
// Request that it is provided without any caching,
|
||||||
// to ensure that we always get the latest version
|
// to ensure that we always get the latest version
|
||||||
api
|
api
|
||||||
.get(response.data.template, {
|
.get(response.data[fileField], {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
Pragma: 'no-cache',
|
Pragma: 'no-cache',
|
||||||
@@ -149,7 +161,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.error(
|
console.error(
|
||||||
`ERR: Could not load template from ${response.data.template}`
|
`ERR: Could not load template from ${response.data[fileField]}`
|
||||||
);
|
);
|
||||||
codeRef.current = undefined;
|
codeRef.current = undefined;
|
||||||
hideNotification('template-load-error');
|
hideNotification('template-load-error');
|
||||||
@@ -244,6 +256,45 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
[previewItem]
|
[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(
|
const previewApiUrl = useMemo(
|
||||||
() =>
|
() =>
|
||||||
ModelInformationDict[template.model_type ?? ModelType.stockitem]
|
ModelInformationDict[template.model_type ?? ModelType.stockitem]
|
||||||
@@ -261,18 +312,20 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
}, [template]);
|
}, [template]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!hasPreview) return;
|
||||||
|
|
||||||
api
|
api
|
||||||
.get(apiUrl(previewApiUrl), { params: { limit: 1, ...templateFilters } })
|
.get(apiUrl(previewApiUrl), { params: { limit: 1, ...templateFilters } })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.data.results.length === 0) return;
|
if (res.data.results.length === 0) return;
|
||||||
setPreviewItem(res.data.results[0].pk);
|
setPreviewItem(res.data.results[0].pk);
|
||||||
});
|
});
|
||||||
}, [previewApiUrl, templateFilters]);
|
}, [hasPreview, previewApiUrl, templateFilters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Boundary label='TemplateEditor'>
|
<Boundary label='TemplateEditor'>
|
||||||
<Stack style={{ height: '100%', flex: '1' }}>
|
<Stack style={{ height: '100%', flex: '1' }}>
|
||||||
<Split visible style={{ flex: 1 }}>
|
<Split visible={hasPreview} style={{ flex: 1 }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={editorValue}
|
value={editorValue}
|
||||||
onChange={async (v) => {
|
onChange={async (v) => {
|
||||||
@@ -282,7 +335,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
minWidth: '300px',
|
minWidth: '300px',
|
||||||
width: '50%',
|
width: hasPreview ? '50%' : '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}
|
}}
|
||||||
@@ -301,29 +354,39 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<Group justify='right' style={{ flex: '1' }} wrap='nowrap'>
|
<Group justify='right' style={{ flex: '1' }} wrap='nowrap'>
|
||||||
<SplitButton
|
{hasPreview ? (
|
||||||
loading={isPreviewLoading}
|
<SplitButton
|
||||||
defaultSelected='preview_save'
|
loading={isPreviewLoading}
|
||||||
name='preview-options'
|
defaultSelected='preview_save'
|
||||||
options={[
|
name='preview-options'
|
||||||
{
|
options={[
|
||||||
key: 'preview',
|
{
|
||||||
name: t`Reload preview`,
|
key: 'preview',
|
||||||
tooltip: t`Use the currently stored template from the server`,
|
name: t`Reload preview`,
|
||||||
icon: IconRefresh,
|
tooltip: t`Use the currently stored template from the server`,
|
||||||
onClick: () => updatePreview(true, false),
|
icon: IconRefresh,
|
||||||
disabled: !previewItem || isPreviewLoading
|
onClick: () => updatePreview(true, false),
|
||||||
},
|
disabled: !previewItem || isPreviewLoading
|
||||||
{
|
},
|
||||||
key: 'preview_save',
|
{
|
||||||
name: t`Save & Reload Preview`,
|
key: 'preview_save',
|
||||||
tooltip: t`Save the current template and reload the preview`,
|
name: t`Save & Reload Preview`,
|
||||||
icon: IconDeviceFloppy,
|
tooltip: t`Save the current template and reload the preview`,
|
||||||
onClick: () => updatePreview(hasSaveConfirmed),
|
icon: IconDeviceFloppy,
|
||||||
disabled: !previewItem || isPreviewLoading
|
onClick: () => updatePreview(hasSaveConfirmed),
|
||||||
}
|
disabled: !previewItem || isPreviewLoading
|
||||||
]}
|
}
|
||||||
/>
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
leftSection={<IconDeviceFloppy size={18} />}
|
||||||
|
loading={isSaving}
|
||||||
|
onClick={() => saveTemplate()}
|
||||||
|
>
|
||||||
|
{t`Save`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
@@ -342,100 +405,102 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
|||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Tabs
|
{hasPreview && (
|
||||||
value={previewValue}
|
<Tabs
|
||||||
onChange={setPreviewValue}
|
value={previewValue}
|
||||||
keepMounted={false}
|
onChange={setPreviewValue}
|
||||||
style={{
|
keepMounted={false}
|
||||||
minWidth: '200px',
|
|
||||||
width: '50%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tabs.List>
|
|
||||||
{previewAreas.map((PreviewArea) => (
|
|
||||||
<Tabs.Tab
|
|
||||||
key={PreviewArea.key}
|
|
||||||
value={PreviewArea.key}
|
|
||||||
leftSection={PreviewArea.icon}
|
|
||||||
>
|
|
||||||
{PreviewArea.name}
|
|
||||||
</Tabs.Tab>
|
|
||||||
))}
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
style={{
|
||||||
minWidth: '100%',
|
minWidth: '200px',
|
||||||
paddingBottom: '10px',
|
width: '50%',
|
||||||
paddingTop: '10px'
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StandaloneField
|
<Tabs.List>
|
||||||
fieldDefinition={{
|
{previewAreas.map((PreviewArea) => (
|
||||||
field_type: 'related field',
|
<Tabs.Tab
|
||||||
api_url: apiUrl(previewApiUrl),
|
key={PreviewArea.key}
|
||||||
description: '',
|
value={PreviewArea.key}
|
||||||
label: t`Select instance to preview`,
|
leftSection={PreviewArea.icon}
|
||||||
model: template.model_type,
|
>
|
||||||
value: previewItem,
|
{PreviewArea.name}
|
||||||
filters: templateFilters,
|
</Tabs.Tab>
|
||||||
onValueChange: (value) => setPreviewItem(value)
|
))}
|
||||||
}}
|
</Tabs.List>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewAreas.map((PreviewArea) => (
|
<div
|
||||||
<Tabs.Panel
|
|
||||||
key={PreviewArea.key}
|
|
||||||
value={PreviewArea.key}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
minWidth: '100%',
|
||||||
flex: previewValue === PreviewArea.key ? 1 : 0
|
paddingBottom: '10px',
|
||||||
|
paddingTop: '10px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<StandaloneField
|
||||||
|
fieldDefinition={{
|
||||||
|
field_type: 'related field',
|
||||||
|
api_url: apiUrl(previewApiUrl),
|
||||||
|
description: '',
|
||||||
|
label: t`Select instance to preview`,
|
||||||
|
model: template.model_type,
|
||||||
|
value: previewItem,
|
||||||
|
filters: templateFilters,
|
||||||
|
onValueChange: (value) => setPreviewItem(value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewAreas.map((PreviewArea) => (
|
||||||
|
<Tabs.Panel
|
||||||
|
key={PreviewArea.key}
|
||||||
|
value={PreviewArea.key}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flex: '1'
|
flex: previewValue === PreviewArea.key ? 1 : 0
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore-next-line */}
|
<div
|
||||||
<PreviewArea.component ref={previewRef} />
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: '1'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* @ts-ignore-next-line */}
|
||||||
|
<PreviewArea.component ref={previewRef} />
|
||||||
|
|
||||||
{renderingErrors && (
|
{renderingErrors && (
|
||||||
<Overlay color='red' center blur={0.2}>
|
<Overlay color='red' center blur={0.2}>
|
||||||
<CloseButton
|
<CloseButton
|
||||||
onClick={() => setRenderingErrors(null)}
|
onClick={() => setRenderingErrors(null)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '10px',
|
top: '10px',
|
||||||
right: '10px',
|
right: '10px',
|
||||||
color: '#fff'
|
color: '#fff'
|
||||||
}}
|
}}
|
||||||
variant='filled'
|
variant='filled'
|
||||||
/>
|
/>
|
||||||
<Alert
|
<Alert
|
||||||
color='red'
|
color='red'
|
||||||
icon={<IconExclamationCircle />}
|
icon={<IconExclamationCircle />}
|
||||||
title={t`Error rendering template`}
|
title={t`Error rendering template`}
|
||||||
mx='10px'
|
mx='10px'
|
||||||
>
|
>
|
||||||
<List>
|
<List>
|
||||||
{renderingErrors.map((error, index) => (
|
{renderingErrors.map((error, index) => (
|
||||||
<ListItem key={index}>{error}</ListItem>
|
<ListItem key={index}>{error}</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
)}
|
||||||
</Split>
|
</Split>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Boundary>
|
</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 { t } from '@lingui/core/macro';
|
||||||
import { Stack } from '@mantine/core';
|
import { Stack } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
@@ -5,6 +8,7 @@ import {
|
|||||||
IconCpu,
|
IconCpu,
|
||||||
IconDevicesPc,
|
IconDevicesPc,
|
||||||
IconExclamationCircle,
|
IconExclamationCircle,
|
||||||
|
IconFileCode,
|
||||||
IconFileDownload,
|
IconFileDownload,
|
||||||
IconFileUpload,
|
IconFileUpload,
|
||||||
IconHome,
|
IconHome,
|
||||||
@@ -12,6 +16,7 @@ import {
|
|||||||
IconListDetails,
|
IconListDetails,
|
||||||
IconMail,
|
IconMail,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
|
IconPhoto,
|
||||||
IconPlugConnected,
|
IconPlugConnected,
|
||||||
IconQrcode,
|
IconQrcode,
|
||||||
IconReport,
|
IconReport,
|
||||||
@@ -21,10 +26,6 @@ import {
|
|||||||
IconUsersGroup
|
IconUsersGroup
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { lazy, useMemo } from '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 PermissionDenied from '../../../../components/errors/PermissionDenied';
|
||||||
import PageTitle from '../../../../components/nav/PageTitle';
|
import PageTitle from '../../../../components/nav/PageTitle';
|
||||||
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
|
import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
|
||||||
@@ -103,6 +104,14 @@ const LocationTypesTable = Loadable(
|
|||||||
lazy(() => import('../../../../tables/stock/LocationTypesTable'))
|
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() {
|
export default function AdminCenter() {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
@@ -221,6 +230,18 @@ export default function AdminCenter() {
|
|||||||
icon: <IconReport />,
|
icon: <IconReport />,
|
||||||
content: <ReportTemplatePanel />
|
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',
|
name: 'location-types',
|
||||||
label: t`Location Types`,
|
label: t`Location Types`,
|
||||||
@@ -273,7 +294,7 @@ export default function AdminCenter() {
|
|||||||
{
|
{
|
||||||
id: 'reporting',
|
id: 'reporting',
|
||||||
label: t`Reporting`,
|
label: t`Reporting`,
|
||||||
panelIDs: ['labels', 'reports']
|
panelIDs: ['labels', 'reports', 'snippets', 'assets']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'plm',
|
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, 'Category Parameters');
|
||||||
await loadTab(page, 'Label Templates');
|
await loadTab(page, 'Label Templates');
|
||||||
await loadTab(page, 'Report 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');
|
await loadTab(page, 'Plugins');
|
||||||
|
|
||||||
// Adjust some "location type" items
|
// Adjust some "location type" items
|
||||||
|
|||||||
Reference in New Issue
Block a user