2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +00:00

Report printing refactor (#7074)

* Adds a new "generic" ReportTemplate model

* expose API endpoints

* Update model / migrations / serializer

* Add new mixin class to existing database models

* - Add detail view for report template
- Revert filters field behaviour

* Filter report list by provided item IDs

- Greatly simplify filtering logic compared to existing implemetation
- Expose to API schema

* Create data migration for converting *old* report templates

* Ignore internal reports for data migration

* Add report mixin to StockLocation model

* Provide model choices in admin interface

* Offload context data generation to the model classes

* Remove old report template models

* Refactor JS code in CUI

* Fix for API filtering

* Add data migration to delete old models

* Remove dead URL

* Updates

* Construct sample report templates on app start

* Bump API version

* Typo fix

* Fix incorrect context calls

* Add new LabelTemplate model

- ReportTemplate and LabelTemplate share common base
- Refactor previous migration

* Expose to admin interface

* Add in extra context from existing label models

* Add migration to create LabelTemplate instances from existing labels

* Add API endpoints for listing and updating LabelTemplate objects

* Adjust 'upload_to' path

* Refactor label printing

* Move default label templates

* Update API endpoints

* Update migrations

* Handle LookupError in migration

* Redirect the "label" API endpoint

* Add new model for handling result of template printing

* Refactor LabelPrinting mixin

* Unlink "labels" app entirely

* Fix typo

* Record 'plugin' used to generate a particular output

* Fix imports

* Generate label print response

- Still not good yet

* Refactoring label printing in CUI

* add "items" count to TemplateOutput model

* Fix for InvenTreeLabelSheetPlugin

* Remove old "label" app

* Make request object optional

* Fix filename generation

* Add help text for "model_type"

* Simplify TemplateTable

* Tweak TemplateTable

* Get template editor to display template data again

* Stringify template name

- Important, otherwise you get a TypeError instead of TemplateDoesNotExist

* Add hooks to reset plugin state

* fix context for StockLocation model

* Tweak log messages

* Fix incorrect serializer

* Cleanup TemplateTable

* Fix broken import

* Filter by target model type

* Remove manual file operations

* Update old migrations

- Remove references to functions that no longer exist

* Refactor asset / snippet uploading

* Update comments

* Retain original filename when editing templatese

* Cleanup

* Refactor model type filter to use new hook

* Add placeholder actions for printing labels and reports

* Improve hookiness

* Add new ReportOutput class

* Report printing works from PUI now!

* More inspired filename pattern for generated reports

* Fix template preview window

- Use new "output" response field across the board

* Remove outdated task

* Update data migration to use raw SQL

- If the 'labels' app is no longer available, this will fail
- So, use raw SQL instead

* Add more API endpoint defs

* Adds placeholder API endpoint for label printing

* Expose plugin field to the printing endpoint

* Adds plugin model type

* Hook to print labels

* Refactor action dropdown items

* Refactor report printing for CUI

* Refactor label print for CUI

- Still needs to handle custom printing options for plugin

* Fix migration

* Update ModelType dict

* playwright test fix

* Unit test fixes

* Fix model ruleset associations

* Fix for report.js

* Add support for "dynamic" fields in metadata.py

* Add in custom fields based on plugin

* Refactoring

* Reset plugin on form close

* Set custom timeout values

* Update migration

- Not atomic

* Cleanup

* Implement more printing actions

* Reduce timeout

* Unit test updates

* Fix part serializers

* Label printing works in CUI again

* js linting

* Update <ActionDropdown>

* Fix for label printing API endpoint

* Fix filterselectdrawer

* Improve button rendering

* Allow printing from StockLocationTable

* Add aria-labels to modal form fields

* Add test for printing stock item labels from table

* Add test for report printing

* Add unit testing for report template editing / preview

* Message refactor

* Refactor InvenTreeReportMixin class

* Update playwright test

* Update 'verbose_name' for a number of models

* Additional admin filtering

* Playwright test updates

* Run checks against new python lib branch

(temporary, will be reverted)

* remove old app reference

* fix testing ref

* fix app init

* remove old tests

* Revert custom target branch

* Expose label and report output objects to API

* refactor

* fix a few tests

* factor plugin_ref out

* fix options testing

* Update table field header

* re-enable full options testing

* fix missing plugin matching

* disable call assert

* Add custom related field for PluginConfig

- Uses 'key' rather than 'pk'
- Revert label print plugin to use slug

* Add support for custom pk field in metadata

* switch to labels for testing

* re-align report testing code

* disable version check

* fix url

* Implement lazy loading

* Allow blank plugin for printing

- Uses the builtin label printer if not specified

* Add printing actions for StockItem

* Fix for metadata helper

* Use key instead of pk in printing actions

* Support non-standard pk values in RelatedModelField

* pass context data to report serializers

* disable template / item discovery

* fix call

* Tweak unit test

* Run python checks against specific branch

* Add task for running docs server

- Option to compile schema as part of task

* Custom branch no longer needed

* Starting on documentation updates

* fix tests for reports

* fix label testing

* Update template context variables

* Refactor report context documentation

* Documentation cleanup

* Docs cleanup

* Include sample report files

* Fix links

* Link cleanup

* Integrate plugin example code into docs

* Code cleanup

* Fix type annotation

* Revert deleted variable

* remove templatetype

* remove unused imports

* extend context testing

* test if plg can print

* re-enable version check

* Update unit tests

* Fix test

* Adjust unit test

* Add debug statement to test

* Fix unit test

- Labels get printed against LabelTemplate items, duh

* Unit test update

* Unit test updates

* Test update

* Patch fix for <PartColumn> component

* Fix ReportSerialierBase class

- Re-initialize field options if not already set

* Fix unit test for sqlite

* Fix kwargs for non-blocking label printing

* Update playwright tests

* Tweak unit test

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2024-05-22 10:17:01 +10:00
committed by GitHub
parent d99b6ae81b
commit aa39582d89
217 changed files with 4507 additions and 6762 deletions

View File

@ -0,0 +1,191 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { extractAvailableFields } from '../../functions/forms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { apiUrl } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState';
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
import { ActionDropdown } from '../items/ActionDropdown';
export function PrintingActions({
items,
enableLabels,
enableReports,
modelType
}: {
items: number[];
enableLabels?: boolean;
enableReports?: boolean;
modelType?: ModelType;
}) {
const { host } = useLocalState.getState();
const enabled = useMemo(() => items.length > 0, [items]);
const [pluginKey, setPluginKey] = useState<string>('');
const loadFields = useCallback(() => {
if (!enableLabels) {
return;
}
api
.options(apiUrl(ApiEndpoints.label_print), {
params: {
plugin: pluginKey || undefined
}
})
.then((response: any) => {
setExtraFields(extractAvailableFields(response, 'POST') || {});
})
.catch(() => {});
}, [enableLabels, pluginKey]);
useEffect(() => {
loadFields();
}, [loadFields, pluginKey]);
const [extraFields, setExtraFields] = useState<ApiFormFieldSet>({});
const labelFields: ApiFormFieldSet = useMemo(() => {
let fields: ApiFormFieldSet = extraFields;
// Override field values
fields['template'] = {
...fields['template'],
filters: {
enabled: true,
model_type: modelType,
items: items.join(',')
}
};
fields['items'] = {
...fields['items'],
value: items,
hidden: true
};
fields['plugin'] = {
...fields['plugin'],
filters: {
active: true,
mixin: 'labels'
},
onValueChange: (value: string, record?: any) => {
console.log('onValueChange:', value, record);
if (record?.key && record?.key != pluginKey) {
setPluginKey(record.key);
}
}
};
return fields;
}, [extraFields, items, loadFields]);
const labelModal = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.label_print),
title: t`Print Label`,
fields: labelFields,
timeout: (items.length + 1) * 1000,
onClose: () => {
setPluginKey('');
},
successMessage: t`Label printing completed successfully`,
onFormSuccess: (response: any) => {
if (!response.complete) {
// TODO: Periodically check for completion (requires server-side changes)
notifications.show({
title: t`Error`,
message: t`The label could not be generated`,
color: 'red'
});
return;
}
if (response.output) {
// An output file was generated
const url = `${host}${response.output}`;
window.open(url, '_blank');
}
}
});
const reportModal = useCreateApiFormModal({
title: t`Print Report`,
url: apiUrl(ApiEndpoints.report_print),
timeout: (items.length + 1) * 1000,
fields: {
template: {
filters: {
enabled: true,
model_type: modelType,
items: items.join(',')
}
},
items: {
hidden: true,
value: items
}
},
successMessage: t`Report printing completed successfully`,
onFormSuccess: (response: any) => {
if (!response.complete) {
// TODO: Periodically check for completion (requires server-side changes)
notifications.show({
title: t`Error`,
message: t`The report could not be generated`,
color: 'red'
});
return;
}
if (response.output) {
// An output file was generated
const url = `${host}${response.output}`;
window.open(url, '_blank');
}
}
});
if (!modelType) {
return null;
}
if (!enableLabels && !enableReports) {
return null;
}
return (
<>
{reportModal.modal}
{labelModal.modal}
<ActionDropdown
tooltip={t`Printing Actions`}
icon={<IconPrinter />}
disabled={!enabled}
actions={[
{
name: t`Print Labels`,
icon: <IconTags />,
onClick: () => labelModal.open(),
hidden: !enableLabels
},
{
name: t`Print Reports`,
icon: <IconReport />,
onClick: () => reportModal.open(),
hidden: !enableReports
}
]}
/>
</>
);
}

