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:
2
.github/workflows/qc_checks.yaml
vendored
2
.github/workflows/qc_checks.yaml
vendored
@@ -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
|
||||||
|
@@ -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)}
|
||||||
<Anchor href={url} target='_blank' rel='noopener noreferrer'>
|
{!!attachment ? (
|
||||||
{text}
|
<Anchor href={url} target='_blank' rel='noopener noreferrer'>
|
||||||
</Anchor>
|
{text}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
text
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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 ||
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -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();
|
||||||
|
Reference in New Issue
Block a user