[UI] Fixes for "tags" field (#13233)

- Fetch appropriate 'tags' detail in the form request
- Additional playwright tests
This commit is contained in:
Oliver authored and GitHub committed 2026-11-09 23:42:12 +11:00
1 parent 061981ac54
commit 3383e865cb
18 files changed
+76 -24

No files matched your search

+2
View File
@@ -75,6 +75,7 @@ export type ApiFormFieldHeader = {
* @param addRow : Callback function to add a new row to a table field
* @param headers : Optional definitions of table headers (for table fields)
* @param singleFetchFunction : Optional function to fetch a single value for this field (used for fetching the initial value when editing an existing object)
* @param fetchParams : Optional query parameters required on the initial data request to populate this field (e.g. optional serializer fields such as 'tags')
*/
export type ApiFormFieldType = {
label?: string;
@@ -138,6 +139,7 @@ export type ApiFormFieldType = {
headers?: ApiFormFieldHeader[];
depends_on?: string[];
singleFetchFunction?: (value: any) => Promise<any> | null;
fetchParams?: Record<string, string>;
};
export type ApiFormFieldSet = Record<string, ApiFormFieldType>;
+48 -11
View File
@@ -261,7 +261,28 @@ export function ApiForm({
props.pathParams,
props.queryParams
),
[props.url, props.pk, props.pathParams]
[props.url, props.pk, props.pathParams, props.queryParams]
);
// Collect any query parameters which individual fields require,
// in order to populate their values from the initial data request
// (e.g. optional serializer fields such as 'tags').
// This ensures the form fetches the required data independent of where it is launched.
const fetchParams: Record<string, string> = useMemo(() => {
const params: Record<string, string> = {};
mapFields(props.fields ?? {}, (_path, field) => {
Object.assign(params, field.fetchParams ?? {});
return undefined;
});
return params;
}, [props.fields]);
// Stable key for the fetch params, for use in query keys
const fetchParamsKey: string = useMemo(
() => new URLSearchParams(fetchParams).toString(),
[fetchParams]
);
// Define function to process API response
@@ -298,22 +319,26 @@ export function ApiForm({
props.method,
props.url,
props.pk,
props.pathParams
props.pathParams,
fetchParamsKey
],
queryFn: async () => {
return await api.get(url).then((response: any) => {
// Process API response
const fetchedData: any = processFields(fields, response.data);
return await api
.get(url, { params: fetchParams })
.then((response: any) => {
// Process API response
const fetchedData: any = processFields(fields, response.data);
// Update form values, but only for the fields specified for this form
form.reset(fetchedData);
return fetchedData;
});
// Update form values, but only for the fields specified for this form
form.reset(fetchedData);
return fetchedData;
});
}
});
useEffect(() => {
const _fields: any = props.fields || {};
// Shallow copy, so that per-key replacements below do not leak into props.fields
const _fields: any = { ...(props.fields || {}) };
const _initialData: any = props.initialData || {};
const _fetchedData: any = initialDataQuery.data || {};
@@ -332,6 +357,17 @@ export function ApiForm({
if (_fetchedData && k in _fetchedData) {
_fields[k].value = _fetchedData[k];
}
// If a field requires specific fetch params, but the server did not return a value,
// exclude the field from the form. Otherwise, submitting an (incorrectly) empty value
// could overwrite existing data on the server (e.g. clearing existing tags)
if (
_fields[k].fetchParams &&
initialDataQuery.data &&
_fetchedData[k] === undefined
) {
_fields[k] = { ..._fields[k], hidden: true, exclude: true };
}
}
// Preserve the previous reference for any field whose definition did not
@@ -362,7 +398,8 @@ export function ApiForm({
props.method,
props.url,
props.pk,
props.pathParams
props.pathParams,
fetchParamsKey
]
});
initialDataQuery.refetch();
@@ -93,6 +93,7 @@ export function ApiFormField({
placeholderWarning: undefined,
placeholderWarningCompare: undefined,
singleFetchFunction: undefined,
fetchParams: undefined,
autoFillFilters: undefined,
onValueChange: undefined,
adjustFilters: undefined,
@@ -58,7 +58,8 @@ function TagsField({
return {
...definition,
allow_null: undefined,
allow_blank: undefined
allow_blank: undefined,
fetchParams: undefined
};
}, [definition]);
+3 -1
View File
@@ -35,7 +35,9 @@ export function TagsField({
field_type: 'tags',
label: label ?? t`Tags`,
description: description ?? t`Tags for this item`,
placeholder: placeholder ?? t`Select tags`
placeholder: placeholder ?? t`Select tags`,
// 'tags' is an optional serializer field, which must be explicitly requested
fetchParams: { tags: 'true' }
};
}
@@ -413,7 +413,6 @@ export default function BuildDetail() {
title: t`Edit Build Order`,
modalId: 'edit-build-order',
fields: editBuildOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -230,7 +230,6 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
pk: company?.pk,
title: t`Edit Company`,
fields: useMemo(() => companyFields({}), []),
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -141,7 +141,6 @@ export default function ManufacturerPartDetail() {
pk: manufacturerPart?.pk,
title: t`Edit Manufacturer Part`,
fields: editManufacturerPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -187,7 +187,6 @@ export default function SupplierPartDetail() {
pk: supplierPart?.pk,
title: t`Edit Supplier Part`,
fields: supplierPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -673,7 +673,6 @@ export default function PartDetail() {
pk: part.pk,
title: t`Edit Part`,
fields: partFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -94,7 +94,6 @@ export default function PurchaseOrderDetail() {
pk: id,
title: t`Edit Purchase Order`,
fields: purchaseOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -205,7 +205,6 @@ export default function ReturnOrderDetail() {
pk: order.pk,
title: t`Edit Return Order`,
fields: returnOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -110,7 +110,6 @@ export default function SalesOrderDetail() {
pk: order.pk,
title: t`Edit Sales Order`,
fields: salesOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -140,7 +140,6 @@ export default function SalesOrderShipmentDetail() {
pk: shipment.pk,
fields: editShipmentFields,
title: t`Edit Shipment`,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshShipment
});
@@ -355,7 +355,6 @@ export default function StockDetail() {
title: t`Edit Stock Item`,
modalId: 'edit-stock-item',
fields: editStockItemFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -229,7 +229,6 @@ export default function TransferOrderDetail() {
pk: order.pk,
title: t`Edit Transfer Order`,
fields: transferOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -166,6 +166,12 @@ test('Build Order - Tags', async ({ browser }) => {
});
await expect(tagsField).toBeVisible();
// Existing tags are pre-filled in the edit form
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Electronics', { exact: true })).toBeVisible();
await expect(dialog.getByText('PCB Assembly', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
});
@@ -306,6 +306,20 @@ test('Sales Orders - Shipments - Tags', async ({ browser }) => {
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Edit a shipment from the table (not the detail page)
// Existing tags must be pre-filled in the edit form (ref: #13224)
await clickOnRowMenu(page.getByRole('cell', { name: 'SO0008' }));
await page.getByRole('menuitem', { name: 'Edit' }).click();
const dialog = page.getByRole('dialog');
await expect(
dialog.getByRole('combobox', { name: 'tags-field-tags' })
).toBeVisible();
await expect(
dialog.getByText('Requires Payment', { exact: true })
).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
// Click through to one of the selected shipments
await page.getByRole('cell', { name: 'SO0007' }).click();
await page.getByText('Sales Order: SO0007').first().waitFor();