2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 22:21:37 +00:00

[UI] Build order visual enhancements (#9931)

* Adjust UI wording

- Separation between "items" and "stock"

* Add info panel if build order has no required items

* Fixes for playwright testing

- Ensure cookies are completely cleaned  between sessions
- Fix base URL based on vite command
- Fix samesite cookie mode
- Prevent /static/ files being served by web server on :8000

* Remove gunicorn option

* Fix unit test

* Readjust base URL

* Simplify doCachedLogin

* Adjust text

* Ensure translations are extracted

- Otherwise, playwright will not find the right strings...

* Make admin test more reliable

* Remove asynciness

* Fix <AttachmentLink>

- Allow null "attachment" value

* Better implementation

* Cleanup
This commit is contained in:
Oliver
2025-07-03 12:15:22 +10:00
committed by GitHub
parent ccc62255c4
commit ee3a574029
8 changed files with 139 additions and 27 deletions

View File

@@ -609,7 +609,7 @@ jobs:
invoke int.rebuild-thumbnails invoke int.rebuild-thumbnails
- name: Install dependencies - name: Install dependencies
run: | run: |
invoke int.frontend-compile invoke int.frontend-compile --extract
cd src/frontend && npx playwright install --with-deps cd src/frontend && npx playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
id: tests id: tests

View File

@@ -6,6 +6,7 @@ import {
IconFileTypePdf, IconFileTypePdf,
IconFileTypeXls, IconFileTypeXls,
IconFileTypeZip, IconFileTypeZip,
IconFileUnknown,
IconLink, IconLink,
IconPhoto IconPhoto
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -17,6 +18,11 @@ import { generateUrl } from '../../functions/urls';
*/ */
export function attachmentIcon(attachment: string): ReactNode { export function attachmentIcon(attachment: string): ReactNode {
const sz = 18; const sz = 18;
if (!attachment) {
return <IconFileUnknown size={sz} />;
}
const suffix = attachment.split('.').pop()?.toLowerCase() ?? ''; const suffix = attachment.split('.').pop()?.toLowerCase() ?? '';
switch (suffix) { switch (suffix) {
case 'pdf': case 'pdf':
@@ -58,8 +64,6 @@ export function AttachmentLink({
attachment: string; attachment: string;
external?: boolean; external?: boolean;
}>): ReactNode { }>): ReactNode {
const text = external ? attachment : attachment.split('/').pop();
const url = useMemo(() => { const url = useMemo(() => {
if (external) { if (external) {
return attachment; return attachment;
@@ -68,12 +72,24 @@ export function AttachmentLink({
return generateUrl(attachment); return generateUrl(attachment);
}, [attachment, external]); }, [attachment, external]);
const text: string = useMemo(() => {
if (!attachment) {
return '-';
}
return external ? attachment : (attachment.split('/').pop() ?? '-');
}, [attachment, external]);
return ( return (
<Group justify='left' gap='sm' wrap='nowrap'> <Group justify='left' gap='sm' wrap='nowrap'>
{external ? <IconLink /> : attachmentIcon(attachment)} {external ? <IconLink /> : attachmentIcon(attachment)}
{!!attachment ? (
<Anchor href={url} target='_blank' rel='noopener noreferrer'> <Anchor href={url} target='_blank' rel='noopener noreferrer'>
{text} {text}
</Anchor> </Anchor>
) : (
text
)}
</Group> </Group>
); );
} }

View File

@@ -31,6 +31,7 @@ export function useInstance<T = any>({
params = {}, params = {},
defaultValue = {}, defaultValue = {},
pathParams, pathParams,
disabled,
hasPrimaryKey = true, hasPrimaryKey = true,
refetchOnMount = true, refetchOnMount = true,
refetchOnWindowFocus = false, refetchOnWindowFocus = false,
@@ -41,6 +42,7 @@ export function useInstance<T = any>({
hasPrimaryKey?: boolean; hasPrimaryKey?: boolean;
params?: any; params?: any;
pathParams?: PathParams; pathParams?: PathParams;
disabled?: boolean;
defaultValue?: any; defaultValue?: any;
refetchOnMount?: boolean; refetchOnMount?: boolean;
refetchOnWindowFocus?: boolean; refetchOnWindowFocus?: boolean;
@@ -51,14 +53,20 @@ export function useInstance<T = any>({
const [instance, setInstance] = useState<T | undefined>(defaultValue); const [instance, setInstance] = useState<T | undefined>(defaultValue);
const instanceQuery = useQuery<T>({ const instanceQuery = useQuery<T>({
enabled: !disabled,
queryKey: [ queryKey: [
'instance', 'instance',
endpoint, endpoint,
pk, pk,
JSON.stringify(params), JSON.stringify(params),
JSON.stringify(pathParams) JSON.stringify(pathParams),
disabled
], ],
queryFn: async () => { queryFn: async () => {
if (disabled) {
return defaultValue;
}
if (hasPrimaryKey) { if (hasPrimaryKey) {
if ( if (
pk == null || pk == null ||

View File

@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { Grid, Skeleton, Stack } from '@mantine/core'; import { Alert, Grid, Skeleton, Stack, Text } from '@mantine/core';
import { import {
IconChecklist, IconChecklist,
IconClipboardCheck, IconClipboardCheck,
@@ -61,6 +61,60 @@ import BuildOutputTable from '../../tables/build/BuildOutputTable';
import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable';
import { StockItemTable } from '../../tables/stock/StockItemTable'; import { StockItemTable } from '../../tables/stock/StockItemTable';
function NoItems() {
return (
<Alert color='blue' icon={<IconInfoCircle />} title={t`No Required Items`}>
<Stack gap='xs'>
<Text>{t`This build order does not have any required items.`}</Text>
<Text>{t`The assembled part may not have a Bill of Materials (BOM) defined, or the BOM is empty.`}</Text>
</Stack>
</Alert>
);
}
/**
* Panel to display the lines of a build order
*/
function BuildLinesPanel({
build,
isLoading,
hasItems
}: Readonly<{
build: any;
isLoading: boolean;
hasItems: boolean;
}>) {
if (isLoading || !build.pk) {
return <Skeleton w={'100%'} h={400} animate />;
}
if (!hasItems) {
return <NoItems />;
}
return <BuildLineTable build={build} />;
}
function BuildAllocationsPanel({
build,
isLoading,
hasItems
}: Readonly<{
build: any;
isLoading: boolean;
hasItems: boolean;
}>) {
if (isLoading || !build.pk) {
return <Skeleton w={'100%'} h={400} animate />;
}
if (!hasItems) {
return <NoItems />;
}
return <BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit />;
}
/** /**
* Detail page for a single Build Order * Detail page for a single Build Order
*/ */
@@ -70,6 +124,19 @@ export default function BuildDetail() {
const user = useUserState(); const user = useUserState();
const globalSettings = useGlobalSettingsState(); const globalSettings = useGlobalSettingsState();
// Fetch the number of BOM items associated with the build order
const { instance: buildLineData, instanceQuery: buildLineQuery } =
useInstance({
endpoint: ApiEndpoints.build_line_list,
params: {
build: id,
limit: 1
},
disabled: !id,
hasPrimaryKey: false,
defaultValue: {}
});
const buildStatus = useStatusCodes({ modelType: ModelType.build }); const buildStatus = useStatusCodes({ modelType: ModelType.build });
const { const {
@@ -334,9 +401,15 @@ export default function BuildDetail() {
}, },
{ {
name: 'line-items', name: 'line-items',
label: t`Required Stock`, label: t`Required Parts`,
icon: <IconListNumbers />, icon: <IconListNumbers />,
content: build?.pk ? <BuildLineTable build={build} /> : <Skeleton /> content: (
<BuildLinesPanel
build={build}
isLoading={buildLineQuery.isFetching || buildLineQuery.isLoading}
hasItems={buildLineData?.count > 0}
/>
)
}, },
{ {
name: 'allocated-stock', name: 'allocated-stock',
@@ -345,10 +418,12 @@ export default function BuildDetail() {
hidden: hidden:
build.status == buildStatus.COMPLETE || build.status == buildStatus.COMPLETE ||
build.status == buildStatus.CANCELLED, build.status == buildStatus.CANCELLED,
content: build.pk ? ( content: (
<BuildAllocatedStockTable buildId={build.pk} showPartInfo allowEdit /> <BuildAllocationsPanel
) : ( build={build}
<Skeleton /> isLoading={buildLineQuery.isFetching || buildLineQuery.isLoading}
hasItems={buildLineData?.count > 0}
/>
) )
}, },
{ {
@@ -438,7 +513,16 @@ export default function BuildDetail() {
model_id: build.pk model_id: build.pk
}) })
]; ];
}, [build, id, user, buildStatus, globalSettings]); }, [
build,
id,
user,
buildStatus,
globalSettings,
buildLineQuery.isFetching,
buildLineQuery.isLoading,
buildLineData
]);
const editBuildOrderFields = useBuildOrderFields({ const editBuildOrderFields = useBuildOrderFields({
create: false, create: false,

View File

@@ -105,6 +105,8 @@ export const loadTab = async (page, tabName) => {
.getByLabel(/panel-tabs-/) .getByLabel(/panel-tabs-/)
.getByRole('tab', { name: tabName }) .getByRole('tab', { name: tabName })
.click(); .click();
await page.waitForLoadState('networkidle');
}; };
// Activate "table" view in certain contexts // Activate "table" view in certain contexts

View File

@@ -66,7 +66,7 @@ test('Build Order - Basic Tests', async ({ browser }) => {
await loadTab(page, 'Attachments'); await loadTab(page, 'Attachments');
await loadTab(page, 'Notes'); await loadTab(page, 'Notes');
await loadTab(page, 'Incomplete Outputs'); await loadTab(page, 'Incomplete Outputs');
await loadTab(page, 'Required Stock'); await loadTab(page, 'Required Parts');
await loadTab(page, 'Allocated Stock'); await loadTab(page, 'Allocated Stock');
// Check for expected text in the table // Check for expected text in the table

View File

@@ -3,7 +3,7 @@
*/ */
import test from '@playwright/test'; import test from '@playwright/test';
import { loadTab } from './helpers'; import { clickOnRowMenu, loadTab } from './helpers';
import { doCachedLogin } from './login'; import { doCachedLogin } from './login';
/** /**
@@ -29,10 +29,10 @@ test('Permissions - Admin', async ({ browser, request }) => {
await page.getByRole('button', { name: 'Cancel' }).click(); await page.getByRole('button', { name: 'Cancel' }).click();
// Change password // Change password
await page.getByRole('cell', { name: 'Ian', exact: true }).click({ await clickOnRowMenu(
button: 'right' await page.getByRole('cell', { name: 'Ian', exact: true })
}); );
await page.getByRole('button', { name: 'Change Password' }).click(); await page.getByRole('menuitem', { name: 'Change Password' }).click();
await page.getByLabel('text-field-password').fill('123'); await page.getByLabel('text-field-password').fill('123');
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText("['This password is too short").waitFor(); await page.getByText("['This password is too short").waitFor();
@@ -46,10 +46,10 @@ test('Permissions - Admin', async ({ browser, request }) => {
await page.getByText('Password updated').click(); await page.getByText('Password updated').click();
// Open profile // Open profile
await page.getByRole('cell', { name: 'Ian', exact: true }).click({ await clickOnRowMenu(
button: 'right' await page.getByRole('cell', { name: 'Ian', exact: true })
}); );
await page.getByRole('button', { name: 'Open Profile' }).click(); await page.getByRole('menuitem', { name: 'Open Profile' }).click();
await page.getByText('User: ian', { exact: true }).click(); await page.getByText('User: ian', { exact: true }).click();
}); });

View File

@@ -197,17 +197,19 @@ test('Settings - Admin - Barcode History', async ({ browser, request }) => {
// Scan some barcodes (via API calls) // Scan some barcodes (via API calls)
const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012']; const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012'];
barcodes.forEach(async (barcode) => { for (let i = 0; i < barcodes.length; i++) {
const barcode = barcodes[i];
const url = new URL('barcode/', apiUrl).toString(); const url = new URL('barcode/', apiUrl).toString();
await request.post(url, { await request.post(url, {
data: { data: {
barcode: barcode barcode: barcode
}, },
timeout: 5000,
headers: { headers: {
Authorization: `Basic ${btoa('admin:inventree')}` Authorization: `Basic ${btoa('admin:inventree')}`
} }
}); });
}); }
await page.getByRole('button', { name: 'admin' }).click(); await page.getByRole('button', { name: 'admin' }).click();
await page.getByRole('menuitem', { name: 'Admin Center' }).click(); await page.getByRole('menuitem', { name: 'Admin Center' }).click();