diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 970c3990b7..2aa8408297 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -1,7 +1,7 @@ import type { ApiFormFieldSet } from '@lib/types/Forms'; import { t } from '@lingui/core/macro'; import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useGlobalSettingsState } from '../states/SettingsStates'; import { TagsField } from './CommonFields'; @@ -23,6 +23,13 @@ export function usePartFields({ const [purchaseable, setPurchaseable] = useState( undefined ); + const [initialStockLocation, setInitialStockLocation] = useState< + number | null + >(null); + const initialStockLocationOverridden = useRef(false); + + const partDefaultLocation = useRef(null); + const categoryDefaultLocation = useRef(null); // Set the initial state for the tracked fields based on the global settings useEffect(() => { @@ -35,6 +42,17 @@ export function usePartFields({ category: { filters: { structural: false + }, + onValueChange: (_value, category) => { + categoryDefaultLocation.current = category?.default_location ?? null; + + // A part-specific default location takes precedence over the + // category default location. + if (create && !initialStockLocationOverridden.current) { + setInitialStockLocation( + partDefaultLocation.current ?? categoryDefaultLocation.current + ); + } } }, name: {}, @@ -59,6 +77,17 @@ export function usePartFields({ units: {}, link: {}, default_location: { + onValueChange: (value) => { + partDefaultLocation.current = value ?? null; + + // Pre-fill the initial stock location, unless the user has already + // selected a different location. + if (!initialStockLocationOverridden.current) { + setInitialStockLocation( + partDefaultLocation.current ?? categoryDefaultLocation.current + ); + } + }, filters: { structural: false } @@ -128,7 +157,13 @@ export function usePartFields({ quantity: { value: 0 }, - location: {} + location: { + value: initialStockLocation, + onValueChange: (value) => { + initialStockLocationOverridden.current = true; + setInitialStockLocation(value); + } + } } }; } @@ -213,6 +248,7 @@ export function usePartFields({ partId, virtual, purchaseable, + initialStockLocation, create, globalSettings, duplicatePartInstance diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index 982e740da4..6c57ee1d66 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -368,6 +368,153 @@ test('Forms - Initial Stock Field', async ({ browser }) => { await setSettingState({ setting: 'PART_CREATE_INITIAL', value: false }); }); +test('Forms - Default Part Location', async ({ browser }) => { + // Create a new part and select a the default stock location for the part + await setSettingState({ setting: 'PART_CREATE_INITIAL', value: true }); + + const partName = `Initial Stock Part ${Date.now()}`; + await deletePart(partName); + + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'part/category/index/parts' + }); + await page.waitForURL('**/part/category/index/**'); + + await page.getByRole('button', { name: 'action-menu-add-parts' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' }) + .click(); + + await page.getByLabel('text-field-name', { exact: true }).fill(partName); + + await page.getByLabel('tree-field-default_location').fill('production'); + await page.getByText('Electronics production facility').click(); + + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Item Created').waitFor(); + + // Check that the part's default location is selected when adding a stock item + await page + .getByLabel('panel-tabs-part') + .getByRole('tab', { name: 'Stock', exact: true }) + .click(); + + await page.getByLabel('action-button-add-stock-item').click(); + + // The stock item location is rendered via a "tree field" (backed by a real input) + const locationField = () => + page.getByRole('textbox', { name: 'tree-field-location' }); + await expect(locationField()).toHaveValue('Electronics Lab'); + + await deletePart(partName); + await setSettingState({ setting: 'PART_CREATE_INITIAL', value: false }); +}); + +test('Forms - Default Category Location', async ({ browser }) => { + const suffix = Date.now(); + const categoryName = `Default Location Category ${suffix}`; + const partName = `Category Default Location Part ${suffix}`; + const api = await createApi({}); + let categoryId: number | undefined; + + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'part/category/index/parts' + }); + + try { + // Create a category with its own default stock location. + await loadTab(page, 'Part Categories'); + await page.getByLabel('action-button-add-part-category').click(); + await page + .getByLabel('text-field-name', { exact: true }) + .fill(categoryName); + await page + .getByLabel('text-field-description', { exact: true }) + .fill('Category default stock location test'); + await page.getByLabel('tree-field-default_location').fill('production'); + await page.getByText('Electronics production facility').click(); + + const categoryCreated = page.waitForResponse( + (response) => + response.url().endsWith('/api/part/category/') && + response.request().method() === 'POST' + ); + await page.getByRole('button', { name: 'Submit' }).click(); + const categoryResponse = await categoryCreated; + expect(categoryResponse.status()).toBe(201); + const category = await categoryResponse.json(); + categoryId = category.pk; + await page.waitForURL(`**/part/category/${categoryId}/**`); + + // Create a part where the default stock location is not set + // so it should inherits the category's on stock creation + await loadTab(page, 'Parts'); + await page.getByRole('button', { name: 'action-menu-add-parts' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-add-parts-create-part' }) + .click(); + await page.getByLabel('text-field-name', { exact: true }).fill(partName); + await expect(page.getByLabel('tree-field-category')).toHaveValue( + categoryName + ); + await expect(page.getByLabel('tree-field-default_location')).toHaveValue( + '' + ); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForURL(/\/part\/\d+\//); + const partUrl = page.url(); + + await page + .getByLabel('panel-tabs-part') + .getByRole('tab', { name: 'Stock', exact: true }) + .click(); + await page.getByLabel('action-button-add-stock-item').click(); + + // Check that the category's default stock location is inherited properly + await expect(page.getByLabel('tree-field-location')).toHaveValue( + 'Electronics Lab' + ); + + // Check that part-specific default must take precedence over the category default. + await page.goto(partUrl); + await openDetailAction(page, 'part', 'edit'); + await expect(page.getByLabel('tree-field-default_location')).toHaveValue( + '' + ); + + // Change the part's default stock location to "Electornics Lab/Reel Storage" + await page.getByLabel('tree-field-default_location').fill('Reel Storage'); + await page.getByText('Storage for component reels').click(); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Item Updated').waitFor(); + + await page + .getByLabel('panel-tabs-part') + .getByRole('tab', { name: 'Stock', exact: true }) + .click(); + await page.getByLabel('action-button-add-stock-item').click(); + + // Check that the part's default stock location is overwrite the category's default + await expect(page.getByLabel('tree-field-location')).toHaveValue( + 'Electronics Lab/Reel Storage' + ); + } finally { + await deletePart(partName); + if (categoryId !== undefined) { + const response = await api.delete(`part/category/${categoryId}/`, { + data: { + delete_child_categories: false, + delete_parts: false + } + }); + expect(response.status()).toBe(204); + } + await api.dispose(); + } +}); + test('Forms - Initial Stock hidden when setting disabled', async ({ browser }) => {