Select default location for new stock items if it is set for a part (#12916)

* Select default location for new stock items in the case if it is set for a part

Fixes #12304

* Add playwright test to check that the part's default stock location has
precedence on the category's default stock location

* Rename test cases for clarity in pui_forms.spec.ts

* Adjust formatting

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Miklós MártonandOliver authored and GitHub committed 2026-10-26 22:58:45 +11:00
1 parent f05b8b44e4
commit dbffb960f7
2 files changed
+185 -2

No files matched your search

+38 -2
View File
@@ -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<boolean | undefined>(
undefined
);
const [initialStockLocation, setInitialStockLocation] = useState<
number | null
>(null);
const initialStockLocationOverridden = useRef(false);
const partDefaultLocation = useRef<number | null>(null);
const categoryDefaultLocation = useRef<number | null>(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
+147
View File
@@ -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
}) => {