View File

@ -10,6 +10,7 @@ import {
import { IconChevronDown } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { identifierString } from '../../functions/conversion';
import { TablerIconType } from '../../functions/icons';
import * as classes from './SplitButton.css';
@ -25,6 +26,7 @@ interface SplitButtonOption {
interface SplitButtonProps {
options: SplitButtonOption[];
defaultSelected: string;
name: string;
selected?: string;
setSelected?: (value: string) => void;
loading?: boolean;
@ -34,6 +36,7 @@ export function SplitButton({
options,
defaultSelected,
selected,
name,
setSelected,
loading
}: Readonly<SplitButtonProps>) {
@ -61,6 +64,7 @@ export function SplitButton({
disabled={loading ? false : currentOption?.disabled}
className={classes.button}
loading={loading}
aria-label={`split-button-${name}`}
>
{currentOption?.name}
</Button>
@ -75,6 +79,7 @@ export function SplitButton({
color={theme.primaryColor}
size={36}
className={classes.icon}
aria-label={`split-button-${name}-action`}
>
<IconChevronDown size={16} />
</ActionIcon>
@ -88,6 +93,9 @@ export function SplitButton({
setCurrent(option.key);
option.onClick();
}}
aria-label={`split-button-${name}-item-${identifierString(
option.key
)}`}
disabled={option.disabled}
leftSection={<option.icon />}
>

View File

@ -1,4 +1,4 @@
import { Trans, t } from '@lingui/macro';
import { Trans } from '@lingui/macro';
import { forwardRef, useImperativeHandle, useState } from 'react';
import { api } from '../../../../App';
@ -13,54 +13,53 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
code,
previewItem,
saveTemplate,
{ uploadKey, uploadUrl, preview: { itemKey }, templateType }
{ templateUrl, printingUrl, template }
) => {
if (saveTemplate) {
const formData = new FormData();
formData.append(uploadKey, new File([code], 'template.html'));
const res = await api.patch(uploadUrl, formData);
const filename =
template.template?.split('/').pop() ?? 'template.html';
formData.append('template', new File([code], filename));
const res = await api.patch(templateUrl, 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}`,
let preview = await api.post(
printingUrl,
{
responseType: templateType === 'label' ? 'json' : 'blob',
items: [previewItem],
template: template.pk
},
{
responseType: 'json',
timeout: 30000,
validateStatus: () => true
}
);
if (preview.status !== 200) {
if (templateType === 'report') {
let data;
try {
data = JSON.parse(await preview.data.text());
} catch (err) {
throw new Error(t`Failed to parse error response from server.`);
}
throw new Error(data.detail?.join(', '));
} else if (preview.data?.non_field_errors) {
if (preview.status !== 200 && preview.status !== 201) {
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, {
if (preview?.data?.output) {
preview = await api.get(preview.data.output, {
responseType: 'blob'
});
}
// ----
let pdf = new Blob([preview.data], {
type: preview.headers['content-type']
});
let srcUrl = URL.createObjectURL(pdf);
setPdfUrl(srcUrl + '#view=fitH');

View File

@ -9,7 +9,7 @@ import {
Tabs
} from '@mantine/core';
import { openConfirmModal } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { notifications, showNotification } from '@mantine/notifications';
import {
IconAlertTriangle,
IconDeviceFloppy,
@ -70,25 +70,16 @@ export type PreviewArea = {
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';
export type TemplateEditorProps = {
templateUrl: string;
printingUrl: string;
editors: Editor[];
previewAreas: PreviewArea[];
template: TemplateI;
};
export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
const { downloadUrl, editors, previewAreas, preview } = props;
const { templateUrl, editors, previewAreas, template } = props;
const editorRef = useRef<EditorRef>();
const previewRef = useRef<PreviewAreaRef>();
@ -131,13 +122,17 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
}, []);
useEffect(() => {
if (!downloadUrl) return;
if (!templateUrl) return;
api.get(downloadUrl).then((res) => {
codeRef.current = res.data;
loadCodeToEditor(res.data);
api.get(templateUrl).then((response: any) => {
if (response.data?.template) {
api.get(response.data.template).then((res) => {
codeRef.current = res.data;
loadCodeToEditor(res.data);
});
}
});
}, [downloadUrl]);
}, [templateUrl]);
useEffect(() => {
if (codeRef.current === undefined) return;
@ -148,7 +143,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
async (confirmed: boolean, saveTemplate: boolean = true) => {
if (!confirmed) {
openConfirmModal({
title: t`Save & Reload preview?`,
title: t`Save & Reload Preview`,
children: (
<Alert
color="yellow"
@ -187,10 +182,14 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
)
.then(() => {
setErrorOverlay(null);
notifications.hide('template-preview');
showNotification({
title: t`Preview updated`,
message: t`The preview has been updated successfully.`,
color: 'green'
color: 'green',
id: 'template-preview'
});
})
.catch((error) => {
@ -204,18 +203,25 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
);
const previewApiUrl = useMemo(
() => ModelInformationDict[preview.model].api_endpoint,
[preview.model]
() =>
ModelInformationDict[template.model_type ?? ModelType.stockitem]
.api_endpoint,
[template]
);
const templateFilters: Record<string, string> = useMemo(() => {
// TODO: Extract custom filters from template
return {};
}, [template]);
useEffect(() => {
api
.get(apiUrl(previewApiUrl), { params: { limit: 1, ...preview.filters } })
.get(apiUrl(previewApiUrl), { params: { limit: 1, ...templateFilters } })
.then((res) => {
if (res.data.results.length === 0) return;
setPreviewItem(res.data.results[0].pk);
});
}, [previewApiUrl, preview.filters]);
}, [previewApiUrl, templateFilters]);
return (
<Stack style={{ height: '100%', flex: '1' }}>
@ -249,6 +255,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
<SplitButton
loading={isPreviewLoading}
defaultSelected="preview_save"
name="preview-options"
options={[
{
key: 'preview',
@ -260,7 +267,7 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
},
{
key: 'preview_save',
name: t`Save & Reload preview`,
name: t`Save & Reload Preview`,
tooltip: t`Save the current template and reload the preview`,
icon: IconDeviceFloppy,
onClick: () => updatePreview(hasSaveConfirmed),
@ -319,10 +326,10 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
field_type: 'related field',
api_url: apiUrl(previewApiUrl),
description: '',
label: t`Select` + ' ' + preview.model + ' ' + t`to preview`,
model: preview.model,
label: t`Select instance to preview`,
model: template.model_type,
value: previewItem,
filters: preview.filters,
filters: templateFilters,
onValueChange: (value) => setPreviewItem(value)
}}
/>

View File

@ -40,6 +40,7 @@ export type ApiFormAdjustFilterType = {
* @param icon : An icon to display next to the field
* @param field_type : The type of field to render
* @param api_url : The API endpoint to fetch data from (for related fields)
* @param pk_field : The primary key field for the related field (default = "pk")
* @param model : The model to use for related fields
* @param filters : Optional API filters to apply to related fields
* @param required : Whether the field is required
@ -74,6 +75,7 @@ export type ApiFormFieldType = {
| 'nested object'
| 'table';
api_url?: string;
pk_field?: string;
model?: ModelType;
modelRenderer?: (instance: any) => ReactNode;
filters?: any;
@ -190,6 +192,7 @@ export function ApiFormField({
{...reducedDefinition}
ref={field.ref}
id={fieldId}
aria-label={`text-field-${field.name}`}
type={definition.field_type}
value={value || ''}
error={error?.message}
@ -208,6 +211,7 @@ export function ApiFormField({
{...reducedDefinition}
ref={ref}
id={fieldId}
aria-label={`boolean-field-${field.name}`}
radius="lg"
size="sm"
checked={isTrue(value)}
@ -228,6 +232,7 @@ export function ApiFormField({
radius="sm"
ref={field.ref}
id={fieldId}
aria-label={`number-field-${field.name}`}
value={numericalValue}
error={error?.message}
decimalScale={definition.field_type == 'integer' ? 0 : 10}

View File

@ -51,6 +51,7 @@ export function ChoiceField({
return (
<Select
id={fieldId}
aria-label={`choice-field-${field.name}`}
error={error?.message}
radius="sm"
{...field}

View File

@ -50,6 +50,7 @@ export default function DateField({
return (
<DateInput
id={fieldId}
aria-label={`date-field-${field.name}`}
radius="sm"
ref={field.ref}
type={undefined}

View File

@ -64,22 +64,24 @@ export function RelatedModelField({
field.value !== ''
) {
const url = `${definition.api_url}${field.value}/`;
api.get(url).then((response) => {
if (response.data && response.data.pk) {
let pk_field = definition.pk_field ?? 'pk';
if (response.data && response.data[pk_field]) {
const value = {
value: response.data.pk,
value: response.data[pk_field],
data: response.data
};
setInitialData(value);
dataRef.current = [value];
setPk(response.data.pk);
setPk(response.data[pk_field]);
}
});
} else {
setPk(null);
}
}, [definition.api_url, field.value]);
}, [definition.api_url, definition.pk_field, field.value]);
// Search input query
const [value, setValue] = useState<string>('');
@ -146,13 +148,15 @@ export function RelatedModelField({
const results = response.data?.results ?? response.data ?? [];
results.forEach((item: any) => {
// do not push already existing items into the values array
if (alreadyPresentPks.includes(item.pk)) return;
let pk_field = definition.pk_field ?? 'pk';
let pk = item[pk_field];
values.push({
value: item.pk ?? -1,
data: item
});
if (pk && !alreadyPresentPks.includes(pk)) {
values.push({
value: pk,
data: item
});
}
});
setData(values);
@ -276,6 +280,7 @@ export function RelatedModelField({
>
<Select
id={fieldId}
aria-label={`related-field-${field.name}`}
value={currentValue}
ref={field.ref}
options={data}

View File

@ -33,11 +33,11 @@ export function TableField({
};
return (
<Table highlightOnHover striped>
<Table highlightOnHover striped aria-label={`table-field-${field.name}`}>
<Table.Thead>
<Table.Tr>
{definition.headers?.map((header) => {
return <th key={header}>{header}</th>;
return <Table.Th key={header}>{header}</Table.Th>;
})}
</Table.Tr>
</Table.Thead>

View File

@ -14,9 +14,9 @@ import {
IconTrash,
IconUnlink
} from '@tabler/icons-react';
import { color } from '@uiw/react-codemirror';
import { ReactNode, useMemo } from 'react';
import { identifierString } from '../../functions/conversion';
import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
@ -42,19 +42,24 @@ export function ActionDropdown({
disabled = false
}: {
icon: ReactNode;
tooltip?: string;
tooltip: string;
actions: ActionDropdownItem[];
disabled?: boolean;
}) {
const hasActions = useMemo(() => {
return actions.some((action) => !action.hidden);
}, [actions]);
const indicatorProps = useMemo(() => {
return actions.find((action) => action.indicator);
}, [actions]);
const menuName: string = useMemo(() => {
return identifierString(`action-menu-${tooltip}`);
}, [tooltip]);
return hasActions ? (
<Menu position="bottom-end">
<Menu position="bottom-end" key={menuName}>
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
<Menu.Target>
<Tooltip label={tooltip} hidden={!tooltip}>
@ -63,6 +68,7 @@ export function ActionDropdown({
radius="sm"
variant="outline"
disabled={disabled}
aria-label={menuName}
>
{icon}
</ActionIcon>
@ -70,15 +76,17 @@ export function ActionDropdown({
</Menu.Target>
</Indicator>
<Menu.Dropdown>
{actions.map((action) =>
action.hidden ? null : (
{actions.map((action) => {
const id: string = identifierString(`${menuName}-${action.name}`);
return action.hidden ? null : (
<Indicator
disabled={!action.indicator}
{...action.indicator}
key={action.name}
>
<Tooltip label={action.tooltip}>
<Tooltip label={action.tooltip} hidden={!action.tooltip}>
<Menu.Item
aria-label={id}
leftSection={action.icon}
onClick={() => {
if (action.onClick != undefined) {
@ -93,8 +101,8 @@ export function ActionDropdown({
</Menu.Item>
</Tooltip>
</Indicator>
)
)}
);
})}
</Menu.Dropdown>
</Menu>
) : null;
@ -108,7 +116,6 @@ export function BarcodeActionDropdown({
}) {
return (
<ActionDropdown
key="barcode-actions"
tooltip={t`Barcode Actions`}
icon={<IconQrcode />}
actions={actions}

View File

@ -26,6 +26,8 @@ import {
RenderPartParameterTemplate,
RenderPartTestTemplate
} from './Part';
import { RenderPlugin } from './Plugin';
import { RenderLabelTemplate, RenderReportTemplate } from './Report';
import {
RenderStockItem,
RenderStockLocation,
@ -72,7 +74,10 @@ const RendererLookup: EnumDictionary<
[ModelType.stockitem]: RenderStockItem,
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser
[ModelType.user]: RenderUser,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin
};
export type RenderInstanceProps = {

View File

@ -195,6 +195,27 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/user',
url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list
},
labeltemplate: {
label: t`Label Template`,
label_multiple: t`Label Templates`,
url_overview: '/labeltemplate',
url_detail: '/labeltemplate/:pk/',
api_endpoint: ApiEndpoints.label_list
},
reporttemplate: {
label: t`Report Template`,
label_multiple: t`Report Templates`,
url_overview: '/reporttemplate',
url_detail: '/reporttemplate/:pk/',
api_endpoint: ApiEndpoints.report_list
},
pluginconfig: {
label: t`Plugin Configuration`,
label_multiple: t`Plugin Configurations`,
url_overview: '/pluginconfig',
url_detail: '/pluginconfig/:pk/',
api_endpoint: ApiEndpoints.plugin_list
}
};

View File

@ -0,0 +1,21 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { ReactNode } from 'react';
import { RenderInlineModel } from './Instance';
export function RenderPlugin({
instance
}: {
instance: Readonly<any>;
}): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.meta?.description}
suffix={
!instance.active && <Badge size="sm" color="red">{t`Inactive`}</Badge>
}
/>
);
}

View File

@ -0,0 +1,29 @@
import { ReactNode } from 'react';
import { RenderInlineModel } from './Instance';
export function RenderReportTemplate({
instance
}: {
instance: any;
}): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
);
}
export function RenderLabelTemplate({
instance
}: {
instance: any;
}): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
);
}

View File

@ -80,7 +80,7 @@ export const StatusRenderer = ({
const statusCodes = statusCodeList[type];
if (statusCodes === undefined) {
console.log('StatusRenderer: statusCodes is undefined');
console.warn('StatusRenderer: statusCodes is undefined');
return null;
}

View File

@ -127,8 +127,14 @@ export enum ApiEndpoints {
return_order_attachment_list = 'order/ro/attachment/',
// Template API endpoints
label_list = 'label/:variant/',
report_list = 'report/:variant/',
label_list = 'label/template/',
label_print = 'label/print/',
label_output = 'label/output/',
report_list = 'report/template/',
report_print = 'report/print/',
report_output = 'report/output/',
report_snippet = 'report/snippet/',
report_asset = 'report/asset/',
// Plugin API endpoints
plugin_list = 'plugins/',

View File

@ -24,5 +24,8 @@ export enum ModelType {
address = 'address',
contact = 'contact',
owner = 'owner',
user = 'user'
user = 'user',
reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig'
}

View File

@ -32,3 +32,8 @@ export function resolveItem(obj: any, path: string): any {
let properties = path.split('.');
return properties.reduce((prev, curr) => prev?.[curr], obj);
}
export function identifierString(value: string): string {
// Convert an input string e.g. "Hello World" into a string that can be used as an identifier, e.g. "hello-world"
return value.toLowerCase().replace(/[^a-z0-9]/g, '-');
}

View File

@ -27,7 +27,7 @@ export function useInstance<T = any>({
updateInterval
}: {
endpoint: ApiEndpoints;
pk?: string | undefined;
pk?: string | number | undefined;
hasPrimaryKey?: boolean;
params?: any;
pathParams?: PathParams;
@ -43,7 +43,12 @@ export function useInstance<T = any>({
queryKey: ['instance', endpoint, pk, params, pathParams],
queryFn: async () => {
if (hasPrimaryKey) {
if (pk == null || pk == undefined || pk.length == 0 || pk == '-1') {
if (
pk == null ||
pk == undefined ||
pk.toString().length == 0 ||
pk == '-1'
) {
setInstance(defaultValue);
return defaultValue;
}

View File

@ -24,6 +24,7 @@ export type TableState = {
expandedRecords: any[];
setExpandedRecords: (records: any[]) => void;
selectedRecords: any[];
selectedIds: number[];
hasSelectedRecords: boolean;
setSelectedRecords: (records: any[]) => void;
clearSelectedRecords: () => void;
@ -77,6 +78,12 @@ export function useTable(tableName: string): TableState {
// Array of selected records
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
// Array of selected primary key values
const selectedIds = useMemo(
() => selectedRecords.map((r) => r.pk ?? r.id),
[selectedRecords]
);
const clearSelectedRecords = useCallback(() => {
setSelectedRecords([]);
}, []);
@ -135,6 +142,7 @@ export function useTable(tableName: string): TableState {
expandedRecords,
setExpandedRecords,
selectedRecords,
selectedIds,
setSelectedRecords,
clearSelectedRecords,
hasSelectedRecords,

View File

@ -202,7 +202,6 @@ function SpotlighPlayground() {
onClick: () => console.log('Secret')
}
]);
console.log('registed');
firstSpotlight.open();
}}
>

View File

@ -684,7 +684,6 @@ function InputImageBarcode({ action }: Readonly<ScanInputInterface>) {
useEffect(() => {
if (cameraValue === null) return;
if (cameraValue === camId?.id) {
console.log('matching value and id');
return;
}

View File

@ -9,9 +9,10 @@ import {
IconListDetails,
IconPackages,
IconPlugConnected,
IconReport,
IconScale,
IconSitemap,
IconTemplate,
IconTags,
IconUsersGroup
} from '@tabler/icons-react';
import { lazy, useMemo } from 'react';
@ -22,6 +23,12 @@ import { SettingsHeader } from '../../../../components/nav/SettingsHeader';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { Loadable } from '../../../../functions/loading';
const ReportTemplatePanel = Loadable(
lazy(() => import('./ReportTemplatePanel'))
);
const LabelTemplatePanel = Loadable(lazy(() => import('./LabelTemplatePanel')));
const UserManagementPanel = Loadable(
lazy(() => import('./UserManagementPanel'))
);
@ -66,10 +73,6 @@ const CurrencyTable = Loadable(
lazy(() => import('../../../../tables/settings/CurrencyTable'))
);
const TemplateManagementPanel = Loadable(
lazy(() => import('./TemplateManagementPanel'))
);
export default function AdminCenter() {
const adminCenterPanels: PanelType[] = useMemo(() => {
return [
@ -127,18 +130,24 @@ export default function AdminCenter() {
icon: <IconSitemap />,
content: <PartCategoryTemplateTable />
},
{
name: 'labels',
label: t`Label Templates`,
icon: <IconTags />,
content: <LabelTemplatePanel />
},
{
name: 'reports',
label: t`Report Templates`,
icon: <IconReport />,
content: <ReportTemplatePanel />
},
{
name: 'location-types',
label: t`Location types`,
icon: <IconPackages />,
content: <LocationTypesTable />
},
{
name: 'templates',
label: t`Templates`,
icon: <IconTemplate />,
content: <TemplateManagementPanel />
},
{
name: 'plugin',
label: t`Plugins`,

View File

@ -0,0 +1,17 @@
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { TemplateTable } from '../../../../tables/settings/TemplateTable';
export default function LabelTemplatePanel() {
return (
<TemplateTable
templateProps={{
templateEndpoint: ApiEndpoints.label_list,
printingEndpoint: ApiEndpoints.label_print,
additionalFormFields: {
width: {},
height: {}
}
}}
/>
);
}

View File

@ -0,0 +1,17 @@
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { TemplateTable } from '../../../../tables/settings/TemplateTable';
export default function ReportTemplateTable() {
return (
<TemplateTable
templateProps={{
templateEndpoint: ApiEndpoints.report_list,
printingEndpoint: ApiEndpoints.report_print,
additionalFormFields: {
page_size: {},
landscape: {}
}
}}
/>
);
}

View File

@ -1,211 +0,0 @@
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.buildline
}
}
]
},
{
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

@ -4,13 +4,11 @@ import {
IconClipboardCheck,
IconClipboardList,
IconDots,
IconFileTypePdf,
IconInfoCircle,
IconList,
IconListCheck,
IconNotes,
IconPaperclip,
IconPrinter,
IconQrcode,
IconSitemap
} from '@tabler/icons-react';
@ -18,6 +16,7 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
@ -358,7 +357,6 @@ export default function BuildDetail() {
return [
<AdminButton model={ModelType.build} pk={build.pk} />,
<ActionDropdown
key="barcode"
tooltip={t`Barcode Actions`}
icon={<IconQrcode />}
actions={[
@ -371,20 +369,12 @@ export default function BuildDetail() {
})
]}
/>,
<ActionDropdown
key="report"
tooltip={t`Reporting Actions`}
icon={<IconPrinter />}
actions={[
{
icon: <IconFileTypePdf />,
name: t`Report`,
tooltip: t`Print build report`
}
]}
<PrintingActions
modelType={ModelType.build}
items={[build.pk]}
enableReports
/>,
<ActionDropdown
key="build"
tooltip={t`Build Order Actions`}
icon={<IconDots />}
actions={[

View File

@ -289,7 +289,6 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
return [
<AdminButton model={ModelType.company} pk={company.pk} />,
<ActionDropdown
key="company"
tooltip={t`Company Actions`}
icon={<IconDots />}
actions={[

View File

@ -210,7 +210,6 @@ export default function ManufacturerPartDetail() {
pk={manufacturerPart.pk}
/>,
<ActionDropdown
key="part"
tooltip={t`Manufacturer Part Actions`}
icon={<IconDots />}
actions={[

View File

@ -246,7 +246,6 @@ export default function SupplierPartDetail() {
return [
<AdminButton model={ModelType.supplierpart} pk={supplierPart.pk} />,
<ActionDropdown
key="part"
tooltip={t`Supplier Part Actions`}
icon={<IconDots />}
actions={[

View File

@ -204,7 +204,6 @@ export default function CategoryDetail({}: {}) {
return [
<AdminButton model={ModelType.partcategory} pk={category.pk} />,
<ActionDropdown
key="category"
tooltip={t`Category Actions`}
icon={<IconDots />}
actions={[

View File

@ -761,7 +761,6 @@ export default function PartDetail() {
key="action_dropdown"
/>,
<ActionDropdown
key="stock"
tooltip={t`Stock Actions`}
icon={<IconPackages />}
actions={[
@ -790,7 +789,6 @@ export default function PartDetail() {
]}
/>,
<ActionDropdown
key="part"
tooltip={t`Part Actions`}
icon={<IconDots />}
actions={[

View File

@ -12,6 +12,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
@ -313,8 +314,12 @@ export default function PurchaseOrderDetail() {
})
]}
/>,
<PrintingActions
modelType={ModelType.purchaseorder}
items={[order.pk]}
enableReports
/>,
<ActionDropdown
key="order-actions"
tooltip={t`Order Actions`}
icon={<IconDots />}
actions={[

View File

@ -11,6 +11,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
@ -287,8 +288,12 @@ export default function ReturnOrderDetail() {
const orderActions = useMemo(() => {
return [
<AdminButton model={ModelType.returnorder} pk={order.pk} />,
<PrintingActions
modelType={ModelType.returnorder}
items={[order.pk]}
enableReports
/>,
<ActionDropdown
key="order-actions"
tooltip={t`Order Actions`}
icon={<IconDots />}
actions={[

View File

@ -14,6 +14,7 @@ import { ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
@ -299,8 +300,12 @@ export default function SalesOrderDetail() {
const soActions = useMemo(() => {
return [
<AdminButton model={ModelType.salesorder} pk={order.pk} />,
<PrintingActions
modelType={ModelType.salesorder}
items={[order.pk]}
enableReports
/>,
<ActionDropdown
key="order-actions"
tooltip={t`Order Actions`}
icon={<IconDots />}
actions={[

View File

@ -11,6 +11,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
@ -290,24 +291,14 @@ export default function Stock() {
}
]}
/>,
<ActionDropdown
key="reports"
icon={<InvenTreeIcon icon="reports" />}
actions={[
{
name: 'Print Label',
icon: '',
tooltip: 'Print label'
},
{
name: 'Print Location Report',
icon: '',
tooltip: 'Print Report'
}
]}
<PrintingActions
modelType={ModelType.stocklocation}
items={[location.pk ?? 0]}
enableLabels
enableReports
/>,
<ActionDropdown
key="operations"
tooltip={t`Stock Actions`}
icon={<InvenTreeIcon icon="stock" />}
actions={[
{
@ -329,7 +320,6 @@ export default function Stock() {
]}
/>,
<ActionDropdown
key="location"
tooltip={t`Location Actions`}
icon={<IconDots />}
actions={[

View File

@ -16,6 +16,7 @@ import { ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
@ -429,8 +430,13 @@ export default function StockDetail() {
})
]}
/>,
<PrintingActions
modelType={ModelType.stockitem}
items={[stockitem.pk]}
enableReports
enableLabels
/>,
<ActionDropdown
key="operations"
tooltip={t`Stock Operations`}
icon={<IconPackages />}
actions={[
@ -473,7 +479,6 @@ export default function StockDetail() {
]}
/>,
<ActionDropdown
key="stock"
tooltip={t`Stock Item Actions`}
icon={<IconDots />}
actions={[

View File

@ -2,7 +2,7 @@
* Common rendering functions for table column data.
*/
import { t } from '@lingui/macro';
import { Anchor, Text } from '@mantine/core';
import { Anchor, Skeleton, Text } from '@mantine/core';
import { YesNoButton } from '../components/buttons/YesNoButton';
import { Thumbnail } from '../components/images/Thumbnail';
@ -18,11 +18,13 @@ import { ProjectCodeHoverCard } from './TableHoverCard';
// Render a Part instance within a table
export function PartColumn(part: any, full_name?: boolean) {
return (
return part ? (
<Thumbnail
src={part?.thumbnail ?? part.image}
text={full_name ? part.full_name : part.name}
src={part?.thumbnail ?? part?.image}
text={full_name ? part?.full_name : part?.name}
/>
) : (
<Skeleton />
);
}
@ -226,8 +228,8 @@ export function CurrencyColumn({
sortable: sortable ?? true,
render: (record: any) => {
let currency_key = currency_accessor ?? `${accessor}_currency`;
return formatCurrency(record[accessor], {
currency: currency ?? record[currency_key]
return formatCurrency(resolveItem(record, accessor), {
currency: currency ?? resolveItem(record, currency_key)
});
}
};

View File

@ -1,6 +1,17 @@
import { t } from '@lingui/macro';
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
import { IconDownload } from '@tabler/icons-react';
import {
IconDownload,
IconFileSpreadsheet,
IconFileText,
IconFileTypeCsv
} from '@tabler/icons-react';
import { useMemo } from 'react';
import {
ActionDropdown,
ActionDropdownItem
} from '../components/items/ActionDropdown';
export function DownloadAction({
downloadCallback
@ -8,34 +19,27 @@ export function DownloadAction({
downloadCallback: (fileFormat: string) => void;
}) {
const formatOptions = [
{ value: 'csv', label: t`CSV` },
{ value: 'tsv', label: t`TSV` },
{ value: 'xlsx', label: t`Excel` }
{ value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> },
{ value: 'tsv', label: t`TSV`, icon: <IconFileText /> },
{ value: 'xls', label: t`Excel (.xls)`, icon: <IconFileSpreadsheet /> },
{ value: 'xlsx', label: t`Excel (.xlsx)`, icon: <IconFileSpreadsheet /> }
];
const actions: ActionDropdownItem[] = useMemo(() => {
return formatOptions.map((format) => ({
name: format.label,
icon: format.icon,
onClick: () => downloadCallback(format.value)
}));
}, [formatOptions, downloadCallback]);
return (
<>
<Menu>
<Menu.Target>
<ActionIcon variant="transparent">
<Tooltip label={t`Download selected data`}>
<IconDownload />
</Tooltip>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{formatOptions.map((format) => (
<Menu.Item
key={format.value}
onClick={() => {
downloadCallback(format.value);
}}
>
{format.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<ActionDropdown
tooltip={t`Download Data`}
icon={<IconDownload />}
actions={actions}
/>
</>
);
}

View File

@ -12,7 +12,7 @@ import {
Text,
Tooltip
} from '@mantine/core';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { StylishText } from '../components/items/StylishText';
import { TableState } from '../hooks/UseTable';
@ -63,18 +63,6 @@ interface FilterProps extends React.ComponentPropsWithoutRef<'div'> {
description?: string;
}
/*
* Custom component for the filter select
*/
const FilterSelectItem = forwardRef<HTMLDivElement, FilterProps>(
({ label, description, ...others }, ref) => (
<div ref={ref} {...others}>
<Text size="sm">{label}</Text>
<Text size="xs">{description}</Text>
</div>
)
);
function FilterAddGroup({
tableState,
availableFilters
@ -144,7 +132,6 @@ function FilterAddGroup({
<Divider />
<Select
data={filterOptions}
component={FilterSelectItem}
searchable={true}
placeholder={t`Select filter`}
label={t`Filter`}

View File

@ -15,7 +15,6 @@ import { showNotification } from '@mantine/notifications';
import {
IconBarcode,
IconFilter,
IconPrinter,
IconRefresh,
IconTrash
} from '@tabler/icons-react';
@ -38,6 +37,7 @@ import { api } from '../App';
import { Boundary } from '../components/Boundary';
import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { PrintingActions } from '../components/buttons/PrintingActions';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ModelType } from '../enums/ModelType';
import { resolveItem } from '../functions/conversion';
@ -46,7 +46,6 @@ import { extractAvailableFields, mapFields } from '../functions/forms';
import { navigateToLink } from '../functions/navigation';
import { getDetailUrl } from '../functions/urls';
import { TableState } from '../hooks/UseTable';
import { base_url } from '../main';
import { useLocalState } from '../states/LocalState';
import { TableColumn } from './Column';
import { TableColumnSelect } from './ColumnSelect';
@ -70,13 +69,14 @@ const defaultPageSize: number = 25;
* @param enableFilters : boolean - Enable filter actions
* @param enableSelection : boolean - Enable row selection
* @param enableSearch : boolean - Enable search actions
* @param enableLabels : boolean - Enable printing of labels against selected items
* @param enableReports : boolean - Enable printing of reports against selected items
* @param enablePagination : boolean - Enable pagination
* @param enableRefresh : boolean - Enable refresh actions
* @param pageSize : number - Number of records per page
* @param barcodeActions : any[] - List of barcode actions
* @param tableFilters : TableFilter[] - List of custom filters
* @param tableActions : any[] - List of custom action groups
* @param printingActions : any[] - List of printing actions
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
* @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
* @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
@ -94,11 +94,12 @@ export type InvenTreeTableProps<T = any> = {
enableSearch?: boolean;
enablePagination?: boolean;
enableRefresh?: boolean;
enableLabels?: boolean;
enableReports?: boolean;
pageSize?: number;
barcodeActions?: any[];
tableFilters?: TableFilter[];
tableActions?: React.ReactNode[];
printingActions?: any[];
rowExpansion?: any;
idAccessor?: string;
dataFormatter?: (data: any) => any;
@ -117,6 +118,8 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
params: {},
noRecordsText: t`No records found`,
enableDownload: false,
enableLabels: false,
enableReports: false,
enableFilters: true,
enablePagination: true,
enableRefresh: true,
@ -124,7 +127,6 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
enableSelection: false,
pageSize: defaultPageSize,
defaultSortColumn: '',
printingActions: [],
barcodeActions: [],
tableFilters: [],
tableActions: [],
@ -226,13 +228,16 @@ export function InvenTreeTable<T = any>({
}, [props]);
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns = columns.some(
(col: TableColumn) => col.switchable ?? true
);
const hasSwitchableColumns: boolean = useMemo(() => {
return columns.some((col: TableColumn) => col.switchable ?? true);
}, [columns]);
const onSelectedRecordsChange = useCallback((records: any[]) => {
tableState.setSelectedRecords(records);
}, []);
const onSelectedRecordsChange = useCallback(
(records: any[]) => {
tableState.setSelectedRecords(records);
},
[tableState.setSelectedRecords]
);
// Update column visibility when hiddenColumns change
const dataColumns: any = useMemo(() => {
@ -572,13 +577,22 @@ export function InvenTreeTable<T = any>({
/>
</Boundary>
)}
<Boundary label="inventreetable">
<Boundary label={`InvenTreeTable-${tableState.tableKey}`}>
<Stack gap="sm">
<Group justify="space-between">
<Group justify="apart" grow>
<Group justify="left" key="custom-actions" gap={5}>
{tableProps.tableActions?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
<PrintingActions
items={tableState.selectedIds}
modelType={tableProps.modelType}
enableLabels={tableProps.enableLabels}
enableReports={tableProps.enableReports}
/>
{(tableProps.barcodeActions?.length ?? 0 > 0) && (
<ButtonMenu
key="barcode-actions"
@ -588,24 +602,18 @@ export function InvenTreeTable<T = any>({
actions={tableProps.barcodeActions ?? []}
/>
)}
{(tableProps.printingActions?.length ?? 0 > 0) && (
<ButtonMenu
key="printing-actions"
icon={<IconPrinter />}
label={t`Print actions`}
tooltip={t`Print actions`}
actions={tableProps.printingActions ?? []}
/>
)}
{(tableProps.enableBulkDelete ?? false) && (
<ActionButton
disabled={tableState.selectedRecords.length == 0}
disabled={!tableState.hasSelectedRecords}
icon={<IconTrash />}
color="red"
tooltip={t`Delete selected records`}
onClick={deleteSelectedRecords}
/>
)}
{tableProps.tableActions?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
</Group>
<Space />
<Group justify="right" gap={5}>
@ -644,12 +652,6 @@ export function InvenTreeTable<T = any>({
</ActionIcon>
</Indicator>
)}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
</Group>
</Group>
<Box pos="relative">

View File

@ -194,7 +194,6 @@ export function BuildOrderTable({
tableState={table}
columns={tableColumns}
props={{
enableDownload: true,
params: {
part: partId,
sales_order: salesOrderId,
@ -203,7 +202,10 @@ export function BuildOrderTable({
},
tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.build
modelType: ModelType.build,
enableSelection: true,
enableReports: true,
enableDownload: true
}}
/>
</>

View File

@ -159,7 +159,10 @@ export function PurchaseOrderTable({
},
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.purchaseorder
modelType: ModelType.purchaseorder,
enableSelection: true,
enableDownload: true,
enableReports: true
}}
/>
</>

View File

@ -146,7 +146,10 @@ export function ReturnOrderTable({ params }: { params?: any }) {
},
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.returnorder
modelType: ModelType.returnorder,
enableSelection: true,
enableDownload: true,
enableReports: true
}}
/>
</>

View File

@ -157,7 +157,10 @@ export function SalesOrderTable({
},
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.salesorder
modelType: ModelType.salesorder,
enableSelection: true,
enableDownload: true,
enableReports: true
}}
/>
</>

View File

@ -1,6 +1,6 @@
import { Trans, t } from '@lingui/macro';
import { Box, Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import { IconDots } from '@tabler/icons-react';
import { Group, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import { IconFileCode } from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
@ -10,15 +10,11 @@ import {
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 { ModelType } from '../../enums/ModelType';
import { useFilters } from '../../hooks/UseFilter';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -27,88 +23,54 @@ import {
import { useInstance } from '../../hooks/UseInstance';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
import {
RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '../RowActions';
export type TemplateI = {
pk: number;
name: string;
description: string;
model_type: ModelType;
filters: string;
filename_pattern: string;
enabled: boolean;
template: string;
};
export interface TemplateProps {
apiEndpoint: ApiEndpoints;
templateType: 'label' | 'report';
templateTypeTranslation: string;
variant: string;
templateKey: string;
templateEndpoint: ApiEndpoints;
printingEndpoint: ApiEndpoints;
additionalFormFields?: ApiFormFieldSet;
preview: TemplatePreviewProps;
defaultTemplate: string;
}
export function TemplateDrawer({
id,
refreshTable,
templateProps
}: {
id: string;
refreshTable: () => void;
id: string | number;
templateProps: TemplateProps;
}) {
const {
apiEndpoint,
templateType,
templateTypeTranslation,
variant,
additionalFormFields
} = templateProps;
const navigate = useNavigate();
const { templateEndpoint, printingEndpoint } = templateProps;
const {
instance: template,
refreshInstance,
instanceQuery: { isFetching, error }
} = useInstance<TemplateI>({
endpoint: apiEndpoint,
pathParams: { variant },
endpoint: templateEndpoint,
hasPrimaryKey: true,
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} />;
}
@ -117,13 +79,9 @@ export function TemplateDrawer({
return (
<Text>
{(error as any)?.response?.status === 404 ? (
<Trans>
{templateTypeTranslation} with id {id} not found
</Trans>
<Trans>Template not found</Trans>
) : (
<Trans>
An error occurred while fetching {templateTypeTranslation} details
</Trans>
<Trans>An error occurred while fetching template details</Trans>
)}
</Text>
);
@ -131,40 +89,13 @@ export function TemplateDrawer({
return (
<Stack gap="xs" style={{ display: 'flex', flex: '1' }}>
{editTemplate.modal}
{deleteTemplate.modal}
<Group justify="space-between">
<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 justify="left">
<Title order={4}>{template?.name}</Title>
</Group>
<TemplateEditor
downloadUrl={(template as any)[templateProps.templateKey]}
uploadUrl={apiUrl(apiEndpoint, id, { variant })}
uploadKey={templateProps.templateKey}
preview={templateProps.preview}
templateType={templateType}
templateUrl={apiUrl(templateEndpoint, id)}
printingUrl={apiUrl(printingEndpoint)}
template={template}
editors={[CodeEditor]}
previewAreas={[PdfPreview]}
@ -178,17 +109,11 @@ export function TemplateTable({
}: {
templateProps: TemplateProps;
}) {
const {
apiEndpoint,
templateType,
templateTypeTranslation,
variant,
templateKey,
additionalFormFields,
defaultTemplate
} = templateProps;
const table = useTable(`${templateType}-${variant}`);
const { templateEndpoint, additionalFormFields } = templateProps;
const table = useTable(`${templateEndpoint}-template`);
const navigate = useNavigate();
const user = useUserState();
const openDetailDrawer = useCallback((pk: number) => navigate(`${pk}/`), []);
@ -196,62 +121,120 @@ export function TemplateTable({
return [
{
accessor: 'name',
sortable: true
sortable: true,
switchable: false
},
{
accessor: 'description',
sortable: false
sortable: false,
switchable: true
},
{
accessor: 'template',
sortable: false,
switchable: true,
render: (record: any) => {
return record.template?.split('/')?.pop() ?? '-';
}
},
{
accessor: 'model_type',
sortable: true,
switchable: false
},
{
accessor: 'revision',
sortable: false,
switchable: true
},
{
accessor: 'filters',
sortable: false
sortable: false,
switchable: true
},
...Object.entries(additionalFormFields || {})?.map(([key, field]) => ({
accessor: key,
sortable: false
sortable: false,
switchable: true
})),
BooleanColumn({ accessor: 'enabled', title: t`Enabled` })
];
}, []);
}, [additionalFormFields]);
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 rowActions = useCallback(
(record: TemplateI): RowAction[] => {
return [
{
title: t`Modify`,
tooltip: t`Modify template file`,
icon: <IconFileCode />,
onClick: () => openDetailDrawer(record.pk)
},
RowEditAction({
onClick: () => {
setSelectedTemplate(record.pk);
editTemplate.open();
}
}),
RowDuplicateAction({
// TODO: Duplicate selected template
}),
RowDeleteAction({
onClick: () => {
setSelectedTemplate(record.pk);
deleteTemplate.open();
}
})
];
},
[user]
);
const templateFields: ApiFormFieldSet = {
name: {},
description: {},
model_type: {},
filters: {},
filename_pattern: {},
enabled: {}
};
const editTemplateFields: ApiFormFieldSet = useMemo(() => {
return {
...templateFields,
...additionalFormFields
};
}, [additionalFormFields]);
const newTemplateFields: ApiFormFieldSet = useMemo(() => {
return {
template: {},
...templateFields,
...additionalFormFields
};
}, [additionalFormFields]);
const editTemplate = useEditApiFormModal({
url: templateEndpoint,
pk: selectedTemplate,
title: t`Edit Template`,
fields: editTemplateFields,
onFormSuccess: (record: any) => table.updateRecord(record)
});
const deleteTemplate = useDeleteApiFormModal({
url: apiEndpoint,
pathParams: { variant },
url: templateEndpoint,
pk: selectedTemplate,
title: t`Delete` + ' ' + templateTypeTranslation,
title: t`Delete template`,
table: table
});
const newTemplate = useCreateApiFormModal({
url: apiEndpoint,
pathParams: { variant },
title: t`Add new` + ' ' + templateTypeTranslation,
fields: {
name: {},
description: {},
filters: {},
enabled: {},
[templateKey]: {
hidden: true,
value: new File([defaultTemplate], 'template.html')
},
...additionalFormFields
},
url: templateEndpoint,
title: t`Add Template`,
fields: newTemplateFields,
onFormSuccess: (data) => {
table.refreshTable();
openDetailDrawer(data.pk);
@ -261,13 +244,25 @@ export function TemplateTable({
const tableActions: ReactNode[] = useMemo(() => {
return [
<AddItemButton
key={`add-${templateType}`}
key="add-template"
onClick={() => newTemplate.open()}
tooltip={t`Add` + ' ' + templateTypeTranslation}
tooltip={t`Add template`}
/>
];
}, []);
const modelTypeFilters = useFilters({
url: apiUrl(templateEndpoint),
method: 'OPTIONS',
accessor: 'data.actions.POST.model_type.choices',
transform: (item: any) => {
return {
value: item.value,
label: item.display_name
};
}
});
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
@ -275,30 +270,31 @@ export function TemplateTable({
label: t`Enabled`,
description: t`Filter by enabled status`,
type: 'checkbox'
},
{
name: 'model_type',
label: t`Model Type`,
description: t`Filter by target model type`,
choices: modelTypeFilters.choices
}
];
}, []);
}, [modelTypeFilters.choices]);
return (
<>
{newTemplate.modal}
{editTemplate.modal}
{deleteTemplate.modal}
<DetailDrawer
title={t`Edit` + ' ' + templateTypeTranslation}
title={t`Edit Template`}
size={'90%'}
closeOnEscape={false}
renderContent={(id) => {
return (
<TemplateDrawer
id={id ?? ''}
refreshTable={table.refreshTable}
templateProps={templateProps}
/>
);
return <TemplateDrawer id={id ?? ''} templateProps={templateProps} />;
}}
/>
<InvenTreeTable
url={apiUrl(apiEndpoint, undefined, { variant })}
url={apiUrl(templateEndpoint)}
tableState={table}
columns={columns}
props={{

View File

@ -406,7 +406,7 @@ export function StockItemTable({
let can_change_order = user.hasChangeRole(UserRoles.purchase_order);
return [
<ActionDropdown
key="stockoperations"
tooltip={t`Stock Actions`}
icon={<InvenTreeIcon icon="stock" />}
disabled={table.selectedRecords.length === 0}
actions={[
@ -520,6 +520,8 @@ export function StockItemTable({
props={{
enableDownload: true,
enableSelection: true,
enableLabels: true,
enableReports: true,
tableFilters: tableFilters,
tableActions: tableActions,
modelType: ModelType.stockitem,

View File

@ -140,7 +140,10 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
tableState={table}
columns={tableColumns}
props={{
enableSelection: true,
enableDownload: true,
enableLabels: true,
enableReports: true,
params: {
parent: parentId
},

View File

@ -19,8 +19,11 @@ test('PUI - Pages - Index - Playground', async ({ page }) => {
.locator('div')
.first()
.click();
await page.getByLabel('Name *').fill(newPartName);
await page.getByLabel('Initial Stock Quantity *').fill('1');
// Set the "name"
await page.getByLabel('text-field-name').fill(newPartName);
await page.getByLabel('number-field-initial_stock.').fill('1');
await page
.getByLabel('Create Part')
.getByRole('button', { name: 'Cancel' })
@ -37,7 +40,7 @@ test('PUI - Pages - Index - Playground', async ({ page }) => {
// Create Stock Item
await page.getByRole('button', { name: 'Create Stock Item' }).click();
await page.locator('#react-select-25-input').fill('R_1K_0402_1');
await page.getByLabel('related-field-part').fill('R_1K_0402_1');
await page.getByText('R_1K_0402_1%').click();
await page
.getByLabel('Add Stock Item')

View File

@ -186,7 +186,8 @@ test('PUI - Admin', async ({ page }) => {
await page.getByRole('tab', { name: 'Custom Units' }).click();
await page.getByRole('tab', { name: 'Part Parameters' }).click();
await page.getByRole('tab', { name: 'Category Parameters' }).click();
await page.getByRole('tab', { name: 'Templates' }).click();
await page.getByRole('tab', { name: 'Label Templates' }).click();
await page.getByRole('tab', { name: 'Report Templates' }).click();
await page.getByRole('tab', { name: 'Plugins' }).click();
await page.getByRole('tab', { name: 'Machines' }).click();
});

View File

@ -0,0 +1,108 @@
import { test } from './baseFixtures.js';
import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
/*
* Test for label printing.
* Select a number of stock items from the table,
* and print labels against them
*/
test('PUI - Label Printing', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
await page.getByRole('tab', { name: 'Stock Items' }).click();
// Select some labels
await page.getByLabel('Select record 1', { exact: true }).click();
await page.getByLabel('Select record 2', { exact: true }).click();
await page.getByLabel('Select record 3', { exact: true }).click();
await page
.getByLabel('Stock Items')
.getByLabel('action-menu-printing-actions')
.click();
await page.getByLabel('action-menu-printing-actions-print-labels').click();
// Select plugin
await page.getByLabel('related-field-plugin').click();
await page.getByText('InvenTreeLabelSheet').click();
// Select label template
await page.getByLabel('related-field-template').click();
await page.getByText('InvenTree Stock Item Label (').click();
await page.waitForTimeout(100);
// Submit the form (second time should result in success)
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('#form-success').waitFor();
await page.getByText('Label printing completed').waitFor();
await page.context().close();
});
/*
* Test for report printing
* Navigate to a PurchaseOrder detail page,
* and print a report against it.
*/
test('PUI - Report Printing', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/stock/location/index/`);
await page.waitForURL('**/platform/stock/location/**');
// Navigate to a specific PurchaseOrder
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0009' }).click();
// Select "print report"
await page.getByLabel('action-menu-printing-actions').click();
await page.getByLabel('action-menu-printing-actions-print-reports').click();
// Select template
await page.getByLabel('related-field-template').click();
await page.getByText('InvenTree Purchase Order').click();
await page.waitForTimeout(100);
// Submit the form (should result in success)
await page.getByRole('button', { name: 'Submit' }).isEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('#form-success').waitFor();
await page.getByText('Report printing completed').waitFor();
await page.context().close();
});
test('PUI - Report Editing', async ({ page }) => {
await doQuickLogin(page, 'admin', 'inventree');
// Navigate to the admin center
await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
await page.getByRole('tab', { name: 'Label Templates' }).click();
await page
.getByRole('cell', { name: 'InvenTree Stock Item Label (' })
.click();
// Generate preview
await page.getByLabel('split-button-preview-options-action').click();
await page
.getByLabel('split-button-preview-options-item-preview-save', {
exact: true
})
.click();
await page.getByRole('button', { name: 'Save & Reload' }).click();
await page.getByText('The preview has been updated').waitFor();
await page.context().close();
});

View File

@ -13,7 +13,9 @@ test('PUI - Stock', async ({ page }) => {
await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('cell', { name: '1551ABK' }).click();
await page.getByRole('tab', { name: 'Stock', exact: true }).click();
await page.waitForURL('**/platform/stock/**');
await page.getByRole('tab', { name: 'Stock Locations' }).click();
await page.getByRole('cell', { name: 'Electronics Lab' }).first().click();
await page.getByRole('tab', { name: 'Default Parts' }).click();
@ -78,11 +80,15 @@ test('PUI - Purchasing', async ({ page }) => {
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Address title *').waitFor();
await page.getByLabel('text-field-title').waitFor();
await page.getByLabel('text-field-line2').waitFor();
// Read the current value of the cell, to ensure we always *change* it!
const value = await page.getByLabel('Line 2').inputValue();
await page.getByLabel('Line 2').fill(value == 'old' ? 'new' : 'old');
const value = await page.getByLabel('text-field-line2').inputValue();
await page
.getByLabel('text-field-line2')
.fill(value == 'old' ? 'new' : 'old');
await page.getByRole('button', { name: 'Submit' }).isEnabled();