Merge commit '6865b0b6e6ed5464c25b4ccca7e3c523ce1876c5' into block-notes

This commit is contained in:
Oliver Walters
2026-07-01 13:52:25 +00:00
101 changed files with 41277 additions and 39795 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ export default defineConfig({
INVENTREE_FRONTEND_API_HOST: 'http://localhost:8000',
INVENTREE_CORS_ORIGIN_ALLOW_ALL: 'True',
INVENTREE_COOKIE_SAMESITE: 'False',
INVENTREE_LOGIN_ATTEMPTS: '100',
INVENTREE_LOGIN_ATTEMPTS: '3',
INVENTREE_PLUGINS_MANDATORY: 'samplelocate',
INVENTREE_CUSTOM_SPLASH: 'img/playwright_custom_splash.png',
INVENTREE_CUSTOM_LOGO: 'img/playwright_custom_logo.png'
@@ -4,6 +4,9 @@ import { useId } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react';
import { type Control, type FieldValues, useController } from 'react-hook-form';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { IconFileUpload } from '@tabler/icons-react';
import type { NavigateFunction } from 'react-router-dom';
@@ -19,6 +22,7 @@ import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
import TagsField from './TagsField';
import TextField from './TextField';
import { TreeField } from './TreeField';
/**
* Render an individual form field
@@ -121,14 +125,48 @@ export function ApiFormField({
const fieldInstance = useMemo(() => {
switch (fieldDefinition.field_type) {
case 'related field':
return (
<RelatedModelField
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.stock_location_list)
) {
// Redirect location fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations'
genericPlaceholder={t`Select location`}
model={ModelType.stocklocation}
navigate={navigate}
/>
);
} else if (
fieldDefinition.api_url === apiUrl(ApiEndpoints.category_list)
) {
// Redirect category fields to the appropriate tree field
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
childIdentifier='subcategories'
genericPlaceholder={t`Select category`}
model={ModelType.partcategory}
navigate={navigate}
/>
);
} else {
return (
<RelatedModelField
definition={fieldDefinition}
controller={controller}
fieldName={fieldName}
navigate={navigate}
/>
);
}
case 'email':
case 'url':
case 'string':
@@ -1,6 +1,7 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
ActionIcon,
Alert,
Container,
Group,
@@ -9,7 +10,10 @@ import {
Table,
Text
} from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import {
IconCornerDownRight,
IconExclamationCircle
} from '@tabler/icons-react';
import {
type ReactNode,
memo,
@@ -63,13 +67,33 @@ function TableFieldRow({
);
}
return modelRenderer({
item: item,
rowId: rowId,
rowErrors: rowErrors,
changeFn: changeFn,
removeFn: removeFn
});
const nonFieldErrors = rowErrors?.non_field_errors;
return (
<>
{modelRenderer({
item: item,
rowId: rowId,
rowErrors: rowErrors,
changeFn: changeFn,
removeFn: removeFn
})}
{nonFieldErrors && (
<Table.Tr key={`table-row-${rowId}-non-field-errors`}>
<Table.Td colSpan={columnCount}>
<Group gap='xs'>
<ActionIcon size='sm' variant='transparent' c='red'>
<IconCornerDownRight />
</ActionIcon>
<Text size='xs' c='red'>
{nonFieldErrors.message ?? nonFieldErrors}
</Text>
</Group>
</Table.Td>
</Table.Tr>
)}
</>
);
}
// Memoize each table field row, so that we don't re-render the entire table when a single row is updated
@@ -0,0 +1,499 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Group,
Input,
Text,
type TreeNodeData,
TreeSelect
} from '@mantine/core';
import { useDebouncedValue, useId } from '@mantine/hooks';
import {
IconChevronDown,
IconChevronRight,
IconLink,
IconX
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import type { NavigateFunction } from 'react-router-dom';
import type { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api';
import { cancelEvent } from '@lib/functions/Events';
import { getDetailUrl, navigateToLink } from '@lib/index';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { useApi } from '../../../contexts/ApiContext';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../../../states/SettingsStates';
import { ScanButton } from '../../buttons/ScanButton';
import { ApiIcon } from '../../items/ApiIcon';
import Expand from '../../items/Expand';
import { ModelHoverCard } from '../../render/ModelHoverCard';
/**
* A form field that renders a hierarchical tree selector backed by a tree API
* endpoint. Supports server-side search, lazy child loading, and (when a model
* type is provided) barcode scanning and a hover-card navigate link.
*/
export function TreeField({
controller,
definition,
fieldName,
endpoint,
childIdentifier,
genericPlaceholder,
model,
navigate
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
fieldName: string;
endpoint: ApiEndpoints;
childIdentifier: string;
genericPlaceholder?: string;
model?: ModelType;
navigate?: NavigateFunction | null;
}>) {
const api = useApi();
const inputId = useId();
const globalSettings = useGlobalSettingsState();
const userSettings = useUserSettingsState();
const {
field,
fieldState: { error }
} = controller;
// Keep the selected pk in sync with form state so we can always request
// the selected node (and its ancestors) for label hydration.
const selectedValue = useMemo(
() => (field.value != null ? Number(field.value) : null),
[field.value]
);
// Track dropdown state to separate server-side search text from the
// read-only display label shown when the field is closed.
const dropdownOpen = useRef(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// Track the pk whose ancestor path was last auto-expanded.
const expandedForValue = useRef<number | null>(null);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const [expandedValues, setExpandedValues] = useState<string[]>([]);
const [nodes, setNodes] = useState<any[]>([]);
const query = useQuery({
enabled:
!definition.disabled &&
!definition.hidden &&
(isDropdownOpen || selectedValue != null),
queryKey: [
'tree-field',
fieldName,
endpoint,
isDropdownOpen,
debouncedSearch,
selectedValue
],
queryFn: () =>
api
.get(apiUrl(endpoint), {
params: {
...definition.filters,
ordering: 'level',
search: debouncedSearch || undefined,
// Include the selected node and its ancestors in the initial response
// so the node label is available before the user interacts with the field.
max_level: debouncedSearch ? undefined : 0,
expand_to: debouncedSearch
? undefined
: (selectedValue ?? undefined)
}
})
.then((res) => res.data ?? [])
});
useEffect(() => {
setNodes(query.data ?? []);
}, [query.data]);
// O(1) lookup for raw node data inside renderNode
const nodeMap = useMemo(() => {
const map: Record<string, any> = {};
for (const n of nodes) map[n.pk.toString()] = n;
return map;
}, [nodes]);
// Expand all returned nodes when a search is active so users can see all matches.
// On the first browse-mode load, expand the ancestors of the initial value so
// the tree shows the path to the currently-selected node.
useEffect(() => {
if (debouncedSearch) {
setExpandedValues(nodes.map((n: any) => n.pk.toString()));
return;
}
if (
selectedValue != null &&
expandedForValue.current !== selectedValue &&
nodes.length > 0
) {
expandedForValue.current = selectedValue;
const map: Record<number, any> = {};
for (const n of nodes) map[n.pk] = n;
const toExpand: string[] = [];
let cur = map[selectedValue];
while (cur?.parent) {
toExpand.push(String(cur.parent));
cur = map[cur.parent];
}
setExpandedValues(toExpand);
return;
}
if (selectedValue == null) {
expandedForValue.current = null;
}
}, [debouncedSearch, nodes, selectedValue]);
// Convert the flat API response (sorted by level) into the nested TreeNodeData structure.
// `children` is intentionally left undefined on leaf nodes: Mantine's flatten logic uses
// Array.isArray(node.children) to detect loaded children, so an empty [] would make every
// node look like a parent. Instead we set node.hasChildren from the server-side count field
// (childIdentifier) and only attach a children array when a child is actually encountered.
const treeData: TreeNodeData[] = useMemo(() => {
const map: Record<number, any> = {};
const tree: TreeNodeData[] = [];
const sorted = [...nodes].sort((a, b) => a.level - b.level);
for (const raw of sorted) {
const node: any = {
value: raw.pk.toString(),
label: raw.name as string,
hasChildren: (raw[childIdentifier] ?? 0) > 0
};
map[raw.pk] = node;
if (!raw.parent) {
tree.push(node);
} else if (map[raw.parent]) {
if (!map[raw.parent].children) {
map[raw.parent].children = [];
}
map[raw.parent].children.push(node);
} else {
// Keep orphaned nodes visible so selected labels can still resolve
// if the API response omits an ancestor.
tree.push(node);
}
}
return tree;
}, [nodes, childIdentifier]);
const onChange = useCallback(
(val: string | null) => {
const pk = val ? Number.parseInt(val, 10) : null;
const raw = val ? (nodeMap[val] ?? {}) : {};
field.onChange(pk);
definition.onValueChange?.(pk, raw);
},
[field, definition, nodeMap]
);
const selectValue = useMemo(
() => (field.value != null ? field.value.toString() : null),
[field.value]
);
const selectedLabel = useMemo(() => {
if (selectValue == null) return '';
const node = nodeMap[selectValue];
return node?.pathstring ?? node?.name ?? selectValue;
}, [nodeMap, selectValue]);
const inputSearchValue = isDropdownOpen ? searchValue : selectedLabel;
const refreshChildren = useCallback(
async (nodeValue: string) => {
const pk = Number.parseInt(nodeValue, 10);
if (Number.isNaN(pk)) return;
const node = nodeMap[nodeValue];
if (!node) return;
const response = await api.get(apiUrl(endpoint), {
params: {
...definition.filters,
ordering: 'level',
parent: pk,
max_level: (node.level ?? 0) + 1
}
});
const children: any[] = response.data ?? [];
setNodes((prev) => {
const base = prev.filter((n) => n.parent !== pk);
const byPk = new Map<number, any>();
for (const n of base) {
byPk.set(n.pk, n);
}
for (const child of children) {
byPk.set(child.pk, child);
}
if (children.length === 0) {
const parentNode = byPk.get(pk);
if (parentNode) {
byPk.set(pk, { ...parentNode, [childIdentifier]: 0 });
}
}
return Array.from(byPk.values());
});
},
[api, endpoint, nodeMap, childIdentifier]
);
const toggleExpanded = useCallback(
(nodeValue: string) => {
setExpandedValues((prev) => {
const isExpanded = prev.includes(nodeValue);
if (!isExpanded) {
void refreshChildren(nodeValue);
return [...prev, nodeValue];
}
return prev.filter((v) => v !== nodeValue);
});
},
[refreshChildren]
);
// --- Navigate / hovercard ---
const detailUrl = useMemo(() => {
if (!model || !selectedValue) return '';
return getDetailUrl(model, selectedValue, true);
}, [model, selectedValue]);
const handleNavigate = useCallback(
(e: any) => {
if (navigate && detailUrl) navigateToLink(detailUrl, navigate, e);
},
[navigate, detailUrl]
);
// When a navigate model is present and a value is selected, swap out
// Mantine's built-in clear button for a custom right section that holds
// both a clear button and a link to the model detail page (with tooltip).
const showNavigateSection = Boolean(model && selectedValue);
const navigateRightSection = showNavigateSection ? (
<Group gap={2} wrap='nowrap' style={{ paddingRight: 4 }}>
{!definition.required && selectValue && (
<ActionIcon
variant='transparent'
size='xs'
color='dimmed'
aria-label={t`Clear`}
onClick={(e: any) => {
cancelEvent(e);
onChange(null);
}}
>
<IconX size={12} />
</ActionIcon>
)}
<ModelHoverCard model={model} pk={selectedValue} navigate={navigate}>
<ActionIcon
variant='transparent'
size='xs'
component='a'
href={detailUrl || '#'}
target='_blank'
aria-label={t`View details`}
onClick={handleNavigate}
>
<IconLink size={12} />
</ActionIcon>
</ModelHoverCard>
</Group>
) : undefined;
// --- Barcode scanning ---
const modelInfo = useMemo(
() => (model ? ModelInformationDict[model] : null),
[model]
);
const addBarcodeField = useMemo(() => {
if (!modelInfo?.supports_barcode) return false;
if (!globalSettings.isSet('BARCODE_ENABLE')) return false;
if (!userSettings.isSet('BARCODE_IN_FORM_FIELDS')) return false;
return true;
}, [modelInfo, globalSettings, userSettings]);
const onBarcodeScan = useCallback(
(_barcode: string, response: any) => {
if (!model) return;
const modelData = response?.[model] ?? null;
if (modelData?.pk) {
field.onChange(modelData.pk);
definition.onValueChange?.(modelData.pk, modelData);
}
},
[model, field, definition]
);
// --- Render ---
return (
<Input.Wrapper
label={definition.label}
description={definition.description}
required={definition.required}
error={definition.error ?? error?.message}
id={inputId}
>
<Group wrap='nowrap' gap={3}>
<Expand>
<TreeSelect
id={inputId}
data={treeData}
aria-label={`tree-field-${fieldName}`}
value={selectValue}
searchValue={inputSearchValue}
onChange={onChange}
onSearchChange={(val) => {
if (dropdownOpen.current) setSearchValue(val);
}}
searchable
filter={() => true}
clearable={showNavigateSection ? false : !definition.required}
rightSection={navigateRightSection}
rightSectionPointerEvents={showNavigateSection ? 'all' : undefined}
rightSectionWidth={
showNavigateSection ? (definition.required ? 28 : 52) : undefined
}
expandedValues={expandedValues}
onExpandedChange={setExpandedValues}
onDropdownOpen={() => {
dropdownOpen.current = true;
setIsDropdownOpen(true);
}}
onDropdownClose={() => {
dropdownOpen.current = false;
setIsDropdownOpen(false);
setSearchValue('');
}}
placeholder={
isDropdownOpen && selectedLabel
? selectedLabel
: (definition.placeholder ?? genericPlaceholder ?? t`Select...`)
}
disabled={definition.disabled}
comboboxProps={{ withinPortal: true }}
maxDropdownHeight={300}
nothingFoundMessage={
query.isFetching ? t`Loading...` : t`No results found`
}
renderNode={({ node, expanded, hasChildren, selected }) => {
const raw = nodeMap[node.value];
return (
<Group
justify='space-between'
gap='xs'
wrap='nowrap'
style={{ flex: 1 }}
>
<Group gap={4} wrap='nowrap'>
{/* Chevron rendered manually so renderNode can coexist with
expand behavior. stopPropagation prevents the
Combobox.Option from selecting the node when the user
clicks the expand toggle. */}
<span
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
style={{
display: 'inline-flex',
width: 16,
alignItems: 'center',
flexShrink: 0,
cursor: hasChildren ? 'pointer' : 'default'
}}
aria-label={expanded ? t`Collapse` : t`Expand`}
onClick={
hasChildren
? (event: any) => {
cancelEvent(event);
toggleExpanded(node.value);
}
: undefined
}
onKeyDown={
hasChildren
? (event: any) => {
if (event.key === 'Enter' || event.key === ' ') {
cancelEvent(event);
toggleExpanded(node.value);
}
}
: undefined
}
>
{hasChildren &&
(expanded ? (
<IconChevronDown size={14} />
) : (
<IconChevronRight size={14} />
))}
</span>
{raw?.icon && <ApiIcon name={raw.icon} size={14} />}
<Text
size='sm'
fw={selected ? 600 : undefined}
fs={raw?.structural ? 'italic' : undefined}
c={raw?.structural ? 'dimmed' : undefined}
>
{raw?.name ?? String(node.label)}
</Text>
</Group>
{raw?.description && (
<Text
size='xs'
c='dimmed'
ta='right'
truncate
flex={1}
maw='50%'
>
{raw.description}
</Text>
)}
</Group>
);
}}
/>
</Expand>
{addBarcodeField && (
<ScanButton modelType={model} onScanSuccess={onBarcodeScan} />
)}
</Group>
</Input.Wrapper>
);
}
@@ -0,0 +1,81 @@
import { t } from '@lingui/core/macro';
import {
ActionIcon,
Anchor,
Group,
HoverCard,
Stack,
Text
} from '@mantine/core';
import type { ReactNode } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { ModelInformationDict } from '@lib/enums/ModelInformation';
import type { ModelType } from '@lib/enums/ModelType';
import { getDetailUrl, navigateToLink } from '@lib/index';
import { IconLink } from '@tabler/icons-react';
/**
* Wraps children in a HoverCard showing the model label, pk, and a "View
* details" link for the given instance. Renders children directly (no card)
* when model or pk is absent.
*/
export function ModelHoverCard({
children,
model,
pk,
navigate
}: {
children: ReactNode;
model: ModelType | undefined;
pk: number | null | undefined;
navigate?: NavigateFunction | null;
}) {
const modelInfo = model ? ModelInformationDict[model] : undefined;
if (!modelInfo || !pk) {
return <>{children}</>;
}
const detailUrl = getDetailUrl(model!, pk, true);
return (
<HoverCard
position='top-end'
withinPortal
openDelay={500}
closeDelay={100}
zIndex={99999}
>
<HoverCard.Target>
<span>{children}</span>
</HoverCard.Target>
<HoverCard.Dropdown>
<Stack gap='xs'>
<Group justify='space-between'>
<Text size='sm' fw='bold'>
{modelInfo.label()}
</Text>
<Text size='xs'>{`[${t`ID`}: ${pk}]`}</Text>
</Group>
{detailUrl && (
<Anchor
href={detailUrl}
target='_blank'
onClick={(event) => {
if (navigate) navigateToLink(detailUrl, navigate, event);
}}
>
<Group gap='xs' wrap='nowrap'>
<ActionIcon variant='transparent' size='xs'>
<IconLink />
</ActionIcon>
<Text size='sm'>{t`View details`}</Text>
</Group>
</Anchor>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
+1 -6
View File
@@ -128,12 +128,7 @@ export function RenderPartCategory(
<RenderInlineModel
{...props}
tooltip={instance.pathstring}
prefix={
<>
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
prefix={instance.icon && <ApiIcon name={instance.icon} />}
primary={category}
suffix={suffix}
url={
+1 -6
View File
@@ -48,12 +48,7 @@ export function RenderStockLocation(
<RenderInlineModel
{...props}
tooltip={instance.pathstring}
prefix={
<>
{instance.level > 0 && `${'- '.repeat(instance.level)}`}
{instance.icon && <ApiIcon name={instance.icon} />}
</>
}
prefix={instance.icon && <ApiIcon name={instance.icon} />}
primary={location}
suffix={suffix}
url={
+16 -3
View File
@@ -129,11 +129,24 @@ export async function doBasicLogin(
});
break;
default:
const data = err.response?.data ?? {};
let msg: string = t`Check your input and try again.`;
// Extract error message from response data
if (data?.detail) {
msg = data.detail;
} else if (data?.message) {
msg = data.message;
} else if (data?.error) {
msg = data.error;
} else if (data?.errors && Array.isArray(data.errors)) {
msg = data.errors[0]?.message ?? msg;
}
notifications.show({
title: `${t`Login failed`} (${err.response.status})`,
message:
err.response?.data?.detail ??
t`Check your input and try again.`,
message: msg,
id: 'auth-login-error',
color: 'red'
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -16
View File
@@ -227,8 +227,10 @@ test('Build Order - Calendar', async ({ browser }) => {
await page
.getByRole('option', { name: 'Part Category', exact: true })
.click();
await page.getByLabel('related-field-filter-category').click();
await page.getByText('Part category, level 1').waitFor();
await page.getByLabel('tree-field-filter-category').click();
await page.getByText('Part category, level 1').click();
await page.getByText('Filter by part category').waitFor();
await page.getByText('Category 0').first().waitFor();
// Required because we downloaded a file
await page.context().close();
@@ -324,9 +326,9 @@ test('Build Order - Build Outputs', async ({ browser }) => {
.getByRole('img', { name: 'field-batch_code-accept-placeholder' })
.click();
await page.getByLabel('related-field-location').click();
await page.getByLabel('related-field-location').fill('Reel');
await page.getByText('- Electronics Lab/Reel Storage').click();
await page.getByLabel('tree-field-location').click();
await page.getByLabel('tree-field-location').fill('Reel');
await page.getByText('Storage for component reels').click();
await page.getByRole('button', { name: 'Submit' }).click();
// Should be an error as the number of serial numbers doesn't match the quantity
@@ -361,9 +363,10 @@ test('Build Order - Build Outputs', async ({ browser }) => {
const row2 = await getRowFromCell(cell2);
await row2.getByLabel(/row-action-menu-/i).click();
await page.getByRole('menuitem', { name: 'Complete' }).click();
await page.getByLabel('related-field-location').click();
await page.getByLabel('tree-field-location').click();
await page.getByLabel('tree-field-location').fill('Mechanical');
await page.getByText('Mechanical Lab').click();
await page.waitForTimeout(250);
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Build outputs have been completed').waitFor();
@@ -385,10 +388,8 @@ test('Build Order - Build Outputs', async ({ browser }) => {
// Next, adjust the "location" field - and check that the "quantity" field does not change
// Ref: https://github.com/inventree/InvenTree/pull/12081
await page
.getByRole('combobox', { name: 'related-field-location' })
.fill('factory');
await page.getByTitle('Factory/Mechanical Lab').click();
await page.getByLabel('tree-field-location').fill('mechanical');
await page.getByText('Mechanical Lab').click();
await page.waitForTimeout(250);
// Check the 'quantity' value again - it should not have changed
@@ -539,11 +540,7 @@ test('Build Order - Auto Allocate Tracked', async ({ browser }) => {
.click();
// Wait for auto-filled form field
await page
.locator('div')
.filter({ hasText: /^Factory$/ })
.first()
.waitFor();
await page.getByText('Factory').waitFor();
await page.getByRole('button', { name: 'Submit' }).click();
// Wait for one of the required parts to be allocated
+70 -5
View File
@@ -495,6 +495,73 @@ test('Parts - Details', async ({ browser }) => {
await page.getByText('Latest Serial Number').waitFor();
});
test('Parts - Details - Upload image modal accepts pasted clipboard image', async ({
browser
}) => {
const page = await doCachedLogin(browser, { url: 'part/113/details' });
await page
.getByRole('tabpanel', { name: 'Part Details' })
.locator('img')
.hover();
const uploadButton = page
.getByLabel('action-button-upload-new-image')
.first();
await uploadButton.waitFor();
await uploadButton.hover();
await uploadButton.click();
const uploadModalTitle = page.getByText('Upload Image', { exact: true });
await uploadModalTitle.waitFor();
await page.evaluate((pngBase64: string) => {
const binary = atob(pngBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const imageFile = new File([bytes], 'pasted.png', { type: 'image/png' });
const dataTransfer = new DataTransfer();
dataTransfer.items.add(imageFile);
const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true
});
// Firefox does not reliably honour `clipboardData` passed via the
// ClipboardEvent constructor for synthetic paste events. Define it
// directly so the app receives the same DataTransfer in all browsers.
Object.defineProperty(pasteEvent, 'clipboardData', {
value: dataTransfer
});
const pasteTarget = document.activeElement ?? document.body;
pasteTarget.dispatchEvent(pasteEvent);
}, 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kb9Lw0AcxV9TtSItDmYQEQlYneyiIo61CkWoEGqFVh3Mj/6CJg1Jiouj4Fpw8Mdi1cHFWVcHV0EQ/AHiHyBOii5S4veSQosYD4778O7e4+4dwDUqimZ1xQFNt810MiFkc6tC6BU9GAGPCEYlxTLmRDEF3/F1jwBb72Isy//cnyOi5i0FCAjEccUwbeIN4plN22C8T8wrJUklPieeMOmCxI9Mlz1+Y1x0mWOZvJlJzxPzxEKxg+UOVkqmRjxNHFU1nfK5rMcq4y3GWqWmtO7JXhjO6yvLTKc5jCQWsQQRAmTUUEYFNmK06qRYSNN+wsc/5PpFcsnkKkMhxwKq0CC5frA/+N2tVZia9JLCCaD7xXE+xoDQLtCsO873seM0T4DgM3Clt/3VBjD7SXq9rUWPgP5t4OK6rcl7wOUOMPhkSKbkSkGaXKEAvJ/RN+WAgVugb83rrbWP0wcgQ12lboCDQ2C8SNnrPu/u7ezt3zOt/n4Aps9yu33ABq8AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfqBh4UDgm4/dnOAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAABpJREFUKM9jrPivzkAKYGIgEYxqGNUwdDQAACVQAb74u92bAAAAAElFTkSuQmCC');
await expect(page.getByText('clipboard-image.png')).toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Image uploaded').waitFor();
// Now remove the associated image
await page
.getByRole('tabpanel', { name: 'Part Details' })
.locator('img')
.hover();
await page
.getByRole('button', { name: 'action-button-delete-image' })
.click();
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByText('The image has been removed successfully').waitFor();
});
test('Parts - Requirements', async ({ browser }) => {
// Navigate to the "Widget Assembly" part detail page
// This part has multiple "variants"
@@ -973,7 +1040,7 @@ test('Parts - Notes', async ({ browser }) => {
await page.keyboard.press('Control+E');
await page.getByLabel('text-field-name', { exact: true }).waitFor();
await page.getByLabel('text-field-description', { exact: true }).waitFor();
await page.getByLabel('related-field-category').waitFor();
await page.getByLabel('tree-field-category').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Enable notes editing
@@ -1024,10 +1091,8 @@ test('Parts - Bulk Edit', async ({ browser }) => {
await openDetailAction(page, 'part', 'set-category');
await page.getByLabel('related-field-category').fill('rnitu');
await page.waitForTimeout(250);
await page.getByRole('option', { name: '- Furniture/Chairs' }).click();
await page.getByLabel('tree-field-category').fill('rnitu');
await page.getByText('Furniture and associated').click();
await page.getByRole('button', { name: 'Update' }).click();
await page.getByText('Items Updated').waitFor();
});
@@ -512,15 +512,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await page.getByLabel('action-button-receive-items').click();
// Check for display of individual locations
await page
.getByRole('cell', { name: /Choose Location/ })
.getByText('Parts Bins')
.waitFor();
await page
.getByRole('cell', { name: /Choose Location/ })
.getByText('Room 101')
.waitFor();
await page.getByText('Parts Bins').first().waitFor();
await page.getByText('Room 101').first().waitFor();
await page.getByText('Mechanical Lab').first().waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
@@ -549,8 +542,8 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await page.getByRole('menuitem', { name: 'Receive line item' }).click();
// Select destination location
await page.getByLabel('related-field-location').click();
await page.getByRole('option', { name: 'Factory', exact: true }).click();
await page.getByLabel('tree-field-location').fill('factory');
await page.getByText('Factory', { exact: true }).click();
// Receive only a *single* item
await page.getByLabel('number-field-quantity').fill('1');
@@ -611,10 +604,8 @@ test('Purchase Orders - Receive Virtual Items', async ({ browser }) => {
.getByRole('button', { name: 'action-button-receive-items' })
.click();
await page
.getByRole('combobox', { name: 'related-field-location' })
.fill('factory');
await page.getByText('Factory/Storage Room A').click();
await page.getByLabel('tree-field-location').fill('factory');
await page.getByText('Factory', { exact: true }).click();
await page.getByRole('button', { name: 'Submit' }).click();
@@ -177,6 +177,7 @@ test('Barcode Scanning - Forms', async ({ browser }) => {
await page
.getByRole('button', { name: 'barcode-scan-button-stocklocation' })
.click();
await page
.getByRole('textbox', { name: 'barcode-scan-keyboard-input' })
.fill('INV-SL37');
+2 -1
View File
@@ -499,7 +499,8 @@ test('Stock - Tracking', async ({ browser }) => {
// Navigate to the "stock tracking" tab
await loadTab(page, 'Stock Tracking');
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
await page.getByText('Factory/Office Block/Room').first().waitFor();
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
+1 -1
View File
@@ -76,7 +76,7 @@ test('Forms - Stock Item Validation', async ({ browser }) => {
.waitFor();
// Set location
await page.getByLabel('related-field-location').click();
await page.getByLabel('tree-field-location').fill('production');
await page.getByText('Electronics production facility').click();
// Create the stock item
+49 -6
View File
@@ -9,12 +9,24 @@ import { TOTP } from 'otpauth';
* Test various types of login failure
*/
test('Login - Failures', async ({ page }) => {
const loginWithError = async () => {
const loginWithError = async ({
msg,
reload = true
}: {
msg?: string;
reload?: boolean;
}) => {
await page.getByRole('button', { name: 'Log In' }).click();
await page.getByText('Login failed', { exact: true }).waitFor();
await page.getByText('Check your input and try again').first().waitFor();
await page.reload();
if (msg) {
await page.getByText(msg).waitFor();
}
if (reload) {
await page.reload();
}
};
// Navigate to the 'login' page
@@ -26,25 +38,56 @@ test('Login - Failures', async ({ page }) => {
await page.getByLabel('login-username').fill('invalid user');
await page.getByLabel('login-password').fill('invalid password');
await loginWithError();
await loginWithError({
msg: 'The username and/or password you specified are not correct'
});
// Attempt login with valid (but disabled) user
await page.getByLabel('login-username').fill('ian');
await page.getByLabel('login-password').fill('inactive');
await loginWithError();
await loginWithError({});
// Attempt login with no username
await page.getByLabel('login-username').fill('');
await page.getByLabel('login-password').fill('hunter2');
await loginWithError();
await loginWithError({});
// Attempt login with no password
await page.getByLabel('login-username').fill('ian');
await page.getByLabel('login-password').fill('');
await loginWithError();
await loginWithError({});
let tooManyAttempts = false;
// Attempt login with incorrect password, multiple attempts
for (let i = 0; i < 10; i++) {
await page.getByLabel('login-username').fill('reader');
await page.getByLabel('login-password').fill('readonlyx');
await loginWithError({ reload: false });
const text = await page.getByText('Too many failed login attempts', {
exact: false
});
if (
await expect(text)
.toBeVisible({ timeout: 100 })
.then(() => true)
.catch(() => false)
) {
tooManyAttempts = true;
break;
}
await page.reload();
}
if (!tooManyAttempts) {
await expect(tooManyAttempts).toEqual(true);
}
});
test('Login - Change Password', async ({ page }) => {