2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

Revision Improvements (#7585)

* Bump djangorestframework from 3.14.0 to 3.15.2 in /src/backend

Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.14.0 to 3.15.2.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.2)

---
updated-dependencies:
- dependency-name: djangorestframework
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix req

* fix deps again

* patch serializer

* bump api version

* Fix "min_value" for DRF decimal fields

* Add default serializer values for 'IPN' and 'revision'

* Add specific serializer for email field

* Fix API version

* Add 'revision_of' field to Part model

* Add validation checks for new revision_of field

* Update migration

* Add unit test for 'revision' rules

* Add API filters for revision control

* Add table filters for PUI

* Add "revision_of" field to PUI form

* Update part forms for PUI

* Render part revision selection dropdown in PUI

* Prevent refetch on focus

* Ensure select renders above other items

* Disable searching

* Cleanup <PartDetail/>

* UI tweak

* Add setting to control revisions for assemblies

* Hide revision selection drop-down if revisions are not enabled

* Query updates

* Validate entire BOM table from PUI

* Sort revisions

* Fix requirements files

* Fix api_version.py

* Reintroduce previous check for IPN / revision uniqueness

* Set default value for refetchOnWindowFocus (false)

* Revert serializer change

* Further CI fixes

* Further unit test updates

* Fix defaults for query client

* Add docs

* Add link to "revision_of" in CUI

* Add playwright test for revisions

* Ignore notification errors for playwright

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2024-07-12 14:37:32 +10:00
committed by GitHub
parent fb17078497
commit 767b76314e
43 changed files with 620 additions and 185 deletions

View File

@ -29,4 +29,10 @@ export function setApiDefaults() {
}
}
export const queryClient = new QueryClient();
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
});

View File

@ -30,17 +30,6 @@ import { StylishText } from '../items/StylishText';
import { getModelInfo } from '../render/ModelType';
import { StatusRenderer } from '../render/StatusRenderer';
export type PartIconsType = {
assembly: boolean;
template: boolean;
component: boolean;
trackable: boolean;
purchaseable: boolean;
saleable: boolean;
virtual: boolean;
active: boolean;
};
export type DetailsField =
| {
hidden?: boolean;

View File

@ -1,92 +0,0 @@
import { Trans, t } from '@lingui/macro';
import { Badge, Tooltip } from '@mantine/core';
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
/**
* Fetches and wraps an InvenTreeIcon in a flex div
* @param icon name of icon
*
*/
function PartIcon(icon: InvenTreeIconType) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<InvenTreeIcon icon={icon} />
</div>
);
}
/**
* Generates a table cell with Part icons.
* Only used for Part Model Details
*/
export function PartIcons({ part }: { part: any }) {
return (
<td colSpan={2}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{part.locked && (
<Tooltip label={t`Part is locked`}>
<Badge color="black" variant="filled">
<Trans>Locked</Trans>
</Badge>
</Tooltip>
)}
{!part.active && (
<Tooltip label={t`Part is not active`}>
<Badge color="red" variant="filled">
<Trans>Inactive</Trans>
</Badge>
</Tooltip>
)}
{part.template && (
<Tooltip
label={t`Part is a template part (variants can be made from this part)`}
children={PartIcon('template')}
/>
)}
{part.assembly && (
<Tooltip
label={t`Part can be assembled from other parts`}
children={PartIcon('assembly')}
/>
)}
{part.component && (
<Tooltip
label={t`Part can be used in assemblies`}
children={PartIcon('component')}
/>
)}
{part.trackable && (
<Tooltip
label={t`Part stock is tracked by serial number`}
children={PartIcon('trackable')}
/>
)}
{part.purchaseable && (
<Tooltip
label={t`Part can be purchased from external suppliers`}
children={PartIcon('purchaseable')}
/>
)}
{part.saleable && (
<Tooltip
label={t`Part can be sold to customers`}
children={PartIcon('saleable')}
/>
)}
{part.virtual && (
<Tooltip label={t`Part is virtual (not a physical part)`}>
<Badge color="yellow" variant="filled">
<div
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
>
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
<Trans>Virtual</Trans>
</div>
</Badge>
</Tooltip>
)}
</div>
</td>
);
}

View File

