diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c200417ff37..578046292d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#10887](https://github.com/inventree/InvenTree/pull/10887) adds the ability to auto-allocate tracked items against specific build outputs. Currently, this will only allocate items where the serial number of the tracked item matches the serial number of the build output, but in future this may be extended to allow for more flexible allocation rules. - [#11372](https://github.com/inventree/InvenTree/pull/11372) adds backup metadata setter and restore metadata validator functions to ensure common footguns are harder to trigger when using the backup and restore functionality. - [#11374](https://github.com/inventree/InvenTree/pull/11374) adds `updated_at` field on purchase, sales and return orders. +- [#11074](https://github.com/inventree/InvenTree/pull/11074) adds "Keep form open" option on create form which leaves dialog with form opened after form submitting. ### Changed diff --git a/docs/docs/assets/images/concepts/ui_form_add_part.png b/docs/docs/assets/images/concepts/ui_form_add_part.png index af8daf98368c..3f2cc70e2231 100644 Binary files a/docs/docs/assets/images/concepts/ui_form_add_part.png and b/docs/docs/assets/images/concepts/ui_form_add_part.png differ diff --git a/docs/docs/concepts/user_interface.md b/docs/docs/concepts/user_interface.md index 665b9fcf02f7..26e7ddf5d02c 100644 --- a/docs/docs/concepts/user_interface.md +++ b/docs/docs/concepts/user_interface.md @@ -224,6 +224,8 @@ Example: Creating a new part via the "Add Part" form: {{ image("concepts/ui_form_add_part.png", "Add Part Button") }} +On several forms is displayed option "Keep form open" in bottom part of the form on left side of Submit button (option is visible on the screenshot above). When this switch is turned on, form window is not closed after submit and filled form data is not reset. This is useful for creating more entries at one time with similar properties (e.g. only different number in name). + ### Data Editing Example: Editing an existing purchase order via the "Edit Purchase Order" form: diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 1bc3dc62f35f..42ba36acfdd3 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -180,6 +180,8 @@ export interface ApiFormProps { follow?: boolean; actions?: ApiFormAction[]; timeout?: number; + keepOpenOption?: boolean; + onKeepOpenChange?: (keepOpen: boolean) => void; } /** diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 089ce4f36bf0..43b2b2294846 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -12,7 +12,7 @@ import { import { useId } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type FieldValues, FormProvider, @@ -42,6 +42,7 @@ import { showTimeoutNotification } from '../../functions/notifications'; import { Boundary } from '../Boundary'; +import { KeepFormOpenSwitch } from './KeepFormOpenSwitch'; import { ApiFormField } from './fields/ApiFormField'; export function OptionsApiForm({ @@ -169,6 +170,12 @@ export function ApiForm({ }>) { const api = useApi(); const queryClient = useQueryClient(); + const keepOpenRef = useRef(false); + + const onKeepOpenChange = (v: boolean) => { + keepOpenRef.current = v; + props.onKeepOpenChange?.(v); + }; // Accessor for the navigation function (which is used to redirect the user) let navigate: NavigateFunction | null = null; @@ -459,9 +466,14 @@ export function ApiForm({ props.onFormSuccess(response.data, form); } - if (props.follow && props.modelType && response.data?.pk) { + if ( + props.follow && + props.modelType && + response.data?.pk && + !keepOpenRef.current + ) { // If we want to automatically follow the returned data - if (!!navigate) { + if (!!navigate && !keepOpenRef.current) { navigate(getDetailUrl(props.modelType, response.data?.pk)); } } else if (props.table) { @@ -588,7 +600,6 @@ export function ApiForm({ ); } - return ( @@ -673,7 +684,12 @@ export function ApiForm({ {/* Footer with Action Buttons */} -
+ + + {props.keepOpenOption && ( + + )} + {props.actions?.map((action, i) => (
+
); diff --git a/src/frontend/src/components/forms/KeepFormOpenSwitch.tsx b/src/frontend/src/components/forms/KeepFormOpenSwitch.tsx new file mode 100644 index 000000000000..1c4d75b2c91a --- /dev/null +++ b/src/frontend/src/components/forms/KeepFormOpenSwitch.tsx @@ -0,0 +1,23 @@ +import { Switch } from '@mantine/core'; +import { useEffect, useState } from 'react'; + +export function KeepFormOpenSwitch({ + onChange +}: { onChange?: (v: boolean) => void }) { + const [keepOpen, setKeepOpen] = useState(false); + + useEffect(() => { + onChange?.(keepOpen); + }, [keepOpen]); + + return ( + setKeepOpen(e.currentTarget.checked)} + /> + ); +} diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 3ab80b754363..102cb2662ce1 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -321,7 +321,8 @@ export function useCreateStockItem() { url: ApiEndpoints.stock_item_list, fields: fields, modalId: 'create-stock-item', - title: t`Add Stock Item` + title: t`Add Stock Item`, + keepOpenOption: true }); } diff --git a/src/frontend/src/hooks/UseForm.tsx b/src/frontend/src/hooks/UseForm.tsx index 6213633e6b0a..c92f321f6dbc 100644 --- a/src/frontend/src/hooks/UseForm.tsx +++ b/src/frontend/src/hooks/UseForm.tsx @@ -24,9 +24,15 @@ export function useApiFormModal(props: ApiFormModalProps) { return props.modalId ?? id; }, [props.modalId, id]); + const keepOpenRef = useRef(false); + const setKeepOpen = (v: boolean) => { + keepOpenRef.current = v; + }; + const formProps = useMemo( () => ({ ...props, + onKeepOpenChange: setKeepOpen, actions: [ ...(props.actions || []), { @@ -38,7 +44,7 @@ export function useApiFormModal(props: ApiFormModalProps) { } ], onFormSuccess: (data, form) => { - if (props.checkClose?.(data, form) ?? true) { + if (!keepOpenRef.current && (props.checkClose?.(data, form) ?? true)) { modalClose.current(); } props.onFormSuccess?.(data, form); diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 115e07ab741d..b4480256cad2 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -218,7 +218,8 @@ export function BuildOrderTable({ parent: parentBuildId }, follow: true, - modelType: ModelType.build + modelType: ModelType.build, + keepOpenOption: true }); const tableActions = useMemo(() => { diff --git a/src/frontend/src/tables/company/CompanyTable.tsx b/src/frontend/src/tables/company/CompanyTable.tsx index e3c612534485..13d9c61159c0 100644 --- a/src/frontend/src/tables/company/CompanyTable.tsx +++ b/src/frontend/src/tables/company/CompanyTable.tsx @@ -80,7 +80,8 @@ export function CompanyTable({ fields: companyFields(), initialData: params, follow: true, - modelType: ModelType.company + modelType: ModelType.company, + keepOpenOption: true }); const [selectedCompany, setSelectedCompany] = useState(0); diff --git a/src/frontend/src/tables/part/PartCategoryTable.tsx b/src/frontend/src/tables/part/PartCategoryTable.tsx index 15abbf839042..0f15524f09a3 100644 --- a/src/frontend/src/tables/part/PartCategoryTable.tsx +++ b/src/frontend/src/tables/part/PartCategoryTable.tsx @@ -109,7 +109,8 @@ export function PartCategoryTable({ parentId }: Readonly<{ parentId?: any }>) { }, follow: true, modelType: ModelType.partcategory, - table: table + table: table, + keepOpenOption: true }); const [selectedCategory, setSelectedCategory] = useState(-1); diff --git a/src/frontend/src/tables/part/PartTable.tsx b/src/frontend/src/tables/part/PartTable.tsx index c6380f9ac0d0..ab4ab27fe792 100644 --- a/src/frontend/src/tables/part/PartTable.tsx +++ b/src/frontend/src/tables/part/PartTable.tsx @@ -407,7 +407,8 @@ export function PartListTable({ fields: newPartFields, initialData: initialPartData, follow: true, - modelType: ModelType.part + modelType: ModelType.part, + keepOpenOption: true }); const [selectedPart, setSelectedPart] = useState({}); diff --git a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx index 674963ff80ac..4c371ad10490 100644 --- a/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx +++ b/src/frontend/src/tables/purchasing/ManufacturerPartTable.tsx @@ -118,7 +118,8 @@ export function ManufacturerPartTable({ initialData: { manufacturer: manufacturerId, part: partId - } + }, + keepOpenOption: true }); const editManufacturerPart = useEditApiFormModal({ diff --git a/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx b/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx index e450ede8e77c..7f9b4e14656a 100644 --- a/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx +++ b/src/frontend/src/tables/purchasing/PurchaseOrderTable.tsx @@ -175,7 +175,8 @@ export function PurchaseOrderTable({ supplier: supplierId }, follow: true, - modelType: ModelType.purchaseorder + modelType: ModelType.purchaseorder, + keepOpenOption: true }); const tableActions = useMemo(() => { diff --git a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx index f0c99c870cf5..ef75f6ade5e1 100644 --- a/src/frontend/src/tables/purchasing/SupplierPartTable.tsx +++ b/src/frontend/src/tables/purchasing/SupplierPartTable.tsx @@ -210,7 +210,8 @@ export function SupplierPartTable({ onFormSuccess: (response: any) => { table.refreshTable(); }, - successMessage: t`Supplier part created` + successMessage: t`Supplier part created`, + keepOpenOption: true }); const supplierPlugins = usePluginsWithMixin('supplier'); diff --git a/src/frontend/src/tables/sales/ReturnOrderTable.tsx b/src/frontend/src/tables/sales/ReturnOrderTable.tsx index 22ad44d9cea2..2f50cdab10ed 100644 --- a/src/frontend/src/tables/sales/ReturnOrderTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderTable.tsx @@ -179,7 +179,8 @@ export function ReturnOrderTable({ customer: customerId }, follow: true, - modelType: ModelType.returnorder + modelType: ModelType.returnorder, + keepOpenOption: true }); const tableActions = useMemo(() => { diff --git a/src/frontend/src/tables/sales/SalesOrderTable.tsx b/src/frontend/src/tables/sales/SalesOrderTable.tsx index 3d97ed09b2c0..6f679f728f7c 100644 --- a/src/frontend/src/tables/sales/SalesOrderTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderTable.tsx @@ -125,7 +125,8 @@ export function SalesOrderTable({ customer: customerId }, follow: true, - modelType: ModelType.salesorder + modelType: ModelType.salesorder, + keepOpenOption: true }); const tableActions = useMemo(() => { diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 04d37dab84a1..c84bd30cfa7f 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -417,7 +417,8 @@ export function StockItemTable({ // Navigate to the first result navigate(getDetailUrl(ModelType.stockitem, response[0].pk)); }, - successMessage: t`Stock item serialized` + successMessage: t`Stock item serialized`, + keepOpenOption: true }); const [partsToOrder, setPartsToOrder] = useState([]); diff --git a/src/frontend/src/tables/stock/StockLocationTable.tsx b/src/frontend/src/tables/stock/StockLocationTable.tsx index 43c338370c3f..bc2baefd872c 100644 --- a/src/frontend/src/tables/stock/StockLocationTable.tsx +++ b/src/frontend/src/tables/stock/StockLocationTable.tsx @@ -110,7 +110,8 @@ export function StockLocationTable({ parentId }: Readonly<{ parentId?: any }>) { }, follow: true, modelType: ModelType.stocklocation, - table: table + table: table, + keepOpenOption: true }); const [selectedLocation, setSelectedLocation] = useState(-1);