mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-18 04:33:48 +00:00
[feature] Break apart assemblies (#12310)
* Implement API endpoint for stock disassembly * Add basic frontend implementation * Adjust required user permission * Add preFormContent * Read-only if serialized * Adjust location and status of each subcomponent * Handle null value * display installed items in frontend form * More unit tests * Traceability * Add docs * Exclude virtual / consumable stock * Additional docs * Added bundled items docs * More docs * more docs tweaks * Updated part docs * Add playwright tests * Robustify test * suppress certain warnings in schema * Adjust playwright tests * bug fix * Tweak playwright tests
This commit is contained in:
@@ -155,6 +155,7 @@ export enum ApiEndpoints {
|
||||
stock_assign = 'stock/assign/',
|
||||
stock_status = 'stock/status/',
|
||||
stock_convert = 'stock/:id/convert/',
|
||||
stock_disassemble = 'stock/:id/disassemble/',
|
||||
stock_install = 'stock/:id/install/',
|
||||
stock_uninstall = 'stock/:id/uninstall/',
|
||||
stock_serialize = 'stock/:id/serialize/',
|
||||
|
||||
@@ -6,16 +6,19 @@ import { InvenTreeIcon } from '../../functions/icons';
|
||||
|
||||
export default function RemoveRowButton({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip = t`Remove this row`,
|
||||
tooltipAlignment
|
||||
}: Readonly<{
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipAlignment?: FloatingPosition;
|
||||
}>) {
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
icon={<InvenTreeIcon icon='square_x' />}
|
||||
tooltip={tooltip}
|
||||
tooltipAlignment={tooltipAlignment ?? 'top-end'}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StylishText } from '@lib/components/StylishText';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { ModelType } from '@lib/enums/ModelType';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { formatDecimal } from '@lib/functions/Formatting';
|
||||
import { getDetailUrl } from '@lib/functions/Navigation';
|
||||
import type {
|
||||
ApiFormAdjustFilterType,
|
||||
@@ -14,18 +15,24 @@ import type {
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Alert,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
List,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
Text,
|
||||
UnstyledButton
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconCalendarExclamation,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconCoins,
|
||||
IconCurrencyDollar,
|
||||
IconLink,
|
||||
@@ -37,6 +44,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -44,7 +52,7 @@ import {
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../App';
|
||||
import RemoveRowButton from '../components/buttons/RemoveRowButton';
|
||||
@@ -54,8 +62,14 @@ import {
|
||||
type TableFieldRowProps
|
||||
} from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
import { RenderStockLocation } from '../components/render/Stock';
|
||||
import {
|
||||
StatusRenderer,
|
||||
getStatusCodeOptions
|
||||
} from '../components/render/StatusRenderer';
|
||||
import {
|
||||
RenderStockItem,
|
||||
RenderStockLocation
|
||||
} from '../components/render/Stock';
|
||||
import { StatusFilterOptions } from '../components/tables/Filter';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
import {
|
||||
@@ -438,6 +452,497 @@ export function useStockItemSerializeFields({
|
||||
}, [serialGenerator.result]);
|
||||
}
|
||||
|
||||
function DisassemblyLineRow({
|
||||
props,
|
||||
record,
|
||||
installedItems,
|
||||
serialized,
|
||||
statuses
|
||||
}: Readonly<{
|
||||
props: TableFieldRowProps;
|
||||
record: any;
|
||||
installedItems: any[];
|
||||
serialized: boolean;
|
||||
statuses: any;
|
||||
}>) {
|
||||
// Number of assemblies being disassembled (top-level form field)
|
||||
const assemblies = useWatch({ name: 'quantity' });
|
||||
|
||||
// Once the user manually edits the row quantity, stop auto-scaling it
|
||||
const [edited, setEdited] = useState<boolean>(false);
|
||||
|
||||
const [installedOpen, installedHandlers] = useDisclosure(false);
|
||||
|
||||
const [locationOpen, locationHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.rowId, 'location', undefined)
|
||||
});
|
||||
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||
onClose: () => props.changeFn(props.rowId, 'status', undefined)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (edited) {
|
||||
return;
|
||||
}
|
||||
|
||||
const n = Number(assemblies);
|
||||
const unit = Number(record.quantity);
|
||||
|
||||
if (Number.isFinite(n) && Number.isFinite(unit)) {
|
||||
const expected = unit * n;
|
||||
|
||||
if (props.item.quantity !== expected) {
|
||||
props.changeFn(props.rowId, 'quantity', expected);
|
||||
}
|
||||
}
|
||||
}, [assemblies, edited]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Group gap='xs' justify='left' wrap='nowrap'>
|
||||
<Thumbnail
|
||||
size={32}
|
||||
src={record.sub_part_detail?.thumbnail}
|
||||
align='center'
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<div>{record.sub_part_detail?.name}</div>
|
||||
{installedItems.length > 0 && (
|
||||
<UnstyledButton
|
||||
onClick={() => installedHandlers.toggle()}
|
||||
aria-label={`toggle-installed-items-${props.rowId}`}
|
||||
>
|
||||
<Group gap={4} wrap='nowrap'>
|
||||
{installedOpen ? (
|
||||
<IconChevronUp size={14} />
|
||||
) : (
|
||||
<IconChevronDown size={14} />
|
||||
)}
|
||||
<Text size='xs' c='blue'>
|
||||
{t`Installed Items`}: {installedItems.length}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{record.quantity}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
{serialized ? (
|
||||
// Quantity is fixed when disassembling a serialized stock item
|
||||
<Text size='sm' aria-label='text-field-quantity'>
|
||||
{formatDecimal(props.item.quantity)}
|
||||
</Text>
|
||||
) : (
|
||||
<TableFieldQuantityInput
|
||||
min={0}
|
||||
value={props.item.quantity ?? ''}
|
||||
onChange={(value) => {
|
||||
setEdited(true);
|
||||
props.changeFn(props.rowId, 'quantity', value);
|
||||
}}
|
||||
error={props.rowErrors?.quantity?.message}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: 'nowrap' }}>
|
||||
<NumberInput
|
||||
radius='sm'
|
||||
aria-label='number-field-purchase_price'
|
||||
placeholder={t`Automatic`}
|
||||
min={0}
|
||||
decimalScale={6}
|
||||
value={props.item.purchase_price ?? ''}
|
||||
onChange={(value) => {
|
||||
props.changeFn(
|
||||
props.rowId,
|
||||
'purchase_price',
|
||||
value === '' ? null : value
|
||||
);
|
||||
}}
|
||||
error={props.rowErrors?.purchase_price?.message}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Flex gap='1px'>
|
||||
<ActionButton
|
||||
size='sm'
|
||||
onClick={() => locationHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon='location' />}
|
||||
tooltip={t`Set Location`}
|
||||
tooltipAlignment='top'
|
||||
variant={locationOpen ? 'outline' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
size='sm'
|
||||
onClick={() => statusHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon='status' />}
|
||||
tooltip={t`Change Status`}
|
||||
tooltipAlignment='top'
|
||||
variant={statusOpen ? 'outline' : 'transparent'}
|
||||
/>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<RemoveRowButton
|
||||
onClick={() => props.removeFn(props.rowId)}
|
||||
disabled={installedItems.length > 0}
|
||||
tooltip={
|
||||
installedItems.length > 0
|
||||
? t`This row cannot be removed as it has installed items`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{installedItems.length > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6} style={{ padding: 0, borderBottom: 'none' }}>
|
||||
<Collapse expanded={installedOpen}>
|
||||
<Paper p='xs' pl='xl'>
|
||||
<Stack gap='xs'>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{t`The following installed items will be uninstalled during disassembly`}
|
||||
</Text>
|
||||
{installedItems.map((item: any) => (
|
||||
<RenderStockItem key={item.pk} instance={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
<TableFieldExtraRow
|
||||
visible={locationOpen}
|
||||
fieldName='location'
|
||||
onValueChange={(value) =>
|
||||
props.changeFn(props.rowId, 'location', value)
|
||||
}
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
model: ModelType.stocklocation,
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
filters: {
|
||||
structural: false
|
||||
},
|
||||
value: props.item.location,
|
||||
label: t`Location`,
|
||||
description: t`Custom location for the component items`,
|
||||
icon: <InvenTreeIcon icon='location' />
|
||||
}}
|
||||
error={props.rowErrors?.location?.message}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={statusOpen}
|
||||
fieldName='status'
|
||||
defaultValue={10}
|
||||
onValueChange={(value) => props.changeFn(props.rowId, 'status', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||
choices: statuses,
|
||||
label: t`Status`,
|
||||
description: t`Status for the component items`
|
||||
}}
|
||||
error={props.rowErrors?.status?.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a summary of the stock item which is about to be disassembled.
|
||||
*/
|
||||
function DisassemblyItemInfo({
|
||||
stockItem
|
||||
}: Readonly<{
|
||||
stockItem: any;
|
||||
}>) {
|
||||
const serialized: boolean =
|
||||
!!stockItem.serial && Number(stockItem.quantity) == 1;
|
||||
|
||||
const details: { label: string; value: ReactNode }[] = useMemo(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: t`Location`,
|
||||
value: stockItem.location_detail?.pathstring ?? t`No location set`
|
||||
}
|
||||
];
|
||||
|
||||
if (serialized) {
|
||||
rows.push({
|
||||
label: t`Serial Number`,
|
||||
value: stockItem.serial
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
label: t`Quantity`,
|
||||
value: formatDecimal(stockItem.quantity)
|
||||
});
|
||||
}
|
||||
|
||||
if (stockItem.batch) {
|
||||
rows.push({
|
||||
label: t`Batch Code`,
|
||||
value: stockItem.batch
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [stockItem, serialized]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md' wrap='nowrap' align='center'>
|
||||
<Thumbnail
|
||||
size={56}
|
||||
src={stockItem.part_detail?.thumbnail}
|
||||
align='center'
|
||||
/>
|
||||
<Group grow wrap='nowrap'>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>
|
||||
{stockItem.part_detail?.full_name ?? stockItem.part_detail?.name}
|
||||
</Text>
|
||||
{stockItem.part_detail?.description && (
|
||||
<Text size='sm' c='dimmed'>
|
||||
{stockItem.part_detail.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Table withRowBorders={false} verticalSpacing={2}>
|
||||
<Table.Tbody>
|
||||
{details.map((row) => (
|
||||
<Table.Tr key={row.label}>
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Text size='sm' c='dimmed'>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size='sm'>{row.value}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display any installed stock items which could not be matched
|
||||
* against a BOM line item for the disassembly operation.
|
||||
*/
|
||||
function DisassemblyLeftoverItems({
|
||||
items
|
||||
}: Readonly<{
|
||||
items: any[];
|
||||
}>) {
|
||||
if (items.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color='yellow'
|
||||
icon={<IconPackage />}
|
||||
title={t`Additional Installed Items`}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<Text size='sm'>
|
||||
{t`The following items are installed within this stock item, but do not match a listed component. They will be uninstalled during disassembly.`}
|
||||
</Text>
|
||||
{items.map((item: any) => (
|
||||
<RenderStockItem key={item.pk} instance={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form for disassembling a stock item into its component parts,
|
||||
* based on the Bill of Materials for the associated part.
|
||||
*/
|
||||
export function useDisassembleStockItem({
|
||||
stockItem,
|
||||
refresh
|
||||
}: {
|
||||
stockItem: any;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
// Fetch BOM lines for the part associated with the stock item
|
||||
const bomQuery = useQuery({
|
||||
queryKey: ['bom-items-disassemble', stockItem.pk, stockItem.part],
|
||||
enabled: !!stockItem.part && (stockItem.part_detail?.assembly ?? false),
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.bom_list), {
|
||||
params: {
|
||||
part: stockItem.part,
|
||||
sub_part_detail: true,
|
||||
substitutes: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? [])
|
||||
});
|
||||
|
||||
// Fetch any stock items which are installed within this stock item
|
||||
const installedQuery = useQuery({
|
||||
queryKey: ['installed-items-disassemble', stockItem.pk],
|
||||
enabled: !!stockItem.pk && (stockItem.part_detail?.assembly ?? false),
|
||||
queryFn: async () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.stock_item_list), {
|
||||
params: {
|
||||
belongs_to: stockItem.pk,
|
||||
part_detail: true
|
||||
}
|
||||
})
|
||||
.then((response) => response.data ?? [])
|
||||
});
|
||||
|
||||
const bomItems: any[] = useMemo(() => {
|
||||
// Consumable BOM lines are excluded from disassembly,
|
||||
// as are any lines which point to a virtual part
|
||||
return (bomQuery.data ?? []).filter(
|
||||
(elem: any) =>
|
||||
!elem.consumable &&
|
||||
!elem.sub_part_detail?.consumable &&
|
||||
!elem.sub_part_detail?.virtual
|
||||
);
|
||||
}, [bomQuery.data]);
|
||||
|
||||
const records = useMemo(
|
||||
() => Object.fromEntries(bomItems.map((elem: any) => [elem.pk, elem])),
|
||||
[bomItems]
|
||||
);
|
||||
|
||||
// Map installed stock items against the available BOM lines.
|
||||
// Note: This mirrors the matching performed by the server during disassembly,
|
||||
// matching against the referenced sub_part or any designated substitute parts.
|
||||
// Variants of the sub_part cannot be resolved client-side, so any such items
|
||||
// are reported as "leftover" items instead.
|
||||
const { installedMap, leftoverItems } = useMemo(() => {
|
||||
const map: Record<number, any[]> = {};
|
||||
const leftover: any[] = [];
|
||||
|
||||
for (const item of installedQuery.data ?? []) {
|
||||
const bomItem = bomItems.find(
|
||||
(elem: any) =>
|
||||
elem.sub_part === item.part ||
|
||||
elem.substitutes?.some((sub: any) => sub.part === item.part)
|
||||
);
|
||||
|
||||
if (bomItem) {
|
||||
map[bomItem.pk] = [...(map[bomItem.pk] ?? []), item];
|
||||
} else {
|
||||
leftover.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { installedMap: map, leftoverItems: leftover };
|
||||
}, [bomItems, installedQuery.data]);
|
||||
|
||||
// A serialized stock item must be disassembled in its entirety
|
||||
const serialized: boolean =
|
||||
!!stockItem.serial && Number(stockItem.quantity) == 1;
|
||||
|
||||
const stockStatusCodes = useMemo(
|
||||
() => getStatusCodeOptions(ModelType.stockitem),
|
||||
[]
|
||||
);
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return {
|
||||
quantity: {
|
||||
disabled: serialized
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false
|
||||
}
|
||||
},
|
||||
items: {
|
||||
field_type: 'table',
|
||||
value: bomItems.map((elem: any) => {
|
||||
return {
|
||||
id: elem.pk,
|
||||
bom_item: elem.pk,
|
||||
quantity:
|
||||
(Number(elem.quantity) || 0) * (Number(stockItem.quantity) || 0),
|
||||
purchase_price: null,
|
||||
purchase_price_currency:
|
||||
stockItem.purchase_price_currency ?? undefined
|
||||
};
|
||||
}),
|
||||
modelRenderer: (row: TableFieldRowProps) => {
|
||||
const record = records[row.item.bom_item];
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DisassemblyLineRow
|
||||
props={row}
|
||||
record={record}
|
||||
installedItems={installedMap[row.item.bom_item] ?? []}
|
||||
serialized={serialized}
|
||||
statuses={stockStatusCodes}
|
||||
key={row.rowId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: [
|
||||
{ title: t`Component`, style: { minWidth: '200px' } },
|
||||
{ title: t`Unit Quantity`, style: { width: '100px' } },
|
||||
{ title: t`Quantity`, style: { width: '200px' } },
|
||||
{ title: t`Unit Price`, style: { width: '200px' } },
|
||||
{ title: t`Actions` },
|
||||
{ title: '', style: { width: '50px' } }
|
||||
]
|
||||
},
|
||||
notes: {}
|
||||
};
|
||||
}, [
|
||||
bomItems,
|
||||
records,
|
||||
installedMap,
|
||||
stockItem,
|
||||
serialized,
|
||||
stockStatusCodes
|
||||
]);
|
||||
|
||||
return useCreateApiFormModal({
|
||||
url: ApiEndpoints.stock_disassemble,
|
||||
pk: stockItem.pk,
|
||||
title: t`Disassemble Stock Item`,
|
||||
fields: fields,
|
||||
preFormContent: <DisassemblyItemInfo stockItem={stockItem} />,
|
||||
postFormContent: <DisassemblyLeftoverItems items={leftoverItems} />,
|
||||
initialData: {
|
||||
quantity: stockItem.quantity,
|
||||
location: stockItem.location
|
||||
},
|
||||
size: '80%',
|
||||
successMessage: t`Stock item disassembled`,
|
||||
onFormSuccess: refresh,
|
||||
onOpen: () => {
|
||||
// Ensure the installed items data is up to date when the form is opened
|
||||
installedQuery.refetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function StockItemDefaultMove({
|
||||
stockItem,
|
||||
value
|
||||
|
||||
@@ -232,6 +232,8 @@ export default function BuildDetail() {
|
||||
part_detail: true,
|
||||
tags: true
|
||||
},
|
||||
hasPrimaryKey: true,
|
||||
defaultValue: {},
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconArrowsSplit,
|
||||
IconBookmark,
|
||||
IconBoxPadding,
|
||||
IconChecklist,
|
||||
@@ -69,6 +70,7 @@ import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
|
||||
import { useApi } from '../../contexts/ApiContext';
|
||||
import { formatCurrency, formatDecimal } from '../../defaults/formatters';
|
||||
import {
|
||||
useDisassembleStockItem,
|
||||
useFindSerialNumberForm,
|
||||
useStockFields,
|
||||
useStockItemSerializeFields
|
||||
@@ -864,6 +866,11 @@ export default function StockDetail() {
|
||||
successMessage: t`Stock item serialized`
|
||||
});
|
||||
|
||||
const disassembleStockItem = useDisassembleStockItem({
|
||||
stockItem: stockitem,
|
||||
refresh: refreshInstance
|
||||
});
|
||||
|
||||
const orderPartsWizard = OrderPartsWizard({
|
||||
parts: stockitem.part_detail ? [stockitem.part_detail] : []
|
||||
});
|
||||
@@ -950,6 +957,18 @@ export default function StockDetail() {
|
||||
serializeStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Disassemble`,
|
||||
tooltip: t`Disassemble this stock item into its component parts`,
|
||||
hidden:
|
||||
!user.hasAddRole(UserRoles.stock) ||
|
||||
!stockitem.in_stock ||
|
||||
stockitem.part_detail?.assembly != true,
|
||||
icon: <IconArrowsSplit color='blue' />,
|
||||
onClick: () => {
|
||||
disassembleStockItem.open();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t`Order`,
|
||||
tooltip: t`Order Stock`,
|
||||
@@ -1118,6 +1137,7 @@ export default function StockDetail() {
|
||||
{convertStockItem.modal}
|
||||
{duplicateStockItem.modal}
|
||||
{serializeStockItem.modal}
|
||||
{disassembleStockItem.modal}
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
{orderPartsWizard.wizard}
|
||||
</>
|
||||
|
||||
@@ -148,6 +148,7 @@ test('Build Order - Tags', async ({ browser }) => {
|
||||
|
||||
// Check for expected results
|
||||
await page.getByRole('cell', { name: 'BO0026' }).click();
|
||||
await page.getByText('Build Order: BO0026').first().waitFor();
|
||||
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
|
||||
|
||||
// Check for tags displayed on BuildOrder detail page
|
||||
@@ -279,6 +280,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
|
||||
await page.getByRole('cell', { name: 'BO0011' }).click();
|
||||
await loadTab(page, 'Incomplete Outputs');
|
||||
await page.getByRole('cell', { name: 'BX-123' }).waitFor();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check the "printing" actions for the selected outputs
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).check();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import { stevenuser } from '../defaults.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickButtonIfVisible,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate,
|
||||
openFilterDrawer,
|
||||
@@ -585,208 +583,68 @@ test('Stock - Location', async ({ browser }) => {
|
||||
await page.getByText('No match found for barcode data').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
// Test disassembly of an assembled stock item
|
||||
test('Stock - Disassembly', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'stock/item/1387/details' });
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
|
||||
await page.getByLabel('action-menu-order-actions-edit').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
// Duplicate this stock item to avoid impacting other tests
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.getByRole('button', { name: 'action-menu-stock-item-actions' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-item-actions-duplicate' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.waitForTimeout(100);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
// Disassemble this stock item
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
.getByRole('button', { name: 'action-menu-stock-operations' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.getByRole('menuitem', { name: 'action-menu-stock-operations-disassemble' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
// Perform partial disassembly
|
||||
await page
|
||||
.getByRole('textbox', {
|
||||
name: 'number-field-quantity',
|
||||
description: 'Number of assemblies to disassemble'
|
||||
})
|
||||
.fill('6');
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Issued', { exact: true }).first().waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
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();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
// Set custom status for one item
|
||||
await page
|
||||
.getByRole('row', { name: 'Thumbnail XT90-M 10 60 action' })
|
||||
.getByLabel('action-button-change-status')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'choice-field-status' })
|
||||
.fill('damaged');
|
||||
await page.getByRole('option', { name: 'Damaged' }).click();
|
||||
|
||||
// Specify different location for one item
|
||||
await page
|
||||
.getByRole('row', { name: 'Thumbnail XT90-F 10 60' })
|
||||
.getByLabel('action-button-set-location')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('cell', { name: 'Location Custom location for' })
|
||||
.getByPlaceholder('Select location')
|
||||
.fill('factory');
|
||||
await page.getByRole('listbox').getByText('Factory', { exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Quantity of this assembly has been reduced
|
||||
await page.getByText('Quantity: 4').first().waitFor();
|
||||
|
||||
await loadTab(page, 'Child Items');
|
||||
await page.getByText('1 - 2 / 2').waitFor();
|
||||
|
||||
await page.getByRole('cell', { name: 'Thumbnail XT90-F' }).waitFor();
|
||||
await page.getByRole('cell', { name: 'Thumbnail XT90-M' }).waitFor();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { expect, test } from '../baseFixtures.js';
|
||||
import {
|
||||
activateCalendarView,
|
||||
clearTableFilters,
|
||||
clickOnRowMenu,
|
||||
loadTab,
|
||||
navigate
|
||||
} from '../helpers.js';
|
||||
import { doCachedLogin } from '../login.js';
|
||||
|
||||
test('Transfer Orders - General', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
await clearTableFilters(page);
|
||||
|
||||
// We have now loaded the "Transfer Orders" table. Check for some expected texts
|
||||
await page.getByText('Complete').first().waitFor();
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
await page.getByText('Cancelled').first().waitFor();
|
||||
|
||||
// Load a particular Transfer Order
|
||||
await page.getByRole('cell', { name: 'TO-0002' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// This transfer order should be "issued"
|
||||
await page.getByText('Issued').first().waitFor();
|
||||
|
||||
// Edit the transfer order (via keyboard shortcut)
|
||||
await page.keyboard.press('Control+E');
|
||||
await page.getByLabel('text-field-reference', { exact: true }).waitFor();
|
||||
await page.getByLabel('related-field-project_code').waitFor();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete Order' }).click();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
// Check for other expected actions
|
||||
await page.getByRole('button', { name: 'action-menu-order-actions' }).click();
|
||||
await page.getByLabel('action-menu-order-actions-edit').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').waitFor();
|
||||
await page.getByLabel('action-menu-order-actions-hold').waitFor();
|
||||
|
||||
// Click on some tabs
|
||||
await loadTab(page, 'Line Items');
|
||||
await loadTab(page, 'Allocated Stock');
|
||||
await loadTab(page, 'Parameters');
|
||||
await loadTab(page, 'Attachments');
|
||||
await loadTab(page, 'Notes');
|
||||
});
|
||||
|
||||
test('Transfer Order - Reference', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
// go to transfer orders
|
||||
await page.getByRole('tab', { name: 'Stock' }).click();
|
||||
await page.waitForURL('**/stock/location/index/**');
|
||||
await loadTab(page, 'Transfer Orders');
|
||||
|
||||
// click add button
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
// Grab the Transfer Order reference
|
||||
const reference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(reference).toMatch(/TO-\d+/);
|
||||
|
||||
await page.getByRole('textbox', { name: 'text-field-description' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-description' })
|
||||
.fill('creating from playwrigh!');
|
||||
|
||||
// create the transfer order
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// go back to stock page
|
||||
await page.getByRole('link', { name: 'Stock', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-add-transfer-' })
|
||||
.click();
|
||||
|
||||
const nextReference: string = await page
|
||||
.getByRole('textbox', { name: 'text-field-reference' })
|
||||
.inputValue();
|
||||
expect(nextReference).toMatch(/TO-\d+/);
|
||||
|
||||
// Ensure that the reference has incremented
|
||||
const refNumber = Number(reference.replace('TO-', ''));
|
||||
const nextRefNumber = Number(nextReference.replace('TO-', ''));
|
||||
expect(nextRefNumber).toBe(refNumber + 1);
|
||||
});
|
||||
|
||||
test('Transfer Order - Calendar', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/location/index/transfer-orders');
|
||||
await activateCalendarView(page);
|
||||
|
||||
// Export calendar data
|
||||
await page.getByLabel('calendar-export-data').click();
|
||||
await page.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
// Required because we downloaded a file
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Transfer Order - Edit', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/2/');
|
||||
|
||||
// Check for expected text items
|
||||
await page.getByText('Consume some paint').first().waitFor();
|
||||
await page.getByText('2026-04-20').waitFor(); // Created date
|
||||
await page.getByText('2026-04-23').waitFor(); // Issue date
|
||||
await page.getByText('PRJ-HEL').waitFor(); // Project Code
|
||||
|
||||
await page.keyboard.press('Control+E');
|
||||
|
||||
// Edit start date
|
||||
await page.getByLabel('date-field-start_date').fill('2026-04-28');
|
||||
|
||||
// Submit the form
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Expect error
|
||||
await page.getByText('Errors exist for one or more form fields').waitFor();
|
||||
await page.getByText('Target date must be after start date').waitFor();
|
||||
|
||||
// Cancel the form
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
});
|
||||
|
||||
test('Transfer Order - Allocate and Transfer', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser);
|
||||
|
||||
await navigate(page, 'stock/transfer-order/6/');
|
||||
|
||||
// Duplicate this transfer order, to ensure a fresh run each time
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Item Created').waitFor();
|
||||
|
||||
// Issue the order
|
||||
await page.getByRole('button', { name: 'Issue Order' }).click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Issued', { exact: true }).first().waitFor();
|
||||
|
||||
await loadTab(page, 'Line Items');
|
||||
|
||||
// Allocate line item 1
|
||||
const cell1 = await page.getByText('C_100pF_0402', { exact: true });
|
||||
await clickOnRowMenu(cell1);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('C_100pF_0402Location:Offsite').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Allocate line item 1
|
||||
const cell2 = await page.getByText('R_2.2K_0603_1%', { exact: true });
|
||||
await clickOnRowMenu(cell2);
|
||||
await page.getByRole('menuitem', { name: 'Allocate Stock' }).click();
|
||||
await page.getByText('R_2.2K_0603_1%Location:').waitFor();
|
||||
await page.waitForTimeout(200);
|
||||
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();
|
||||
await page.getByText('Complete', { exact: true }).first().waitFor();
|
||||
|
||||
// Tab should have changed to Transferred Stock
|
||||
await loadTab(page, 'Transferred Stock');
|
||||
await page.getByText('C_100pF_0402').waitFor();
|
||||
await page.getByText('2.2K resistor in 0603 SMD').waitFor();
|
||||
});
|
||||
|
||||
test('Transfer Orders - Duplicate', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
url: 'stock/transfer-order/1/detail'
|
||||
});
|
||||
|
||||
await page.getByLabel('action-menu-order-actions').click();
|
||||
await page.getByLabel('action-menu-order-actions-duplicate').click();
|
||||
|
||||
// Ensure a new reference is suggested
|
||||
await expect(
|
||||
page.getByLabel('text-field-reference', { exact: true })
|
||||
).not.toBeEmpty();
|
||||
|
||||
// Submit the duplicate request and ensure it completes
|
||||
await page.getByRole('button', { name: 'Submit' }).isEnabled();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Order Details' }).click();
|
||||
|
||||
await page.getByText('Pending').first().waitFor();
|
||||
});
|
||||
@@ -233,10 +233,10 @@ test('Forms - DateTime Field', async ({ browser }) => {
|
||||
hour: string,
|
||||
minute: string
|
||||
) => {
|
||||
const field = page.getByLabel(fieldLabel);
|
||||
const field = page.getByLabel(fieldLabel).first();
|
||||
await expect(field).toBeVisible();
|
||||
await field.click();
|
||||
await page.getByRole('button', { name: day }).click();
|
||||
await page.getByRole('button', { name: day }).first().click();
|
||||
|
||||
const spinbuttons = page.getByRole('spinbutton');
|
||||
await spinbuttons.nth(0).fill(hour);
|
||||
|
||||
Reference in New Issue
Block a user