@ -125,7 +125,6 @@ export function OptionsApiForm({
const optionsQuery = useQuery({
enabled: true,
refetchOnMount: false,
refetchOnWindowFocus: false,
queryKey: [
'form-options-data',
id,

View File

@ -39,12 +39,14 @@ export function ActionDropdown({
icon,
tooltip,
actions,
disabled = false
disabled = false,
hidden = false
}: {
icon: ReactNode;
tooltip: string;
actions: ActionDropdownItem[];
disabled?: boolean;
hidden?: boolean;
}) {
const hasActions = useMemo(() => {
return actions.some((action) => !action.hidden);
@ -58,7 +60,7 @@ export function ActionDropdown({
return identifierString(`action-menu-${tooltip}`);
}, [tooltip]);
return hasActions ? (
return !hidden && hasActions ? (
<Menu position="bottom-end" key={menuName}>
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
<Menu.Target>

View File

@ -70,8 +70,7 @@ export function Header() {
}
},
refetchInterval: 30000,
refetchOnMount: true,
refetchOnWindowFocus: false
refetchOnMount: true
});
// Sync Navigation Drawer state with zustand

View File

@ -53,8 +53,7 @@ export function NotificationDrawer({
.catch((error) => {
return error;
}),
refetchOnMount: false,
refetchOnWindowFocus: false
refetchOnMount: false
});
const hasNotifications: boolean = useMemo(() => {

View File

@ -275,8 +275,7 @@ export function SearchDrawer({
// Search query manager
const searchQuery = useQuery({
queryKey: ['search', searchText, searchRegex, searchWhole],
queryFn: performSearch,
refetchOnWindowFocus: false
queryFn: performSearch
});
// A list of queries which return valid results

View File

@ -47,6 +47,7 @@ export interface InstanceRenderInterface {
instance: any;
link?: boolean;
navigate?: any;
showSecondary?: boolean;
}
/**
@ -149,10 +150,12 @@ export function RenderInlineModel({
image,
labels,
url,
navigate
navigate,
showSecondary = true
}: {
primary: string;
secondary?: string;
showSecondary?: boolean;
suffix?: ReactNode;
image?: string;
labels?: string[];
@ -181,7 +184,7 @@ export function RenderInlineModel({
) : (
<Text size="sm">{primary}</Text>
)}
{secondary && <Text size="xs">{secondary}</Text>}
{showSecondary && secondary && <Text size="xs">{secondary}</Text>}
</Group>
{suffix && (
<>

View File

@ -1,4 +1,5 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { ReactNode } from 'react';
import { ModelType } from '../../enums/ModelType';
@ -12,14 +13,35 @@ export function RenderPart(
props: Readonly<InstanceRenderInterface>
): ReactNode {
const { instance } = props;
const stock = t`Stock` + `: ${instance.in_stock}`;
let badgeText = '';
let badgeColor = 'green';
let stock = instance.total_in_stock;
if (instance.active == false) {
badgeColor = 'red';
badgeText = t`Inactive`;
} else if (stock <= 0) {
badgeColor = 'orange';
badgeText = t`No stock`;
} else {
badgeText = t`Stock` + `: ${stock}`;
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
}
const badge = (
<Badge size="xs" color={badgeColor}>
{badgeText}
</Badge>
);
return (
<RenderInlineModel
{...props}
primary={instance.name}
primary={instance.full_name ?? instance.name}
secondary={instance.description}
suffix={stock}
suffix={badge}
image={instance.thumnbnail || instance.image}
url={props.link ? getDetailUrl(ModelType.part, instance.pk) : undefined}
/>

View File

@ -69,6 +69,7 @@ export enum ApiEndpoints {
bom_list = 'bom/',
bom_item_validate = 'bom/:id/validate/',
bom_validate = 'part/:id/bom-validate/',
// Part API endpoints
part_list = 'part/',

View File

@ -3,6 +3,7 @@ import { IconPackages } from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { useGlobalSettingsState } from '../states/SettingsState';
/**
* Construct a set of fields for creating / editing a Part instance
@ -21,9 +22,19 @@ export function usePartFields({
},
name: {},
IPN: {},
revision: {},
description: {},
variant_of: {},
revision: {},
revision_of: {
filters: {
is_revision: false,
is_template: false
}
},
variant_of: {
filters: {
is_template: true
}
},
keywords: {},
units: {},
link: {},
@ -82,13 +93,22 @@ export function usePartFields({
};
}
// TODO: pop 'expiry' field if expiry not enabled
delete fields['default_expiry'];
const settings = useGlobalSettingsState.getState();
// TODO: pop 'revision' field if PART_ENABLE_REVISION is False
delete fields['revision'];
if (settings.isSet('PART_REVISION_ASSEMBLY_ONLY')) {
fields.revision_of.filters['assembly'] = true;
}
// TODO: handle part duplications
// Pop 'revision' field if PART_ENABLE_REVISION is False
if (!settings.isSet('PART_ENABLE_REVISION')) {
delete fields['revision'];
delete fields['revision_of'];
}
// Pop 'expiry' field if expiry not enabled
if (!settings.isSet('STOCK_ENABLE_EXPIRY')) {
delete fields['default_expiry'];
}
return fields;
}, [create]);

View File

@ -33,6 +33,7 @@ import {
IconGitBranch,
IconGridDots,
IconHash,
IconHierarchy,
IconInfoCircle,
IconLayersLinked,
IconLink,
@ -89,7 +90,8 @@ import React from 'react';
const icons = {
name: IconPoint,
description: IconInfoCircle,
variant_of: IconStatusChange,
variant_of: IconHierarchy,
revision_of: IconStatusChange,
unallocated_stock: IconPackage,
total_in_stock: IconPackages,
minimum_stock: IconFlag,

View File

@ -85,7 +85,7 @@ export function useInstance<T = any>({
});
},
refetchOnMount: refetchOnMount,
refetchOnWindowFocus: refetchOnWindowFocus,
refetchOnWindowFocus: refetchOnWindowFocus ?? false,
refetchInterval: updateInterval
});

View File

@ -180,11 +180,12 @@ export default function SystemSettings() {
content: (
<GlobalSettingList
keys={[
'PART_ENABLE_REVISION',
'PART_IPN_REGEX',
'PART_ALLOW_DUPLICATE_IPN',
'PART_ALLOW_EDIT_IPN',
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
'PART_ENABLE_REVISION',
'PART_REVISION_ASSEMBLY_ONLY',
'PART_NAME_FORMAT',
'PART_SHOW_RELATED',
'PART_CREATE_INITIAL',

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Alert, Grid, Skeleton, Stack, Table } from '@mantine/core';
import { Alert, Grid, Skeleton, Space, Stack, Text } from '@mantine/core';
import {
IconBookmarks,
IconBuilding,
@ -22,9 +22,10 @@ import {
IconTruckDelivery,
IconVersions
} from '@tabler/icons-react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select';
import { api } from '../../App';
import AdminButton from '../../components/buttons/AdminButton';
@ -32,7 +33,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
import DetailsBadge from '../../components/details/DetailsBadge';
import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import { PartIcons } from '../../components/details/PartIcons';
import NotesEditor from '../../components/editors/NotesEditor';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { Thumbnail } from '../../components/images/Thumbnail';
@ -51,6 +51,7 @@ import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -70,6 +71,7 @@ import {
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { BomTable } from '../../tables/bom/BomTable';
import { UsedInTable } from '../../tables/bom/UsedInTable';
@ -96,6 +98,8 @@ export default function PartDetail() {
const [treeOpen, setTreeOpen] = useState(false);
const globalSettings = useGlobalSettingsState();
const {
instance: part,
refreshInstance,
@ -137,6 +141,20 @@ export default function PartDetail() {
model: ModelType.part,
hidden: !part.variant_of
},
{
type: 'link',
name: 'revision_of',
label: t`Revision of`,
model: ModelType.part,
hidden: !part.revision_of
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
hidden: !part.revision,
copy: true
},
{
type: 'link',
name: 'category',
@ -164,13 +182,6 @@ export default function PartDetail() {
copy: true,
hidden: !part.IPN
},
{
type: 'string',
name: 'revision',
label: t`Revision`,
copy: true,
hidden: !part.revision
},
{
type: 'string',
name: 'units',
@ -449,7 +460,7 @@ export default function PartDetail() {
});
}
return (
return part ? (
<ItemDetailsGrid>
<Grid>
<Grid.Col span={4}>
@ -467,22 +478,15 @@ export default function PartDetail() {
/>
</Grid.Col>
<Grid.Col span={8}>
<Stack gap="xs">
<Table>
<Table.Tbody>
<Table.Tr>
<PartIcons part={part} />
</Table.Tr>
</Table.Tbody>
</Table>
<DetailsTable fields={tl} item={part} />
</Stack>
<DetailsTable fields={tl} item={part} />
</Grid.Col>
</Grid>
<DetailsTable fields={tr} item={part} />
<DetailsTable fields={bl} item={part} />
<DetailsTable fields={br} item={part} />
</ItemDetailsGrid>
) : (
<Skeleton />
);
}, [part, instanceQuery]);
@ -655,6 +659,89 @@ export default function PartDetail() {
];
}, [id, part, user]);
// Fetch information on part revision
const partRevisionQuery = useQuery({
refetchOnMount: true,
queryKey: [
'part_revisions',
part.pk,
part.revision_of,
part.revision_count
],
queryFn: async () => {
if (!part.revision_of && !part.revision_count) {
return [];
}
let revisions = [];
// First, fetch information for the top-level part
if (part.revision_of) {
await api
.get(apiUrl(ApiEndpoints.part_list, part.revision_of))
.then((response) => {
revisions.push(response.data);
});
} else {
revisions.push(part);
}
const url = apiUrl(ApiEndpoints.part_list);
await api
.get(url, {
params: {
revision_of: part.revision_of || part.pk
}
})
.then((response) => {
switch (response.status) {
case 200:
response.data.forEach((r: any) => {
revisions.push(r);
});
break;
default:
break;
}
})
.catch(() => {});
return revisions;
}
});
const partRevisionOptions: any[] = useMemo(() => {
if (partRevisionQuery.isFetching || !partRevisionQuery.data) {
return [];
}
if (!part.revision_of && !part.revision_count) {
return [];
}
let options: any[] = partRevisionQuery.data.map((revision: any) => {
return {
value: revision.pk,
label: revision.full_name,
part: revision
};
});
// Add this part if not already available
if (!options.find((o) => o.value == part.pk)) {
options.push({
value: part.pk,
label: part.full_name,
part: part
});
}
return options.sort((a, b) => {
return ('' + a.part.revision).localeCompare(b.part.revision);
});
}, [part, partRevisionQuery.isFetching, partRevisionQuery.data]);
const breadcrumbs = useMemo(
() => [
{ name: t`Parts`, url: '/part' },
@ -686,7 +773,7 @@ export default function PartDetail() {
/>,
<DetailsBadge
label={t`No Stock`}
color="red"
color="orange"
visible={part.total_in_stock == 0}
key="no_stock"
/>,
@ -705,7 +792,7 @@ export default function PartDetail() {
<DetailsBadge
label={t`Locked`}
color="black"
visible={part.locked}
visible={part.locked == true}
key="locked"
/>,
<DetailsBadge
@ -738,14 +825,22 @@ export default function PartDetail() {
value: part.pk,
hidden: true
},
copy_image: {},
copy_bom: {},
copy_notes: {},
copy_parameters: {}
copy_image: {
value: true
},
copy_bom: {
value: globalSettings.isSet('PART_COPY_BOM')
},
copy_notes: {
value: true
},
copy_parameters: {
value: globalSettings.isSet('PART_COPY_PARAMETERS')
}
}
}
};
}, [createPartFields, part]);
}, [createPartFields, globalSettings, part]);
const duplicatePart = useCreateApiFormModal({
url: ApiEndpoints.part_list,
@ -859,6 +954,13 @@ export default function PartDetail() {
];
}, [id, part, user]);
const enableRevisionSelection: boolean = useMemo(() => {
return (
partRevisionOptions.length > 0 &&
globalSettings.isSet('PART_ENABLE_REVISION')
);
}, [partRevisionOptions, globalSettings]);
return (
<>
{duplicatePart.modal}
@ -886,6 +988,40 @@ export default function PartDetail() {
setTreeOpen(true);
}}
actions={partActions}
detail={
enableRevisionSelection ? (
<Stack gap="xs">
<Text>{t`Select Part Revision`}</Text>
<Select
id="part-revision-select"
aria-label="part-revision-select"
options={partRevisionOptions}
value={{
value: part.pk,
label: part.full_name,
part: part
}}
isSearchable={false}
formatOptionLabel={(option: any) =>
RenderPart({
instance: option.part,
showSecondary: false
})
}
onChange={(value: any) => {
navigate(getDetailUrl(ModelType.part, value.value));
}}
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
menu: (base: any) => ({ ...base, zIndex: 9999 }),
menuList: (base: any) => ({ ...base, zIndex: 9999 })
}}
/>
</Stack>
) : (
<Space />
)
}
/>
<PanelGroup pageKey="part" panels={partPanels} />
{transferStockItems.modal}

View File

@ -178,7 +178,6 @@ export function InvenTreeTable<T = any>({
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
retry: 3,
refetchOnMount: true,
refetchOnWindowFocus: false,
queryFn: async () => {
if (props.enableColumnCaching == false) {
return null;
@ -483,7 +482,6 @@ export function InvenTreeTable<T = any>({
tableState.searchTerm
],
queryFn: fetchTableData,
refetchOnWindowFocus: false,
refetchOnMount: true
});

View File

@ -11,6 +11,7 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { YesNoButton } from '../../components/buttons/YesNoButton';
import { Thumbnail } from '../../components/images/Thumbnail';
@ -20,6 +21,7 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { bomItemFields } from '../../forms/BomForms';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
@ -371,6 +373,26 @@ export function BomTable({
table: table
});
const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate,
method: 'PUT',
fields: {
valid: {
hidden: true,
value: true
}
},
title: t`Validate BOM`,
pk: partId,
preFormContent: (
<Alert color="green" icon={<IconCircleCheck />} title={t`Validate BOM`}>
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
</Alert>
),
successMessage: t`BOM validated`,
onFormSuccess: () => table.refreshTable()
});
const validateBomItem = useCallback((record: any) => {
const url = apiUrl(ApiEndpoints.bom_item_validate, record.pk);
@ -445,6 +467,12 @@ export function BomTable({
const tableActions = useMemo(() => {
return [
<ActionButton
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
tooltip={t`Validate BOM`}
icon={<IconCircleCheck />}
onClick={() => validateBom.open()}
/>,
<AddItemButton
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
tooltip={t`Add BOM Item`}
@ -457,12 +485,13 @@ export function BomTable({
<>
{newBomItem.modal}
{editBomItem.modal}
{validateBom.modal}
{deleteBomItem.modal}
<Stack gap="xs">
{partLocked && (
<Alert
title={t`Part is Locked`}
color="red"
color="orange"
icon={<IconLock />}
p="xs"
>

View File

@ -189,7 +189,7 @@ export function PartParameterTable({
{partLocked && (
<Alert
title={t`Part is Locked`}
color="red"
color="orange"
icon={<IconLock />}
p="xs"
>

View File

@ -34,6 +34,10 @@ function partTableColumns(): TableColumn[] {
accessor: 'IPN',
sortable: true
},
{
accessor: 'revision',
sortable: true
},
{
accessor: 'units',
sortable: true
@ -257,6 +261,16 @@ function partTableFilters(): TableFilter[] {
description: t`Filter by parts which are templates`,
type: 'boolean'
},
{
name: 'is_revision',
label: t`Is Revision`,
description: t`Filter by parts which are revisions`
},
{
name: 'has_revisions',
label: t`Has Revisions`,
description: t`Filter by parts which have revisions`
},
{
name: 'has_pricing',
label: t`Has Pricing`,

View File

@ -67,8 +67,9 @@ export const test = baseTest.extend({
url != 'http://localhost:8000/api/user/me/' &&
url != 'http://localhost:8000/api/user/token/' &&
url != 'http://localhost:8000/api/barcode/' &&
url != 'http://localhost:8000/api/news/?search=&offset=0&limit=25' &&
url != 'https://docs.inventree.org/en/versions.json' &&
!url.startsWith('http://localhost:8000/api/news/') &&
!url.startsWith('http://localhost:8000/api/notifications/') &&
!url.startsWith('chrome://') &&
url.indexOf('99999') < 0
)

View File

@ -254,3 +254,21 @@ test('PUI - Pages - Part - 404', async ({ page }) => {
// Clear out any console error messages
await page.evaluate(() => console.clear());
});
test('PUI - Pages - Part - Revision', async ({ page }) => {
await doQuickLogin(page);
await page.goto(`${baseUrl}/part/906/details`);
await page.getByText('Revision of').waitFor();
await page.getByText('Select Part Revision').waitFor();
await page
.getByText('Green Round Table (revision B) | B', { exact: true })
.click();
await page
.getByRole('option', { name: 'Thumbnail Green Round Table No stock' })
.click();
await page.waitForURL('**/platform/part/101/**');
await page.getByText('Select Part Revision').waitFor();
});