2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-03 10:01:07 +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

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