2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-05 06:32:55 +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:
Oliver
2026-07-03 19:04:50 +10:00
committed by GitHub
parent 15c64d6695
commit 4cb29f37c6
9 changed files with 634 additions and 141 deletions
@@ -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,29 +354,39 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
})}
<Group justify='right' style={{ flex: '1' }} wrap='nowrap'>
<SplitButton
loading={isPreviewLoading}
defaultSelected='preview_save'
name='preview-options'
options={[
{
key: 'preview',
name: t`Reload preview`,
tooltip: t`Use the currently stored template from the server`,
icon: IconRefresh,
onClick: () => 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 ? (
<SplitButton
loading={isPreviewLoading}
defaultSelected='preview_save'
name='preview-options'
options={[
{
key: 'preview',
name: t`Reload preview`,
tooltip: t`Use the currently stored template from the server`,
icon: IconRefresh,
onClick: () => 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
}
]}
/>
) : (
<Button
leftSection={<IconDeviceFloppy size={18} />}
loading={isSaving}
onClick={() => saveTemplate()}
>
{t`Save`}
</Button>
)}
</Group>
</Tabs.List>
@@ -342,100 +405,102 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
))}
</Tabs>
<Tabs
value={previewValue}
onChange={setPreviewValue}
keepMounted={false}
style={{
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
{hasPreview && (
<Tabs
value={previewValue}
onChange={setPreviewValue}
keepMounted={false}
style={{
minWidth: '100%',
paddingBottom: '10px',
paddingTop: '10px'
minWidth: '200px',
width: '50%',
display: 'flex',
flexDirection: 'column'
}}
>
<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>
<Tabs.List>
{previewAreas.map((PreviewArea) => (
<Tabs.Tab
key={PreviewArea.key}
value={PreviewArea.key}
leftSection={PreviewArea.icon}
>
{PreviewArea.name}
</Tabs.Tab>
))}
</Tabs.List>
{previewAreas.map((PreviewArea) => (
<Tabs.Panel
key={PreviewArea.key}
value={PreviewArea.key}
<div
style={{
display: 'flex',
flex: previewValue === PreviewArea.key ? 1 : 0
minWidth: '100%',
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={{
height: '100%',
position: 'relative',
display: 'flex',
flex: '1'
flex: previewValue === PreviewArea.key ? 1 : 0
}}
>
{/* @ts-ignore-next-line */}
<PreviewArea.component ref={previewRef} />
<div
style={{
height: '100%',
position: 'relative',
display: 'flex',
flex: '1'
}}
>
{/* @ts-ignore-next-line */}
<PreviewArea.component ref={previewRef} />
{renderingErrors && (
<Overlay color='red' center blur={0.2}>
<CloseButton
onClick={() => setRenderingErrors(null)}
style={{
position: 'absolute',
top: '10px',
right: '10px',
color: '#fff'
}}
variant='filled'
/>
<Alert
color='red'
icon={<IconExclamationCircle />}
title={t`Error rendering template`}
mx='10px'
>
<List>
{renderingErrors.map((error, index) => (
<ListItem key={index}>{error}</ListItem>
))}
</List>
</Alert>
</Overlay>
)}
</div>
</Tabs.Panel>
))}
</Tabs>
{renderingErrors && (
<Overlay color='red' center blur={0.2}>
<CloseButton
onClick={() => setRenderingErrors(null)}
style={{
position: 'absolute',
top: '10px',
right: '10px',
color: '#fff'
}}
variant='filled'
/>
<Alert
color='red'
icon={<IconExclamationCircle />}
title={t`Error rendering template`}
mx='10px'
>
<List>
{renderingErrors.map((error, index) => (
<ListItem key={index}>{error}</ListItem>
))}
</List>
</Alert>
</Overlay>
)}
</div>
</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)
}}
/>
</>
);
}
+31
View File
@@ -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