diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 28e10bde2c..f02c790ece 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 180 +INVENTREE_API_VERSION = 181 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v181 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6541 + - Adds "width" and "height" fields to the LabelTemplate API endpoint + - Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint + v180 - 2024-3-02 : https://github.com/inventree/InvenTree/pull/6463 - Tweaks to API documentation to allow automatic documentation generation diff --git a/InvenTree/label/serializers.py b/InvenTree/label/serializers.py index a38f4bb3ac..ef1f467937 100644 --- a/InvenTree/label/serializers.py +++ b/InvenTree/label/serializers.py @@ -15,7 +15,16 @@ class LabelSerializerBase(InvenTreeModelSerializer): @staticmethod def label_fields(): """Generic serializer fields for a label template.""" - return ['pk', 'name', 'description', 'label', 'filters', 'enabled'] + return [ + 'pk', + 'name', + 'description', + 'label', + 'filters', + 'width', + 'height', + 'enabled', + ] class StockItemLabelSerializer(LabelSerializerBase): diff --git a/InvenTree/report/serializers.py b/InvenTree/report/serializers.py index 362aa3519e..e3632df733 100644 --- a/InvenTree/report/serializers.py +++ b/InvenTree/report/serializers.py @@ -24,7 +24,16 @@ class ReportSerializerBase(InvenTreeModelSerializer): @staticmethod def report_fields(): """Generic serializer fields for a report template.""" - return ['pk', 'name', 'description', 'template', 'filters', 'enabled'] + return [ + 'pk', + 'name', + 'description', + 'template', + 'filters', + 'page_size', + 'landscape', + 'enabled', + ] class TestReportSerializer(ReportSerializerBase): diff --git a/docs/docs/assets/images/report/template-editor.png b/docs/docs/assets/images/report/template-editor.png new file mode 100644 index 0000000000..20b8e8bbfe Binary files /dev/null and b/docs/docs/assets/images/report/template-editor.png differ diff --git a/docs/docs/assets/images/report/template-table.png b/docs/docs/assets/images/report/template-table.png new file mode 100644 index 0000000000..36ec1164fd Binary files /dev/null and b/docs/docs/assets/images/report/template-table.png differ diff --git a/docs/docs/report/template_editor.md b/docs/docs/report/template_editor.md new file mode 100644 index 0000000000..ddc515c64b --- /dev/null +++ b/docs/docs/report/template_editor.md @@ -0,0 +1,33 @@ +--- +title: Template editor +--- + +## Template editor + +The Template Editor is integrated into the Admin center of Platform UI (PUI). It allows users to create and edit label and report templates directly with a side by side preview for a more productive workflow. + +![Template Table](../assets/images/report/template-table.png) + +On the left side (1) are all possible possible template types for labels and reports listed. With the "+" button (2), above the template table (3), new templates for the selected type can be created. To switch to the template editor click on a template. + +### Editing Templates + +The Template Editor provides a split pane interface, with a [code editor (1)](#code-editor-1) on the left and a [preview area (2)](#previewing-templates-2) on the right. The split view can be resized as required with the drag handler in-between. + +![Template Editor](../assets/images/report/template-editor.png) + +#### Code editor (1) + +The code editor supports syntax highlighting and making it easier to write and edit templates. + +#### Previewing Templates (2) + +One of the key features of the Template Editor is the ability to preview the rendered output of the templates. Users can select an InvenTree item (3) to render the template for, allowing them to see how the final output will look in production. + +To render the preview currently **overriding the production template is required** due to API limitations. To do so, first select an item (3) to use for the preview and then press the "Save & Reload preview" button at the top right of the code editor. The first time a confirm dialog opens that you need to confirm that the production template now will be overridden. + +If you don't want to override the template, but just render a preview for a template how it is currently stored in InvenTree, click on the down arrow on the right of that button and select "Reload preview". That will just render the selected item (4) with the InvenTree stored template. + +#### 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. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 0ed500b342..79b0c02a1e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -135,6 +135,7 @@ nav: - Project Codes: order/project_codes.md - Report: - Templates: report/report.md + - Template Editor: report/template_editor.md - Report Types: - Test Reports: report/test.md - Build Order: report/build.md diff --git a/src/frontend/package.json b/src/frontend/package.json index 567f0b6b0b..1811bd8543 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -11,6 +11,7 @@ "compile": "lingui compile --typescript" }, "dependencies": { + "@codemirror/lang-liquid": "^6.2.1", "@emotion/react": "^11.11.1", "@fortawesome/fontawesome-svg-core": "^6.4.2", "@fortawesome/free-regular-svg-icons": "^6.4.2", @@ -30,6 +31,9 @@ "@sentry/react": "^7.74.1", "@tabler/icons-react": "^2.39.0", "@tanstack/react-query": "^5.0.0", + "@uiw/codemirror-theme-vscode": "^4.21.22", + "@uiw/react-codemirror": "^4.21.22", + "@uiw/react-split": "^5.9.3", "axios": "^1.6.0", "dayjs": "^1.11.10", "easymde": "^2.18.0", diff --git a/src/frontend/src/components/buttons/SplitButton.tsx b/src/frontend/src/components/buttons/SplitButton.tsx new file mode 100644 index 0000000000..4844cd3719 --- /dev/null +++ b/src/frontend/src/components/buttons/SplitButton.tsx @@ -0,0 +1,118 @@ +import { + ActionIcon, + Button, + Group, + Menu, + Text, + Tooltip, + createStyles, + useMantineTheme +} from '@mantine/core'; +import { IconChevronDown, TablerIconsProps } from '@tabler/icons-react'; +import { useEffect, useMemo, useState } from 'react'; + +interface SplitButtonOption { + key: string; + name: string; + onClick: () => void; + icon: (props: TablerIconsProps) => JSX.Element; + disabled?: boolean; + tooltip?: string; +} + +interface SplitButtonProps { + options: SplitButtonOption[]; + defaultSelected: string; + selected?: string; + setSelected?: (value: string) => void; + loading?: boolean; +} + +const useStyles = createStyles((theme) => ({ + button: { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + '&::before': { + borderRadius: '0 !important' + } + }, + icon: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + border: 0, + borderLeft: `1px solid ${theme.primaryShade}` + } +})); + +export function SplitButton({ + options, + defaultSelected, + selected, + setSelected, + loading +}: SplitButtonProps) { + const [current, setCurrent] = useState(defaultSelected); + const { classes } = useStyles(); + + useEffect(() => { + setSelected?.(current); + }, [current]); + + useEffect(() => { + if (!selected) return; + setCurrent(selected); + }, [selected]); + + const currentOption = useMemo(() => { + return options.find((option) => option.key === current); + }, [current, options]); + + const theme = useMantineTheme(); + + return ( + + + + + + + + + + + {options.map((option) => ( + { + setCurrent(option.key); + option.onClick(); + }} + disabled={option.disabled} + icon={} + > + + {option.name} + + + ))} + + + + ); +} diff --git a/src/frontend/src/components/details/Details.tsx b/src/frontend/src/components/details/Details.tsx index 02de921a19..8996059747 100644 --- a/src/frontend/src/components/details/Details.tsx +++ b/src/frontend/src/components/details/Details.tsx @@ -17,7 +17,7 @@ import { Suspense, useMemo } from 'react'; import { api } from '../../App'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ModelType } from '../../enums/ModelType'; -import { InvenTreeIcon } from '../../functions/icons'; +import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons'; import { getDetailUrl } from '../../functions/urls'; import { apiUrl } from '../../states/ApiState'; import { useGlobalSettingsState } from '../../states/SettingsState'; @@ -368,7 +368,7 @@ export function DetailsTableField({ justifyContent: 'flex-start' }} > - + {field.label} diff --git a/src/frontend/src/components/details/PartIcons.tsx b/src/frontend/src/components/details/PartIcons.tsx index ccaf7bef73..5a39a85be1 100644 --- a/src/frontend/src/components/details/PartIcons.tsx +++ b/src/frontend/src/components/details/PartIcons.tsx @@ -1,14 +1,14 @@ import { Trans, t } from '@lingui/macro'; import { Badge, Tooltip } from '@mantine/core'; -import { InvenTreeIcon } from '../../functions/icons'; +import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons'; /** * Fetches and wraps an InvenTreeIcon in a flex div * @param icon name of icon * */ -function PartIcon(icon: string) { +function PartIcon(icon: InvenTreeIconType) { return (
diff --git a/src/frontend/src/components/editors/TemplateEditor/CodeEditor/CodeEditor.tsx b/src/frontend/src/components/editors/TemplateEditor/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000000..e69d8136e7 --- /dev/null +++ b/src/frontend/src/components/editors/TemplateEditor/CodeEditor/CodeEditor.tsx @@ -0,0 +1,152 @@ +import { liquid } from '@codemirror/lang-liquid'; +import { vscodeDark } from '@uiw/codemirror-theme-vscode'; +import { EditorView, hoverTooltip, useCodeMirror } from '@uiw/react-codemirror'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; + +import { EditorComponent } from '../TemplateEditor'; + +type Tag = { + label: string; + description: string; + args: string[]; + kwargs: { [name: string]: string }; + returns: string; +}; + +const tags: Tag[] = [ + { + label: 'qrcode', + description: 'Generate a QR code image', + args: ['data'], + kwargs: { + fill_color: 'Fill color (default = black)', + back_color: 'Background color (default = white)', + version: 'Version (default = 1)', + box_size: 'Box size (default = 20)', + border: 'Border width (default = 1)', + format: 'Format (default = PNG)' + }, + returns: 'base64 encoded qr code image data' + }, + { + label: 'barcode', + description: 'Generate a barcode image', + args: ['data'], + kwargs: { + barcode_class: 'Barcode code', + type: 'Barcode type (default = code128)', + format: 'Format (default = PNG)' + }, + returns: 'base64 encoded barcode image data' + } +]; +const tagsMap = Object.fromEntries(tags.map((tag) => [tag.label, tag])); + +const renderHelp = (tag: Tag) => { + const dom = document.createElement('div'); + dom.style.whiteSpace = 'pre-line'; + dom.style.width = '400px'; + dom.style.padding = '5px'; + dom.style.height = '200px'; + dom.style.overflowY = 'scroll'; + dom.style.border = '1px solid #000'; + + const argsStr = tag.args + .map((arg) => ` - ${arg}`) + .join('\n'); + + const kwargsStr = Object.entries(tag.kwargs) + .map( + ([name, description]) => + ` - ${name}: ${description}` + ) + .join('\n'); + + dom.innerHTML = `Name: ${tag.label} +${tag.description} +Arguments: +${argsStr} +Keyword arguments: +${kwargsStr} +Returns: ${tag.returns}`; + + return dom; +}; + +const tooltips = hoverTooltip((view, pos, side) => { + // extract the word at the current hover position into the variable text + let { from, to, text } = view.state.doc.lineAt(pos); + let start = pos, + end = pos; + while (start > from && /\w/.test(text[start - from - 1])) start--; + while (end < to && /\w/.test(text[end - from])) end++; + if ((start == pos && side < 0) || (end == pos && side > 0)) return null; + text = text.slice(start - from, end - from); + + if (!(text in tagsMap)) return null; + return { + pos: start, + end, + above: true, + create(view) { + return { dom: renderHelp(tagsMap[text]) }; + } + }; +}); + +const extensions = [ + liquid({ + tags: Object.values(tagsMap).map((tag) => { + return { + label: tag.label, + type: 'function', + info: () => renderHelp(tag), + boost: 99 + }; + }) + }), + tooltips, + EditorView.theme({ + '&.cm-editor': { + height: '100%' + } + }) +]; + +export const CodeEditorComponent: EditorComponent = forwardRef((props, ref) => { + const editor = useRef(null); + const [code, setCode] = useState(''); + const { setContainer } = useCodeMirror({ + container: editor.current, + extensions, + value: code, + onChange: (value) => setCode(value), + theme: vscodeDark + }); + + useImperativeHandle(ref, () => ({ + setCode: (code) => setCode(code), + getCode: () => code + })); + + useEffect(() => { + if (editor.current) { + setContainer(editor.current); + } + }, [editor.current]); + + return ( +
+
+
+ ); +}); diff --git a/src/frontend/src/components/editors/TemplateEditor/CodeEditor/index.tsx b/src/frontend/src/components/editors/TemplateEditor/CodeEditor/index.tsx new file mode 100644 index 0000000000..f8f15dac63 --- /dev/null +++ b/src/frontend/src/components/editors/TemplateEditor/CodeEditor/index.tsx @@ -0,0 +1,12 @@ +import { t } from '@lingui/macro'; +import { IconCode } from '@tabler/icons-react'; + +import { Editor } from '../TemplateEditor'; +import { CodeEditorComponent } from './CodeEditor'; + +export const CodeEditor: Editor = { + key: 'code', + name: t`Code`, + icon: IconCode, + component: CodeEditorComponent +}; diff --git a/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx b/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx new file mode 100644 index 0000000000..5711756704 --- /dev/null +++ b/src/frontend/src/components/editors/TemplateEditor/PdfPreview/PdfPreview.tsx @@ -0,0 +1,80 @@ +import { Trans } from '@lingui/macro'; +import { forwardRef, useImperativeHandle, useState } from 'react'; + +import { api } from '../../../../App'; +import { PreviewAreaComponent } from '../TemplateEditor'; + +export const PdfPreviewComponent: PreviewAreaComponent = forwardRef( + (props, ref) => { + const [pdfUrl, setPdfUrl] = useState(''); + + useImperativeHandle(ref, () => ({ + updatePreview: async ( + code, + previewItem, + saveTemplate, + { uploadKey, uploadUrl, preview: { itemKey }, templateType } + ) => { + if (saveTemplate) { + const formData = new FormData(); + formData.append(uploadKey, new File([code], 'template.html')); + + const res = await api.patch(uploadUrl, formData); + if (res.status !== 200) { + throw new Error(res.data); + } + } + + // ---- TODO: Fix this when implementing the new API ---- + let preview = await api.get( + uploadUrl + `print/?plugin=inventreelabel&${itemKey}=${previewItem}`, + { + responseType: templateType === 'label' ? 'json' : 'blob', + timeout: 30000, + validateStatus: () => true + } + ); + + if (preview.status !== 200) { + if (preview.data?.non_field_errors) { + throw new Error(preview.data?.non_field_errors.join(', ')); + } + + throw new Error(preview.data); + } + + if (templateType === 'label') { + preview = await api.get(preview.data.file, { + responseType: 'blob' + }); + } + // ---- + let pdf = new Blob([preview.data], { + type: preview.headers['content-type'] + }); + let srcUrl = URL.createObjectURL(pdf); + + setPdfUrl(srcUrl + '#view=fitH'); + } + })); + + return ( + <> + {!pdfUrl && ( +
+ Preview not available, click "Reload Preview". +
+ )} + {pdfUrl &&