mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +00:00 
			
		
		
		
	PUI Template editor (#6541)
* Added first POC for label editor * Added preview item selection * Split code * Fix import * Use liquid lang and added custom tooltips * Auto load first item for preview and add BOM part assembly filter * Make the save&reload action more obvious * Make save optional and use server stored template * Fix icons and inherit model url * Add label/report extra fields to serializer and default templates * Bump api version to v176 * Remove generic and pass template to editor * Added error overlay * Moved default tempaltes in default folder * Only show detail drawer back button if necessary * Rename action dropdown disabled to hidden and add loading disabled to template editor * Fix types * Add icons to editor/preview tabs * Add draggable split pane and make editors use full height * Add SplitButton component * add code editor tag description * fix related model field if empty string * remove debug console.log * move code editor/pdf preview into their own folder * Update api_version.py * add support for multiple editors * fix template editor error handleing while loading/saving code * add documentation for the template editor
This commit is contained in:
		@@ -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",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										118
									
								
								src/frontend/src/components/buttons/SplitButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/frontend/src/components/buttons/SplitButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -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<string>(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 (
 | 
			
		||||
    <Group noWrap style={{ gap: 0 }}>
 | 
			
		||||
      <Button
 | 
			
		||||
        onClick={currentOption?.onClick}
 | 
			
		||||
        disabled={loading ? false : currentOption?.disabled}
 | 
			
		||||
        className={classes.button}
 | 
			
		||||
        loading={loading}
 | 
			
		||||
      >
 | 
			
		||||
        {currentOption?.name}
 | 
			
		||||
      </Button>
 | 
			
		||||
      <Menu
 | 
			
		||||
        transitionProps={{ transition: 'pop' }}
 | 
			
		||||
        position="bottom-end"
 | 
			
		||||
        withinPortal
 | 
			
		||||
      >
 | 
			
		||||
        <Menu.Target>
 | 
			
		||||
          <ActionIcon
 | 
			
		||||
            variant="filled"
 | 
			
		||||
            color={theme.primaryColor}
 | 
			
		||||
            size={36}
 | 
			
		||||
            className={classes.icon}
 | 
			
		||||
          >
 | 
			
		||||
            <IconChevronDown size={16} />
 | 
			
		||||
          </ActionIcon>
 | 
			
		||||
        </Menu.Target>
 | 
			
		||||
 | 
			
		||||
        <Menu.Dropdown>
 | 
			
		||||
          {options.map((option) => (
 | 
			
		||||
            <Menu.Item
 | 
			
		||||
              key={option.key}
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                setCurrent(option.key);
 | 
			
		||||
                option.onClick();
 | 
			
		||||
              }}
 | 
			
		||||
              disabled={option.disabled}
 | 
			
		||||
              icon={<option.icon />}
 | 
			
		||||
            >
 | 
			
		||||
              <Tooltip label={option.tooltip} position="right">
 | 
			
		||||
                <Text>{option.name}</Text>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </Menu.Item>
 | 
			
		||||
          ))}
 | 
			
		||||
        </Menu.Dropdown>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    </Group>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -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'
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <InvenTreeIcon icon={field.icon ?? field.name} />
 | 
			
		||||
        <InvenTreeIcon icon={(field.icon ?? field.name) as InvenTreeIconType} />
 | 
			
		||||
      </td>
 | 
			
		||||
      <td>
 | 
			
		||||
        <Text>{field.label}</Text>
 | 
			
		||||
 
 | 
			
		||||
@@ -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 (
 | 
			
		||||
    <div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
 | 
			
		||||
      <InvenTreeIcon icon={icon} />
 | 
			
		||||
 
 | 
			
		||||
@@ -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) => `  - <code style="color: #9cdcfe;">${arg}</code>`)
 | 
			
		||||
    .join('\n');
 | 
			
		||||
 | 
			
		||||
  const kwargsStr = Object.entries(tag.kwargs)
 | 
			
		||||
    .map(
 | 
			
		||||
      ([name, description]) =>
 | 
			
		||||
        `  - <code style="color: #9cdcfe;">${name}</code>: <small>${description}</small>`
 | 
			
		||||
    )
 | 
			
		||||
    .join('\n');
 | 
			
		||||
 | 
			
		||||
  dom.innerHTML = `Name: <code style="color: #4ec9b0;">${tag.label}</code>
 | 
			
		||||
<small>${tag.description}</small>
 | 
			
		||||
Arguments:
 | 
			
		||||
${argsStr}
 | 
			
		||||
Keyword arguments:
 | 
			
		||||
${kwargsStr}
 | 
			
		||||
Returns: <small>${tag.returns}</small>`;
 | 
			
		||||
 | 
			
		||||
  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<HTMLDivElement | null>(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 (
 | 
			
		||||
    <div style={{ display: 'flex', flex: '1', position: 'relative' }}>
 | 
			
		||||
      <div
 | 
			
		||||
        style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}
 | 
			
		||||
        ref={editor}
 | 
			
		||||
      ></div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
@@ -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
 | 
			
		||||
};
 | 
			
		||||
