diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b71fd37a4..590f179f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,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 af8daf9836..3f2cc70e22 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 665b9fcf02..26e7ddf5d0 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 1bc3dc62f3..42ba36acfd 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 089ce4f36b..43b2b22948 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 0000000000..1c4d75b2c9 --- /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 3ab80b7543..102cb2662c 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 6213633e6b..c92f321f6d 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 115e07ab74..b4480256ca 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 e3c6125344..13d9c61159 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 15abbf8390..0f15524f09 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 c6380f9ac0..ab4ab27fe7 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 674963ff80..4c371ad104 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 e450ede8e7..7f9b4e1465 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 f0c99c870c..ef75f6ade5 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 22ad44d9ce..2f50cdab10 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 3d97ed09b2..6f679f728f 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 4f5f352e46..e23cd60a4c 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 created` + successMessage: t`Stock item created`, + 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 43c338370c..bc2baefd87 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); diff --git a/src/frontend/tests/pui_forms.spec.ts b/src/frontend/tests/pui_forms.spec.ts index 267f8e2a64..c86a490720 100644 --- a/src/frontend/tests/pui_forms.spec.ts +++ b/src/frontend/tests/pui_forms.spec.ts @@ -1,5 +1,5 @@ /** Unit tests for form validation, rendering, etc */ -import test from 'playwright/test'; +import { expect, test } from 'playwright/test'; import { stevenuser } from './defaults'; import { navigate } from './helpers'; import { doCachedLogin } from './login'; @@ -134,3 +134,37 @@ test('Forms - Supplier Validation', async ({ browser }) => { await page.getByText('Form Error').waitFor(); await page.getByRole('button', { name: 'Cancel' }).click(); }); + +test('Forms - Keep form open option', async ({ browser }) => { + const page = await doCachedLogin(browser, { + user: stevenuser, + url: 'stock/location/index/sublocations' + }); + await page.waitForURL('**/stock/location/index/**'); + + await page.getByLabel('action-button-add-stock-location').click(); + + // Generate unique location name + const locationName = `New Sublocation ${new Date().getTime()}`; + + await page.getByLabel('text-field-name', { exact: true }).fill(locationName); + + // Check keep form open switch and submit + await page.getByRole('switch', { name: 'Keep form open' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Location should be created, form should remain opened + await page.getByText('Item Created').waitFor(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Create another location and uncheck this option + await page + .getByLabel('text-field-name', { exact: true }) + .fill(`Another ${locationName}`); + await page.getByRole('switch', { name: 'Keep form open' }).click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Location should be created, and the form (modal) should disappear + await page.getByText('Item Created').waitFor(); + await expect(page.getByRole('dialog')).toBeHidden(); +});