2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-01 17:11:12 +00:00

Added keep open boolean field to Stock Location modal form (#11074)

* Added keep open boolean field to Stock Location modal form

* Rewrite keep form open field feature to avoid calling methods in form field definitions

* Rewrite keep form open feature as common form property

* Removed unused artefact from previous implementation

* keepOpenOption removed as default option for all create forms. Instead it's enabled on selected forms.

* keepOpenOption field speed improvement

- using useRef instead of useState
- keepOpenSwitch moved to own component

* Added keep form open feature to changelog

* Updated documentation: keep form open feature added to concepts/user_interface docs

* Added test case for "keep form open" feature

* Changed switch selector in keep form open feature test

---------

Co-authored-by: spm <jan.krajdl@cecolo.com>
Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Jan Krajdl
2026-03-29 06:09:47 +02:00
committed by GitHub
parent e3c9a35bae
commit 9cd0b520c2
20 changed files with 116 additions and 20 deletions

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -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:

View File

@@ -180,6 +180,8 @@ export interface ApiFormProps {
follow?: boolean;
actions?: ApiFormAction[];
timeout?: number;
keepOpenOption?: boolean;
onKeepOpenChange?: (keepOpen: boolean) => void;
}
/**

View File

@@ -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({
</Paper>
);
}
return (
<Stack>
<Boundary label={`ApiForm-${id}`}>
@@ -673,7 +684,12 @@ export function ApiForm({
{/* Footer with Action Buttons */}
<Divider />
<div>
<Group justify='space-between'>
<Group justify='left'>
{props.keepOpenOption && (
<KeepFormOpenSwitch onChange={onKeepOpenChange} />
)}
</Group>
<Group justify='right'>
{props.actions?.map((action, i) => (
<Button
@@ -696,7 +712,7 @@ export function ApiForm({
{props.submitText ?? t`Submit`}
</Button>
</Group>
</div>
</Group>
</Boundary>
</Stack>
);

View File

@@ -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 (
<Switch
checked={keepOpen}
radius='lg'
size='sm'
label='Keep form open'
description='Keep form open after submitting'
onChange={(e) => setKeepOpen(e.currentTarget.checked)}
/>
);
}

View File

@@ -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
});
}

View File

@@ -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<ApiFormModalProps>(
() => ({
...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);

View File

@@ -218,7 +218,8 @@ export function BuildOrderTable({
parent: parentBuildId
},
follow: true,
modelType: ModelType.build
modelType: ModelType.build,
keepOpenOption: true
});
const tableActions = useMemo(() => {

View File

@@ -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<number>(0);

View File

@@ -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<number>(-1);

View File

@@ -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<any>({});

View File

@@ -118,7 +118,8 @@ export function ManufacturerPartTable({
initialData: {
manufacturer: manufacturerId,
part: partId
}
},
keepOpenOption: true
});
const editManufacturerPart = useEditApiFormModal({

View File

@@ -175,7 +175,8 @@ export function PurchaseOrderTable({
supplier: supplierId
},
follow: true,
modelType: ModelType.purchaseorder
modelType: ModelType.purchaseorder,
keepOpenOption: true
});
const tableActions = useMemo(() => {

View File

@@ -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');

View File

@@ -179,7 +179,8 @@ export function ReturnOrderTable({
customer: customerId
},
follow: true,
modelType: ModelType.returnorder
modelType: ModelType.returnorder,
keepOpenOption: true
});
const tableActions = useMemo(() => {

View File

@@ -125,7 +125,8 @@ export function SalesOrderTable({
customer: customerId
},
follow: true,
modelType: ModelType.salesorder
modelType: ModelType.salesorder,
keepOpenOption: true
});
const tableActions = useMemo(() => {

View File

@@ -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<any[]>([]);

View File

@@ -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<number>(-1);

View File

@@ -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();
});