2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 11:10:54 +00:00

[UI] Query Improvements (#9791)

* Fix for stockOperationModal

* Re-check when opened status changes

* rename stockOperationModal -> useStockOperationModal

* Fix enabled status of query

* Add option to specify modalId

* Track modal state when open / close

* Prevent generators from running until forms are open

* Prevent double loading of tables

* Fix useQuery

* Fix queryKey

* Revert API change
This commit is contained in:
Oliver
2025-06-17 22:16:19 +10:00
committed by GitHub
parent 21d44d0039
commit 92667876fe
19 changed files with 222 additions and 55 deletions

View File

@ -174,6 +174,7 @@ export interface ApiFormProps {
*/
export interface ApiFormModalProps extends ApiFormProps {
title: string;
modalId?: string;
cancelText?: string;
cancelColor?: string;
onClose?: () => void;

View File

@ -1,6 +1,7 @@
import type { UiSizeType } from './Core';
export interface UseModalProps {
id: string;
title: string;
children: React.ReactElement;
size?: UiSizeType;

View File

@ -155,7 +155,7 @@ export function TableField({
</Table.Thead>
<Table.Tbody>
{value.length > 0 ? (
{(value?.length ?? 0) > 0 ? (
value.map((item: any, idx: number) => {
return (
<TableFieldRow

View File

@ -36,9 +36,11 @@ import { PartColumn } from '../tables/ColumnRenderers';
* Field set for BuildOrder forms
*/
export function useBuildOrderFields({
create
create,
modalId
}: {
create: boolean;
modalId: string;
}): ApiFormFieldSet {
const [destination, setDestination] = useState<number | null | undefined>(
null
@ -47,6 +49,7 @@ export function useBuildOrderFields({
const [batchCode, setBatchCode] = useState<string>('');
const batchGenerator = useBatchCodeGenerator({
modalId: modalId,
onGenerate: (value: any) => {
setBatchCode((batch: any) => batch || value);
}
@ -152,9 +155,11 @@ export function useBuildOrderFields({
}
export function useBuildOrderOutputFields({
build
build,
modalId
}: {
build: any;
modalId: string;
}): ApiFormFieldSet {
const trackable: boolean = useMemo(() => {
return build.part_detail?.trackable ?? false;
@ -176,12 +181,14 @@ export function useBuildOrderOutputFields({
}, [build]);
const serialGenerator = useSerialNumberGenerator({
modalId: modalId,
initialQuery: {
part: build.part || build.part_detail?.pk
}
});
const batchGenerator = useBatchCodeGenerator({
modalId: modalId,
initialQuery: {
part: build.part || build.part_detail?.pk,
quantity: build.quantity

View File

@ -296,6 +296,7 @@ function LineItemFormRow({
// Batch code generator
const batchCodeGenerator = useBatchCodeGenerator({
isEnabled: () => batchOpen,
onGenerate: (value: any) => {
if (value) {
props.changeFn(props.idx, 'batch_code', value);
@ -305,6 +306,7 @@ function LineItemFormRow({
// Serial number generator
const serialNumberGenerator = useSerialNumberGenerator({
isEnabled: () => batchOpen && trackable,
onGenerate: (value: any) => {
if (value) {
props.changeFn(props.idx, 'serial_numbers', value);

View File

@ -65,10 +65,12 @@ import { StatusFilterOptions } from '../tables/Filter';
export function useStockFields({
partId,
stockItem,
modalId,
create = false
}: {
partId?: number;
stockItem?: any;
modalId: string;
create: boolean;
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
@ -81,12 +83,14 @@ export function useStockFields({
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const batchGenerator = useBatchCodeGenerator({
modalId: modalId,
initialQuery: {
part: partInstance?.pk || partId
}
});
const serialGenerator = useSerialNumberGenerator({
modalId: modalId,
initialQuery: {
part: partInstance?.pk || partId
}
@ -235,11 +239,15 @@ export function useStockFields({
* Launch a form to create a new StockItem instance
*/
export function useCreateStockItem() {
const fields = useStockFields({ create: true });
const fields = useStockFields({
create: true,
modalId: 'create-stock-item'
});
return useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,
fields: fields,
modalId: 'create-stock-item',
title: t`Add Stock Item`
});
}
@ -318,12 +326,15 @@ export function useStockItemInstallFields({
*/
export function useStockItemSerializeFields({
partId,
trackable
trackable,
modalId
}: {
partId: number;
trackable: boolean;
modalId: string;
}) {
const serialGenerator = useSerialNumberGenerator({
modalId: modalId,
initialQuery: {
part: partId
}
@ -1018,7 +1029,7 @@ type apiModalFunc = (props: ApiFormModalProps) => {
modal: JSX.Element;
};
function stockOperationModal({
function useStockOperationModal({
items,
pk,
model,
@ -1061,25 +1072,29 @@ function stockOperationModal({
return query_params;
}, [baseParams, filters, model, pk]);
const [opened, setOpened] = useState<boolean>(false);
const { data } = useQuery({
queryKey: ['stockitems', model, pk, items, params],
queryKey: ['stockitems', opened, model, pk, items, params],
queryFn: async () => {
if (items) {
// If a list of items is provided, use that directly
return Array.isArray(items) ? items : [items];
}
if (!pk || !opened) {
return [];
}
const url = apiUrl(ApiEndpoints.stock_item_list);
return api
.get(url, {
params: params
})
.then((response) => {
if (response.status === 200) {
return response.data;
}
})
.then((response) => response.data ?? [])
.catch(() => {
return null;
return [];
});
}
});
@ -1095,7 +1110,9 @@ function stockOperationModal({
title: title,
size: '80%',
successMessage: successMessage,
onFormSuccess: () => refresh()
onFormSuccess: () => refresh(),
onClose: () => setOpened(false),
onOpen: () => setOpened(true)
});
}
@ -1108,7 +1125,7 @@ export type StockOperationProps = {
};
export function useAddStockItem(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockAddFields,
endpoint: ApiEndpoints.stock_add,
@ -1118,7 +1135,7 @@ export function useAddStockItem(props: StockOperationProps) {
}
export function useRemoveStockItem(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockRemoveFields,
endpoint: ApiEndpoints.stock_remove,
@ -1128,7 +1145,7 @@ export function useRemoveStockItem(props: StockOperationProps) {
}
export function useTransferStockItem(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockTransferFields,
endpoint: ApiEndpoints.stock_transfer,
@ -1138,7 +1155,7 @@ export function useTransferStockItem(props: StockOperationProps) {
}
export function useCountStockItem(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockCountFields,
endpoint: ApiEndpoints.stock_count,
@ -1148,7 +1165,7 @@ export function useCountStockItem(props: StockOperationProps) {
}
export function useChangeStockStatus(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockChangeStatusFields,
endpoint: ApiEndpoints.stock_change_status,
@ -1158,7 +1175,7 @@ export function useChangeStockStatus(props: StockOperationProps) {
}
export function useMergeStockItem(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockMergeFields,
endpoint: ApiEndpoints.stock_merge,
@ -1182,7 +1199,7 @@ export function useAssignStockItem(props: StockOperationProps) {
return props.items?.filter((item) => item?.part_detail?.salable);
}, [props.items]);
return stockOperationModal({
return useStockOperationModal({
...props,
items: items,
fieldGenerator: stockAssignFields,
@ -1193,7 +1210,7 @@ export function useAssignStockItem(props: StockOperationProps) {
}
export function useDeleteStockItem(props: StockOperationProps) {
return stockOperationModal({
return useStockOperationModal({
...props,
fieldGenerator: stockDeleteFields,
endpoint: ApiEndpoints.stock_item_list,

View File

@ -8,6 +8,7 @@ import type {
BulkEditApiFormModalProps
} from '@lib/types/Forms';
import { OptionsApiForm } from '../components/forms/ApiForm';
import { useModalState } from '../states/ModalState';
import { useModal } from './UseModal';
/**
@ -17,6 +18,12 @@ export function useApiFormModal(props: ApiFormModalProps) {
const id = useId();
const modalClose = useRef(() => {});
const modalState = useModalState();
const modalId = useMemo(() => {
return props.modalId ?? id;
}, [props.modalId, id]);
const formProps = useMemo<ApiFormModalProps>(
() => ({
...props,
@ -44,15 +51,22 @@ export function useApiFormModal(props: ApiFormModalProps) {
);
const modal = useModal({
id: modalId,
title: formProps.title,
onOpen: formProps.onOpen,
onClose: formProps.onClose,
onOpen: () => {
modalState.setModalOpen(modalId, true);
formProps.onOpen?.();
},
onClose: () => {
modalState.setModalOpen(modalId, false);
formProps.onClose?.();
},
closeOnClickOutside: formProps.closeOnClickOutside,
size: props.size ?? 'xl',
children: (
<Stack gap={'xs'}>
<Divider />
<OptionsApiForm props={formProps} id={id} />
<OptionsApiForm props={formProps} id={modalId} />
</Stack>
)
});

View File

@ -5,12 +5,15 @@ import { useCallback, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../App';
import { useModalState } from '../states/ModalState';
export type GeneratorProps = {
endpoint: ApiEndpoints;
key: string;
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
isEnabled?: () => boolean;
modalId?: string;
};
export type GeneratorState = {
@ -25,6 +28,8 @@ export type GeneratorState = {
* Each update calls a new query to the API, and the result is stored in the state.
*/
export function useGenerator(props: GeneratorProps): GeneratorState {
const modalState = useModalState();
// Track the result
const [result, setResult] = useState<any>(null);
@ -34,6 +39,24 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
// Prevent rapid updates
const [debouncedQuery] = useDebouncedValue<Record<string, any>>(query, 100);
// Callback to determine if the function is enabled
const isEnabled = useCallback(() => {
if (props.isEnabled?.() == false) {
return false;
}
if (props.modalId && !modalState.isModalOpen(props.modalId)) {
return false;
}
return true;
}, [
modalState.isModalOpen,
modalState.openModals,
props.isEnabled,
props.modalId
]);
// Callback to update the generator query
const update = useCallback(
(params: Record<string, any>, overwrite?: boolean) => {
@ -57,6 +80,7 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
props.key,
props.endpoint,
props.initialQuery,
modalState.openModals,
debouncedQuery
],
refetchOnMount: false,
@ -67,6 +91,11 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
...(props.initialQuery ?? {})
};
if (!isEnabled()) {
setResult(null);
return;
}
return api
.post(apiUrl(props.endpoint), generatorQuery)
.then((response) => {
@ -96,31 +125,43 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
// Generate a batch code with provided data
export function useBatchCodeGenerator({
initialQuery,
onGenerate
onGenerate,
isEnabled,
modalId
}: {
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
isEnabled?: () => boolean;
modalId?: string;
}): GeneratorState {
return useGenerator({
endpoint: ApiEndpoints.generate_batch_code,
key: 'batch_code',
initialQuery: initialQuery,
onGenerate: onGenerate
onGenerate: onGenerate,
isEnabled: isEnabled,
modalId: modalId
});
}
// Generate a serial number with provided data
export function useSerialNumberGenerator({
initialQuery,
onGenerate
onGenerate,
isEnabled,
modalId
}: {
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
isEnabled?: () => boolean;
modalId?: string;
}): GeneratorState {
return useGenerator({
endpoint: ApiEndpoints.generate_serial_number,
key: 'serial_number',
initialQuery: initialQuery,
onGenerate: onGenerate
onGenerate: onGenerate,
isEnabled: isEnabled,
modalId: modalId
});
}

View File

@ -25,6 +25,7 @@ export function useModal(props: UseModalProps): UseModalReturn {
toggle,
modal: (
<Modal
key={props.id}
opened={opened}
onClose={close}
closeOnClickOutside={props.closeOnClickOutside}

View File

@ -416,13 +416,17 @@ export default function BuildDetail() {
];
}, [build, id, user, buildStatus, globalSettings]);
const buildOrderFields = useBuildOrderFields({ create: false });
const editBuildOrderFields = useBuildOrderFields({
create: false,
modalId: 'edit-build-order'
});
const editBuild = useEditApiFormModal({
url: ApiEndpoints.build_order_list,
pk: build.pk,
title: t`Edit Build Order`,
fields: buildOrderFields,
modalId: 'edit-build-order',
fields: editBuildOrderFields,
onFormSuccess: refreshInstance
});
@ -435,10 +439,16 @@ export default function BuildDetail() {
return data;
}, [build]);
const duplicateBuildOrderFields = useBuildOrderFields({
create: false,
modalId: 'duplicate-build-order'
});
const duplicateBuild = useCreateApiFormModal({
url: ApiEndpoints.build_order_list,
title: t`Add Build Order`,
fields: buildOrderFields,
modalId: 'duplicate-build-order',
fields: duplicateBuildOrderFields,
initialData: duplicateBuildOrderInitialData,
follow: true,
modelType: ModelType.build

View File

@ -1005,11 +1005,13 @@ export default function PartDetail() {
return (
<>
{duplicatePart.modal}
{editPart.modal}
{deletePart.modal}
{findBySerialNumber.modal}
{duplicatePart.modal}
{countStockItems.modal}
{orderPartsWizard.wizard}
{findBySerialNumber.modal}
{transferStockItems.modal}
<InstanceDetail
status={requestStatus}
loading={instanceQuery.isFetching}
@ -1096,8 +1098,6 @@ export default function PartDetail() {
model={ModelType.part}
id={part.pk}
/>
{transferStockItems.modal}
{countStockItems.modal}
</Stack>
</InstanceDetail>
</>

View File

@ -640,22 +640,28 @@ export default function StockDetail() {
const editStockItemFields = useStockFields({
create: false,
stockItem: stockitem,
partId: stockitem.part
partId: stockitem.part,
modalId: 'edit-stock-item'
});
const editStockItem = useEditApiFormModal({
url: ApiEndpoints.stock_item_list,
pk: stockitem.pk,
title: t`Edit Stock Item`,
modalId: 'edit-stock-item',
fields: editStockItemFields,
onFormSuccess: refreshInstance
});
const duplicateStockItemFields = useStockFields({ create: true });
const duplicateStockItemFields = useStockFields({
create: true,
modalId: 'duplicate-stock-item'
});
const duplicateStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,
title: t`Add Stock Item`,
modalId: 'duplicate-stock-item',
fields: duplicateStockItemFields,
initialData: {
...stockitem
@ -700,13 +706,15 @@ export default function StockDetail() {
const serializeStockFields = useStockItemSerializeFields({
partId: stockitem.part,
trackable: stockitem.part_detail?.trackable
trackable: stockitem.part_detail?.trackable,
modalId: 'stock-item-serialize'
});
const serializeStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_serialize,
pk: stockitem.pk,
title: t`Serialize Stock Item`,
modalId: 'stock-item-serialize',
fields: serializeStockFields,
initialData: {
quantity: stockitem.quantity,

View File

@ -0,0 +1,28 @@
import { create } from 'zustand';
interface ModalStateProps {
openModals: Record<string, boolean>;
isModalOpen: (modalKey: string) => boolean;
setModalOpen: (modalKey: string, isOpen: boolean) => void;
}
/**
* Global state manager for determining modal visibility.
* Useful to share modal state (open / closed) between components.
*/
export const useModalState = create<ModalStateProps>()((set, get) => ({
openModals: {},
isModalOpen: (modalKey: string) => {
return get().openModals[modalKey] ?? false;
},
setModalOpen: (modalKey: string, isOpen: boolean) => {
set((state) => ({
openModals: {
...state.openModals,
[modalKey]: isOpen
}
}));
}
}));

View File

@ -176,10 +176,24 @@ export function InvenTreeTable<T extends Record<string, any>>({
);
}, [props.tableFilters, fieldNames]);
// Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps<T> = useMemo(() => {
return {
...defaultInvenTreeTableProps,
...props
};
}, [props]);
// Request OPTIONS data from the API, before we load the table
const tableOptionQuery = useQuery({
enabled: !!url && !tableData,
queryKey: ['options', url, cacheKey, props.enableColumnCaching],
queryKey: [
'options',
url,
cacheKey,
tableProps.params,
props.enableColumnCaching
],
retry: 3,
refetchOnMount: true,
gcTime: 5000,
@ -255,14 +269,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
tableOptionQuery.refetch();
}, [cacheKey, url, props.params, props.enableColumnCaching]);
// Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps<T> = useMemo(() => {
return {
...defaultInvenTreeTableProps,
...props
};
}, [props]);
const enableSelection: boolean = useMemo(() => {
return tableProps.enableSelection || tableProps.enableBulkDelete || false;
}, [tableProps]);
@ -450,13 +456,17 @@ export function InvenTreeTable<T extends Record<string, any>>({
]
);
const [sortingLoaded, setSortingLoaded] = useState<boolean>(false);
useEffect(() => {
const tableKey: string = tableState.tableKey.split('-')[0];
const sorting: DataTableSortStatus = getTableSorting(tableKey);
if (sorting) {
if (sorting && !!sorting.columnAccessor && !!sorting.direction) {
setSortStatus(sorting);
}
setSortingLoaded(true);
}, []);
// Return the ordering parameter
@ -492,6 +502,12 @@ export function InvenTreeTable<T extends Record<string, any>>({
const queryParams = getTableFilters(true);
if (!url) {
// No URL supplied - do not load!
return [];
}
if (!sortingLoaded) {
// Sorting not yet loaded - do not load!
return [];
}
@ -560,6 +576,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
url,
tableState.page,
props.params,
sortingLoaded,
sortStatus.columnAccessor,
sortStatus.direction,
tableState.tableKey,

View File

@ -419,7 +419,10 @@ export default function BuildLineTable({
];
}, [hasOutput, isActive, table, output]);
const buildOrderFields = useBuildOrderFields({ create: true });
const buildOrderFields = useBuildOrderFields({
create: true,
modalId: 'new-build-order'
});
const [initialData, setInitialData] = useState<any>({});
@ -431,6 +434,7 @@ export default function BuildLineTable({
url: ApiEndpoints.build_order_list,
title: t`Create Build Order`,
fields: buildOrderFields,
modalId: 'new-build-order',
initialData: initialData,
follow: true,
modelType: ModelType.build

View File

@ -194,11 +194,15 @@ export function BuildOrderTable({
const user = useUserState();
const buildOrderFields = useBuildOrderFields({ create: true });
const buildOrderFields = useBuildOrderFields({
create: true,
modalId: 'create-build-order'
});
const newBuild = useCreateApiFormModal({
url: ApiEndpoints.build_order_list,
title: t`Add Build Order`,
modalId: 'create-build-order',
fields: buildOrderFields,
initialData: {
part: partId,

View File

@ -256,11 +256,15 @@ export default function BuildOutputTable({
[partId, buildId, testTemplates, trackedItems]
);
const buildOutputFields = useBuildOrderOutputFields({ build: build });
const buildOutputFields = useBuildOrderOutputFields({
build: build,
modalId: 'add-build-output'
});
const addBuildOutput = useCreateApiFormModal({
url: apiUrl(ApiEndpoints.build_output_create, buildId),
title: t`Add Build Output`,
modalId: 'add-build-output',
fields: buildOutputFields,
timeout: 10000,
initialData: {
@ -302,13 +306,15 @@ export default function BuildOutputTable({
const editStockItemFields = useStockFields({
create: false,
partId: partId,
stockItem: selectedOutputs[0]
stockItem: selectedOutputs[0],
modalId: 'edit-build-output'
});
const editBuildOutput = useEditApiFormModal({
url: ApiEndpoints.stock_item_list,
pk: selectedOutputs[0]?.pk,
title: t`Edit Build Output`,
modalId: 'edit-build-output',
fields: editStockItemFields,
table: table
});

View File

@ -276,11 +276,15 @@ export default function SalesOrderLineItemTable({
table: table
});
const buildOrderFields = useBuildOrderFields({ create: true });
const buildOrderFields = useBuildOrderFields({
create: true,
modalId: 'build-order-create-from-sales-order'
});
const newBuildOrder = useCreateApiFormModal({
url: ApiEndpoints.build_order_list,
title: t`Create Build Order`,
modalId: 'build-order-create-from-sales-order',
fields: buildOrderFields,
initialData: initialData,
follow: true,

View File

@ -526,12 +526,14 @@ export function StockItemTable({
const newStockItemFields = useStockFields({
create: true,
partId: params.part
partId: params.part,
modalId: 'add-stock-item'
});
const newStockItem = useCreateApiFormModal({
url: ApiEndpoints.stock_item_list,
title: t`Add Stock Item`,
modalId: 'add-stock-item',
fields: newStockItemFields,
initialData: {
part: params.part,