2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-06 09:43:38 +00:00

[UI] BOM compare (#11853)

* Refactor existing components

* Select assembly for comparison

* Rough BOM comparison table

* Select  display mode

* Layout tweaks

* Reset secondary part when drawer is closed

* Responsive grids

* Documentation

* Update CHANGELOG.md

* Add playwright tests

* Update wording

* Allow specification of secondary part with URL search params

* Update URL params when value changes

* Clearer display using icons

* Improve diff  layout

* Adjust playwright tests
This commit is contained in:
Oliver
2026-05-03 12:18:44 +10:00
committed by GitHub
parent f0edb002d0
commit 24ce51c5ca
13 changed files with 764 additions and 219 deletions
@@ -13,7 +13,7 @@ v480 -> 2026-04-27 : https://github.com/inventree/InvenTree/pull/11816
- The "issued_by" field on the Build API endpoint is now read-only, and is automatically set to the current user when a build is created
v479 -> 2026-04-11 : https://github.com/inventree/InvenTree/pull/11723
- POST /api//notifications/readall/ now requires a POST action
- POST /api/notifications/readall/ now requires a POST action
- POST /api/admin/email/test/ - now returns a 200 on. a successful test
- GET /api/notifications/ - now uses user-centric permissions, not a general read
@@ -71,6 +71,7 @@ export function ApiImage(props: Readonly<ApiImageProps>) {
src={imageUrl}
fit='contain'
style={{
...props.style,
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.2s ease'
}}
@@ -56,7 +56,7 @@ export function Thumbnail({
w={size}
fit='contain'
radius='xs'
style={{ maxHeight: size }}
style={{ maxHeight: size, height: size }}
/>
{inner}
</Group>
+4 -139
View File
@@ -5,9 +5,7 @@ import {
Center,
Grid,
Group,
HoverCard,
Loader,
type MantineColor,
Paper,
Skeleton,
Stack,
@@ -17,13 +15,11 @@ import {
IconBookmarks,
IconBuilding,
IconChecklist,
IconCircleCheck,
IconClipboardList,
IconCurrencyDollar,
IconExclamationCircle,
IconInfoCircle,
IconLayersLinked,
IconListCheck,
IconListDetails,
IconListTree,
IconLock,
@@ -47,7 +43,6 @@ 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 { ActionButton } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
@@ -76,20 +71,17 @@ import NotesPanel from '../../components/panels/NotesPanel';
import type { PanelType } from '../../components/panels/Panel';
import { PanelGroup } from '../../components/panels/PanelGroup';
import { RenderPart } from '../../components/render/Part';
import { RenderUser } from '../../components/render/User';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { useApi } from '../../contexts/ApiContext';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { usePartFields } from '../../forms/PartForms';
import { useFindSerialNumberForm } from '../../forms/StockForms';
import useBackgroundTask from '../../hooks/UseBackgroundTask';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { type UseInstanceResult, useInstance } from '../../hooks/UseInstance';
import { useInstance } from '../../hooks/UseInstance';
import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions';
import {
useGlobalSettingsState,
@@ -112,6 +104,7 @@ import PartAllocationPanel from './PartAllocationPanel';
import PartPricingPanel from './PartPricingPanel';
import PartStockHistoryDetail from './PartStockHistoryDetail';
import PartSupplierDetail from './PartSupplierDetail';
import { BomActions } from './bom/BomActions';
/**
* Render a part revision selector component
@@ -154,132 +147,6 @@ function RevisionSelector({
);
}
/**
* A hover-over component which displays information about the BOM validation for a given part
*/
function BomValidationInformation({
bomInformation,
partId
}: {
bomInformation: UseInstanceResult;
partId: number;
}) {
const user = useUserState();
const [taskId, setTaskId] = useState<string>('');
useBackgroundTask({
taskId: taskId,
message: t`Validating BOM`,
successMessage: t`BOM validated`,
onComplete: () => {
bomInformation.instanceQuery.refetch();
}
});
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: null,
onFormSuccess: (response: any) => {
// If the process has been offloaded to a background task
if (response.task_id) {
setTaskId(response.task_id);
} else {
bomInformation.instanceQuery.refetch();
}
}
});
if (bomInformation.instanceQuery.isFetching) {
return <Loader size='sm' />;
}
let icon: ReactNode;
let color: MantineColor;
let title = '';
let description = '';
if (bomInformation.instance?.bom_validated) {
color = 'green';
icon = <IconListCheck />;
title = t`BOM Validated`;
description = t`The Bill of Materials for this part has been validated`;
} else if (bomInformation.instance?.bom_checked_date) {
color = 'yellow';
icon = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has previously been checked, but requires revalidation`;
} else {
color = 'red';
icon = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has not yet been validated`;
}
return (
<>
{validateBom.modal}
<Group gap='xs' justify='flex-end'>
{!bomInformation.instance?.bom_validated &&
user.hasChangeRole(UserRoles.bom) && (
<ActionButton
icon={<IconCircleCheck />}
color='green'
tooltip={t`Validate BOM`}
onClick={validateBom.open}
/>
)}
<HoverCard position='bottom-end'>
<HoverCard.Target>
<ActionIcon
color={color}
variant='transparent'
aria-label='bom-validation-info'
>
{icon}
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Alert color={color} icon={icon} title={title}>
<Stack gap='xs'>
<Text size='sm'>{description}</Text>
{bomInformation.instance?.bom_checked_date && (
<Text size='sm'>
{t`Validated On`}:{' '}
{bomInformation.instance.bom_checked_date}
</Text>
)}
{bomInformation.instance?.bom_checked_by_detail && (
<Group gap='xs'>
<Text size='sm'>{t`Validated By`}: </Text>
<RenderUser
instance={bomInformation.instance.bom_checked_by_detail}
/>
</Group>
)}
</Stack>
</Alert>
</HoverCard.Dropdown>
</HoverCard>
</Group>
</>
);
}
/**
* Detail view for a single Part instance
*/
@@ -295,6 +162,7 @@ export default function PartDetail() {
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
// BOM validation information (used for hover-over info on the BOM tab)
const bomInformation = useInstance({
endpoint: ApiEndpoints.bom_validate,
pk: id,
@@ -808,10 +676,7 @@ export default function PartDetail() {
name: 'bom',
label: t`Bill of Materials`,
controls: (
<BomValidationInformation
bomInformation={bomInformation}
partId={part.pk ?? -1}
/>
<BomActions bomInformation={bomInformation} partInstance={part} />
),
icon: <IconListTree />,
hidden: !part.assembly || !user.hasViewRole(UserRoles.bom),
@@ -0,0 +1,191 @@
import { ActionButton } from '@lib/components/ActionButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { UserRoles } from '@lib/enums/Roles';
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Group,
HoverCard,
Loader,
type MantineColor,
Stack,
Text
} from '@mantine/core';
import {
IconCircleCheck,
IconExclamationCircle,
IconGitCompare,
IconListCheck
} from '@tabler/icons-react';
import { type ReactNode, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { RenderUser } from '../../../components/render/User';
import useBackgroundTask from '../../../hooks/UseBackgroundTask';
import { useApiFormModal } from '../../../hooks/UseForm';
import type { UseInstanceResult } from '../../../hooks/UseInstance';
import { useUserState } from '../../../states/UserState';
import { BomCompareDrawer } from './BomCompare';
/**
* A hover-over component which displays information about the BOM validation for a given part
*/
export function BomActions({
bomInformation,
partInstance
}: Readonly<{
bomInformation: UseInstanceResult;
partInstance: any;
}>) {
const user = useUserState();
const [bomCompareOpen, setBomCompareOpen] = useState<boolean>(false);
const [bomCompareId, setBomCompareId] = useState<string>('');
const [searchParams, setSearchParams] = useSearchParams();
// Open the BOM compare drawer if the URL contains the relevant query parameter
useEffect(() => {
if (
searchParams.has('compare') &&
!!searchParams.get('compare') &&
!bomCompareOpen
) {
setBomCompareId(searchParams.get('compare') as string);
setBomCompareOpen(true);
}
}, [searchParams]);
const [taskId, setTaskId] = useState<string>('');
useBackgroundTask({
taskId: taskId,
message: t`Validating BOM`,
successMessage: t`BOM validated`,
onComplete: () => {
bomInformation.instanceQuery.refetch();
}
});
const validateBom = useApiFormModal({
url: ApiEndpoints.bom_validate,
method: 'PUT',
fields: {
valid: {
hidden: true,
value: true
}
},
title: t`Validate BOM`,
pk: partInstance.pk,
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: null,
onFormSuccess: (response: any) => {
// If the process has been offloaded to a background task
if (response.task_id) {
setTaskId(response.task_id);
} else {
bomInformation.instanceQuery.refetch();
}
}
});
if (bomInformation.instanceQuery.isFetching) {
return <Loader size='sm' />;
}
let icon: ReactNode;
let color: MantineColor;
let title = '';
let description = '';
if (bomInformation.instance?.bom_validated) {
color = 'green';
icon = <IconListCheck />;
title = t`BOM Validated`;
description = t`The Bill of Materials for this part has been validated`;
} else if (bomInformation.instance?.bom_checked_date) {
color = 'yellow';
icon = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has previously been checked, but requires revalidation`;
} else {
color = 'red';
icon = <IconExclamationCircle />;
title = t`BOM Not Validated`;
description = t`The Bill of Materials for this part has not yet been validated`;
}
return (
<>
{validateBom.modal}
<Group gap='xs' justify='flex-end'>
<ActionButton
icon={<IconGitCompare />}
color='blue'
tooltip={t`Compare Bill of Materials`}
onClick={() => setBomCompareOpen(true)}
/>
{!bomInformation.instance?.bom_validated &&
user.hasChangeRole(UserRoles.bom) && (
<ActionButton
icon={<IconCircleCheck />}
color='green'
tooltip={t`Validate BOM`}
onClick={validateBom.open}
/>
)}
<HoverCard position='bottom-end'>
<HoverCard.Target>
<ActionIcon
color={color}
variant='transparent'
aria-label='bom-validation-info'
>
{icon}
</ActionIcon>
</HoverCard.Target>
<HoverCard.Dropdown>
<Alert color={color} icon={icon} title={title}>
<Stack gap='xs'>
<Text size='sm'>{description}</Text>
{bomInformation.instance?.bom_checked_date && (
<Text size='sm'>
{t`Validated On`}:{' '}
{bomInformation.instance.bom_checked_date}
</Text>
)}
{bomInformation.instance?.bom_checked_by_detail && (
<Group gap='xs'>
<Text size='sm'>{t`Validated By`}: </Text>
<RenderUser
instance={bomInformation.instance.bom_checked_by_detail}
/>
</Group>
)}
</Stack>
</Alert>
</HoverCard.Dropdown>
</HoverCard>
</Group>
<BomCompareDrawer
partInstance={partInstance}
compareId={bomCompareId}
opened={bomCompareOpen}
onClosed={() => {
setBomCompareId('');
setBomCompareOpen(false);
setSearchParams((params: URLSearchParams) => {
params.delete('compare');
return params;
});
}}
/>
</>
);
}
@@ -0,0 +1,428 @@
import { ApiEndpoints, ModelType, StylishText, apiUrl } from '@lib/index';
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Alert,
Divider,
Drawer,
Group,
Paper,
Select,
SimpleGrid,
Stack,
Table,
Text
} from '@mantine/core';
import {
IconArrowRight,
IconCircleCheck,
IconCirclePlus,
IconCircleX,
IconStatusChange
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../../App';
import { StandaloneField } from '../../../components/forms/StandaloneField';
import Expand from '../../../components/items/Expand';
import { RenderPartColumn } from '../../../tables/ColumnRenderers';
// Field to check for differences when comparing BOM items
const DELTA_FIELDS = {
quantity: t`Quantity`,
reference: t`Reference`,
allow_variants: t`Allow Variants`,
inherited: t`Inherited`,
optional: t`Optional`,
consumable: t`Consumable`,
setup_quantity: t`Setup Quantity`,
attrition: t`Attrition`,
rounding_multiple: t`Rounding Multiple`
};
type BomCompareRow = {
part_detail: any;
primary: any;
secondary: any;
match: boolean;
deltas: string[];
key: string;
};
type BomDisplayMode = 'all' | 'different' | 'common';
function getBomDeltas(primary: any, secondary: any): string[] {
const deltas: string[] = [];
Object.entries(DELTA_FIELDS).forEach(([field, label]) => {
if (primary?.[field] != secondary?.[field]) {
deltas.push(field);
}
});
return deltas;
}
function BomTableRow({
item
}: Readonly<{
item: BomCompareRow;
}>) {
const partMatch = !!item.primary && !!item.secondary;
const quantityMatch =
partMatch && item.primary.quantity == item.secondary.quantity;
const deltas: any[] = useMemo(() => {
const fields: any[] = [];
item.deltas.forEach((delta) => {
fields.push({
field: delta,
label: DELTA_FIELDS[delta as keyof typeof DELTA_FIELDS],
primaryValue: item.primary?.[delta] ?? null,
secondaryValue: item.secondary?.[delta] ?? null
});
});
return fields;
}, [item]);
// Determine the appropriate icon to display for this row
const rowIcon: ReactNode = useMemo(() => {
if (!!item.primary != !!item.secondary) {
if (!!item.secondary) {
// Part was added to the secondary BOM (exists in secondary but not primary)
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-yellow-5)'
>
<IconCirclePlus />
</ActionIcon>
);
} else {
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-red-5)'
>
<IconCircleX />
</ActionIcon>
);
}
} else if (
!!item.deltas?.length ||
item.primary?.quantity != item.secondary?.quantity
) {
// Part exists in both BOMs but has differences
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-blue-5)'
>
<IconStatusChange />
</ActionIcon>
);
} else {
return (
<ActionIcon
variant='transparent'
size='sm'
color='var(--mantine-color-green-5)'
>
<IconCircleCheck />
</ActionIcon>
);
}
}, [item]);
return (
<Table.Tr>
<Table.Td>
<Group gap='xs'>
{rowIcon}
<RenderPartColumn part={item.part_detail} />
</Group>
</Table.Td>
<Table.Td>
<Group gap='xs'>
<Text size='sm'>
{item.primary?.quantity ?? item.secondary?.quantity ?? '-'}
</Text>
{item.part_detail?.units && (
<Text size='xs'>[{item.part_detail.units}]</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Group gap='xs'>
{rowIcon}
{partMatch && deltas.length > 0 ? (
<Stack gap='xs'>
{deltas.map((delta, index) => (
<Group key={delta.field} gap='xs' justify='space-between'>
<Text size='xs'>{delta.label}</Text>
<Text size='xs'>{delta.primaryValue ?? '-'}</Text>
<ActionIcon size='xs' variant='transparent'>
<IconArrowRight />
</ActionIcon>
<Text size='xs'>{delta.secondaryValue ?? '-'}</Text>
</Group>
))}
</Stack>
) : (
<Text size='xs'>
{partMatch
? t`No changes`
: !!item.primary
? t`Part removed from BOM`
: t`Part added to BOM`}
</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
);
}
function BomTable({
items
}: Readonly<{
items: BomCompareRow[];
}>) {
return (
<Paper p='xs' withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>{t`Part`}</Table.Th>
<Table.Th>{t`Quantity`}</Table.Th>
<Table.Th>{t`Changes`}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item: any, index) => (
<BomTableRow key={index} item={item} />
))}
</Table.Tbody>
</Table>
</Paper>
);
}
export function BomCompareDrawer({
opened,
onClosed,
compareId,
partInstance
}: {
opened: boolean;
onClosed: () => void;
compareId?: string;
partInstance: any;
}) {
const [displayMode, setDisplayMode] = useState<BomDisplayMode>('all');
const [searchParam, setSearchParams] = useSearchParams();
// Fetch entire BOM for the part
const primaryBom = useQuery({
queryKey: ['bom-compare-primary', partInstance.pk, opened],
enabled: opened && !!partInstance.pk,
queryFn: async () => {
return api
.get(apiUrl(ApiEndpoints.bom_list), {
params: {
part: partInstance.pk,
sub_part_detail: true
}
})
.then((response) => response.data);
}
});
// Secondary part ID
const [secondaryPartId, setSecondaryPartId] = useState<string>(
compareId ?? ''
);
useEffect(() => {
setSecondaryPartId(compareId ?? '');
}, [opened]);
// Fetch BOM for the secondary part
const secondaryBom = useQuery({
queryKey: ['bom-compare-secondary', secondaryPartId, opened],
enabled: opened && !!secondaryPartId,
queryFn: async () => {
return api
.get(apiUrl(ApiEndpoints.bom_list), {
params: {
part: secondaryPartId,
sub_part_detail: true
}
})
.then((response) => response.data);
}
});
// Perform comparison against
const comparedItems: any[] = useMemo(() => {
let rows: BomCompareRow[] = [];
const primaryPartIds = new Set();
const secondaryPartIds = new Set();
// First, iterate through the "primary" BOM to generate the initial data
primaryBom.data?.forEach((item: any) => {
let subPartId = `${item.sub_part}`;
while (primaryPartIds.has(subPartId)) {
subPartId += '_dup';
}
primaryPartIds.add(subPartId);
rows.push({
part_detail: item.sub_part_detail,
primary: item,
secondary: null,
match: false,
deltas: getBomDeltas(item, null), // Initialize deltas with all fields (since no match yet)
key: subPartId
});
});
// Next, iterate through the "secondary" BOM to find matches and update the data
secondaryBom.data?.forEach((item: any) => {
let subPartId = `${item.sub_part}`;
while (secondaryPartIds.has(subPartId)) {
subPartId += '_dup';
}
secondaryPartIds.add(subPartId);
// Try to find a matching part in the primary BOM
const match = rows.find((row) => row.key == subPartId);
if (match) {
// If a match is found, update the existing row
match.secondary = item;
match.match = true; // Mark as a match
match.deltas = getBomDeltas(match.primary, match.secondary); // Update deltas with actual differences
} else {
// If no match is found, add a new row for the secondary item
rows.push({
part_detail: item.sub_part_detail,
primary: null,
secondary: item,
match: false,
deltas: getBomDeltas(null, item),
key: subPartId
});
}
});
switch (displayMode) {
case 'different':
// Show only *different* parts
rows = rows.filter((row) => !row.match);
break;
case 'common':
// Show only *common* parts
rows = rows.filter((row) => row.match);
break;
default:
case 'all':
break;
}
// Return rows, sorted by part name
return rows.sort((a, b) => {
const nameA = a.part_detail?.name ?? '';
const nameB = b.part_detail?.name ?? '';
return nameA.localeCompare(nameB);
});
}, [displayMode, primaryBom.data, secondaryBom.data]);
return (
<Drawer
opened={opened}
onClose={onClosed}
withCloseButton
position='bottom'
size='80%'
title={
<StylishText size='lg'>{t`Compare Bill of Materials`}</StylishText>
}
>
<Stack gap='xs'>
<Divider />
<Paper p='xs' withBorder>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Stack gap='xs' justify='flex-start' align='stretch'>
<Text size='sm'>{t`Primary Assembly`}</Text>
<Text
size='xs'
c='dimmed'
>{t`Primary assembly for comparison`}</Text>
<RenderPartColumn part={partInstance} />
</Stack>
<Expand>
<StandaloneField
fieldName='assembly'
fieldDefinition={{
description: t`Select assembly to compare`,
label: t`Secondary Assembly`,
field_type: 'related field',
value: secondaryPartId,
api_url: apiUrl(ApiEndpoints.part_list),
model: ModelType.part,
required: true,
filters: {
assembly: true
},
onValueChange: (value) => {
setSecondaryPartId(value);
if (opened) {
setSearchParams(
{
compare: value
},
{ replace: true }
);
}
}
}}
/>
</Expand>
<Select
label={t`Display Mode`}
aria-label='bom-compare-display-mode'
description={t`Select display mode for BOM comparison`}
defaultValue={'all'}
onChange={(value) => setDisplayMode(value as any)}
data={[
{ value: 'all', label: t`Show all Parts` },
{ value: 'different', label: t`Show different Parts` },
{ value: 'common', label: t`Show common Parts` }
]}
/>
</SimpleGrid>
</Paper>
{secondaryPartId ? (
<BomTable items={comparedItems} />
) : (
<Alert color='yellow'>{t`Select an assembly to view Bill of Materials comparison`}</Alert>
)}
</Stack>
</Drawer>
);
}
+31
View File
@@ -268,6 +268,37 @@ test('Parts - BOM Validation', async ({ browser }) => {
await page.getByText('Validated By: allaccessAlly').waitFor();
});
test('Parts - BOM Comparison', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/bom' });
await page
.getByRole('button', { name: 'action-button-compare-bill-of' })
.click();
await page.getByText('Select an assembly to view').waitFor();
await page
.getByRole('combobox', { name: 'related-field-assembly' })
.fill('blue round table');
await page.getByText('Blue Round TableA round table').click();
await page.getByRole('columnheader', { name: 'Quantity' }).first().waitFor();
await page.getByRole('columnheader', { name: 'Changes' }).first().waitFor();
await page.getByText('No changes').first().waitFor();
await page.getByText('Part added to BOM').first().waitFor();
await page.getByText('Removed from BOM').first().waitFor();
// Change display mode
await page.getByRole('textbox', { name: 'bom-compare-display-mode' }).click();
await page.getByRole('option', { name: 'Show different Parts' }).click();
// Use URL params to compare directly
await navigate(page, 'part/108/bom?compare=107');
await page.waitForLoadState('networkidle');
await page.getByText('0.125').nth(1).waitFor();
await page.getByText('Red Paint', { exact: true }).first().waitFor();
await page.getByText('Blue Paint', { exact: true }).first().waitFor();
});
test('Parts - Editing', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'part/104/details' });