@@ -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 && (
 | 
			
		||||
          <div
 | 
			
		||||
            style={{
 | 
			
		||||
              display: 'flex',
 | 
			
		||||
              justifyContent: 'center',
 | 
			
		||||
              alignItems: 'center',
 | 
			
		||||
              height: '100%',
 | 
			
		||||
              width: '100%'
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Trans>Preview not available, click "Reload Preview".</Trans>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {pdfUrl && <iframe src={pdfUrl} width="100%" height="100%" />}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
@@ -0,0 +1,12 @@
 | 
			
		||||
import { t } from '@lingui/macro';
 | 
			
		||||
import { IconFileTypePdf } from '@tabler/icons-react';
 | 
			
		||||
 | 
			
		||||
import { PreviewArea } from '../TemplateEditor';
 | 
			
		||||
import { PdfPreviewComponent } from './PdfPreview';
 | 
			
		||||
 | 
			
		||||
export const PdfPreview: PreviewArea = {
 | 
			
		||||
  key: 'pdf-preview',
 | 
			
		||||
  name: t`PDF Preview`,
 | 
			
		||||
  icon: IconFileTypePdf,
 | 
			
		||||
  component: PdfPreviewComponent
 | 
			
		||||
};
 | 
			
		||||
@@ -0,0 +1,380 @@
 | 
			
		||||
import { t } from '@lingui/macro';
 | 
			
		||||
import {
 | 
			
		||||
  Alert,
 | 
			
		||||
  CloseButton,
 | 
			
		||||
  Code,
 | 
			
		||||
  Group,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  Stack,
 | 
			
		||||
  Tabs
 | 
			
		||||
} from '@mantine/core';
 | 
			
		||||
import { openConfirmModal } from '@mantine/modals';
 | 
			
		||||
import { showNotification } from '@mantine/notifications';
 | 
			
		||||
import {
 | 
			
		||||
  IconAlertTriangle,
 | 
			
		||||
  IconDeviceFloppy,
 | 
			
		||||
  IconExclamationCircle,
 | 
			
		||||
  IconRefresh,
 | 
			
		||||
  TablerIconsProps
 | 
			
		||||
} from '@tabler/icons-react';
 | 
			
		||||
import Split from '@uiw/react-split';
 | 
			
		||||
import React, {
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useRef,
 | 
			
		||||
  useState
 | 
			
		||||
} from 'react';
 | 
			
		||||
 | 
			
		||||
import { api } from '../../../App';
 | 
			
		||||
import { ModelType } from '../../../enums/ModelType';
 | 
			
		||||
import { apiUrl } from '../../../states/ApiState';
 | 
			
		||||
import { TemplateI } from '../../../tables/settings/TemplateTable';
 | 
			
		||||
import { SplitButton } from '../../buttons/SplitButton';
 | 
			
		||||
import { StandaloneField } from '../../forms/StandaloneField';
 | 
			
		||||
import { ModelInformationDict } from '../../render/ModelType';
 | 
			
		||||
 | 
			
		||||
type EditorProps = {
 | 
			
		||||
  template: TemplateI;
 | 
			
		||||
};
 | 
			
		||||
type EditorRef = {
 | 
			
		||||
  setCode: (code: string) => void | Promise<void>;
 | 
			
		||||
  getCode: () => (string | undefined) | Promise<string | undefined>;
 | 
			
		||||
};
 | 
			
		||||
export type EditorComponent = React.ForwardRefExoticComponent<
 | 
			
		||||
  EditorProps & React.RefAttributes<EditorRef>
 | 
			
		||||
>;
 | 
			
		||||
export type Editor = {
 | 
			
		||||
  key: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  icon: (props: TablerIconsProps) => React.JSX.Element;
 | 
			
		||||
  component: EditorComponent;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type PreviewAreaProps = {};
 | 
			
		||||
type PreviewAreaRef = {
 | 
			
		||||
  updatePreview: (
 | 
			
		||||
    code: string,
 | 
			
		||||
    previewItem: string,
 | 
			
		||||
    saveTemplate: boolean,
 | 
			
		||||
    templateEditorProps: TemplateEditorProps
 | 
			
		||||
  ) => void | Promise<void>;
 | 
			
		||||
};
 | 
			
		||||
export type PreviewAreaComponent = React.ForwardRefExoticComponent<
 | 
			
		||||
  PreviewAreaProps & React.RefAttributes<PreviewAreaRef>
 | 
			
		||||
>;
 | 
			
		||||
export type PreviewArea = {
 | 
			
		||||
  key: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  icon: (props: TablerIconsProps) => React.JSX.Element;
 | 
			
		||||
  component: PreviewAreaComponent;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type TemplatePreviewProps = {
 | 
			
		||||
  itemKey: string;
 | 
			
		||||
  model: ModelType;
 | 
			
		||||
  filters?: Record<string, any>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TemplateEditorProps = {
 | 
			
		||||
  downloadUrl: string;
 | 
			
		||||
  uploadUrl: string;
 | 
			
		||||
  uploadKey: string;
 | 
			
		||||
  preview: TemplatePreviewProps;
 | 
			
		||||
  templateType: 'label' | 'report';
 | 
			
		||||
  editors: Editor[];
 | 
			
		||||
  previewAreas: PreviewArea[];
 | 
			
		||||
  template: TemplateI;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function TemplateEditor(props: TemplateEditorProps) {
 | 
			
		||||
  const { downloadUrl, editors, previewAreas, preview } = props;
 | 
			
		||||
  const editorRef = useRef<EditorRef>();
 | 
			
		||||
  const previewRef = useRef<PreviewAreaRef>();
 | 
			
		||||
 | 
			
		||||
  const [hasSaveConfirmed, setHasSaveConfirmed] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const [previewItem, setPreviewItem] = useState<string>('');
 | 
			
		||||
  const [errorOverlay, setErrorOverlay] = useState(null);
 | 
			
		||||
  const [isPreviewLoading, setIsPreviewLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const [editorValue, setEditorValue] = useState<null | string>(editors[0].key);
 | 
			
		||||
  const [previewValue, setPreviewValue] = useState<null | string>(
 | 
			
		||||
    previewAreas[0].key
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const codeRef = useRef<string | undefined>();
 | 
			
		||||
 | 
			
		||||
  const loadCodeToEditor = useCallback(async (code: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      return await Promise.resolve(editorRef.current?.setCode(code));
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      showNotification({
 | 
			
		||||
        title: t`Error loading template`,
 | 
			
		||||
        message: e?.message ?? e,
 | 
			
		||||
        color: 'red'
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const getCodeFromEditor = useCallback(async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      return await Promise.resolve(editorRef.current?.getCode());
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      showNotification({
 | 
			
		||||
        title: t`Error saving template`,
 | 
			
		||||
        message: e?.message ?? e,
 | 
			
		||||
        color: 'red'
 | 
			
		||||
      });
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!downloadUrl) return;
 | 
			
		||||
 | 
			
		||||
    api.get(downloadUrl).then((res) => {
 | 
			
		||||
      codeRef.current = res.data;
 | 
			
		||||
      loadCodeToEditor(res.data);
 | 
			
		||||
    });
 | 
			
		||||
  }, [downloadUrl]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (codeRef.current === undefined) return;
 | 
			
		||||
    loadCodeToEditor(codeRef.current);
 | 
			
		||||
  }, [editorValue]);
 | 
			
		||||
 | 
			
		||||
  const updatePreview = useCallback(
 | 
			
		||||
    async (confirmed: boolean, saveTemplate: boolean = true) => {
 | 
			
		||||
      if (!confirmed) {
 | 
			
		||||
        openConfirmModal({
 | 
			
		||||
          title: t`Save & Reload preview?`,
 | 
			
		||||
          children: (
 | 
			
		||||
            <Alert
 | 
			
		||||
              color="yellow"
 | 
			
		||||
              icon={<IconAlertTriangle />}
 | 
			
		||||
              title={t`Are you sure you want to Save & Reload the preview?`}
 | 
			
		||||
            >
 | 
			
		||||
              {t`To render the preview the current template needs to be replaced on the server with your modifications which may break the label if it is under active use. Do you want to proceed?`}
 | 
			
		||||
            </Alert>
 | 
			
		||||
          ),
 | 
			
		||||
          labels: {
 | 
			
		||||
            confirm: t`Save & Reload`,
 | 
			
		||||
            cancel: t`Cancel`
 | 
			
		||||
          },
 | 
			
		||||
          confirmProps: {
 | 
			
		||||
            color: 'yellow'
 | 
			
		||||
          },
 | 
			
		||||
          onConfirm: () => {
 | 
			
		||||
            setHasSaveConfirmed(true);
 | 
			
		||||
            updatePreview(true);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const code = await getCodeFromEditor();
 | 
			
		||||
      if (code === undefined || !previewItem) return;
 | 
			
		||||
 | 
			
		||||
      setIsPreviewLoading(true);
 | 
			
		||||
      Promise.resolve(
 | 
			
		||||
        previewRef.current?.updatePreview(
 | 
			
		||||
          code,
 | 
			
		||||
          previewItem,
 | 
			
		||||
          saveTemplate,
 | 
			
		||||
          props
 | 
			
		||||
        )
 | 
			
		||||
      )
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          setErrorOverlay(null);
 | 
			
		||||
          showNotification({
 | 
			
		||||
            title: t`Preview updated`,
 | 
			
		||||
            message: t`The preview has been updated successfully.`,
 | 
			
		||||
            color: 'green'
 | 
			
		||||
          });
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          setErrorOverlay(error.message);
 | 
			
		||||
        })
 | 
			
		||||
        .finally(() => {
 | 
			
		||||
          setIsPreviewLoading(false);
 | 
			
		||||
        });
 | 
			
		||||
    },
 | 
			
		||||
    [previewItem]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const previewApiUrl = useMemo(
 | 
			
		||||
    () => ModelInformationDict[preview.model].api_endpoint,
 | 
			
		||||
    [preview.model]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    api
 | 
			
		||||
      .get(apiUrl(previewApiUrl), { params: { limit: 1, ...preview.filters } })
 | 
			
		||||
      .then((res) => {
 | 
			
		||||
        if (res.data.results.length === 0) return;
 | 
			
		||||
        setPreviewItem(res.data.results[0].pk);
 | 
			
		||||
      });
 | 
			
		||||
  }, [previewApiUrl, preview.filters]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack style={{ height: '100%', flex: '1' }}>
 | 
			
		||||
      <Split style={{ gap: '10px' }}>
 | 
			
		||||
        <Tabs
 | 
			
		||||
          value={editorValue}
 | 
			
		||||
          onTabChange={async (v) => {
 | 
			
		||||
            codeRef.current = await getCodeFromEditor();
 | 
			
		||||
            setEditorValue(v);
 | 
			
		||||
          }}
 | 
			
		||||
          keepMounted={false}
 | 
			
		||||
          style={{
 | 
			
		||||
            minWidth: '300px',
 | 
			
		||||
            flex: '1',
 | 
			
		||||
            display: 'flex',
 | 
			
		||||
            flexDirection: 'column'
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Tabs.List>
 | 
			
		||||
            {editors.map((Editor) => (
 | 
			
		||||
              <Tabs.Tab
 | 
			
		||||
                key={Editor.key}
 | 
			
		||||
                value={Editor.key}
 | 
			
		||||
                icon={<Editor.icon size="0.8rem" />}
 | 
			
		||||
              >
 | 
			
		||||
                {Editor.name}
 | 
			
		||||
              </Tabs.Tab>
 | 
			
		||||
            ))}
 | 
			
		||||
 | 
			
		||||
            <Group position="right" style={{ flex: '1' }} noWrap>
 | 
			
		||||
              <SplitButton
 | 
			
		||||
                loading={isPreviewLoading}
 | 
			
		||||
                defaultSelected="preview_save"
 | 
			
		||||
                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
 | 
			
		||||
                  }
 | 
			
		||||
                ]}
 | 
			
		||||
              />
 | 
			
		||||
            </Group>
 | 
			
		||||
          </Tabs.List>
 | 
			
		||||
 | 
			
		||||
          {editors.map((Editor) => (
 | 
			
		||||
            <Tabs.Panel
 | 
			
		||||
              key={Editor.key}
 | 
			
		||||
              value={Editor.key}
 | 
			
		||||
              style={{
 | 
			
		||||
                display: 'flex',
 | 
			
		||||
                flex: editorValue === Editor.key ? 1 : 0
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              {/* @ts-ignore-next-line */}
 | 
			
		||||
              <Editor.component ref={editorRef} template={props.template} />
 | 
			
		||||
            </Tabs.Panel>
 | 
			
		||||
          ))}
 | 
			
		||||
        </Tabs>
 | 
			
		||||
 | 
			
		||||
        <Tabs
 | 
			
		||||
          value={previewValue}
 | 
			
		||||
          onTabChange={setPreviewValue}
 | 
			
		||||
          style={{
 | 
			
		||||
            minWidth: '200px',
 | 
			
		||||
            display: 'flex',
 | 
			
		||||
            flexDirection: 'column'
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Tabs.List>
 | 
			
		||||
            {previewAreas.map((PreviewArea) => (
 | 
			
		||||
              <Tabs.Tab
 | 
			
		||||
                key={PreviewArea.key}
 | 
			
		||||
                value={PreviewArea.key}
 | 
			
		||||
                icon={<PreviewArea.icon size="0.8rem" />}
 | 
			
		||||
              >
 | 
			
		||||
                {PreviewArea.name}
 | 
			
		||||
              </Tabs.Tab>
 | 
			
		||||
            ))}
 | 
			
		||||
          </Tabs.List>
 | 
			
		||||
 | 
			
		||||
          <div
 | 
			
		||||
            style={{
 | 
			
		||||
              minWidth: '100%',
 | 
			
		||||
              paddingBottom: '10px',
 | 
			
		||||
              paddingTop: '10px'
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <StandaloneField
 | 
			
		||||
              fieldDefinition={{
 | 
			
		||||
                field_type: 'related field',
 | 
			
		||||
                api_url: apiUrl(previewApiUrl),
 | 
			
		||||
                description: '',
 | 
			
		||||
                label: t`Select` + ' ' + preview.model + ' ' + t`to preview`,
 | 
			
		||||
                model: preview.model,
 | 
			
		||||
                value: previewItem,
 | 
			
		||||
                filters: preview.filters,
 | 
			
		||||
                onValueChange: (value) => setPreviewItem(value)
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {previewAreas.map((PreviewArea) => (
 | 
			
		||||
            <Tabs.Panel
 | 
			
		||||
              key={PreviewArea.key}
 | 
			
		||||
              value={PreviewArea.key}
 | 
			
		||||
              style={{
 | 
			
		||||
                display: 'flex',
 | 
			
		||||
                flex: previewValue === PreviewArea.key ? 1 : 0
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <div
 | 
			
		||||
                style={{
 | 
			
		||||
                  height: '100%',
 | 
			
		||||
                  position: 'relative',
 | 
			
		||||
                  display: 'flex',
 | 
			
		||||
                  flex: '1'
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                {/* @ts-ignore-next-line */}
 | 
			
		||||
                <PreviewArea.component ref={previewRef} />
 | 
			
		||||
 | 
			
		||||
                {errorOverlay && (
 | 
			
		||||
                  <Overlay color="red" center blur={0.2}>
 | 
			
		||||
                    <CloseButton
 | 
			
		||||
                      onClick={() => setErrorOverlay(null)}
 | 
			
		||||
                      style={{
 | 
			
		||||
                        position: 'absolute',
 | 
			
		||||
                        top: '10px',
 | 
			
		||||
                        right: '10px',
 | 
			
		||||
                        color: '#fff'
 | 
			
		||||
                      }}
 | 
			
		||||
                      variant="filled"
 | 
			
		||||
                    />
 | 
			
		||||
                    <Alert
 | 
			
		||||
                      color="red"
 | 
			
		||||
                      icon={<IconExclamationCircle />}
 | 
			
		||||
                      title={t`Error rendering template`}
 | 
			
		||||
                      mx="10px"
 | 
			
		||||
                    >
 | 
			
		||||
                      <Code>{errorOverlay}</Code>
 | 
			
		||||
                    </Alert>
 | 
			
		||||
                  </Overlay>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </Tabs.Panel>
 | 
			
		||||
          ))}
 | 
			
		||||
        </Tabs>
 | 
			
		||||
      </Split>
 | 
			
		||||
    </Stack>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
export { TemplateEditor } from './TemplateEditor';
 | 
			
		||||
export { CodeEditor } from './CodeEditor';
 | 
			
		||||
export { PdfPreview } from './PdfPreview';
 | 
			
		||||
							
								
								
									
										35
									
								
								src/frontend/src/components/forms/StandaloneField.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/frontend/src/components/forms/StandaloneField.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import { FormProvider, useForm } from 'react-hook-form';
 | 
			
		||||
 | 
			
		||||
import { ApiFormField, ApiFormFieldType } from './fields/ApiFormField';
 | 
			
		||||
 | 
			
		||||
export function StandaloneField({
 | 
			
		||||
  fieldDefinition,
 | 
			
		||||
  defaultValue
 | 
			
		||||
}: {
 | 
			
		||||
  fieldDefinition: ApiFormFieldType;
 | 
			
		||||
  defaultValue?: any;
 | 
			
		||||
}) {
 | 
			
		||||
  const defaultValues = useMemo(() => {
 | 
			
		||||
    if (defaultValue)
 | 
			
		||||
      return {
 | 
			
		||||
        field: defaultValue
 | 
			
		||||
      };
 | 
			
		||||
    return {};
 | 
			
		||||
  }, [defaultValue]);
 | 
			
		||||
 | 
			
		||||
  const form = useForm<{}>({
 | 
			
		||||
    criteriaMode: 'all',
 | 
			
		||||
    defaultValues
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <FormProvider {...form}>
 | 
			
		||||
      <ApiFormField
 | 
			
		||||
        fieldName="field"
 | 
			
		||||
        definition={fieldDefinition}
 | 
			
		||||
        control={form.control}
 | 
			
		||||
      />
 | 
			
		||||
    </FormProvider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -53,7 +53,11 @@ export function RelatedModelField({
 | 
			
		||||
    // If the value is unchanged, do nothing
 | 
			
		||||
    if (field.value === pk) return;
 | 
			
		||||
 | 
			
		||||
    if (field.value !== null && field.value !== undefined) {
 | 
			
		||||
    if (
 | 
			
		||||
      field.value !== null &&
 | 
			
		||||
      field.value !== undefined &&
 | 
			
		||||
      field.value !== ''
 | 
			
		||||
    ) {
 | 
			
		||||
      const url = `${definition.api_url}${field.value}/`;
 | 
			
		||||
 | 
			
		||||
      api.get(url).then((response) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,7 @@ export type ActionDropdownItem = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  tooltip?: string;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
  indicator?: Omit<IndicatorProps, 'children'>;
 | 
			
		||||
};
 | 
			
		||||
@@ -42,7 +43,7 @@ export function ActionDropdown({
 | 
			
		||||
  actions: ActionDropdownItem[];
 | 
			
		||||
}) {
 | 
			
		||||
  const hasActions = useMemo(() => {
 | 
			
		||||
    return actions.some((action) => !action.disabled);
 | 
			
		||||
    return actions.some((action) => !action.hidden);
 | 
			
		||||
  }, [actions]);
 | 
			
		||||
  const indicatorProps = useMemo(() => {
 | 
			
		||||
    return actions.find((action) => action.indicator);
 | 
			
		||||
@@ -61,7 +62,7 @@ export function ActionDropdown({
 | 
			
		||||
      </Indicator>
 | 
			
		||||
      <Menu.Dropdown>
 | 
			
		||||
        {actions.map((action) =>
 | 
			
		||||
          action.disabled ? null : (
 | 
			
		||||
          action.hidden ? null : (
 | 
			
		||||
            <Indicator
 | 
			
		||||
              disabled={!action.indicator}
 | 
			
		||||
              {...action.indicator}
 | 
			
		||||
@@ -108,10 +109,10 @@ export function BarcodeActionDropdown({
 | 
			
		||||
 | 
			
		||||
// Common action button for viewing a barcode
 | 
			
		||||
export function ViewBarcodeAction({
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  onClick
 | 
			
		||||
}: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}): ActionDropdownItem {
 | 
			
		||||
  return {
 | 
			
		||||
@@ -119,16 +120,16 @@ export function ViewBarcodeAction({
 | 
			
		||||
    name: t`View`,
 | 
			
		||||
    tooltip: t`View barcode`,
 | 
			
		||||
    onClick: onClick,
 | 
			
		||||
    disabled: disabled
 | 
			
		||||
    hidden: hidden
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Common action button for linking a custom barcode
 | 
			
		||||
export function LinkBarcodeAction({
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  onClick
 | 
			
		||||
}: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}): ActionDropdownItem {
 | 
			
		||||
  return {
 | 
			
		||||
@@ -136,16 +137,16 @@ export function LinkBarcodeAction({
 | 
			
		||||
    name: t`Link Barcode`,
 | 
			
		||||
    tooltip: t`Link custom barcode`,
 | 
			
		||||
    onClick: onClick,
 | 
			
		||||
    disabled: disabled
 | 
			
		||||
    hidden: hidden
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Common action button for un-linking a custom barcode
 | 
			
		||||
export function UnlinkBarcodeAction({
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  onClick
 | 
			
		||||
}: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}): ActionDropdownItem {
 | 
			
		||||
  return {
 | 
			
		||||
@@ -153,17 +154,17 @@ export function UnlinkBarcodeAction({
 | 
			
		||||
    name: t`Unlink Barcode`,
 | 
			
		||||
    tooltip: t`Unlink custom barcode`,
 | 
			
		||||
    onClick: onClick,
 | 
			
		||||
    disabled: disabled
 | 
			
		||||
    hidden: hidden
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Common action button for editing an item
 | 
			
		||||
export function EditItemAction({
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  tooltip,
 | 
			
		||||
  onClick
 | 
			
		||||
}: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  tooltip?: string;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}): ActionDropdownItem {
 | 
			
		||||
@@ -172,17 +173,17 @@ export function EditItemAction({
 | 
			
		||||
    name: t`Edit`,
 | 
			
		||||
    tooltip: tooltip ?? `Edit item`,
 | 
			
		||||
    onClick: onClick,
 | 
			
		||||
    disabled: disabled
 | 
			
		||||
    hidden: hidden
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Common action button for deleting an item
 | 
			
		||||
export function DeleteItemAction({
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  tooltip,
 | 
			
		||||
  onClick
 | 
			
		||||
}: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  tooltip?: string;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}): ActionDropdownItem {
 | 
			
		||||
@@ -191,17 +192,17 @@ export function DeleteItemAction({
 | 
			
		||||
    name: t`Delete`,
 | 
			
		||||
    tooltip: tooltip ?? t`Delete item`,
 | 
			
		||||
    onClick: onClick,
 | 
			
		||||
    disabled: disabled
 | 
			
		||||
    hidden: hidden
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Common action button for duplicating an item
 | 
			
		||||
export function DuplicateItemAction({
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  hidden = false,
 | 
			
		||||
  tooltip,
 | 
			
		||||
  onClick
 | 
			
		||||
}: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  tooltip?: string;
 | 
			
		||||
  onClick?: () => void;
 | 
			
		||||
}): ActionDropdownItem {
 | 
			
		||||
@@ -210,6 +211,6 @@ export function DuplicateItemAction({
 | 
			
		||||
    name: t`Duplicate`,
 | 
			
		||||
    tooltip: tooltip ?? t`Duplicate item`,
 | 
			
		||||
    onClick: onClick,
 | 
			
		||||
    disabled: disabled
 | 
			
		||||
    hidden: hidden
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro';
 | 
			
		||||
import { Code, Flex, Group, Text } from '@mantine/core';
 | 
			
		||||
import { Link, To } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { DetailDrawerLink } from '../nav/DetailDrawer';
 | 
			
		||||
import { YesNoButton } from './YesNoButton';
 | 
			
		||||
 | 
			
		||||
export function InfoItem({
 | 
			
		||||
@@ -9,13 +10,15 @@ export function InfoItem({
 | 
			
		||||
  children,
 | 
			
		||||
  type,
 | 
			
		||||
  value,
 | 
			
		||||
  link
 | 
			
		||||
  link,
 | 
			
		||||
  detailDrawerLink
 | 
			
		||||
}: {
 | 
			
		||||
  name: string;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
  type?: 'text' | 'boolean' | 'code';
 | 
			
		||||
  value?: any;
 | 
			
		||||
  link?: To;
 | 
			
		||||
  detailDrawerLink?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  function renderComponent() {
 | 
			
		||||
    if (value === undefined) return null;
 | 
			
		||||
@@ -46,7 +49,15 @@ export function InfoItem({
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Flex>
 | 
			
		||||
        {children}
 | 
			
		||||
        {link ? <Link to={link}>{renderComponent()}</Link> : renderComponent()}
 | 
			
		||||
        {link ? (
 | 
			
		||||
          detailDrawerLink ? (
 | 
			
		||||
            <DetailDrawerLink to={link} text={value} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <Link to={link}>{renderComponent()}</Link>
 | 
			
		||||
          )
 | 
			
		||||
        ) : (
 | 
			
		||||
          renderComponent()
 | 
			
		||||
        )}
 | 
			
		||||
      </Flex>
 | 
			
		||||
    </Group>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -5,11 +5,15 @@ import {
 | 
			
		||||
  Group,
 | 
			
		||||
  MantineNumberSize,
 | 
			
		||||
  Stack,
 | 
			
		||||
  Text
 | 
			
		||||
  Text,
 | 
			
		||||
  createStyles
 | 
			
		||||
} from '@mantine/core';
 | 
			
		||||
import { IconChevronLeft } from '@tabler/icons-react';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import { Route, Routes, useNavigate, useParams } from 'react-router-dom';
 | 
			
		||||
import { useCallback, useMemo } from 'react';
 | 
			
		||||
import { Link, Route, Routes, useNavigate, useParams } from 'react-router-dom';
 | 
			
		||||
import type { To } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { useLocalState } from '../../states/LocalState';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param title - drawer title
 | 
			
		||||
@@ -25,6 +29,13 @@ export interface DrawerProps {
 | 
			
		||||
  size?: MantineNumberSize;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useStyles = createStyles(() => ({
 | 
			
		||||
  flex: {
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
    flex: 1
 | 
			
		||||
  }
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
function DetailDrawerComponent({
 | 
			
		||||
  title,
 | 
			
		||||
  position = 'right',
 | 
			
		||||
@@ -33,28 +44,47 @@ function DetailDrawerComponent({
 | 
			
		||||
}: DrawerProps) {
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const { id } = useParams();
 | 
			
		||||
  const { classes } = useStyles();
 | 
			
		||||
 | 
			
		||||
  const content = renderContent(id);
 | 
			
		||||
  const opened = useMemo(() => !!id && !!content, [id, content]);
 | 
			
		||||
 | 
			
		||||
  const [detailDrawerStack, addDetailDrawer] = useLocalState((state) => [
 | 
			
		||||
    state.detailDrawerStack,
 | 
			
		||||
    state.addDetailDrawer
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Drawer
 | 
			
		||||
      opened={opened}
 | 
			
		||||
      onClose={() => navigate('../')}
 | 
			
		||||
      onClose={() => {
 | 
			
		||||
        navigate('../');
 | 
			
		||||
        addDetailDrawer(false);
 | 
			
		||||
      }}
 | 
			
		||||
      position={position}
 | 
			
		||||
      size={size}
 | 
			
		||||
      classNames={{ root: classes.flex, body: classes.flex }}
 | 
			
		||||
      scrollAreaComponent={Stack}
 | 
			
		||||
      title={
 | 
			
		||||
        <Group>
 | 
			
		||||
          <ActionIcon variant="outline" onClick={() => navigate(-1)}>
 | 
			
		||||
            <IconChevronLeft />
 | 
			
		||||
          </ActionIcon>
 | 
			
		||||
          {detailDrawerStack > 0 && (
 | 
			
		||||
            <ActionIcon
 | 
			
		||||
              variant="outline"
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                navigate(-1);
 | 
			
		||||
                addDetailDrawer(-1);
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <IconChevronLeft />
 | 
			
		||||
            </ActionIcon>
 | 
			
		||||
          )}
 | 
			
		||||
          <Text size="xl" fw={600} variant="gradient">
 | 
			
		||||
            {title}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Group>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Stack spacing={'xs'}>
 | 
			
		||||
      <Stack spacing={'xs'} className={classes.flex}>
 | 
			
		||||
        <Divider />
 | 
			
		||||
        {content}
 | 
			
		||||
      </Stack>
 | 
			
		||||
@@ -69,3 +99,17 @@ export function DetailDrawer(props: DrawerProps) {
 | 
			
		||||
    </Routes>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function DetailDrawerLink({ to, text }: { to: To; text: string }) {
 | 
			
		||||
  const addDetailDrawer = useLocalState((state) => state.addDetailDrawer);
 | 
			
		||||
 | 
			
		||||
  const onNavigate = useCallback(() => {
 | 
			
		||||
    addDetailDrawer(1);
 | 
			
		||||
  }, [addDetailDrawer]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link to={to} onClick={onNavigate}>
 | 
			
		||||
      <Text>{text}</Text>
 | 
			
		||||
    </Link>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,6 +34,7 @@ export type PanelType = {
 | 
			
		||||
  content?: ReactNode;
 | 
			
		||||
  hidden?: boolean;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  showHeadline?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type PanelProps = {
 | 
			
		||||
@@ -125,6 +126,8 @@ function BasePanelGroup({
 | 
			
		||||
                    //                    icon={(<InvenTreeIcon icon={panel.name}/>)}  // Enable when implementing Icon manager everywhere
 | 
			
		||||
                    icon={panel.icon}
 | 
			
		||||
                    hidden={panel.hidden}
 | 
			
		||||
                    disabled={panel.disabled}
 | 
			
		||||
                    style={{ cursor: panel.disabled ? 'unset' : 'pointer' }}
 | 
			
		||||
                  >
 | 
			
		||||
                    {expanded && panel.label}
 | 
			
		||||
                  </Tabs.Tab>
 | 
			
		||||
@@ -159,8 +162,12 @@ function BasePanelGroup({
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Stack spacing="md">
 | 
			
		||||
                  <StylishText size="xl">{panel.label}</StylishText>
 | 
			
		||||
                  <Divider />
 | 
			
		||||
                  {panel.showHeadline !== false && (
 | 
			
		||||
                    <>
 | 
			
		||||
                      <StylishText size="xl">{panel.label}</StylishText>
 | 
			
		||||
                      <Divider />
 | 
			
		||||
                    </>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {panel.content ?? <PlaceholderPanel />}
 | 
			
		||||
                </Stack>
 | 
			
		||||
              </Tabs.Panel>
 | 
			
		||||
@@ -176,12 +183,11 @@ function IndexPanelComponent({ pageKey, selectedPanel, panels }: PanelProps) {
 | 
			
		||||
    const panelName =
 | 
			
		||||
      selectedPanel || state.lastUsedPanels[pageKey] || panels[0]?.name;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      panels.findIndex(
 | 
			
		||||
        (p) => p.name === panelName && !p.disabled && !p.hidden
 | 
			
		||||
      ) === -1
 | 
			
		||||
    ) {
 | 
			
		||||
      return panels[0]?.name;
 | 
			
		||||
    const panel = panels.findIndex(
 | 
			
		||||
      (p) => p.name === panelName && !p.disabled && !p.hidden
 | 
			
		||||
    );
 | 
			
		||||
    if (panel === -1) {
 | 
			
		||||
      return panels.find((p) => !p.disabled && !p.hidden)?.name || '';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return panelName;
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ export interface ModelInformationInterface {
 | 
			
		||||
  label_multiple: string;
 | 
			
		||||
  url_overview?: string;
 | 
			
		||||
  url_detail?: string;
 | 
			
		||||
  api_endpoint?: ApiEndpoints;
 | 
			
		||||
  api_endpoint: ApiEndpoints;
 | 
			
		||||
  cui_detail?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										53
									
								
								src/frontend/src/defaults/templates.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/frontend/src/defaults/templates.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
export const defaultLabelTemplate = `{% extends "label/label_base.html" %}
 | 
			
		||||
 | 
			
		||||
{% load l10n i18n barcode %}
 | 
			
		||||
 | 
			
		||||
{% block style %}
 | 
			
		||||
 | 
			
		||||
.qr {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0mm;
 | 
			
		||||
    top: 0mm;
 | 
			
		||||
    {% localize off %}
 | 
			
		||||
    height: {{ height }}mm;
 | 
			
		||||
    width: {{ height }}mm;
 | 
			
		||||
    {% endlocalize %}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
{% endblock style %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>
 | 
			
		||||
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
export const defaultReportTemplate = `{% extends "report/inventree_report_base.html" %}
 | 
			
		||||
 | 
			
		||||
{% load i18n report barcode inventree_extras %}
 | 
			
		||||
 | 
			
		||||
{% block page_margin %}
 | 
			
		||||
margin: 2cm;
 | 
			
		||||
margin-top: 4cm;
 | 
			
		||||
{% endblock page_margin %}
 | 
			
		||||
 | 
			
		||||
{% block bottom_left %}
 | 
			
		||||
content: "v{{ report_revision }} - {{ date.isoformat }}";
 | 
			
		||||
{% endblock bottom_left %}
 | 
			
		||||
 | 
			
		||||
{% block bottom_center %}
 | 
			
		||||
content: "{% inventree_version shortstring=True %}";
 | 
			
		||||
{% endblock bottom_center %}
 | 
			
		||||
 | 
			
		||||
{% block style %}
 | 
			
		||||
<!-- Custom style -->
 | 
			
		||||
{% endblock style %}
 | 
			
		||||
 | 
			
		||||
{% block header_content %}
 | 
			
		||||
<!-- Custom header content -->
 | 
			
		||||
{% endblock header_content %}
 | 
			
		||||
 | 
			
		||||
{% block page_content %}
 | 
			
		||||
<!-- Custom page content -->
 | 
			
		||||
{% endblock page_content %}
 | 
			
		||||
`;
 | 
			
		||||
@@ -95,6 +95,10 @@ export enum ApiEndpoints {
 | 
			
		||||
  return_order_list = 'order/ro/',
 | 
			
		||||
  return_order_attachment_list = 'order/ro/attachment/',
 | 
			
		||||
 | 
			
		||||
  // Template API endpoints
 | 
			
		||||
  label_list = 'label/:variant/',
 | 
			
		||||
  report_list = 'report/:variant/',
 | 
			
		||||
 | 
			
		||||
  // Plugin API endpoints
 | 
			
		||||
  plugin_list = 'plugins/',
 | 
			
		||||
  plugin_setting_list = 'plugins/:plugin/settings/',
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ import {
 | 
			
		||||
  IconBuildingStore,
 | 
			
		||||
  IconCalendar,
 | 
			
		||||
  IconCalendarStats,
 | 
			
		||||
  IconCategory,
 | 
			
		||||
  IconCheck,
 | 
			
		||||
  IconClipboardList,
 | 
			
		||||
  IconCopy,
 | 
			
		||||
@@ -58,102 +59,105 @@ import {
 | 
			
		||||
  IconX
 | 
			
		||||
} from '@tabler/icons-react';
 | 
			
		||||
import { IconFlag } from '@tabler/icons-react';
 | 
			
		||||
import { IconTruckReturn } from '@tabler/icons-react';
 | 
			
		||||
import { IconInfoCircle } from '@tabler/icons-react';
 | 
			
		||||
import { IconCalendarTime } from '@tabler/icons-react';
 | 
			
		||||
import { TablerIconsProps } from '@tabler/icons-react';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
const icons: { [key: string]: (props: TablerIconsProps) => React.JSX.Element } =
 | 
			
		||||
  {
 | 
			
		||||
    description: IconInfoCircle,
 | 
			
		||||
    variant_of: IconStatusChange,
 | 
			
		||||
    unallocated_stock: IconPackage,
 | 
			
		||||
    total_in_stock: IconPackages,
 | 
			
		||||
    minimum_stock: IconFlag,
 | 
			
		||||
    allocated_to_build_orders: IconTool,
 | 
			
		||||
    allocated_to_sales_orders: IconTruck,
 | 
			
		||||
    can_build: IconTools,
 | 
			
		||||
    ordering: IconShoppingCart,
 | 
			
		||||
    building: IconTool,
 | 
			
		||||
    category: IconBinaryTree2,
 | 
			
		||||
    IPN: Icon123,
 | 
			
		||||
    revision: IconGitBranch,
 | 
			
		||||
    units: IconRulerMeasure,
 | 
			
		||||
    keywords: IconTag,
 | 
			
		||||
    status: IconInfoCircle,
 | 
			
		||||
    info: IconInfoCircle,
 | 
			
		||||
    details: IconInfoCircle,
 | 
			
		||||
    parameters: IconList,
 | 
			
		||||
    stock: IconPackages,
 | 
			
		||||
    variants: IconVersions,
 | 
			
		||||
    allocations: IconBookmarks,
 | 
			
		||||
    bom: IconListTree,
 | 
			
		||||
    builds: IconTools,
 | 
			
		||||
    used_in: IconStack2,
 | 
			
		||||
    manufacturers: IconBuildingFactory2,
 | 
			
		||||
    suppliers: IconBuilding,
 | 
			
		||||
    customers: IconBuildingStore,
 | 
			
		||||
    purchase_orders: IconShoppingCart,
 | 
			
		||||
    sales_orders: IconTruckDelivery,
 | 
			
		||||
    shipment: IconTruckDelivery,
 | 
			
		||||
    scheduling: IconCalendarStats,
 | 
			
		||||
    test_templates: IconTestPipe,
 | 
			
		||||
    related_parts: IconLayersLinked,
 | 
			
		||||
    attachments: IconPaperclip,
 | 
			
		||||
    notes: IconNotes,
 | 
			
		||||
    photo: IconPhoto,
 | 
			
		||||
    upload: IconFileUpload,
 | 
			
		||||
    reject: IconX,
 | 
			
		||||
    select_image: IconGridDots,
 | 
			
		||||
    delete: IconTrash,
 | 
			
		||||
const icons = {
 | 
			
		||||
  description: IconInfoCircle,
 | 
			
		||||
  variant_of: IconStatusChange,
 | 
			
		||||
  unallocated_stock: IconPackage,
 | 
			
		||||
  total_in_stock: IconPackages,
 | 
			
		||||
  minimum_stock: IconFlag,
 | 
			
		||||
  allocated_to_build_orders: IconTool,
 | 
			
		||||
  allocated_to_sales_orders: IconTruck,
 | 
			
		||||
  can_build: IconTools,
 | 
			
		||||
  ordering: IconShoppingCart,
 | 
			
		||||
  building: IconTool,
 | 
			
		||||
  category: IconBinaryTree2,
 | 
			
		||||
  IPN: Icon123,
 | 
			
		||||
  revision: IconGitBranch,
 | 
			
		||||
  units: IconRulerMeasure,
 | 
			
		||||
  keywords: IconTag,
 | 
			
		||||
  status: IconInfoCircle,
 | 
			
		||||
  info: IconInfoCircle,
 | 
			
		||||
  details: IconInfoCircle,
 | 
			
		||||
  parameters: IconList,
 | 
			
		||||
  stock: IconPackages,
 | 
			
		||||
  variants: IconVersions,
 | 
			
		||||
  allocations: IconBookmarks,
 | 
			
		||||
  bom: IconListTree,
 | 
			
		||||
  builds: IconTools,
 | 
			
		||||
  used_in: IconStack2,
 | 
			
		||||
  manufacturers: IconBuildingFactory2,
 | 
			
		||||
  suppliers: IconBuilding,
 | 
			
		||||
  customers: IconBuildingStore,
 | 
			
		||||
  purchase_orders: IconShoppingCart,
 | 
			
		||||
  sales_orders: IconTruckDelivery,
 | 
			
		||||
  return_orders: IconTruckReturn,
 | 
			
		||||
  shipment: IconTruckDelivery,
 | 
			
		||||
  scheduling: IconCalendarStats,
 | 
			
		||||
  test_templates: IconTestPipe,
 | 
			
		||||
  related_parts: IconLayersLinked,
 | 
			
		||||
  attachments: IconPaperclip,
 | 
			
		||||
  notes: IconNotes,
 | 
			
		||||
  photo: IconPhoto,
 | 
			
		||||
  upload: IconFileUpload,
 | 
			
		||||
  reject: IconX,
 | 
			
		||||
  select_image: IconGridDots,
 | 
			
		||||
  delete: IconTrash,
 | 
			
		||||
 | 
			
		||||
    // Part Icons
 | 
			
		||||
    active: IconCheck,
 | 
			
		||||
    template: IconCopy,
 | 
			
		||||
    assembly: IconTool,
 | 
			
		||||
    component: IconGridDots,
 | 
			
		||||
    trackable: IconCornerUpRightDouble,
 | 
			
		||||
    purchaseable: IconShoppingCart,
 | 
			
		||||
    saleable: IconCurrencyDollar,
 | 
			
		||||
    virtual: IconWorldCode,
 | 
			
		||||
    inactive: IconX,
 | 
			
		||||
    part: IconBox,
 | 
			
		||||
    supplier_part: IconPackageImport,
 | 
			
		||||
  // Part Icons
 | 
			
		||||
  active: IconCheck,
 | 
			
		||||
  template: IconCopy,
 | 
			
		||||
  assembly: IconTool,
 | 
			
		||||
  component: IconGridDots,
 | 
			
		||||
  trackable: IconCornerUpRightDouble,
 | 
			
		||||
  purchaseable: IconShoppingCart,
 | 
			
		||||
  saleable: IconCurrencyDollar,
 | 
			
		||||
  virtual: IconWorldCode,
 | 
			
		||||
  inactive: IconX,
 | 
			
		||||
  part: IconBox,
 | 
			
		||||
  supplier_part: IconPackageImport,
 | 
			
		||||
 | 
			
		||||
    calendar: IconCalendar,
 | 
			
		||||
    external: IconExternalLink,
 | 
			
		||||
    creation_date: IconCalendarTime,
 | 
			
		||||
    location: IconMapPin,
 | 
			
		||||
    default_location: IconMapPinHeart,
 | 
			
		||||
    default_supplier: IconShoppingCartHeart,
 | 
			
		||||
    link: IconLink,
 | 
			
		||||
    responsible: IconUserStar,
 | 
			
		||||
    pricing: IconCurrencyDollar,
 | 
			
		||||
    currency: IconCurrencyDollar,
 | 
			
		||||
    stocktake: IconClipboardList,
 | 
			
		||||
    user: IconUser,
 | 
			
		||||
    group: IconUsersGroup,
 | 
			
		||||
    check: IconCheck,
 | 
			
		||||
    copy: IconCopy,
 | 
			
		||||
    quantity: IconNumbers,
 | 
			
		||||
    progress: IconProgressCheck,
 | 
			
		||||
    reference: IconHash,
 | 
			
		||||
    website: IconWorld,
 | 
			
		||||
    email: IconMail,
 | 
			
		||||
    phone: IconPhone,
 | 
			
		||||
    sitemap: IconSitemap
 | 
			
		||||
  };
 | 
			
		||||
  calendar: IconCalendar,
 | 
			
		||||
  external: IconExternalLink,
 | 
			
		||||
  creation_date: IconCalendarTime,
 | 
			
		||||
  location: IconMapPin,
 | 
			
		||||
  default_location: IconMapPinHeart,
 | 
			
		||||
  default_supplier: IconShoppingCartHeart,
 | 
			
		||||
  link: IconLink,
 | 
			
		||||
  responsible: IconUserStar,
 | 
			
		||||
  pricing: IconCurrencyDollar,
 | 
			
		||||
  currency: IconCurrencyDollar,
 | 
			
		||||
  stocktake: IconClipboardList,
 | 
			
		||||
  user: IconUser,
 | 
			
		||||
  group: IconUsersGroup,
 | 
			
		||||
  check: IconCheck,
 | 
			
		||||
  copy: IconCopy,
 | 
			
		||||
  quantity: IconNumbers,
 | 
			
		||||
  progress: IconProgressCheck,
 | 
			
		||||
  reference: IconHash,
 | 
			
		||||
  website: IconWorld,
 | 
			
		||||
  email: IconMail,
 | 
			
		||||
  phone: IconPhone,
 | 
			
		||||
  sitemap: IconSitemap
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type InvenTreeIconType = keyof typeof icons;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Returns a Tabler Icon for the model field name supplied
 | 
			
		||||
 * @param field string defining field name
 | 
			
		||||
 */
 | 
			
		||||
export function GetIcon(field: keyof typeof icons) {
 | 
			
		||||
export function GetIcon(field: InvenTreeIconType) {
 | 
			
		||||
  return icons[field];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type IconProps = {
 | 
			
		||||
  icon: string;
 | 
			
		||||
  icon: InvenTreeIconType;
 | 
			
		||||
  iconProps?: TablerIconsProps;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ import {
 | 
			
		||||
  IconListDetails,
 | 
			
		||||
  IconPlugConnected,
 | 
			
		||||
  IconScale,
 | 
			
		||||
  IconTemplate,
 | 
			
		||||
  IconUsersGroup
 | 
			
		||||
} from '@tabler/icons-react';
 | 
			
		||||
import { lazy, useMemo } from 'react';
 | 
			
		||||
@@ -55,6 +56,10 @@ const CurrencyTable = Loadable(
 | 
			
		||||
  lazy(() => import('../../../../tables/settings/CurrencyTable'))
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const TemplateManagementPanel = Loadable(
 | 
			
		||||
  lazy(() => import('./TemplateManagementPanel'))
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export default function AdminCenter() {
 | 
			
		||||
  const adminCenterPanels: PanelType[] = useMemo(() => {
 | 
			
		||||
    return [
 | 
			
		||||
@@ -106,6 +111,12 @@ export default function AdminCenter() {
 | 
			
		||||
        icon: <IconList />,
 | 
			
		||||
        content: <PartParameterTemplateTable />
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'templates',
 | 
			
		||||
        label: t`Templates`,
 | 
			
		||||
        icon: <IconTemplate />,
 | 
			
		||||
        content: <TemplateManagementPanel />
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        name: 'plugin',
 | 
			
		||||
        label: t`Plugins`,
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,211 @@
 | 
			
		||||
import { t } from '@lingui/macro';
 | 
			
		||||
import { Stack } from '@mantine/core';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
import { TemplatePreviewProps } from '../../../../components/editors/TemplateEditor/TemplateEditor';
 | 
			
		||||
import { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
 | 
			
		||||
import { PanelGroup } from '../../../../components/nav/PanelGroup';
 | 
			
		||||
import {
 | 
			
		||||
  defaultLabelTemplate,
 | 
			
		||||
  defaultReportTemplate
 | 
			
		||||
} from '../../../../defaults/templates';
 | 
			
		||||
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
 | 
			
		||||
import { ModelType } from '../../../../enums/ModelType';
 | 
			
		||||
import { InvenTreeIcon, InvenTreeIconType } from '../../../../functions/icons';
 | 
			
		||||
import { TemplateTable } from '../../../../tables/settings/TemplateTable';
 | 
			
		||||
 | 
			
		||||
type TemplateType = {
 | 
			
		||||
  type: 'label' | 'report';
 | 
			
		||||
  name: string;
 | 
			
		||||
  singularName: string;
 | 
			
		||||
  apiEndpoints: ApiEndpoints;
 | 
			
		||||
  templateKey: string;
 | 
			
		||||
  additionalFormFields?: ApiFormFieldSet;
 | 
			
		||||
  defaultTemplate: string;
 | 
			
		||||
  variants: {
 | 
			
		||||
    name: string;
 | 
			
		||||
    key: string;
 | 
			
		||||
    icon: InvenTreeIconType;
 | 
			
		||||
    preview: TemplatePreviewProps;
 | 
			
		||||
  }[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function TemplateManagementPanel() {
 | 
			
		||||
  const templateTypes = useMemo(() => {
 | 
			
		||||
    const templateTypes: TemplateType[] = [
 | 
			
		||||
      {
 | 
			
		||||
        type: 'label',
 | 
			
		||||
        name: t`Labels`,
 | 
			
		||||
        singularName: t`Label`,
 | 
			
		||||
        apiEndpoints: ApiEndpoints.label_list,
 | 
			
		||||
        templateKey: 'label',
 | 
			
		||||
        additionalFormFields: {
 | 
			
		||||
          width: {},
 | 
			
		||||
          height: {}
 | 
			
		||||
        },
 | 
			
		||||
        defaultTemplate: defaultLabelTemplate,
 | 
			
		||||
        variants: [
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Part`,
 | 
			
		||||
            key: 'part',
 | 
			
		||||
            icon: 'part',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'part',
 | 
			
		||||
              model: ModelType.part
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Location`,
 | 
			
		||||
            key: 'location',
 | 
			
		||||
            icon: 'location',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'location',
 | 
			
		||||
              model: ModelType.stocklocation
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Stock item`,
 | 
			
		||||
            key: 'stock',
 | 
			
		||||
            icon: 'stock',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'item',
 | 
			
		||||
              model: ModelType.stockitem
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Build line`,
 | 
			
		||||
            key: 'buildline',
 | 
			
		||||
            icon: 'builds',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'line',
 | 
			
		||||
              model: ModelType.build
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        type: 'report',
 | 
			
		||||
        name: t`Reports`,
 | 
			
		||||
        singularName: t`Report`,
 | 
			
		||||
        apiEndpoints: ApiEndpoints.report_list,
 | 
			
		||||
        templateKey: 'template',
 | 
			
		||||
        additionalFormFields: {
 | 
			
		||||
          page_size: {},
 | 
			
		||||
          landscape: {}
 | 
			
		||||
        },
 | 
			
		||||
        defaultTemplate: defaultReportTemplate,
 | 
			
		||||
        variants: [
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Purchase order`,
 | 
			
		||||
            key: 'po',
 | 
			
		||||
            icon: 'purchase_orders',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'order',
 | 
			
		||||
              model: ModelType.purchaseorder
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Sales order`,
 | 
			
		||||
            key: 'so',
 | 
			
		||||
            icon: 'sales_orders',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'order',
 | 
			
		||||
              model: ModelType.salesorder
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Return order`,
 | 
			
		||||
            key: 'ro',
 | 
			
		||||
            icon: 'return_orders',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'order',
 | 
			
		||||
              model: ModelType.returnorder
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Build`,
 | 
			
		||||
            key: 'build',
 | 
			
		||||
            icon: 'builds',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'build',
 | 
			
		||||
              model: ModelType.build
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Bill of Materials`,
 | 
			
		||||
            key: 'bom',
 | 
			
		||||
            icon: 'bom',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'part',
 | 
			
		||||
              model: ModelType.part,
 | 
			
		||||
              filters: { assembly: true }
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Tests`,
 | 
			
		||||
            key: 'test',
 | 
			
		||||
            icon: 'test_templates',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'item',
 | 
			
		||||
              model: ModelType.stockitem
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: t`Stock location`,
 | 
			
		||||
            key: 'slr',
 | 
			
		||||
            icon: 'default_location',
 | 
			
		||||
            preview: {
 | 
			
		||||
              itemKey: 'location',
 | 
			
		||||
              model: ModelType.stocklocation
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    return templateTypes;
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const panels = useMemo(() => {
 | 
			
		||||
    return templateTypes.flatMap((templateType) => {
 | 
			
		||||
      return [
 | 
			
		||||
        // Add panel headline
 | 
			
		||||
        { name: templateType.type, label: templateType.name, disabled: true },
 | 
			
		||||
 | 
			
		||||
        // Add panel for each variant
 | 
			
		||||
        ...templateType.variants.map((variant) => {
 | 
			
		||||
          return {
 | 
			
		||||
            name: variant.key,
 | 
			
		||||
            label: variant.name,
 | 
			
		||||
            content: (
 | 
			
		||||
              <TemplateTable
 | 
			
		||||
                templateProps={{
 | 
			
		||||
                  apiEndpoint: templateType.apiEndpoints,
 | 
			
		||||
                  templateType: templateType.type as 'label' | 'report',
 | 
			
		||||
                  templateTypeTranslation: templateType.singularName,
 | 
			
		||||
                  variant: variant.key,
 | 
			
		||||
                  templateKey: templateType.templateKey,
 | 
			
		||||
                  preview: variant.preview,
 | 
			
		||||
                  additionalFormFields: templateType.additionalFormFields,
 | 
			
		||||
                  defaultTemplate: templateType.defaultTemplate
 | 
			
		||||
                }}
 | 
			
		||||
              />
 | 
			
		||||
            ),
 | 
			
		||||
            icon: <InvenTreeIcon icon={variant.icon} />,
 | 
			
		||||
            showHeadline: false
 | 
			
		||||
          };
 | 
			
		||||
        })
 | 
			
		||||
      ];
 | 
			
		||||
    });
 | 
			
		||||
  }, [templateTypes]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack>
 | 
			
		||||
      <PanelGroup
 | 
			
		||||
        pageKey="admin-center-templates"
 | 
			
		||||
        panels={panels}
 | 
			
		||||
        collapsible={false}
 | 
			
		||||
      />
 | 
			
		||||
    </Stack>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -300,10 +300,10 @@ export default function BuildDetail() {
 | 
			
		||||
        actions={[
 | 
			
		||||
          ViewBarcodeAction({}),
 | 
			
		||||
          LinkBarcodeAction({
 | 
			
		||||
            disabled: build?.barcode_hash
 | 
			
		||||
            hidden: build?.barcode_hash
 | 
			
		||||
          }),
 | 
			
		||||
          UnlinkBarcodeAction({
 | 
			
		||||
            disabled: !build?.barcode_hash
 | 
			
		||||
            hidden: !build?.barcode_hash
 | 
			
		||||
          })
 | 
			
		||||
        ]}
 | 
			
		||||
      />,
 | 
			
		||||
@@ -326,7 +326,7 @@ export default function BuildDetail() {
 | 
			
		||||
        actions={[
 | 
			
		||||
          EditItemAction({
 | 
			
		||||
            onClick: () => editBuild.open(),
 | 
			
		||||
            disabled: !user.hasChangeRole(UserRoles.build)
 | 
			
		||||
            hidden: !user.hasChangeRole(UserRoles.build)
 | 
			
		||||
          }),
 | 
			
		||||
          DuplicateItemAction({})
 | 
			
		||||
        ]}
 | 
			
		||||
 
 | 
			
		||||
@@ -282,11 +282,11 @@ export default function CompanyDetail(props: CompanyDetailProps) {
 | 
			
		||||
        icon={<IconDots />}
 | 
			
		||||
        actions={[
 | 
			
		||||
          EditItemAction({
 | 
			
		||||
            disabled: !user.hasChangeRole(UserRoles.purchase_order),
 | 
			
		||||
            hidden: !user.hasChangeRole(UserRoles.purchase_order),
 | 
			
		||||
            onClick: () => editCompany.open()
 | 
			
		||||
          }),
 | 
			
		||||
          DeleteItemAction({
 | 
			
		||||
            disabled: !user.hasDeleteRole(UserRoles.purchase_order)
 | 
			
		||||
            hidden: !user.hasDeleteRole(UserRoles.purchase_order)
 | 
			
		||||
          })
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
 
 | 
			
		||||
@@ -638,10 +638,10 @@ export default function PartDetail() {
 | 
			
		||||
        actions={[
 | 
			
		||||
          ViewBarcodeAction({}),
 | 
			
		||||
          LinkBarcodeAction({
 | 
			
		||||
            disabled: part?.barcode_hash
 | 
			
		||||
            hidden: part?.barcode_hash
 | 
			
		||||
          }),
 | 
			
		||||
          UnlinkBarcodeAction({
 | 
			
		||||
            disabled: !part?.barcode_hash
 | 
			
		||||
            hidden: !part?.barcode_hash
 | 
			
		||||
          })
 | 
			
		||||
        ]}
 | 
			
		||||
      />,
 | 
			
		||||
@@ -669,11 +669,11 @@ export default function PartDetail() {
 | 
			
		||||
        actions={[
 | 
			
		||||
          DuplicateItemAction({}),
 | 
			
		||||
          EditItemAction({
 | 
			
		||||
            disabled: !user.hasChangeRole(UserRoles.part),
 | 
			
		||||
            hidden: !user.hasChangeRole(UserRoles.part),
 | 
			
		||||
            onClick: () => editPart.open()
 | 
			
		||||
          }),
 | 
			
		||||
          DeleteItemAction({
 | 
			
		||||
            disabled: part?.active
 | 
			
		||||
            hidden: part?.active
 | 
			
		||||
          })
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
 
 | 
			
		||||
@@ -273,10 +273,10 @@ export default function PurchaseOrderDetail() {
 | 
			
		||||
        actions={[
 | 
			
		||||
          ViewBarcodeAction({}),
 | 
			
		||||
          LinkBarcodeAction({
 | 
			
		||||
            disabled: order?.barcode_hash
 | 
			
		||||
            hidden: order?.barcode_hash
 | 
			
		||||
          }),
 | 
			
		||||
          UnlinkBarcodeAction({
 | 
			
		||||
            disabled: !order?.barcode_hash
 | 
			
		||||
            hidden: !order?.barcode_hash
 | 
			
		||||
          })
 | 
			
		||||
        ]}
 | 
			
		||||
      />,
 | 
			
		||||
 
 | 
			
		||||
@@ -317,10 +317,10 @@ export default function StockDetail() {
 | 
			
		||||
        actions={[
 | 
			
		||||
          ViewBarcodeAction({}),
 | 
			
		||||
          LinkBarcodeAction({
 | 
			
		||||
            disabled: stockitem?.barcode_hash
 | 
			
		||||
            hidden: stockitem?.barcode_hash
 | 
			
		||||
          }),
 | 
			
		||||
          UnlinkBarcodeAction({
 | 
			
		||||
            disabled: !stockitem?.barcode_hash
 | 
			
		||||
            hidden: !stockitem?.barcode_hash
 | 
			
		||||
          })
 | 
			
		||||
        ]}
 | 
			
		||||
      />,
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,8 @@ interface LocalStateProps {
 | 
			
		||||
    tableKey: string
 | 
			
		||||
  ) => (names: Record<string, string>) => void;
 | 
			
		||||
  clearTableColumnNames: () => void;
 | 
			
		||||
  detailDrawerStack: number;
 | 
			
		||||
  addDetailDrawer: (value: number | false) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useLocalState = create<LocalStateProps>()(
 | 
			
		||||
@@ -61,6 +63,7 @@ export const useLocalState = create<LocalStateProps>()(
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      // tables
 | 
			
		||||
      tableColumnNames: {},
 | 
			
		||||
      getTableColumnNames: (tableKey) => {
 | 
			
		||||
        return get().tableColumnNames[tableKey] || {};
 | 
			
		||||
@@ -76,6 +79,14 @@ export const useLocalState = create<LocalStateProps>()(
 | 
			
		||||
      },
 | 
			
		||||
      clearTableColumnNames: () => {
 | 
			
		||||
        set({ tableColumnNames: {} });
 | 
			
		||||
      },
 | 
			
		||||
      // detail drawers
 | 
			
		||||
      detailDrawerStack: 0,
 | 
			
		||||
      addDetailDrawer: (value) => {
 | 
			
		||||
        set({
 | 
			
		||||
          detailDrawerStack:
 | 
			
		||||
            value === false ? 0 : get().detailDrawerStack + value
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }),
 | 
			
		||||
    {
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,6 @@ import { IconCheck, IconDots, IconRefresh } from '@tabler/icons-react';
 | 
			
		||||
import { useQuery } from '@tanstack/react-query';
 | 
			
		||||
import { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
import { Link } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { api } from '../../App';
 | 
			
		||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
			
		||||
@@ -32,7 +31,10 @@ import {
 | 
			
		||||
import { InfoItem } from '../../components/items/InfoItem';
 | 
			
		||||
import { UnavailableIndicator } from '../../components/items/UnavailableIndicator';
 | 
			
		||||
import { YesNoButton } from '../../components/items/YesNoButton';
 | 
			
		||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
 | 
			
		||||
import {
 | 
			
		||||
  DetailDrawer,
 | 
			
		||||
  DetailDrawerLink
 | 
			
		||||
} from '../../components/nav/DetailDrawer';
 | 
			
		||||
import {
 | 
			
		||||
  StatusRenderer,
 | 
			
		||||
  TableStatusRenderer
 | 
			
		||||
@@ -289,9 +291,10 @@ function MachineDrawer({
 | 
			
		||||
            <InfoItem name={t`Machine Type`}>
 | 
			
		||||
              <Group spacing="xs">
 | 
			
		||||
                {machineType ? (
 | 
			
		||||
                  <Link to={`../type-${machine?.machine_type}`}>
 | 
			
		||||
                    <Text>{machineType.name}</Text>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                  <DetailDrawerLink
 | 
			
		||||
                    to={`../type-${machine?.machine_type}`}
 | 
			
		||||
                    text={machineType.name}
 | 
			
		||||
                  />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Text>{machine?.machine_type}</Text>
 | 
			
		||||
                )}
 | 
			
		||||
@@ -301,9 +304,10 @@ function MachineDrawer({
 | 
			
		||||
            <InfoItem name={t`Machine Driver`}>
 | 
			
		||||
              <Group spacing="xs">
 | 
			
		||||
                {machineDriver ? (
 | 
			
		||||
                  <Link to={`../driver-${machine?.driver}`}>
 | 
			
		||||
                    <Text>{machineDriver.name}</Text>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                  <DetailDrawerLink
 | 
			
		||||
                    to={`../driver-${machine?.driver}`}
 | 
			
		||||
                    text={machineDriver.name}
 | 
			
		||||
                  />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Text>{machine?.driver}</Text>
 | 
			
		||||
                )}
 | 
			
		||||
 
 | 
			
		||||
@@ -120,6 +120,7 @@ function MachineTypeDrawer({ machineTypeSlug }: { machineTypeSlug: string }) {
 | 
			
		||||
                    ? `../../plugin/${machineType?.provider_plugin?.pk}/`
 | 
			
		||||
                    : undefined
 | 
			
		||||
                }
 | 
			
		||||
                detailDrawerLink
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            <InfoItem
 | 
			
		||||
@@ -224,6 +225,7 @@ function MachineDriverDrawer({
 | 
			
		||||
                  ? `../type-${machineDriver?.machine_type}`
 | 
			
		||||
                  : undefined
 | 
			
		||||
              }
 | 
			
		||||
              detailDrawerLink
 | 
			
		||||
            />
 | 
			
		||||
            {!machineDriver?.is_builtin && (
 | 
			
		||||
              <InfoItem
 | 
			
		||||
@@ -235,6 +237,7 @@ function MachineDriverDrawer({
 | 
			
		||||
                    ? `../../plugin/${machineDriver?.provider_plugin?.pk}/`
 | 
			
		||||
                    : undefined
 | 
			
		||||
                }
 | 
			
		||||
                detailDrawerLink
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            <InfoItem
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										303
									
								
								src/frontend/src/tables/settings/TemplateTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										303
									
								
								src/frontend/src/tables/settings/TemplateTable.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,303 @@
 | 
			
		||||
import { Trans, t } from '@lingui/macro';
 | 
			
		||||
import { Box, Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
 | 
			
		||||
import { IconDots } from '@tabler/icons-react';
 | 
			
		||||
import { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
			
		||||
import {
 | 
			
		||||
  CodeEditor,
 | 
			
		||||
  PdfPreview,
 | 
			
		||||
  TemplateEditor
 | 
			
		||||
} from '../../components/editors/TemplateEditor';
 | 
			
		||||
import { TemplatePreviewProps } from '../../components/editors/TemplateEditor/TemplateEditor';
 | 
			
		||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
 | 
			
		||||
import {
 | 
			
		||||
  ActionDropdown,
 | 
			
		||||
  DeleteItemAction,
 | 
			
		||||
  EditItemAction
 | 
			
		||||
} from '../../components/items/ActionDropdown';
 | 
			
		||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
 | 
			
		||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
			
		||||
import {
 | 
			
		||||
  useCreateApiFormModal,
 | 
			
		||||
  useDeleteApiFormModal,
 | 
			
		||||
  useEditApiFormModal
 | 
			
		||||
} from '../../hooks/UseForm';
 | 
			
		||||
import { useInstance } from '../../hooks/UseInstance';
 | 
			
		||||
import { useTable } from '../../hooks/UseTable';
 | 
			
		||||
import { apiUrl } from '../../states/ApiState';
 | 
			
		||||
import { TableColumn } from '../Column';
 | 
			
		||||
import { BooleanColumn } from '../ColumnRenderers';
 | 
			
		||||
import { InvenTreeTable } from '../InvenTreeTable';
 | 
			
		||||
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
 | 
			
		||||
 | 
			
		||||
export type TemplateI = {
 | 
			
		||||
  pk: number;
 | 
			
		||||
  name: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  filters: string;
 | 
			
		||||
  enabled: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface TemplateProps {
 | 
			
		||||
  apiEndpoint: ApiEndpoints;
 | 
			
		||||
  templateType: 'label' | 'report';
 | 
			
		||||
  templateTypeTranslation: string;
 | 
			
		||||
  variant: string;
 | 
			
		||||
  templateKey: string;
 | 
			
		||||
  additionalFormFields?: ApiFormFieldSet;
 | 
			
		||||
  preview: TemplatePreviewProps;
 | 
			
		||||
  defaultTemplate: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TemplateDrawer({
 | 
			
		||||
  id,
 | 
			
		||||
  refreshTable,
 | 
			
		||||
  templateProps
 | 
			
		||||
}: {
 | 
			
		||||
  id: string;
 | 
			
		||||
  refreshTable: () => void;
 | 
			
		||||
  templateProps: TemplateProps;
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    apiEndpoint,
 | 
			
		||||
    templateType,
 | 
			
		||||
    templateTypeTranslation,
 | 
			
		||||
    variant,
 | 
			
		||||
    additionalFormFields
 | 
			
		||||
  } = templateProps;
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const {
 | 
			
		||||
    instance: template,
 | 
			
		||||
    refreshInstance,
 | 
			
		||||
    instanceQuery: { isFetching, error }
 | 
			
		||||
  } = useInstance<TemplateI>({
 | 
			
		||||
    endpoint: apiEndpoint,
 | 
			
		||||
    pathParams: { variant },
 | 
			
		||||
    pk: id,
 | 
			
		||||
    throwError: true
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const editTemplate = useEditApiFormModal({
 | 
			
		||||
    url: apiEndpoint,
 | 
			
		||||
    pathParams: { variant },
 | 
			
		||||
    pk: id,
 | 
			
		||||
    title: t`Edit` + ' ' + templateTypeTranslation,
 | 
			
		||||
    fields: {
 | 
			
		||||
      name: {},
 | 
			
		||||
      description: {},
 | 
			
		||||
      filters: {},
 | 
			
		||||
      enabled: {},
 | 
			
		||||
      ...additionalFormFields
 | 
			
		||||
    },
 | 
			
		||||
    onFormSuccess: (data) => {
 | 
			
		||||
      refreshInstance();
 | 
			
		||||
      refreshTable();
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const deleteTemplate = useDeleteApiFormModal({
 | 
			
		||||
    url: apiEndpoint,
 | 
			
		||||
    pathParams: { variant },
 | 
			
		||||
    pk: id,
 | 
			
		||||
    title: t`Delete` + ' ' + templateTypeTranslation,
 | 
			
		||||
    onFormSuccess: () => {
 | 
			
		||||
      refreshTable();
 | 
			
		||||
      navigate('../');
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (isFetching) {
 | 
			
		||||
    return <LoadingOverlay visible={true} />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (error || !template) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Text>
 | 
			
		||||
        {(error as any)?.response?.status === 404 ? (
 | 
			
		||||
          <Trans>
 | 
			
		||||
            {templateTypeTranslation} with id {id} not found
 | 
			
		||||
          </Trans>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Trans>
 | 
			
		||||
            An error occurred while fetching {templateTypeTranslation} details
 | 
			
		||||
          </Trans>
 | 
			
		||||
        )}
 | 
			
		||||
      </Text>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack spacing="xs" style={{ display: 'flex', flex: '1' }}>
 | 
			
		||||
      {editTemplate.modal}
 | 
			
		||||
      {deleteTemplate.modal}
 | 
			
		||||
 | 
			
		||||
      <Group position="apart">
 | 
			
		||||
        <Box></Box>
 | 
			
		||||
 | 
			
		||||
        <Group>
 | 
			
		||||
          <Title order={4}>{template?.name}</Title>
 | 
			
		||||
        </Group>
 | 
			
		||||
 | 
			
		||||
        <Group>
 | 
			
		||||
          <ActionDropdown
 | 
			
		||||
            tooltip={templateTypeTranslation + ' ' + t`actions`}
 | 
			
		||||
            icon={<IconDots />}
 | 
			
		||||
            actions={[
 | 
			
		||||
              EditItemAction({
 | 
			
		||||
                tooltip: t`Edit` + ' ' + templateTypeTranslation,
 | 
			
		||||
                onClick: editTemplate.open
 | 
			
		||||
              }),
 | 
			
		||||
              DeleteItemAction({
 | 
			
		||||
                tooltip: t`Delete` + ' ' + templateTypeTranslation,
 | 
			
		||||
                onClick: deleteTemplate.open
 | 
			
		||||
              })
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
        </Group>
 | 
			
		||||
      </Group>
 | 
			
		||||
 | 
			
		||||
      <TemplateEditor
 | 
			
		||||
        downloadUrl={(template as any)[templateProps.templateKey]}
 | 
			
		||||
        uploadUrl={apiUrl(apiEndpoint, id, { variant })}
 | 
			
		||||
        uploadKey={templateProps.templateKey}
 | 
			
		||||
        preview={templateProps.preview}
 | 
			
		||||
        templateType={templateType}
 | 
			
		||||
        template={template}
 | 
			
		||||
        editors={[CodeEditor]}
 | 
			
		||||
        previewAreas={[PdfPreview]}
 | 
			
		||||
      />
 | 
			
		||||
    </Stack>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function TemplateTable({
 | 
			
		||||
  templateProps
 | 
			
		||||
}: {
 | 
			
		||||
  templateProps: TemplateProps;
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    apiEndpoint,
 | 
			
		||||
    templateType,
 | 
			
		||||
    templateTypeTranslation,
 | 
			
		||||
    variant,
 | 
			
		||||
    templateKey,
 | 
			
		||||
    additionalFormFields,
 | 
			
		||||
    defaultTemplate
 | 
			
		||||
  } = templateProps;
 | 
			
		||||
  const table = useTable(`${templateType}-${variant}`);
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
 | 
			
		||||
  const openDetailDrawer = useCallback((pk: number) => navigate(`${pk}/`), []);
 | 
			
		||||
 | 
			
		||||
  const columns: TableColumn<TemplateI>[] = useMemo(() => {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
        accessor: 'name',
 | 
			
		||||
        sortable: true
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        accessor: 'description',
 | 
			
		||||
        sortable: false
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        accessor: 'filters',
 | 
			
		||||
        sortable: false
 | 
			
		||||
      },
 | 
			
		||||
      ...Object.entries(additionalFormFields || {})?.map(([key, field]) => ({
 | 
			
		||||
        accessor: key,
 | 
			
		||||
        sortable: false
 | 
			
		||||
      })),
 | 
			
		||||
      BooleanColumn({ accessor: 'enabled', title: t`Enabled` })
 | 
			
		||||
    ];
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const [selectedTemplate, setSelectedTemplate] = useState<number>(-1);
 | 
			
		||||
 | 
			
		||||
  const rowActions = useCallback((record: TemplateI): RowAction[] => {
 | 
			
		||||
    return [
 | 
			
		||||
      RowEditAction({
 | 
			
		||||
        onClick: () => openDetailDrawer(record.pk)
 | 
			
		||||
      }),
 | 
			
		||||
      RowDeleteAction({
 | 
			
		||||
        onClick: () => {
 | 
			
		||||
          setSelectedTemplate(record.pk), deleteTemplate.open();
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    ];
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const deleteTemplate = useDeleteApiFormModal({
 | 
			
		||||
    url: apiEndpoint,
 | 
			
		||||
    pathParams: { variant },
 | 
			
		||||
    pk: selectedTemplate,
 | 
			
		||||
    title: t`Delete` + ' ' + templateTypeTranslation,
 | 
			
		||||
    onFormSuccess: table.refreshTable
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const newTemplate = useCreateApiFormModal({
 | 
			
		||||
    url: apiEndpoint,
 | 
			
		||||
    pathParams: { variant },
 | 
			
		||||
    title: t`Create new` + ' ' + templateTypeTranslation,
 | 
			
		||||
    fields: {
 | 
			
		||||
      name: {},
 | 
			
		||||
      description: {},
 | 
			
		||||
      filters: {},
 | 
			
		||||
      enabled: {},
 | 
			
		||||
      [templateKey]: {
 | 
			
		||||
        hidden: true,
 | 
			
		||||
        value: new File([defaultTemplate], 'template.html')
 | 
			
		||||
      },
 | 
			
		||||
      ...additionalFormFields
 | 
			
		||||
    },
 | 
			
		||||
    onFormSuccess: (data) => {
 | 
			
		||||
      table.refreshTable();
 | 
			
		||||
      openDetailDrawer(data.pk);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const tableActions = useMemo(() => {
 | 
			
		||||
    let actions = [];
 | 
			
		||||
 | 
			
		||||
    actions.push(
 | 
			
		||||
      <AddItemButton
 | 
			
		||||
        key={`add-${templateType}`}
 | 
			
		||||
        onClick={() => newTemplate.open()}
 | 
			
		||||
        tooltip={t`Add` + ' ' + templateTypeTranslation}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return actions;
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {newTemplate.modal}
 | 
			
		||||
      {deleteTemplate.modal}
 | 
			
		||||
      <DetailDrawer
 | 
			
		||||
        title={t`Edit` + ' ' + templateTypeTranslation}
 | 
			
		||||
        size={'90%'}
 | 
			
		||||
        renderContent={(id) => {
 | 
			
		||||
          return (
 | 
			
		||||
            <TemplateDrawer
 | 
			
		||||
              id={id ?? ''}
 | 
			
		||||
              refreshTable={table.refreshTable}
 | 
			
		||||
              templateProps={templateProps}
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <InvenTreeTable
 | 
			
		||||
        url={apiUrl(apiEndpoint, undefined, { variant })}
 | 
			
		||||
        tableState={table}
 | 
			
		||||
        columns={columns}
 | 
			
		||||
        props={{
 | 
			
		||||
          rowActions: rowActions,
 | 
			
		||||
          tableActions: tableActions,
 | 
			
		||||
          onRowClick: (record) => openDetailDrawer(record.pk)
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -7,7 +7,10 @@ import { Link } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
 | 
			
		||||
import { EditApiForm } from '../../components/forms/ApiForm';
 | 
			
		||||
import { DetailDrawer } from '../../components/nav/DetailDrawer';
 | 
			
		||||
import {
 | 
			
		||||
  DetailDrawer,
 | 
			
		||||
  DetailDrawerLink
 | 
			
		||||
} from '../../components/nav/DetailDrawer';
 | 
			
		||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
 | 
			
		||||
import { openCreateApiForm, openDeleteApiForm } from '../../functions/forms';
 | 
			
		||||
import { useInstance } from '../../hooks/UseInstance';
 | 
			
		||||
@@ -125,7 +128,10 @@ export function UserDrawer({
 | 
			
		||||
          <List>
 | 
			
		||||
            {userDetail?.groups?.map((group) => (
 | 
			
		||||
              <List.Item key={group.pk}>
 | 
			
		||||
                <Link to={`../group-${group.pk}`}>{group.name}</Link>
 | 
			
		||||
                <DetailDrawerLink
 | 
			
		||||
                  to={`../group-${group.pk}`}
 | 
			
		||||
                  text={group.name}
 | 
			
		||||
                />
 | 
			
		||||
              </List.Item>
 | 
			
		||||
            ))}
 | 
			
		||||
          </List>
 | 
			
		||||
 
 | 
			
		||||
@@ -322,6 +322,13 @@
 | 
			
		||||
  dependencies:
 | 
			
		||||
    regenerator-runtime "^0.14.0"
 | 
			
		||||
 | 
			
		||||
"@babel/runtime@^7.18.6":
 | 
			
		||||
  version "7.23.9"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
 | 
			
		||||
  integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    regenerator-runtime "^0.14.0"
 | 
			
		||||
 | 
			
		||||
"@babel/template@^7.22.15":
 | 
			
		||||
  version "7.22.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
 | 
			
		||||
@@ -356,6 +363,133 @@
 | 
			
		||||
    "@babel/helper-validator-identifier" "^7.22.20"
 | 
			
		||||
    to-fast-properties "^2.0.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/autocomplete@^6.0.0":
 | 
			
		||||
  version "6.12.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz#3fa620a8a3f42ded7751749916e8375f6bbbb333"
 | 
			
		||||
  integrity sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.17.0"
 | 
			
		||||
    "@lezer/common" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0":
 | 
			
		||||
  version "6.3.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.3.3.tgz#03face5bf5f3de0fc4e09b177b3c91eda2ceb7e9"
 | 
			
		||||
  integrity sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.4.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
    "@lezer/common" "^1.1.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/lang-css@^6.0.0":
 | 
			
		||||
  version "6.2.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.1.tgz#5dc0a43b8e3c31f6af7aabd55ff07fe9aef2a227"
 | 
			
		||||
  integrity sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/autocomplete" "^6.0.0"
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@lezer/common" "^1.0.2"
 | 
			
		||||
    "@lezer/css" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/lang-html@^6.0.0":
 | 
			
		||||
  version "6.4.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.8.tgz#961db9b1037efcb1d0f50ae6082e5a367fa1470c"
 | 
			
		||||
  integrity sha512-tE2YK7wDlb9ZpAH6mpTPiYm6rhfdQKVDa5r9IwIFlwwgvVaKsCfuKKZoJGWsmMZIf3FQAuJ5CHMPLymOtg1hXw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/autocomplete" "^6.0.0"
 | 
			
		||||
    "@codemirror/lang-css" "^6.0.0"
 | 
			
		||||
    "@codemirror/lang-javascript" "^6.0.0"
 | 
			
		||||
    "@codemirror/language" "^6.4.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.17.0"
 | 
			
		||||
    "@lezer/common" "^1.0.0"
 | 
			
		||||
    "@lezer/css" "^1.1.0"
 | 
			
		||||
    "@lezer/html" "^1.3.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/lang-javascript@^6.0.0":
 | 
			
		||||
  version "6.2.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz#7141090b22994bef85bcc5608a3bc1257f2db2ad"
 | 
			
		||||
  integrity sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/autocomplete" "^6.0.0"
 | 
			
		||||
    "@codemirror/language" "^6.6.0"
 | 
			
		||||
    "@codemirror/lint" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.17.0"
 | 
			
		||||
    "@lezer/common" "^1.0.0"
 | 
			
		||||
    "@lezer/javascript" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/lang-liquid@^6.2.1":
 | 
			
		||||
  version "6.2.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/lang-liquid/-/lang-liquid-6.2.1.tgz#78ded5e5b2aabbdf4687787ba9a29fce0da7e2ad"
 | 
			
		||||
  integrity sha512-J1Mratcm6JLNEiX+U2OlCDTysGuwbHD76XwuL5o5bo9soJtSbz2g6RU3vGHFyS5DC8rgVmFSzi7i6oBftm7tnA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/autocomplete" "^6.0.0"
 | 
			
		||||
    "@codemirror/lang-html" "^6.0.0"
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
    "@lezer/common" "^1.0.0"
 | 
			
		||||
    "@lezer/highlight" "^1.0.0"
 | 
			
		||||
    "@lezer/lr" "^1.3.1"
 | 
			
		||||
 | 
			
		||||
"@codemirror/language@^6.0.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
 | 
			
		||||
  version "6.10.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.1.tgz#428c932a158cb75942387acfe513c1ece1090b05"
 | 
			
		||||
  integrity sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.23.0"
 | 
			
		||||
    "@lezer/common" "^1.1.0"
 | 
			
		||||
    "@lezer/highlight" "^1.0.0"
 | 
			
		||||
    "@lezer/lr" "^1.0.0"
 | 
			
		||||
    style-mod "^4.0.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/lint@^6.0.0":
 | 
			
		||||
  version "6.5.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.5.0.tgz#ea43b6e653dcc5bcd93456b55e9fe62e63f326d9"
 | 
			
		||||
  integrity sha512-+5YyicIaaAZKU8K43IQi8TBy6mF6giGeWAH7N96Z5LC30Wm5JMjqxOYIE9mxwMG1NbhT2mA3l9hA4uuKUM3E5g==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
    crelt "^1.0.5"
 | 
			
		||||
 | 
			
		||||
"@codemirror/search@^6.0.0":
 | 
			
		||||
  version "6.5.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.6.tgz#8f858b9e678d675869112e475f082d1e8488db93"
 | 
			
		||||
  integrity sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
    crelt "^1.0.5"
 | 
			
		||||
 | 
			
		||||
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0":
 | 
			
		||||
  version "6.4.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b"
 | 
			
		||||
  integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==
 | 
			
		||||
 | 
			
		||||
"@codemirror/theme-one-dark@^6.0.0":
 | 
			
		||||
  version "6.1.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz#fcef9f9cfc17a07836cb7da17c9f6d7231064df8"
 | 
			
		||||
  integrity sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
    "@lezer/highlight" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0":
 | 
			
		||||
  version "6.24.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.24.1.tgz#c151d589dc27f9197c68d395811b93c21c801767"
 | 
			
		||||
  integrity sha512-sBfP4rniPBRQzNakwuQEqjEuiJDWJyF2kqLLqij4WXRoVwPPJfjx966Eq3F7+OPQxDtMt/Q9MWLoZLWjeveBlg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/state" "^6.4.0"
 | 
			
		||||
    style-mod "^4.1.0"
 | 
			
		||||
    w3c-keyname "^2.2.4"
 | 
			
		||||
 | 
			
		||||
"@emotion/babel-plugin@^11.11.0":
 | 
			
		||||
  version "11.11.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c"
 | 
			
		||||
@@ -791,6 +925,52 @@
 | 
			
		||||
    "@jridgewell/resolve-uri" "^3.1.0"
 | 
			
		||||
    "@jridgewell/sourcemap-codec" "^1.4.14"
 | 
			
		||||
 | 
			
		||||
"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
 | 
			
		||||
  version "1.2.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.1.tgz#198b278b7869668e1bebbe687586e12a42731049"
 | 
			
		||||
  integrity sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==
 | 
			
		||||
 | 
			
		||||
"@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
 | 
			
		||||
  version "1.1.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.8.tgz#11fd456dac53bc899b266778794ed4ca9576a5a4"
 | 
			
		||||
  integrity sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@lezer/common" "^1.2.0"
 | 
			
		||||
    "@lezer/highlight" "^1.0.0"
 | 
			
		||||
    "@lezer/lr" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
 | 
			
		||||
  version "1.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.0.tgz#e5898c3644208b4b589084089dceeea2966f7780"
 | 
			
		||||
  integrity sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@lezer/common" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@lezer/html@^1.3.0":
 | 
			
		||||
  version "1.3.9"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.9.tgz#097150f0fb0d14e274515d3b3e50e7bd4a1d7ebc"
 | 
			
		||||
  integrity sha512-MXxeCMPyrcemSLGaTQEZx0dBUH0i+RPl8RN5GwMAzo53nTsd/Unc/t5ZxACeQoyPUM5/GkPLRUs2WliOImzkRA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@lezer/common" "^1.2.0"
 | 
			
		||||
    "@lezer/highlight" "^1.0.0"
 | 
			
		||||
    "@lezer/lr" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@lezer/javascript@^1.0.0":
 | 
			
		||||
  version "1.4.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.13.tgz#e6459a000e1d7369db3e97b1764da63eeb5afe1b"
 | 
			
		||||
  integrity sha512-5IBr8LIO3xJdJH1e9aj/ZNLE4LSbdsx25wFmGRAZsj2zSmwAYjx26JyU/BYOCpRQlu1jcv1z3vy4NB9+UkfRow==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@lezer/common" "^1.2.0"
 | 
			
		||||
    "@lezer/highlight" "^1.1.3"
 | 
			
		||||
    "@lezer/lr" "^1.3.0"
 | 
			
		||||
 | 
			
		||||
"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1":
 | 
			
		||||
  version "1.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.0.tgz#ed52a75dbbfbb0d1eb63710ea84c35ee647cb67e"
 | 
			
		||||
  integrity sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@lezer/common" "^1.0.0"
 | 
			
		||||
 | 
			
		||||
"@lingui/babel-plugin-extract-messages@4.5.0":
 | 
			
		||||
  version "4.5.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@lingui/babel-plugin-extract-messages/-/babel-plugin-extract-messages-4.5.0.tgz#71e56cc2eae73890caeea15a00ae4965413430ec"
 | 
			
		||||
@@ -1342,6 +1522,52 @@
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@types/yargs-parser" "*"
 | 
			
		||||
 | 
			
		||||
"@uiw/codemirror-extensions-basic-setup@4.21.22":
 | 
			
		||||
  version "4.21.22"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.21.22.tgz#7e68e5f9fb305d7a35948190351f219a1443d775"
 | 
			
		||||
  integrity sha512-Lxq2EitQb/MwbNrMHBmVdSIR96WmaICnYBYeZbLUxmr4kQcbrA6HXqNSNZJ0V4ZihPfKnNs9+g87QK0HsadE6A==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/autocomplete" "^6.0.0"
 | 
			
		||||
    "@codemirror/commands" "^6.0.0"
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/lint" "^6.0.0"
 | 
			
		||||
    "@codemirror/search" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
 | 
			
		||||
"@uiw/codemirror-theme-vscode@^4.21.22":
 | 
			
		||||
  version "4.21.22"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@uiw/codemirror-theme-vscode/-/codemirror-theme-vscode-4.21.22.tgz#76cdcacbcda93de4a409c673bb5e52901c4588f1"
 | 
			
		||||
  integrity sha512-8E7txA1IFCB+a38tovL7ZoKLC1mDfA3X83XYU+KQoaxyPzImm8aJLVDghHH8EUF0gOdJWPSW13OPVAGCayBKCA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@uiw/codemirror-themes" "4.21.22"
 | 
			
		||||
 | 
			
		||||
"@uiw/codemirror-themes@4.21.22":
 | 
			
		||||
  version "4.21.22"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@uiw/codemirror-themes/-/codemirror-themes-4.21.22.tgz#3a1b813a1b7b9bfd48fc2935df24d277a78ee294"
 | 
			
		||||
  integrity sha512-oRMNtDmD6ER0EH2/NKGbrUzeRJbZ/4+GE3/9OItaAGhdsd2V33WGqVX7QwXsjLNhpNfscbVKB3PYLyRooBdlfg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
 | 
			
		||||
"@uiw/react-codemirror@^4.21.22":
 | 
			
		||||
  version "4.21.22"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.21.22.tgz#38482e1b94e45eb4f33e7f8d5337762aa5891987"
 | 
			
		||||
  integrity sha512-VmxU9oRXwcleG2u5Ui2xVXaLVPL8cBuRN3vA41hlu4OQ/ftJb+4p+dBd6bZ+NJKSXm3LufbPGzu8oKwNO4tG4A==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/runtime" "^7.18.6"
 | 
			
		||||
    "@codemirror/commands" "^6.1.0"
 | 
			
		||||
    "@codemirror/state" "^6.1.1"
 | 
			
		||||
    "@codemirror/theme-one-dark" "^6.0.0"
 | 
			
		||||
    "@uiw/codemirror-extensions-basic-setup" "4.21.22"
 | 
			
		||||
    codemirror "^6.0.0"
 | 
			
		||||
 | 
			
		||||
"@uiw/react-split@^5.9.3":
 | 
			
		||||
  version "5.9.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@uiw/react-split/-/react-split-5.9.3.tgz#3d99f2b62288d4a6eb716b0fd6b37a2c5dc7f82e"
 | 
			
		||||
  integrity sha512-HgwETU+kRhzZAp+YiE4Yu8bNJm3jxxnGgGPfkadUl8jA1wsMD3aMMskuhQF5akiUUUadiLUvAc8e1RH9Y/SKDw==
 | 
			
		||||
 | 
			
		||||
"@vitejs/plugin-react@^4.1.0":
 | 
			
		||||
  version "4.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.1.0.tgz#e4f56f46fd737c5d386bb1f1ade86ba275fe09bd"
 | 
			
		||||
@@ -1602,6 +1828,19 @@ codemirror@^5.63.1:
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.15.tgz#66899278f44a7acde0eb641388cd563fe6dfbe19"
 | 
			
		||||
  integrity sha512-YC4EHbbwQeubZzxLl5G4nlbLc1T21QTrKGaOal/Pkm9dVDMZXMH7+ieSPEOZCtO9I68i8/oteJKOxzHC2zR+0g==
 | 
			
		||||
 | 
			
		||||
codemirror@^6.0.0:
 | 
			
		||||
  version "6.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29"
 | 
			
		||||
  integrity sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@codemirror/autocomplete" "^6.0.0"
 | 
			
		||||
    "@codemirror/commands" "^6.0.0"
 | 
			
		||||
    "@codemirror/language" "^6.0.0"
 | 
			
		||||
    "@codemirror/lint" "^6.0.0"
 | 
			
		||||
    "@codemirror/search" "^6.0.0"
 | 
			
		||||
    "@codemirror/state" "^6.0.0"
 | 
			
		||||
    "@codemirror/view" "^6.0.0"
 | 
			
		||||
 | 
			
		||||
color-convert@^1.9.0:
 | 
			
		||||
  version "1.9.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
 | 
			
		||||
@@ -1679,6 +1918,11 @@ cosmiconfig@^8.0.0:
 | 
			
		||||
    parse-json "^5.2.0"
 | 
			
		||||
    path-type "^4.0.0"
 | 
			
		||||
 | 
			
		||||
crelt@^1.0.5:
 | 
			
		||||
  version "1.0.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
 | 
			
		||||
  integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
 | 
			
		||||
 | 
			
		||||
css-color-keywords@^1.0.0:
 | 
			
		||||
  version "1.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
 | 
			
		||||
@@ -2796,6 +3040,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
 | 
			
		||||
  dependencies:
 | 
			
		||||
    ansi-regex "^5.0.1"
 | 
			
		||||
 | 
			
		||||
style-mod@^4.0.0, style-mod@^4.1.0:
 | 
			
		||||
  version "4.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.0.tgz#a313a14f4ae8bb4d52878c0053c4327fb787ec09"
 | 
			
		||||
  integrity sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==
 | 
			
		||||
 | 
			
		||||
styled-components@^6.1.0:
 | 
			
		||||
  version "6.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.1.0.tgz#228e3ab9c1ee1daa4b0a06aae30df0ed14fda274"
 | 
			
		||||
@@ -2983,6 +3232,11 @@ vite@^4.5.2:
 | 
			
		||||
  optionalDependencies:
 | 
			
		||||
    fsevents "~2.3.2"
 | 
			
		||||
 | 
			
		||||
w3c-keyname@^2.2.4:
 | 
			
		||||
  version "2.2.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
 | 
			
		||||
  integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
 | 
			
		||||
 | 
			
		||||
wcwidth@^1.0.1:
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user