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:
Lukas 2024-03-04 21:58:12 +01:00 committed by GitHub
parent 950bda4ef6
commit e4d2e2f96b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1932 additions and 150 deletions

View File

@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 180
INVENTREE_API_VERSION = 181
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v181 - 2024-02-21 : https://github.com/inventree/InvenTree/pull/6541
- Adds "width" and "height" fields to the LabelTemplate API endpoint
- Adds "page_size" and "landscape" fields to the ReportTemplate API endpoint
v180 - 2024-3-02 : https://github.com/inventree/InvenTree/pull/6463
- Tweaks to API documentation to allow automatic documentation generation

View File

@ -15,7 +15,16 @@ class LabelSerializerBase(InvenTreeModelSerializer):
@staticmethod
def label_fields():
"""Generic serializer fields for a label template."""
return ['pk', 'name', 'description', 'label', 'filters', 'enabled']
return [
'pk',
'name',
'description',
'label',
'filters',
'width',
'height',
'enabled',
]
class StockItemLabelSerializer(LabelSerializerBase):

View File

@ -24,7 +24,16 @@ class ReportSerializerBase(InvenTreeModelSerializer):
@staticmethod
def report_fields():
"""Generic serializer fields for a report template."""
return ['pk', 'name', 'description', 'template', 'filters', 'enabled']
return [
'pk',
'name',
'description',
'template',
'filters',
'page_size',
'landscape',
'enabled',
]
class TestReportSerializer(ReportSerializerBase):

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

View File

@ -0,0 +1,33 @@
---
title: Template editor
---
## Template editor
The Template Editor is integrated into the Admin center of Platform UI (PUI). It allows users to create and edit label and report templates directly with a side by side preview for a more productive workflow.
![Template Table](../assets/images/report/template-table.png)
On the left side (1) are all possible possible template types for labels and reports listed. With the "+" button (2), above the template table (3), new templates for the selected type can be created. To switch to the template editor click on a template.
### Editing Templates
The Template Editor provides a split pane interface, with a [code editor (1)](#code-editor-1) on the left and a [preview area (2)](#previewing-templates-2) on the right. The split view can be resized as required with the drag handler in-between.
![Template Editor](../assets/images/report/template-editor.png)
#### Code editor (1)
The code editor supports syntax highlighting and making it easier to write and edit templates.
#### Previewing Templates (2)
One of the key features of the Template Editor is the ability to preview the rendered output of the templates. Users can select an InvenTree item (3) to render the template for, allowing them to see how the final output will look in production.
To render the preview currently **overriding the production template is required** due to API limitations. To do so, first select an item (3) to use for the preview and then press the "Save & Reload preview" button at the top right of the code editor. The first time a confirm dialog opens that you need to confirm that the production template now will be overridden.
If you don't want to override the template, but just render a preview for a template how it is currently stored in InvenTree, click on the down arrow on the right of that button and select "Reload preview". That will just render the selected item (4) with the InvenTree stored template.
#### Edit template metadata
Editing metadata such as name, description, filters and even width/height for labels and orientation/page size for reports can be done from the edit modal accessible when clicking on the three dots (4) and select "Edit" in the dropdown menu.

View File

@ -135,6 +135,7 @@ nav:
- Project Codes: order/project_codes.md
- Report:
- Templates: report/report.md
- Template Editor: report/template_editor.md
- Report Types:
- Test Reports: report/test.md
- Build Order: report/build.md

View File

@ -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",

View 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>
);
}

View File

@ -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>

View File

@ -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} />

View File

@ -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>
);
});

View File

@ -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
};

View File

@ -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%" />}
</>
);
}
);

View File

@ -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
};

View File

@ -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>
);
}

View File

@ -0,0 +1,3 @@
export { TemplateEditor } from './TemplateEditor';
export { CodeEditor } from './CodeEditor';
export { PdfPreview } from './PdfPreview';

View 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>
);
}

View File

@ -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) => {

View File

@ -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
};
}

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;
}

View 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 %}
`;

View File

@ -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/',

View File

@ -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;
};

View File

@ -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`,

View File

@ -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>
);
}

View File

@ -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({})
]}

View File

@ -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)
})
]}
/>

View File

@ -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
})
]}
/>

View File

@ -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
})
]}
/>,

View File

@ -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
})
]}
/>,

View File

@ -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
});
}
}),
{

View File

@ -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>
)}

View File

@ -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

View 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)
}}
/>
</>
);
}

View File

@ -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>

View File

@ -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"