mirror of
https://github.com/inventree/InvenTree.git
synced 2025-09-13 22:21:37 +00:00
Machine registry improvements (#10150)
* Add wrapper function for machine registry * Decorate entrypoint functions * Docstrings * Fix for boolean setting * Add playwright tests * Use proper entrypoints * Ensure settings are fetched correctly * Prevent recursion of machine registry decorator * Fix machine status display * Enhanced warning msg * Add simple machine sample printer * Adds playwright tests for machine UI * re-throw exception * Define 'machine' plugin mixin class * Adjust machine discovery * Use plugin mixins for registering machine types and drivers * Adjust unit test * Remove plugin static files when deactivating * Force machine reload when plugin registry changes * Add plugins specific to testing framework * Add test for plugin loading sequence * Add session caching - Significantly reduce DB hits * Enhanced unit testing and test plugins * Refactor unit tests * Further unit test fixes * Adjust instance rendering * Display table of available drivers * Cleanup * ADjust unit test * Tweak unit test * Add docs on new mixin type * Tweak machine overview docs * Tweak playwright tests * Additional unit test * Add unit test for calling machine func * Enhanced playwright tests * Account for database not being ready
This commit is contained in:
@@ -9,7 +9,7 @@ export function RenderProjectCode({
|
||||
instance && (
|
||||
<RenderInlineModel
|
||||
primary={instance.code}
|
||||
secondary={instance.description}
|
||||
suffix={instance.description}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
@@ -67,7 +67,7 @@ export function RenderPartCategory(
|
||||
const suffix: ReactNode = (
|
||||
<Group gap='xs'>
|
||||
<TableHoverCard
|
||||
value={<Text size='sm'>{instance.description}</Text>}
|
||||
value={<Text size='xs'>{instance.description}</Text>}
|
||||
position='bottom-end'
|
||||
zIndex={10000}
|
||||
icon='sitemap'
|
||||
|
@@ -12,8 +12,8 @@ export function RenderPlugin({
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
secondary={instance.meta?.description}
|
||||
suffix={
|
||||
suffix={instance.meta?.description}
|
||||
secondary={
|
||||
!instance.active && <Badge size='sm' color='red'>{t`Inactive`}</Badge>
|
||||
}
|
||||
/>
|
||||
|
@@ -8,10 +8,7 @@ export function RenderReportTemplate({
|
||||
instance: any;
|
||||
}>): ReactNode {
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
secondary={instance.description}
|
||||
/>
|
||||
<RenderInlineModel primary={instance.name} suffix={instance.description} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,9 +18,6 @@ export function RenderLabelTemplate({
|
||||
instance: any;
|
||||
}>): ReactNode {
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
secondary={instance.description}
|
||||
/>
|
||||
<RenderInlineModel primary={instance.name} suffix={instance.description} />
|
||||
);
|
||||
}
|
||||
|
@@ -74,14 +74,16 @@ export function getStatusCodes(
|
||||
const statusCodeList = useGlobalStatusState.getState().status;
|
||||
|
||||
if (statusCodeList === undefined) {
|
||||
console.log('StatusRenderer: statusCodeList is undefined');
|
||||
console.warn('StatusRenderer: statusCodeList is undefined');
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusCodes = statusCodeList[type];
|
||||
|
||||
if (statusCodes === undefined) {
|
||||
console.log('StatusRenderer: statusCodes is undefined');
|
||||
console.warn(
|
||||
`StatusRenderer: statusCodes is undefined for model '${type}'`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -175,7 +177,9 @@ export const StatusRenderer = ({
|
||||
}
|
||||
|
||||
if (statusCodes === undefined || statusCodes === null) {
|
||||
console.warn('StatusRenderer: statusCodes is undefined');
|
||||
console.warn(
|
||||
`StatusRenderer: statusCodes is undefined for model '${type}'`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@@ -29,7 +29,7 @@ export function RenderStockLocation(
|
||||
const suffix: ReactNode = (
|
||||
<Group gap='xs'>
|
||||
<TableHoverCard
|
||||
value={<Text size='sm'>{instance.description}</Text>}
|
||||
value={<Text size='xs'>{instance.description}</Text>}
|
||||
position='bottom-end'
|
||||
zIndex={10000}
|
||||
icon='sitemap'
|
||||
@@ -75,7 +75,7 @@ export function RenderStockLocationType({
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
secondary={`${instance.description} (${instance.location_count})`}
|
||||
suffix={`${instance.description} (${instance.location_count})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@ export function RenderUser({
|
||||
}
|
||||
suffix={
|
||||
<Group gap='xs'>
|
||||
<Text size='sm'>
|
||||
<Text size='xs'>
|
||||
{instance.first_name} {instance.last_name}
|
||||
</Text>
|
||||
<IconUser size={16} />
|
||||
|
@@ -18,7 +18,10 @@ import { apiUrl } from '@lib/functions/Api';
|
||||
import { api } from '../../../../App';
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { MachineListTable } from '../../../../tables/machine/MachineListTable';
|
||||
import { MachineTypeListTable } from '../../../../tables/machine/MachineTypeTable';
|
||||
import {
|
||||
MachineDriverTable,
|
||||
MachineTypeListTable
|
||||
} from '../../../../tables/machine/MachineTypeTable';
|
||||
|
||||
interface MachineRegistryStatusI {
|
||||
registry_errors: { message: string }[];
|
||||
@@ -42,7 +45,7 @@ export default function MachineManagementPanel() {
|
||||
}, [registryStatus]);
|
||||
|
||||
return (
|
||||
<Accordion multiple defaultValue={['machinelist', 'machinetypes']}>
|
||||
<Accordion multiple defaultValue={['machinelist']}>
|
||||
<Accordion.Item value='machinelist'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Machines`}</StylishText>
|
||||
@@ -51,6 +54,14 @@ export default function MachineManagementPanel() {
|
||||
<MachineListTable props={{}} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='drivertypes'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Machine Drivers`}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<MachineDriverTable />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='machinetypes'>
|
||||
<Accordion.Control>
|
||||
<StylishText size='lg'>{t`Machine Types`}</StylishText>
|
||||
|
@@ -210,7 +210,7 @@ export const createMachineSettingsState = ({
|
||||
}: CreateMachineSettingStateProps) => {
|
||||
const pathParams: PathParams = { machine, config_type: configType };
|
||||
|
||||
return createStore<SettingsStateProps>()((set, get) => ({
|
||||
const store = createStore<SettingsStateProps>()((set, get) => ({
|
||||
settings: [],
|
||||
lookup: {},
|
||||
loaded: false,
|
||||
@@ -255,6 +255,12 @@ export const createMachineSettingsState = ({
|
||||
return isTrue(value);
|
||||
}
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
store.getState().fetchSettings();
|
||||
}, [machine, configType]);
|
||||
|
||||
return store;
|
||||
};
|
||||
|
||||
/*
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
@@ -23,9 +24,11 @@ import { AddItemButton } from '@lib/components/AddItemButton';
|
||||
import { YesNoButton } from '@lib/components/YesNoButton';
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import type { TableColumn } from '@lib/types/Tables';
|
||||
import { RowDeleteAction, RowEditAction } from '@lib/index';
|
||||
import type { RowAction, TableColumn } from '@lib/types/Tables';
|
||||
import type { InvenTreeTableProps } from '@lib/types/Tables';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { api } from '../../App';
|
||||
import {
|
||||
DeleteItemAction,
|
||||
EditItemAction,
|
||||
@@ -100,6 +103,32 @@ function MachineStatusIndicator({ machine }: Readonly<{ machine: MachineI }>) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to restart a machine with the provided ID
|
||||
*/
|
||||
function restartMachine({
|
||||
machinePk,
|
||||
callback
|
||||
}: {
|
||||
machinePk: string;
|
||||
callback?: () => void;
|
||||
}) {
|
||||
api
|
||||
.post(
|
||||
apiUrl(ApiEndpoints.machine_restart, undefined, {
|
||||
machine: machinePk
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
notifications.show({
|
||||
message: t`Machine restarted`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size='1rem' />
|
||||
});
|
||||
callback?.();
|
||||
});
|
||||
}
|
||||
|
||||
export function useMachineTypeDriver({
|
||||
includeTypes = true,
|
||||
includeDrivers = true
|
||||
@@ -192,26 +221,6 @@ function MachineDrawer({
|
||||
refreshTable();
|
||||
}, [refetch, refreshTable]);
|
||||
|
||||
const restartMachine = useCallback(
|
||||
(machinePk: string) => {
|
||||
api
|
||||
.post(
|
||||
apiUrl(ApiEndpoints.machine_restart, undefined, {
|
||||
machine: machinePk
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
refreshAll();
|
||||
notifications.show({
|
||||
message: t`Machine restarted`,
|
||||
color: 'green',
|
||||
icon: <IconCheck size='1rem' />
|
||||
});
|
||||
});
|
||||
},
|
||||
[refreshAll]
|
||||
);
|
||||
|
||||
const machineEditModal = useEditApiFormModal({
|
||||
title: t`Edit machine`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
@@ -232,7 +241,9 @@ function MachineDrawer({
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: machinePk,
|
||||
preFormContent: (
|
||||
<Text>{t`Are you sure you want to remove the machine "${machine?.name ?? 'unknown'}"?`}</Text>
|
||||
<Alert color='red'>
|
||||
{t`Are you sure you want to remove this machine?`}
|
||||
</Alert>
|
||||
),
|
||||
onFormSuccess: () => {
|
||||
refreshTable();
|
||||
@@ -245,7 +256,6 @@ function MachineDrawer({
|
||||
<Stack gap='xs'>
|
||||
{machineEditModal.modal}
|
||||
{machineDeleteModal.modal}
|
||||
|
||||
<Group justify='space-between'>
|
||||
<Group>
|
||||
{machine && <MachineStatusIndicator machine={machine} />}
|
||||
@@ -279,7 +289,14 @@ function MachineDrawer({
|
||||
indicator: machine?.restart_required
|
||||
? { color: 'red' }
|
||||
: undefined,
|
||||
onClick: () => machine && restartMachine(machine?.pk)
|
||||
onClick: () => {
|
||||
if (machine) {
|
||||
restartMachine({
|
||||
machinePk: machine?.pk,
|
||||
callback: refreshAll
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -487,9 +504,7 @@ export function MachineListTable({
|
||||
accessor: 'status',
|
||||
sortable: false,
|
||||
render: (record) => {
|
||||
const renderer = TableStatusRenderer(
|
||||
`MachineStatus__${record.status_model}` as any
|
||||
);
|
||||
const renderer = TableStatusRenderer(`${record.status_model}` as any);
|
||||
if (renderer && record.status !== -1) {
|
||||
return renderer(record);
|
||||
}
|
||||
@@ -552,6 +567,65 @@ export function MachineListTable({
|
||||
}
|
||||
});
|
||||
|
||||
const [selectedMachinePk, setSelectedMachinePk] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const deleteMachineForm = useDeleteApiFormModal({
|
||||
title: t`Delete Machine`,
|
||||
successMessage: t`Machine successfully deleted.`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: selectedMachinePk,
|
||||
preFormContent: (
|
||||
<Alert color='red'>
|
||||
{t`Are you sure you want to remove this machine?`}
|
||||
</Alert>
|
||||
),
|
||||
table: table
|
||||
});
|
||||
|
||||
const editMachineForm = useEditApiFormModal({
|
||||
title: t`Edit Machine`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: selectedMachinePk,
|
||||
fields: {
|
||||
name: {},
|
||||
active: {}
|
||||
},
|
||||
table: table
|
||||
});
|
||||
|
||||
const rowActions = useCallback((record: any): RowAction[] => {
|
||||
return [
|
||||
{
|
||||
icon: <IconRefresh />,
|
||||
title: t`Restart Machine`,
|
||||
onClick: () => {
|
||||
restartMachine({
|
||||
machinePk: record.pk,
|
||||
callback: () => {
|
||||
table.refreshTable();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
title: t`Edit machine`,
|
||||
onClick: () => {
|
||||
setSelectedMachinePk(record.pk);
|
||||
editMachineForm.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
title: t`Delete Machine`,
|
||||
onClick: () => {
|
||||
setSelectedMachinePk(record.pk);
|
||||
deleteMachineForm.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
@@ -568,6 +642,8 @@ export function MachineListTable({
|
||||
return (
|
||||
<>
|
||||
{createMachineForm.modal}
|
||||
{editMachineForm.modal}
|
||||
{deleteMachineForm.modal}
|
||||
{renderMachineDrawer && (
|
||||
<DetailDrawer
|
||||
title={t`Machine Detail`}
|
||||
@@ -596,7 +672,8 @@ export function MachineListTable({
|
||||
? `machine-${machine.pk}/`
|
||||
: `../machine-${machine.pk}/`
|
||||
),
|
||||
tableActions,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
params: {
|
||||
...props.params
|
||||
},
|
||||
|
@@ -50,6 +50,56 @@ export interface MachineDriverI {
|
||||
driver_errors: string[];
|
||||
}
|
||||
|
||||
export function MachineDriverTable({
|
||||
machineType,
|
||||
prefix
|
||||
}: {
|
||||
machineType?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const table = useTable('machine-drivers');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'name',
|
||||
title: t`Name`
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
{
|
||||
accessor: 'machine_type',
|
||||
title: t`Driver Type`
|
||||
},
|
||||
BooleanColumn({
|
||||
accessor: 'is_builtin',
|
||||
title: t`Builtin driver`
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.machine_driver_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableDownload: false,
|
||||
enableSearch: false,
|
||||
onRowClick: (machine) => {
|
||||
navigate(`${prefix ?? '.'}/driver-${machine.slug}/`);
|
||||
},
|
||||
dataFormatter: (data: any) => {
|
||||
if (machineType) {
|
||||
return data.filter((d: any) => d.machine_type === machineType);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineTypeDrawer({
|
||||
machineTypeSlug
|
||||
}: Readonly<{ machineTypeSlug: string }>) {
|
||||
@@ -63,23 +113,6 @@ function MachineTypeDrawer({
|
||||
[machineTypes, machineTypeSlug]
|
||||
);
|
||||
|
||||
const table = useTable('machineDrivers');
|
||||
|
||||
const machineDriverTableColumns = useMemo<TableColumn<MachineDriverI>[]>(
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
title: t`Name`
|
||||
},
|
||||
DescriptionColumn({}),
|
||||
BooleanColumn({
|
||||
accessor: 'is_builtin',
|
||||
title: t`Builtin driver`
|
||||
})
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
@@ -162,22 +195,7 @@ function MachineTypeDrawer({
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Card withBorder>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.machine_driver_list)}
|
||||
tableState={table}
|
||||
columns={machineDriverTableColumns}
|
||||
props={{
|
||||
dataFormatter: (data: any) => {
|
||||
return data.filter(
|
||||
(d: any) => d.machine_type === machineTypeSlug
|
||||
);
|
||||
},
|
||||
enableDownload: false,
|
||||
enableSearch: false,
|
||||
onRowClick: (machine) =>
|
||||
navigate(`../driver-${machine.slug}/`)
|
||||
}}
|
||||
/>
|
||||
<MachineDriverTable machineType={machineTypeSlug} prefix='..' />
|
||||
</Card>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
@@ -379,7 +397,7 @@ export function MachineTypeListTable({
|
||||
...props,
|
||||
enableDownload: false,
|
||||
enableSearch: false,
|
||||
onRowClick: (machine) => navigate(`type-${machine.slug}/`),
|
||||
onRowClick: (machine) => navigate(`./type-${machine.slug}/`),
|
||||
params: {
|
||||
...props.params
|
||||
}
|
||||
|
113
src/frontend/tests/pui_machines.spec.ts
Normal file
113
src/frontend/tests/pui_machines.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from 'playwright/test';
|
||||
import { clickOnRowMenu, navigate } from './helpers';
|
||||
import { doCachedLogin } from './login';
|
||||
import { setPluginState } from './settings';
|
||||
|
||||
test('Machines - Admin Panel', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
username: 'admin',
|
||||
password: 'inventree',
|
||||
url: 'settings/admin/machine'
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Machines' }).click();
|
||||
await page.getByRole('button', { name: 'Machine Drivers' }).click();
|
||||
await page.getByRole('button', { name: 'Machine Types' }).click();
|
||||
await page.getByRole('button', { name: 'Machine Errors' }).click();
|
||||
|
||||
await page.getByText('There are no machine registry errors').waitFor();
|
||||
});
|
||||
|
||||
test('Machines - Activation', async ({ browser, request }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
username: 'admin',
|
||||
password: 'inventree',
|
||||
url: 'settings/admin/machine'
|
||||
});
|
||||
|
||||
// Ensure that the sample machine plugin is enabled
|
||||
await setPluginState({
|
||||
request,
|
||||
plugin: 'sample-printer-machine-plugin',
|
||||
state: true
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole('button', { name: 'action-button-add-machine' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-name' })
|
||||
.fill('my-dummy-machine');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'choice-field-machine_type' })
|
||||
.fill('label');
|
||||
await page.getByRole('option', { name: 'Label Printer' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'choice-field-driver' }).click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Sample Label Printer Driver' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Creating the new machine opens the "machine drawer"
|
||||
|
||||
// Check for "machine type" settings
|
||||
await page.getByText('Scope the printer to a specific location').waitFor();
|
||||
|
||||
// Check for "machine driver" settings
|
||||
await page.getByText('Custom string for connecting').waitFor();
|
||||
|
||||
// Edit the available setting
|
||||
await page.getByRole('button', { name: 'edit-setting-CONNECTION' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'text-field-value' })
|
||||
.fill('a new value');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('Setting CONNECTION updated successfully').waitFor();
|
||||
|
||||
// Close the drawer
|
||||
await page.getByRole('banner').getByRole('button').first().click();
|
||||
const cell = await page.getByRole('cell', { name: 'my-dummy-machine' });
|
||||
|
||||
// Let's restart the machine now
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).waitFor();
|
||||
await page.getByRole('menuitem', { name: 'Restart' }).click();
|
||||
await page.getByText('Machine restarted').waitFor();
|
||||
|
||||
// Let's print something with the machine
|
||||
await navigate(page, 'stock/location/1/stock-items');
|
||||
|
||||
await page.getByRole('checkbox', { name: 'Select all records' }).click();
|
||||
await page
|
||||
.getByRole('tabpanel', { name: 'Stock Items' })
|
||||
.getByLabel('action-menu-printing-actions')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'action-menu-printing-actions-print-labels'
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByLabel('related-field-plugin').fill('machine');
|
||||
await page.getByText('InvenTreeLabelMachine').click();
|
||||
|
||||
await page
|
||||
.getByRole('textbox', { name: 'choice-field-machine' })
|
||||
.fill('dummy');
|
||||
await page.getByRole('option', { name: 'my-dummy-machine' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Print', exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByText('Process completed successfully').waitFor();
|
||||
|
||||
await navigate(page, 'settings/admin/machine/');
|
||||
|
||||
// Finally, delete the machine configuration
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByText('Machine successfully deleted.').waitFor();
|
||||
});
|
Reference in New Issue
Block a user