diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 8e3a574f01..47c6dc70cc 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -174,6 +174,7 @@ export interface ApiFormProps { */ export interface ApiFormModalProps extends ApiFormProps { title: string; + modalId?: string; cancelText?: string; cancelColor?: string; onClose?: () => void; diff --git a/src/frontend/lib/types/Modals.tsx b/src/frontend/lib/types/Modals.tsx index a0df9c8b0c..5aabf2e248 100644 --- a/src/frontend/lib/types/Modals.tsx +++ b/src/frontend/lib/types/Modals.tsx @@ -1,6 +1,7 @@ import type { UiSizeType } from './Core'; export interface UseModalProps { + id: string; title: string; children: React.ReactElement; size?: UiSizeType; diff --git a/src/frontend/src/components/forms/fields/TableField.tsx b/src/frontend/src/components/forms/fields/TableField.tsx index cfa7ed741f..09eaa9153e 100644 --- a/src/frontend/src/components/forms/fields/TableField.tsx +++ b/src/frontend/src/components/forms/fields/TableField.tsx @@ -155,7 +155,7 @@ export function TableField({ - {value.length > 0 ? ( + {(value?.length ?? 0) > 0 ? ( value.map((item: any, idx: number) => { return ( ( null @@ -47,6 +49,7 @@ export function useBuildOrderFields({ const [batchCode, setBatchCode] = useState(''); 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 diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index f95e762089..575d626976 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -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); diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index d4c6cb0317..47ee508363 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -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(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(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, diff --git a/src/frontend/src/hooks/UseForm.tsx b/src/frontend/src/hooks/UseForm.tsx index dd8b10f1dc..878d0daf82 100644 --- a/src/frontend/src/hooks/UseForm.tsx +++ b/src/frontend/src/hooks/UseForm.tsx @@ -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( () => ({ ...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: ( - + ) }); diff --git a/src/frontend/src/hooks/UseGenerator.tsx b/src/frontend/src/hooks/UseGenerator.tsx index c923a2c0e1..b1ad515844 100644 --- a/src/frontend/src/hooks/UseGenerator.tsx +++ b/src/frontend/src/hooks/UseGenerator.tsx @@ -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; 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(null); @@ -34,6 +39,24 @@ export function useGenerator(props: GeneratorProps): GeneratorState { // Prevent rapid updates const [debouncedQuery] = useDebouncedValue>(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, 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; 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; 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 }); } diff --git a/src/frontend/src/hooks/UseModal.tsx b/src/frontend/src/hooks/UseModal.tsx index 47c6d53d08..284cd623ae 100644 --- a/src/frontend/src/hooks/UseModal.tsx +++ b/src/frontend/src/hooks/UseModal.tsx @@ -25,6 +25,7 @@ export function useModal(props: UseModalProps): UseModalReturn { toggle, modal: ( - {duplicatePart.modal} {editPart.modal} {deletePart.modal} - {findBySerialNumber.modal} + {duplicatePart.modal} + {countStockItems.modal} {orderPartsWizard.wizard} + {findBySerialNumber.modal} + {transferStockItems.modal} - {transferStockItems.modal} - {countStockItems.modal} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 0c4de8082a..088fa283b4 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -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, diff --git a/src/frontend/src/states/ModalState.tsx b/src/frontend/src/states/ModalState.tsx new file mode 100644 index 0000000000..42d9b7aced --- /dev/null +++ b/src/frontend/src/states/ModalState.tsx @@ -0,0 +1,28 @@ +import { create } from 'zustand'; + +interface ModalStateProps { + openModals: Record; + 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()((set, get) => ({ + openModals: {}, + + isModalOpen: (modalKey: string) => { + return get().openModals[modalKey] ?? false; + }, + + setModalOpen: (modalKey: string, isOpen: boolean) => { + set((state) => ({ + openModals: { + ...state.openModals, + [modalKey]: isOpen + } + })); + } +})); diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index a61a98e607..8e7a26b7b9 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -176,10 +176,24 @@ export function InvenTreeTable>({ ); }, [props.tableFilters, fieldNames]); + // Build table properties based on provided props (and default props) + const tableProps: InvenTreeTableProps = 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>({ tableOptionQuery.refetch(); }, [cacheKey, url, props.params, props.enableColumnCaching]); - // Build table properties based on provided props (and default props) - const tableProps: InvenTreeTableProps = useMemo(() => { - return { - ...defaultInvenTreeTableProps, - ...props - }; - }, [props]); - const enableSelection: boolean = useMemo(() => { return tableProps.enableSelection || tableProps.enableBulkDelete || false; }, [tableProps]); @@ -450,13 +456,17 @@ export function InvenTreeTable>({ ] ); + const [sortingLoaded, setSortingLoaded] = useState(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>({ 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>({ url, tableState.page, props.params, + sortingLoaded, sortStatus.columnAccessor, sortStatus.direction, tableState.tableKey, diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx index 1a5030189f..00e5a86940 100644 --- a/src/frontend/src/tables/build/BuildLineTable.tsx +++ b/src/frontend/src/tables/build/BuildLineTable.tsx @@ -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({}); @@ -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 diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 8c407e216f..275f6f0220 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -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, diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 22ebbef86b..59aadfe8e4 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -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 }); diff --git a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx index 1872c3b6bd..a727513c10 100644 --- a/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderLineItemTable.tsx @@ -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, diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 53adb8f38a..9d2479811d 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -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,