[API] Refactor API for duplicating objects (#12294)

* Implement generic serializer for custom model duplication

* Apply pattern to BuildOrder

* Add more generic options

* Bump API version

* Adjust default option

* Refactor existing implementations

* Dynamic class typing

* Add duplicate field to more model types

- Company
- ManufacturerPart
- SupplierPart
- SalesOrderShipment

* Implement parameter duplication for more models:

- Company
- ManufacturerPart
- SupplierPart

* Simplify code

* Refactor
This commit is contained in:
Oliver
2026-07-03 18:04:59 +10:00
committed by GitHub
parent 5595a0a52b
commit 15c64d6695
25 changed files with 458 additions and 169 deletions
+22 -3
View File
@@ -36,16 +36,18 @@ import {
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { ProjectCodeField, TagsField } from './CommonFields';
import { DuplicateField, ProjectCodeField, TagsField } from './CommonFields';
/**
* Field set for BuildOrder forms
*/
export function useBuildOrderFields({
create,
duplicateBuildId,
modalId
}: {
create: boolean;
duplicateBuildId?: number | null;
modalId: string;
}): ApiFormFieldSet {
const [destination, setDestination] = useState<number | null | undefined>(
@@ -133,7 +135,13 @@ export function useBuildOrderFields({
is_active: true
}
},
external: {}
external: {},
duplicate: DuplicateField({
originalId: duplicateBuildId,
extraFields: {
copy_parameters: {}
}
})
};
if (!globalSettings.isSet('PROJECT_CODES_ENABLED', true)) {
@@ -144,8 +152,19 @@ export function useBuildOrderFields({
delete fields.external;
}
if (!duplicateBuildId) {
delete fields.duplicate;
}
return fields;
}, [create, destination, batchCode, batchGenerator.result, globalSettings]);
}, [
create,
destination,
batchCode,
batchGenerator.result,
globalSettings,
duplicateBuildId
]);
}
export function useBuildOrderOutputFields({
+20
View File
@@ -2,6 +2,26 @@ import type { ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { IconList } from '@tabler/icons-react';
// Generic field for implementing a "duplication options" form field
export function DuplicateField({
originalId,
extraFields
}: Readonly<{
originalId?: number | null;
extraFields?: Record<string, any>;
}>): ApiFormFieldType {
return {
children: {
original: {
value: originalId,
hidden: true
},
...extraFields
}
};
}
// Generic field for rendering a list of tags within a form
export function TagsField({
label,
description,
+58 -10
View File
@@ -13,7 +13,7 @@ import {
IconPhone
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { TagsField } from './CommonFields';
import { DuplicateField, TagsField } from './CommonFields';
/**
* Field set for SupplierPart instance
@@ -21,11 +21,13 @@ import { TagsField } from './CommonFields';
export function useSupplierPartFields({
manufacturerId,
manufacturerPartId,
partId
partId,
duplicateSupplierPartId
}: {
manufacturerId?: number;
manufacturerPartId?: number;
partId?: number;
duplicateSupplierPartId?: number | null;
}) {
const [part, setPart] = useState<any>({});
@@ -95,14 +97,34 @@ export function useSupplierPartFields({
icon: <IconPackage />
},
primary: {},
active: {}
active: {},
duplicate: DuplicateField({
originalId: duplicateSupplierPartId,
extraFields: {
copy_parameters: {}
}
})
};
if (!duplicateSupplierPartId) {
delete fields.duplicate;
}
return fields;
}, [manufacturerId, manufacturerPartId, partId, part]);
}, [
manufacturerId,
manufacturerPartId,
partId,
part,
duplicateSupplierPartId
]);
}
export function useManufacturerPartFields() {
export function useManufacturerPartFields({
duplicateManufacturerPartId
}: {
duplicateManufacturerPartId?: number | null;
} = {}) {
return useMemo(() => {
const fields: ApiFormFieldSet = {
part: {},
@@ -120,18 +142,32 @@ export function useManufacturerPartFields() {
MPN: {},
description: {},
tags: TagsField({}),
link: {}
link: {},
duplicate: DuplicateField({
originalId: duplicateManufacturerPartId,
extraFields: {
copy_parameters: {}
}
})
};
if (!duplicateManufacturerPartId) {
delete fields.duplicate;
}
return fields;
}, []);
}, [duplicateManufacturerPartId]);
}
/**
* Field set for editing a company instance
*/
export function companyFields(): ApiFormFieldSet {
return {
export function companyFields({
duplicateCompanyId
}: {
duplicateCompanyId?: number | null;
} = {}): ApiFormFieldSet {
const fields: ApiFormFieldSet = {
name: {},
description: {},
website: {
@@ -151,6 +187,18 @@ export function companyFields(): ApiFormFieldSet {
is_supplier: {},
is_manufacturer: {},
is_customer: {},
active: {}
active: {},
duplicate: DuplicateField({
originalId: duplicateCompanyId,
extraFields: {
copy_parameters: {}
}
})
};
if (!duplicateCompanyId) {
delete fields.duplicate;
}
return fields;
}
+1 -1
View File
@@ -156,7 +156,7 @@ export function usePartFields({
fields.duplicate = {
icon: <IconCopy />,
children: {
part: {
original: {
value: duplicatePartInstance?.pk,
hidden: true
},
@@ -316,7 +316,7 @@ export function usePurchaseOrderFields({
if (!!duplicateOrderId) {
fields.duplicate = {
children: {
order_id: {
original: {
hidden: true,
value: duplicateOrderId
},
+1 -6
View File
@@ -84,15 +84,10 @@ export function useReturnOrderFields({
if (!!duplicateOrderId) {
fields.duplicate = {
children: {
order_id: {
original: {
hidden: true,
value: duplicateOrderId
},
copy_lines: {
// Cannot duplicate lines from a return order!
value: false,
hidden: true
},
copy_extra_lines: {},
copy_parameters: {}
}
+1 -1
View File
@@ -97,7 +97,7 @@ export function useSalesOrderFields({
if (!!duplicateOrderId) {
fields.duplicate = {
children: {
order_id: {
original: {
hidden: true,
value: duplicateOrderId
},
@@ -54,13 +54,12 @@ export function useTransferOrderFields({
if (!!duplicateOrderId) {
fields.duplicate = {
children: {
order_id: {
original: {
hidden: true,
value: duplicateOrderId
},
copy_lines: {},
// Transfer Orders don't have extra lines for now...
copy_extra_lines: { hidden: true, value: false }
copy_parameters: {}
}
};
}
@@ -632,6 +632,7 @@ export default function BuildDetail() {
const duplicateBuildOrderFields = useBuildOrderFields({
create: false,
duplicateBuildId: build.pk,
modalId: 'duplicate-build-order'
});
@@ -31,6 +31,7 @@ import { DetailsImage } from '../../components/details/DetailsImage';
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
import {
DeleteItemAction,
DuplicateItemAction,
EditItemAction,
OptionsActionDropdown
} from '../../components/items/ActionDropdown';
@@ -43,6 +44,7 @@ import { PanelGroup } from '../../components/panels/PanelGroup';
import ParametersPanel from '../../components/panels/ParametersPanel';
import { companyFields } from '../../forms/CompanyForms';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
@@ -293,11 +295,23 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
url: ApiEndpoints.company_list,
pk: company?.pk,
title: t`Edit Company`,
fields: companyFields(),
fields: useMemo(() => companyFields({}), []),
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
const duplicateCompany = useCreateApiFormModal({
url: ApiEndpoints.company_list,
title: t`Duplicate Company`,
initialData: useMemo(() => ({ ...company }), [company]),
fields: useMemo(
() => companyFields({ duplicateCompanyId: company?.pk }),
[company]
),
follow: true,
modelType: ModelType.company
});
const deleteCompany = useDeleteApiFormModal({
url: ApiEndpoints.company_list,
pk: company?.pk,
@@ -322,6 +336,10 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
hidden: !user.hasChangeRole(UserRoles.purchase_order),
onClick: () => editCompany.open()
}),
DuplicateItemAction({
hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: () => duplicateCompany.open()
}),
DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.purchase_order),
onClick: () => deleteCompany.open()
@@ -345,6 +363,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
<>
{editCompany.modal}
{deleteCompany.modal}
{duplicateCompany.modal}
<InstanceDetail
query={instanceQuery}
requiredPermission={ModelType.company}
@@ -220,10 +220,14 @@ export default function ManufacturerPartDetail() {
onFormSuccess: refreshInstance
});
const duplicateManufacturerPartFields = useManufacturerPartFields({
duplicateManufacturerPartId: manufacturerPart?.pk
});
const duplicateManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
title: t`Add Manufacturer Part`,
fields: editManufacturerPartFields,
fields: duplicateManufacturerPartFields,
initialData: {
...manufacturerPart
},
@@ -357,10 +357,14 @@ export default function SupplierPartDetail() {
}
});
const duplicateSupplierPartFields = useSupplierPartFields({
duplicateSupplierPartId: supplierPart?.pk
});
const duplicateSupplierPart = useCreateApiFormModal({
url: ApiEndpoints.supplier_part_list,
title: t`Add Supplier Part`,
fields: supplierPartFields,
fields: duplicateSupplierPartFields,
initialData: {
...supplierPart
},
+5 -7
View File
@@ -24,6 +24,7 @@ import { ActionDropdown } from '../../components/items/ActionDropdown';
import ImportPartWizard from '../../components/wizards/ImportPartWizard';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
import { DuplicateField } from '../../forms/CommonFields';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons';
@@ -312,12 +313,9 @@ export function PartListTable({
const duplicatePartFields: ApiFormFieldSet = useMemo(() => {
return {
...createPartFields,
duplicate: {
children: {
part: {
value: selectedPart.pk,
hidden: true
},
duplicate: DuplicateField({
originalId: selectedPart.pk,
extraFields: {
copy_image: {
value: true
},
@@ -337,7 +335,7 @@ export function PartListTable({
hidden: !selectedPart.testable
}
}
}
})
};
}, [createPartFields, globalSettings, selectedPart]);
@@ -133,10 +133,14 @@ export function ManufacturerPartTable({
table: table
});
const duplicateManufacturerPartFields = useManufacturerPartFields({
duplicateManufacturerPartId: selectedPart?.pk
});
const duplicateManufacturerPart = useCreateApiFormModal({
url: ApiEndpoints.manufacturer_part_list,
title: t`Add Manufacturer Part`,
fields: useMemo(() => manufacturerPartFields, [manufacturerPartFields]),
fields: duplicateManufacturerPartFields,
table: table,
initialData: {
...selectedPart
@@ -292,10 +292,14 @@ export function SupplierPartTable({
}
});
const duplicateSupplierPartFields = useSupplierPartFields({
duplicateSupplierPartId: selectedSupplierPart?.pk
});
const duplicateSupplierPart = useCreateApiFormModal({
url: ApiEndpoints.supplier_part_list,
title: t`Add Supplier Part`,
fields: useMemo(() => editSupplierPartFields, [editSupplierPartFields]),
fields: duplicateSupplierPartFields,
initialData: {
...selectedSupplierPart,
primary: false,