2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-03-23 20:48:35 +00:00

Merge branch 'master' into generic-parameters

This commit is contained in:
Oliver
2025-11-20 22:32:29 +11:00
committed by GitHub
120 changed files with 81562 additions and 73215 deletions

View File

@@ -7,6 +7,28 @@ import { cancelEvent } from './Events';
export const getBaseUrl = (): string =>
(window as any).INVENTREE_SETTINGS?.base_url || 'web';
/**
* Returns the overview URL for a given model type.
* This is the UI URL, not the API URL.
*/
export function getOverviewUrl(model: ModelType, absolute?: boolean): string {
const modelInfo = ModelInformationDict[model];
if (modelInfo?.url_overview) {
const url = modelInfo.url_overview;
const base = getBaseUrl();
if (absolute && base) {
return `/${base}${url}`;
} else {
return url;
}
}
console.error(`No overview URL found for model ${model}`);
return '';
}
/**
* Returns the detail view URL for a given model type.
* This is the UI URL, not the API URL.

View File

@@ -7,6 +7,7 @@
[build.environment]
VITE_DEMO = "true"
PYTHON_VERSION = "3.11"
# Send requests to subpath

View File

@@ -24,11 +24,7 @@ import { type NavigateFunction, useNavigate } from 'react-router-dom';
import { isTrue } from '@lib/functions/Conversion';
import { getDetailUrl } from '@lib/functions/Navigation';
import type {
ApiFormFieldSet,
ApiFormFieldType,
ApiFormProps
} from '@lib/types/Forms';
import type { ApiFormFieldSet, ApiFormProps } from '@lib/types/Forms';
import { useApi } from '../../contexts/ApiContext';
import {
type NestedDict,
@@ -46,9 +42,11 @@ import { ApiFormField } from './fields/ApiFormField';
export function OptionsApiForm({
props: _props,
opened,
id: pId
}: Readonly<{
props: ApiFormProps;
opened?: boolean;
id?: string;
}>) {
const api = useApi();
@@ -75,24 +73,26 @@ export function OptionsApiForm({
);
const optionsQuery = useQuery({
enabled: true,
enabled: opened !== false && props.ignorePermissionCheck !== true,
refetchOnMount: false,
queryKey: [
'form-options-data',
id,
opened,
props.ignorePermissionCheck,
props.method,
props.url,
props.pk,
props.pathParams
],
queryFn: async () => {
const response = await api.options(url);
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(response, props.method);
if (props.ignorePermissionCheck === true || opened === false) {
return {};
}
return fields;
return api.options(url).then((response: any) => {
return extractAvailableFields(response, props.method);
});
},
throwOnError: (error: any) => {
if (error.response) {
@@ -110,6 +110,13 @@ export function OptionsApiForm({
}
});
// Refetch form options whenever the modal is opened
useEffect(() => {
if (opened !== false) {
optionsQuery.refetch();
}
}, [opened]);
const formProps: ApiFormProps = useMemo(() => {
const _props = { ...props };

View File

@@ -62,6 +62,13 @@ export function RelatedModelField({
const [isOpen, setIsOpen] = useState<boolean>(false);
const [autoFilled, setAutoFilled] = useState<boolean>(false);
useEffect(() => {
// Reset auto-fill status when the form is reconstructed
setAutoFilled(false);
}, []);
// Auto-fill the field with data from the API
useEffect(() => {
// If there is *no value defined*, and autoFill is enabled, then fetch data from the API
@@ -69,10 +76,17 @@ export function RelatedModelField({
return;
}
// Return if the autofill has already been performed
if (autoFilled) {
return;
}
if (field.value != undefined) {
return;
}
setAutoFilled(true);
// Construct parameters for auto-filling the field
const params = {
...(definition?.filters ?? {}),
@@ -114,6 +128,7 @@ export function RelatedModelField({
}
});
}, [
autoFilled,
definition.autoFill,
definition.api_url,
definition.filters,
@@ -395,7 +410,14 @@ export function RelatedModelField({
menuPortalTarget={document.body}
noOptionsMessage={() => t`No results found`}
menuPosition='fixed'
styles={{ menuPortal: (base: any) => ({ ...base, zIndex: 9999 }) }}
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
clearIndicator: (base: any) => ({
...base,
color: 'red',
':hover': { color: 'red' }
})
}}
formatOptionLabel={(option: any) => formatOption(option)}
theme={(theme) => {
return {

View File

@@ -1,6 +1,5 @@
import { t } from '@lingui/core/macro';
import { TextInput, Tooltip } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { IconCopyCheck, IconX } from '@tabler/icons-react';
import {
type ReactNode,
@@ -40,30 +39,26 @@ export default function TextField({
const { value } = useMemo(() => field, [field]);
const [rawText, setRawText] = useState<string>(value || '');
const [textValue, setTextValue] = useState<string>(value || '');
const [debouncedText] = useDebouncedValue(rawText, 100);
const onTextChange = useCallback(
(value: any) => {
setTextValue(value);
onChange(value);
},
[onChange]
);
useEffect(() => {
setRawText(value || '');
setTextValue(value || '');
}, [value]);
const onTextChange = useCallback((value: any) => {
setRawText(value);
}, []);
useEffect(() => {
if (debouncedText !== value) {
onChange(debouncedText);
}
}, [debouncedText]);
// Construct a "right section" for the text field
const textFieldRightSection: ReactNode = useMemo(() => {
if (definition.rightSection) {
// Use the specified override value
return definition.rightSection;
} else if (value) {
} else if (textValue) {
if (!definition.required && !definition.disabled) {
// Render a button to clear the text field
return (
@@ -78,7 +73,7 @@ export default function TextField({
);
}
} else if (
!value &&
!textValue &&
definition.placeholder &&
placeholderAutofill &&
!definition.disabled
@@ -94,7 +89,7 @@ export default function TextField({
</Tooltip>
);
}
}, [placeholderAutofill, definition, value]);
}, [placeholderAutofill, definition, textValue]);
return (
<TextInput
@@ -103,19 +98,19 @@ export default function TextField({
id={fieldId}
aria-label={`text-field-${field.name}`}
type={definition.field_type}
value={rawText || ''}
value={textValue || ''}
error={definition.error ?? error?.message}
radius='sm'
onChange={(event) => onTextChange(event.currentTarget.value)}
onBlur={(event) => {
if (event.currentTarget.value != value) {
onChange(event.currentTarget.value);
if (event.currentTarget.value != textValue) {
onTextChange(event.currentTarget.value);
}
}}
onKeyDown={(event) => {
if (event.code === 'Enter') {
// Bypass debounce on enter key
onChange(event.currentTarget.value);
onTextChange(event.currentTarget.value);
}
onKeyDown(event.code);
}}

View File

@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { useDebouncedValue } from '@mantine/hooks';
import { useApi } from '../../contexts/ApiContext';
import type { ImportSessionState } from '../../hooks/UseImportSession';
import { StandaloneField } from '../forms/StandaloneField';
@@ -83,6 +84,14 @@ function ImporterDefaultField({
}) {
const api = useApi();
const [rawValue, setRawValue] = useState<any>('');
const fieldType: string = useMemo(() => {
return session.availableFields[fieldName]?.type;
}, [fieldName, session.availableFields]);
const [value] = useDebouncedValue(rawValue, fieldType == 'string' ? 500 : 10);
const onChange = useCallback(
(value: any) => {
// Update the default value for the field
@@ -105,6 +114,11 @@ function ImporterDefaultField({
[fieldName, session, session.fieldDefaults]
);
// Update the default value after the debounced value changes
useEffect(() => {
onChange(value);
}, [value]);
const fieldDef: ApiFormFieldType = useMemo(() => {
let def: any = session.availableFields[fieldName];
@@ -114,7 +128,10 @@ function ImporterDefaultField({
value: session.fieldDefaults[fieldName],
field_type: def.type,
description: def.help_text,
onValueChange: onChange
required: false,
onValueChange: (value: string) => {
setRawValue(value);
}
};
}

View File

@@ -215,7 +215,7 @@ export function RenderInlineModel({
return (
<Group gap='xs' justify='space-between' title={tooltip}>
<Group gap='xs' justify='left' wrap='nowrap'>
<Group gap='xs' justify='left'>
{prefix}
{image && <Thumbnail src={image} size={18} />}
{url ? (

View File

@@ -517,7 +517,7 @@ function BuildAllocateLineRow({
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_item_list),
model: ModelType.stockitem,
autoFill: !!output?.serial,
autoFill: !output || !!output?.serial,
autoFillFilters: {
serial: output?.serial
},
@@ -814,7 +814,7 @@ export function useConsumeBuildItemsForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items consumed`,
successMessage: t`Stock items scheduled to be consumed`,
onFormSuccess: onFormSuccess,
size: '80%',
fields: consumeFields,
@@ -915,7 +915,7 @@ export function useConsumeBuildLinesForm({
url: ApiEndpoints.build_order_consume,
pk: buildId,
title: t`Consume Stock`,
successMessage: t`Stock items consumed`,
successMessage: t`Stock items scheduled to be consumed`,
onFormSuccess: onFormSuccess,
fields: consumeFields,
initialData: {

View File

@@ -20,6 +20,9 @@ export function usePartFields({
}): ApiFormFieldSet {
const settings = useGlobalSettingsState();
const [virtual, setVirtual] = useState<boolean>(false);
const [purchaseable, setPurchaseable] = useState<boolean>(false);
return useMemo(() => {
const fields: ApiFormFieldSet = {
category: {
@@ -62,9 +65,19 @@ export function usePartFields({
is_template: {},
testable: {},
trackable: {},
purchaseable: {},
purchaseable: {
value: purchaseable,
onValueChange: (value: boolean) => {
setPurchaseable(value);
}
},
salable: {},
virtual: {},
virtual: {
value: virtual,
onValueChange: (value: boolean) => {
setVirtual(value);
}
},
locked: {},
active: {},
starred: {
@@ -80,33 +93,37 @@ export function usePartFields({
if (create) {
fields.copy_category_parameters = {};
fields.initial_stock = {
icon: <IconPackages />,
children: {
quantity: {
value: 0
},
location: {}
}
};
if (!virtual) {
fields.initial_stock = {
icon: <IconPackages />,
children: {
quantity: {
value: 0
},
location: {}
}
};
}
fields.initial_supplier = {
icon: <IconBuildingStore />,
children: {
supplier: {
filters: {
is_supplier: true
}
},
sku: {},
manufacturer: {
filters: {
is_manufacturer: true
}
},
mpn: {}
}
};
if (purchaseable) {
fields.initial_supplier = {
icon: <IconBuildingStore />,
children: {
supplier: {
filters: {
is_supplier: true
}
},
sku: {},
manufacturer: {
filters: {
is_manufacturer: true
}
},
mpn: {}
}
};
}
}
// Additional fields for part duplication
@@ -159,7 +176,7 @@ export function usePartFields({
}
return fields;
}, [create, duplicatePartInstance, settings]);
}, [virtual, purchaseable, create, duplicatePartInstance, settings]);
}
/**

View File

@@ -125,8 +125,11 @@ export function useSalesOrderLineItemFields({
)
.sort((a: any, b: any) => a.quantity - b.quantity);
if (applicablePriceBreaks.length)
if (applicablePriceBreaks.length) {
setSalePrice(applicablePriceBreaks[0].price);
} else {
setSalePrice('');
}
}, [part, quantity, partCurrency, create]);
return useMemo(() => {
@@ -184,6 +187,7 @@ function SalesOrderAllocateLineRow({
field_type: 'related field',
api_url: apiUrl(ApiEndpoints.stock_item_list),
model: ModelType.stockitem,
autoFill: true,
filters: {
available: true,
part_detail: true,

View File

@@ -1,7 +1,7 @@
import { t } from '@lingui/core/macro';
import { Alert, Divider, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
ApiFormModalProps,
@@ -50,14 +50,18 @@ export function useApiFormModal(props: ApiFormModalProps) {
[props]
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const modal = useModal({
id: modalId,
title: formProps.title,
onOpen: () => {
setIsOpen(true);
modalState.setModalOpen(modalId, true);
formProps.onOpen?.();
},
onClose: () => {
setIsOpen(false);
modalState.setModalOpen(modalId, false);
formProps.onClose?.();
},
@@ -66,7 +70,7 @@ export function useApiFormModal(props: ApiFormModalProps) {
children: (
<Stack gap={'xs'}>
<Divider />
<OptionsApiForm props={formProps} id={modalId} />
<OptionsApiForm props={formProps} id={modalId} opened={isOpen} />
</Stack>
)
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
import type { StockOperationProps } from '@lib/types/Forms';
import { notifications } from '@mantine/notifications';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
@@ -730,7 +730,20 @@ export default function StockDetail() {
return {
items: [stockitem],
model: ModelType.stockitem,
refresh: refreshInstance,
refresh: () => {
const location = stockitem?.location;
refreshInstancePromise().then((response) => {
if (response.status == 'error') {
// If an error occurs refreshing the instance,
// the stock likely has likely been depleted
if (location) {
navigate(getDetailUrl(ModelType.stocklocation, location));
} else {
navigate(getOverviewUrl(ModelType.stockitem));
}
}
});
},
filters: {
in_stock: true
}

View File

@@ -23,11 +23,7 @@ import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
import { ProgressBar } from '@lib/components/ProgressBar';
import {
type RowAction,
RowEditAction,
RowViewAction
} from '@lib/components/RowActions';
import { type RowAction, RowEditAction } from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -569,13 +565,7 @@ export default function BuildOutputTable({
setSelectedOutputs([record]);
cancelBuildOutputsForm.open();
}
},
RowViewAction({
title: t`View Build Output`,
modelId: record.pk,
modelType: ModelType.stockitem,
navigate: navigate
})
}
];
},
[buildStatus, user, partId, hasTrackedItems]

View File

@@ -5,6 +5,7 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@@ -88,9 +89,7 @@ export function ManufacturerPartTable({
const manufacturerPartFields = useManufacturerPartFields();
const [selectedPart, setSelectedPart] = useState<number | undefined>(
undefined
);
const [selectedPart, setSelectedPart] = useState<any>(undefined);
const createManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
@@ -105,15 +104,25 @@ export function ManufacturerPartTable({
const editManufacturerPart = useEditApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
pk: selectedPart,
pk: selectedPart?.pk,
title: t`Edit Manufacturer Part`,
fields: manufacturerPartFields,
fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]),
table: table
});
const duplicateManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
title: t`Add Manufacturer Part`,
fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]),
table: table,
initialData: {
...selectedPart
}
});
const deleteManufacturerPart = useDeleteApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
pk: selectedPart,
pk: selectedPart?.pk,
title: t`Delete Manufacturer Part`,
table: table
});
@@ -157,14 +166,21 @@ export function ManufacturerPartTable({
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => {
setSelectedPart(record.pk);
setSelectedPart(record);
editManufacturerPart.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: () => {
setSelectedPart(record);
duplicateManufacturerPart.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
onClick: () => {
setSelectedPart(record.pk);
setSelectedPart(record);
deleteManufacturerPart.open();
}
})
@@ -176,6 +192,7 @@ export function ManufacturerPartTable({
return (
<>
{createManufacturerPart.modal}
{duplicateManufacturerPart.modal}
{editManufacturerPart.modal}
{deleteManufacturerPart.modal}
<InvenTreeTable

View File

@@ -7,13 +7,13 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import {
type RowAction,
RowDeleteAction,
RowDuplicateAction,
RowEditAction
} from '@lib/components/RowActions';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { formatDecimal } from '@lib/functions/Formatting';
import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables';
import { IconPackageImport } from '@tabler/icons-react';
@@ -118,7 +118,6 @@ export function SupplierPartTable({
{
accessor: 'pack_quantity',
sortable: true,
render: (record: any) => {
const part = record?.part_detail ?? {};
@@ -126,7 +125,7 @@ export function SupplierPartTable({
if (part.units) {
extra.push(
<Text key='base'>
<Text key='base' size='sm'>
{t`Base units`} : {part.units}
</Text>
);
@@ -134,7 +133,7 @@ export function SupplierPartTable({
return (
<TableHoverCard
value={formatDecimal(record.pack_quantity)}
value={record.pack_quantity}
extra={extra}
title={t`Pack Quantity`}
/>
@@ -236,19 +235,32 @@ export function SupplierPartTable({
const editSupplierPartFields = useSupplierPartFields({});
const [selectedSupplierPart, setSelectedSupplierPart] = useState<number>(0);
const [selectedSupplierPart, setSelectedSupplierPart] =
useState<any>(undefined);
const editSupplierPart = useEditApiFormModal({
url: ApiEndpoints.supplier_part_list,
pk: selectedSupplierPart,
pk: selectedSupplierPart?.pk,
title: t`Edit Supplier Part`,
fields: editSupplierPartFields,
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
table: table
});
const duplicateSupplierPart = useCreateApiFormModal({
url: ApiEndpoints.supplier_part_list,
title: t`Add Supplier Part`,
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
initialData: {
...selectedSupplierPart,
active: true
},
table: table,
successMessage: t`Supplier part created`
});
const deleteSupplierPart = useDeleteApiFormModal({
url: ApiEndpoints.supplier_part_list,
pk: selectedSupplierPart,
pk: selectedSupplierPart?.pk,
title: t`Delete Supplier Part`,
table: table
});
@@ -260,14 +272,21 @@ export function SupplierPartTable({
RowEditAction({
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => {
setSelectedSupplierPart(record.pk);
setSelectedSupplierPart(record);
editSupplierPart.open();
}
}),
RowDuplicateAction({
hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: () => {
setSelectedSupplierPart(record);
duplicateSupplierPart.open();
}
}),
RowDeleteAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
onClick: () => {
setSelectedSupplierPart(record.pk);
setSelectedSupplierPart(record);
deleteSupplierPart.open();
}
})
@@ -280,6 +299,7 @@ export function SupplierPartTable({
<>
{addSupplierPart.modal}
{editSupplierPart.modal}
{duplicateSupplierPart.modal}
{deleteSupplierPart.modal}
{importPartWizard.wizard}
<InvenTreeTable

View File

@@ -320,7 +320,9 @@ export default function SalesOrderLineItemTable({
const allocateStock = useAllocateToSalesOrderForm({
orderId: orderId,
lineItems: selectedItems,
lineItems: selectedItems.filter(
(item) => item.part_detail?.virtual !== true
),
onFormSuccess: () => {
table.refreshTable();
table.clearSelectedRecords();

View File

@@ -99,6 +99,50 @@ test('Build Order - Basic Tests', async ({ browser }) => {
.waitFor();
});
// Test that the build order reference field increments correctly
test('Build Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/index/buildorders'
});
await page
.getByRole('button', { name: 'action-button-add-build-order' })
.click();
await page.getByRole('button', { name: 'Submit' }).waitFor();
// Grab the next BuildOrder reference
const reference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(reference).toMatch(/BO\d+/);
// Select a part
await page.getByLabel('related-field-part').fill('MAST');
await page.getByText('MAST | Master Assembly').click();
// Submit the form
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Item Created').waitFor();
// Back to the "build order" page - to create a new order
await navigate(page, 'manufacturing/index/buildorders');
await page
.getByRole('button', { name: 'action-button-add-build-order' })
.click();
await page.getByRole('button', { name: 'Submit' }).waitFor();
const nextReference: string = await page
.getByRole('textbox', { name: 'text-field-reference' })
.inputValue();
expect(nextReference).toMatch(/BO\d+/);
// Ensure that the reference has incremented
const refNumber = Number(reference.replace('BO', ''));
const nextRefNumber = Number(nextReference.replace('BO', ''));
expect(nextRefNumber).toBe(refNumber + 1);
});
test('Build Order - Calendar', async ({ browser }) => {
const page = await doCachedLogin(browser);

View File

@@ -212,7 +212,7 @@ test('Parts - Details', async ({ browser }) => {
// Depending on the state of other tests, the "In Production" value may vary
// This could be either 4 / 49, or 5 / 49
await page.getByText(/[4|5] \/ 49/).waitFor();
await page.getByText(/[4|5] \/ \d+/).waitFor();
// Badges
await page.getByText('Required: 10').waitFor();
@@ -232,14 +232,14 @@ test('Parts - Requirements', async ({ browser }) => {
// Check top-level badges
await page.getByText('In Stock: 209').waitFor();
await page.getByText('Available: 204').waitFor();
await page.getByText('Required: 275').waitFor();
await page.getByText(/Required: 2\d+/).waitFor();
await page.getByText('In Production: 24').waitFor();
// Check requirements details
await page.getByText('204 / 209').waitFor(); // Available stock
await page.getByText('0 / 100').waitFor(); // Allocated to build orders
await page.getByText(/0 \/ 1\d+/).waitFor(); // Allocated to build orders
await page.getByText('5 / 175').waitFor(); // Allocated to sales orders
await page.getByText('24 / 214').waitFor(); // In production
await page.getByText(/24 \/ 2\d+/).waitFor(); // In production
// Let's check out the "variants" for this part, too
await navigate(page, 'part/81/details'); // WID-REV-A
@@ -408,12 +408,10 @@ test('Parts - Pricing (Variant)', async ({ browser }) => {
await loadTab(page, 'Part Pricing');
await page.getByLabel('Part Pricing').getByText('Part Pricing').waitFor();
await page.getByRole('button', { name: 'Pricing Overview' }).waitFor();
await page.getByText('Last Updated').waitFor();
await page.getByRole('button', { name: 'Internal Pricing' }).isDisabled();
await page.getByText('Last Updated').first().waitFor();
await page.getByRole('button', { name: 'Internal Pricing' }).isEnabled();
await page.getByRole('button', { name: 'BOM Pricing' }).isEnabled();
await page.getByRole('button', { name: 'Variant Pricing' }).isEnabled();
await page.getByRole('button', { name: 'Sale Pricing' }).isDisabled();
await page.getByRole('button', { name: 'Sale History' }).isDisabled();
// Variant Pricing
await page.getByRole('button', { name: 'Variant Pricing' }).click();
@@ -556,7 +554,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
await clearTableFilters(page);
// All parts should be available (no filters applied)
await page.getByText('/ 425').waitFor();
await page.getByText(/\/ 42\d/).waitFor();
const clickOnParamFilter = async (name: string) => {
const button = await page
@@ -584,7 +582,7 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
// Reset the filter
await clearParamFilter('Color');
await page.getByText('/ 425').waitFor();
await page.getByText(/\/ 42\d/).waitFor();
});
test('Parts - Notes', async ({ browser }) => {
@@ -624,7 +622,7 @@ test('Parts - Revision', async ({ browser }) => {
.getByText('Green Round Table (revision B) | B', { exact: true })
.click();
await page
.getByRole('option', { name: 'Thumbnail Green Round Table Virtual' })
.getByRole('option', { name: 'Thumbnail Green Round Table No stock' })
.click();
await page.waitForURL('**/web/part/101/**');

View File

@@ -225,7 +225,7 @@ test('Sales Orders - Shipments', async ({ browser }) => {
test('Sales Orders - Duplicate', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'sales/sales-order/11/detail'
url: 'sales/sales-order/14/detail'
});
await page.getByLabel('action-menu-order-actions').click();
@@ -243,4 +243,39 @@ test('Sales Orders - Duplicate', async ({ browser }) => {
await page.getByRole('tab', { name: 'Order Details' }).click();
await page.getByText('Pending').first().waitFor();
// Issue the order
await page.getByRole('button', { name: 'Issue Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('In Progress').first().waitFor();
// Cancel the outstanding shipment
await loadTab(page, 'Shipments');
await clearTableFilters(page);
const cell = await page.getByRole('cell', { name: '1', exact: true });
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
// Check for expected line items
await loadTab(page, 'Line Items');
await page.getByRole('cell', { name: 'SW-001' }).waitFor();
await page.getByRole('cell', { name: 'SW-002' }).waitFor();
await page.getByText('1 - 2 / 2').waitFor();
// Ship the order
await page.getByRole('button', { name: 'Ship Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Complete the order
await page.getByRole('button', { name: 'Complete Order' }).click();
await page.getByRole('button', { name: 'Submit' }).click();
// Go to the "details" tab
await loadTab(page, 'Order Details');
// Check for expected results
// 2 line items completed, as they are both virtual (no stock)
await page.getByText('Complete').first().waitFor();
await page.getByText('2 / 2').waitFor();
});

View File

@@ -98,7 +98,7 @@ test('Forms - Supplier Validation', async ({ browser }) => {
// Check for validation errors
await page.getByText('Form Error').waitFor();
await page.getByText('Errors exist for one or more').waitFor();
await page.getByText('This field may not be blank.').waitFor();
await page.getByText('This field is required').waitFor();
await page.getByText('Enter a valid URL.').waitFor();
// Fill out another field, expect that the errors persist
@@ -106,7 +106,7 @@ test('Forms - Supplier Validation', async ({ browser }) => {
.getByLabel('text-field-description', { exact: true })
.fill('A description');
await page.waitForTimeout(250);
await page.getByText('This field may not be blank.').waitFor();
await page.getByText('This field is required').waitFor();
await page.getByText('Enter a valid URL.').waitFor();
// Generate a unique supplier name

View File

@@ -116,9 +116,15 @@ test('Importing - BOM', async ({ browser }) => {
// Delete selected rows
await page
.getByRole('dialog', { name: 'Importing Data Upload File 2' })
.getByRole('dialog', { name: 'Importing Data Upload File' })
.getByLabel('action-button-delete-selected')
.waitFor();
await page.waitForTimeout(200);
await page
.getByRole('dialog', { name: 'Importing Data Upload File' })
.getByLabel('action-button-delete-selected')
.click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
await page.getByText('Success', { exact: true }).waitFor();