diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 86b8ef87d6..b8234692fc 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -5,6 +5,7 @@ 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'; @@ -131,6 +132,8 @@ export function ApiFormField({ fieldName={fieldName} endpoint={ApiEndpoints.stock_location_tree} childIdentifier='sublocations' + model={ModelType.stocklocation} + navigate={navigate} /> ); case 'category': @@ -141,6 +144,8 @@ export function ApiFormField({ fieldName={fieldName} endpoint={ApiEndpoints.category_tree} childIdentifier='subcategories' + model={ModelType.partcategory} + navigate={navigate} /> ); case 'related field': @@ -155,6 +160,8 @@ export function ApiFormField({ fieldName={fieldName} endpoint={ApiEndpoints.stock_location_tree} childIdentifier='sublocations' + model={ModelType.stocklocation} + navigate={navigate} /> ); } else if ( @@ -167,6 +174,8 @@ export function ApiFormField({ fieldName={fieldName} endpoint={ApiEndpoints.category_tree} childIdentifier='subcategories' + model={ModelType.partcategory} + navigate={navigate} /> ); } else { diff --git a/src/frontend/src/components/forms/fields/TreeField.tsx b/src/frontend/src/components/forms/fields/TreeField.tsx index a34a3c4a05..f10571c3b8 100644 --- a/src/frontend/src/components/forms/fields/TreeField.tsx +++ b/src/frontend/src/components/forms/fields/TreeField.tsx @@ -1,37 +1,68 @@ import { t } from '@lingui/core/macro'; -import { Group, Text, type TreeNodeData, TreeSelect } from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; -import { IconChevronDown, IconChevronRight } from '@tabler/icons-react'; +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: when the user types, the API is queried with the search term - * and the backend returns matching nodes plus their ancestors for context. + * 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 + childIdentifier, + model, + navigate }: Readonly<{ controller: UseControllerReturn; definition: ApiFormFieldType; fieldName: string; endpoint: ApiEndpoints; childIdentifier: string; + model?: ModelType; + navigate?: NavigateFunction | null; }>) { const api = useApi(); + const inputId = useId(); + const globalSettings = useGlobalSettingsState(); + const userSettings = useUserSettingsState(); + const { field, fieldState: { error } @@ -249,102 +280,208 @@ export function TreeField({ [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 ? ( + + {!definition.required && selectValue && ( + { + cancelEvent(e); + onChange(null); + }} + > + + + )} + + + + + + + ) : 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 ( - { - if (dropdownOpen.current) setSearchValue(val); - }} - searchable - filter={() => true} - clearable={!definition.required} - expandedValues={expandedValues} - onExpandedChange={setExpandedValues} - onDropdownOpen={() => { - dropdownOpen.current = true; - setIsDropdownOpen(true); - }} - onDropdownClose={() => { - dropdownOpen.current = false; - setIsDropdownOpen(false); - setSearchValue(''); - }} + { - const raw = nodeMap[node.value]; - return ( - - - {/* 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. */} - { - cancelEvent(event); - toggleExpanded(node.value); + id={inputId} + > + + + { + 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={definition.placeholder ?? 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 ( + + + {/* 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. */} + { + cancelEvent(event); + toggleExpanded(node.value); + } + : undefined } - : undefined - } - onKeyDown={ - hasChildren - ? (event: any) => { - if (event.key === 'Enter' || event.key === ' ') { - cancelEvent(event); - toggleExpanded(node.value); - } + onKeyDown={ + hasChildren + ? (event: any) => { + if (event.key === 'Enter' || event.key === ' ') { + cancelEvent(event); + toggleExpanded(node.value); + } + } + : undefined } - : undefined - } - > - {hasChildren && - (expanded ? ( - - ) : ( - - ))} - - {raw?.icon && } - - {raw?.name ?? String(node.label)} - - - {raw?.description && ( - - {raw.description} - - )} - - ); - }} - /> + > + {hasChildren && + (expanded ? ( + + ) : ( + + ))} + + {raw?.icon && } + + {raw?.name ?? String(node.label)} + + + {raw?.description && ( + + {raw.description} + + )} + + ); + }} + /> + + {addBarcodeField && ( + + )} + + ); } diff --git a/src/frontend/src/components/render/ModelHoverCard.tsx b/src/frontend/src/components/render/ModelHoverCard.tsx new file mode 100644 index 0000000000..6de4dd694b --- /dev/null +++ b/src/frontend/src/components/render/ModelHoverCard.tsx @@ -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 ( + + + {children} + + + + + + {modelInfo.label()} + + {`[${t`ID`}: ${pk}]`} + + {detailUrl && ( + { + if (navigate) navigateToLink(detailUrl, navigate, event); + }} + > + + + + + {t`View details`} + + + )} + + + + ); +} diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 319554f717..c5931873c8 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -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 diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 32bfc2f456..08a4d9f86b 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -973,7 +973,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 +1024,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(); }); diff --git a/src/frontend/tests/pages/pui_purchasing.spec.ts b/src/frontend/tests/pages/pui_purchasing.spec.ts index 8b88a7c5bc..f4a8bd28fb 100644 --- a/src/frontend/tests/pages/pui_purchasing.spec.ts +++ b/src/frontend/tests/pages/pui_purchasing.spec.ts @@ -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(); diff --git a/src/frontend/tests/pages/pui_scan.spec.ts b/src/frontend/tests/pages/pui_scan.spec.ts index 97c8a7328f..8a59b1845a 100644 --- a/src/frontend/tests/pages/pui_scan.spec.ts +++ b/src/frontend/tests/pages/pui_scan.spec.ts @@ -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'); diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index ee00dc7abc..692b26351c 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -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