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:
@@ -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 |
@@ -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:
|
||||
|
||||
@@ -180,6 +180,8 @@ export interface ApiFormProps {
|
||||
follow?: boolean;
|
||||
actions?: ApiFormAction[];
|
||||
timeout?: number;
|
||||
keepOpenOption?: boolean;
|
||||
onKeepOpenChange?: (keepOpen: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
23
src/frontend/src/components/forms/KeepFormOpenSwitch.tsx
Normal file
23
src/frontend/src/components/forms/KeepFormOpenSwitch.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -218,7 +218,8 @@ export function BuildOrderTable({
|
||||
parent: parentBuildId
|
||||
},
|
||||
follow: true,
|
||||
modelType: ModelType.build
|
||||
modelType: ModelType.build,
|
||||
keepOpenOption: true
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>({});
|
||||
|
||||
@@ -118,7 +118,8 @@ export function ManufacturerPartTable({
|
||||
initialData: {
|
||||
manufacturer: manufacturerId,
|
||||
part: partId
|
||||
}
|
||||
},
|
||||
keepOpenOption: true
|
||||
});
|
||||
|
||||
const editManufacturerPart = useEditApiFormModal({
|
||||
|
||||
@@ -175,7 +175,8 @@ export function PurchaseOrderTable({
|
||||
supplier: supplierId
|
||||
},
|
||||
follow: true,
|
||||
modelType: ModelType.purchaseorder
|
||||
modelType: ModelType.purchaseorder,
|
||||
keepOpenOption: true
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -179,7 +179,8 @@ export function ReturnOrderTable({
|
||||
customer: customerId
|
||||
},
|
||||
follow: true,
|
||||
modelType: ModelType.returnorder
|
||||
modelType: ModelType.returnorder,
|
||||
keepOpenOption: true
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
|
||||
@@ -125,7 +125,8 @@ export function SalesOrderTable({
|
||||
customer: customerId
|
||||
},
|
||||
follow: true,
|
||||
modelType: ModelType.salesorder
|
||||
modelType: ModelType.salesorder,
|
||||
keepOpenOption: true
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user