From 9756cb92bb04c755587b91fa8960c43e7a29d437 Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 19 Dec 2025 03:13:56 -0500 Subject: [PATCH 1/2] perf: memoize inline locations and gate editor search Gate new-row item search to Editor Mode and use memoized location filtering in inline rows; virtualization deferred to #85. --- .../inventory/InventoryInlineRow.tsx | 37 ++-- frontend/src/hooks/useMemoizedLocations.ts | 31 ++++ .../src/pages/Inventory.editor-mode.test.tsx | 18 ++ frontend/src/pages/Inventory.tsx | 162 +++++++++--------- 4 files changed, 145 insertions(+), 103 deletions(-) create mode 100644 frontend/src/hooks/useMemoizedLocations.ts diff --git a/frontend/src/components/inventory/InventoryInlineRow.tsx b/frontend/src/components/inventory/InventoryInlineRow.tsx index f338786..adc2af1 100644 --- a/frontend/src/components/inventory/InventoryInlineRow.tsx +++ b/frontend/src/components/inventory/InventoryInlineRow.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import type { MouseEvent } from 'react'; import { Autocomplete, @@ -14,6 +13,7 @@ import CheckIcon from '@mui/icons-material/Check'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import type { InventoryItem, OrgInventoryItem } from '../../services/inventory.service'; import type { FocusController } from '../../utils/focusController'; +import { useMemoizedLocations } from '../../hooks/useMemoizedLocations'; export type InventoryRecord = InventoryItem | OrgInventoryItem; @@ -73,31 +73,18 @@ export const InventoryInlineRow = ({ typeof inlineDraft.locationId === 'string' ? Number(inlineDraft.locationId) : inlineDraft.locationId; - const selectedLocation = - allLocations.find((loc) => loc.id === draftLocationId) || - (typeof draftLocationId === 'number' - ? { - id: draftLocationId, - name: item.locationName || `Location #${draftLocationId}`, - } - : null); - const filteredOptions = useMemo(() => { - const filterTerm = inlineLocationInput.trim().toLowerCase(); - return allLocations - .filter((opt) => opt.name.toLowerCase().includes(filterTerm)) - .sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - const aStarts = aName.startsWith(filterTerm); - const bStarts = bName.startsWith(filterTerm); - if (aStarts !== bStarts) return aStarts ? -1 : 1; - const aIndex = aName.indexOf(filterTerm); - const bIndex = bName.indexOf(filterTerm); - if (aIndex !== bIndex) return aIndex - bIndex; - return a.name.localeCompare(b.name); - }); - }, [allLocations, inlineLocationInput]); + const { filtered: filteredOptions, getSelected } = useMemoizedLocations( + allLocations, + inlineLocationInput, + ); + const selectedLocation = + typeof draftLocationId === 'number' + ? getSelected(draftLocationId) || + (item.locationName + ? { id: draftLocationId, name: item.locationName } + : null) + : null; const draftQuantityNumber = Number(inlineDraft.quantity); diff --git a/frontend/src/hooks/useMemoizedLocations.ts b/frontend/src/hooks/useMemoizedLocations.ts new file mode 100644 index 0000000..1239456 --- /dev/null +++ b/frontend/src/hooks/useMemoizedLocations.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +interface LocationOption { + id: number; + name: string; +} + +export const useMemoizedLocations = (allLocations: LocationOption[], input: string) => { + const filtered = useMemo(() => { + const term = input.trim().toLowerCase(); + return allLocations + .filter((opt) => opt.name.toLowerCase().includes(term)) + .sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + const aStarts = aName.startsWith(term); + const bStarts = bName.startsWith(term); + if (aStarts !== bStarts) return aStarts ? -1 : 1; + const aIndex = aName.indexOf(term); + const bIndex = bName.indexOf(term); + if (aIndex !== bIndex) return aIndex - bIndex; + return a.name.localeCompare(b.name); + }); + }, [allLocations, input]); + + const getSelected = (id: number | '') => + typeof id === 'number' ? allLocations.find((loc) => loc.id === id) ?? null : null; + + return { filtered, getSelected }; +}; + diff --git a/frontend/src/pages/Inventory.editor-mode.test.tsx b/frontend/src/pages/Inventory.editor-mode.test.tsx index eedefad..9f83eed 100644 --- a/frontend/src/pages/Inventory.editor-mode.test.tsx +++ b/frontend/src/pages/Inventory.editor-mode.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import InventoryPage from './Inventory'; import { locationCache } from '../services/locationCache'; import type { LocationRecord } from '../services/location.service'; +import { useMemoizedLocations } from '../hooks/useMemoizedLocations'; const mockUpdateItem = jest.fn(); const mockGetInventory = jest.fn(); @@ -40,6 +41,13 @@ jest.mock('../services/uex.service', () => ({ getStarSystems: jest.fn(), }, })); +jest.mock('../hooks/useMemoizedLocations', () => { + const original = jest.requireActual('../hooks/useMemoizedLocations'); + return { + ...original, + useMemoizedLocations: jest.fn((...args: unknown[]) => original.useMemoizedLocations(...args)), + }; +}); const mockItem = { id: 'item-1', userId: 1, @@ -446,4 +454,14 @@ describe('Inventory editor mode inline controls', () => { const saveButton = await screen.findByTestId('inline-save-item-1'); await waitFor(() => expect(document.activeElement).toBe(saveButton)); }); + + it('memoizes location filtering for inline rows', () => { + const { useMemoizedLocations: mockedHook } = jest.requireMock('../hooks/useMemoizedLocations'); + render( + + + , + ); + expect(mockedHook).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 3025390..e21b99c 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -1439,6 +1439,89 @@ const InventoryPage = () => { } const showEmptyState = filteredItems.length === 0 && !refreshing; + const renderInlineRow = (item: InventoryRecord) => { + const rowKey = item.id.toString(); + const draft = inlineDrafts[item.id] ?? { + locationId: Number(item.locationId) || '', + quantity: Number(item.quantity) || 0, + }; + const originalLocationId = Number(item.locationId) || ''; + const draftLocationId = + typeof draft.locationId === 'string' ? Number(draft.locationId) : draft.locationId; + const originalQuantity = Number(item.quantity) || 0; + const draftQuantityNumber = Number(draft.quantity); + const isDirty = + draftLocationId !== originalLocationId || draftQuantityNumber !== originalQuantity; + const inlineLocationValue = + inlineLocationInputs[rowKey] ?? + (locationEditing[rowKey] + ? '' + : allLocations.find((loc) => loc.id === (typeof draft.locationId === 'number' ? draft.locationId : Number(draft.locationId)))?.name ?? + item.locationName ?? + ''); + const saving = inlineSaving.has(item.id); + const errorText = inlineError[item.id]; + + return ( + setInlineDraft(item.id, changes)} + onErrorChange={(message) => + setInlineError((prev) => ({ + ...prev, + [item.id]: message, + })) + } + onLocationInputChange={(value) => + setInlineLocationInputs((prev) => ({ + ...prev, + [rowKey]: value, + })) + } + onLocationFocus={() => { + setInlineLocationInputs((prev) => ({ + ...prev, + [rowKey]: '', + })); + setLocationEditing((prev) => ({ ...prev, [rowKey]: true })); + setInlineError((prev) => ({ ...prev, [item.id]: null })); + }} + onLocationBlur={(selectedName) => { + setInlineLocationInputs((prev) => ({ + ...prev, + [rowKey]: + selectedName ?? + allLocations.find((loc) => loc.id === draftLocationId)?.name ?? + '', + })); + setLocationEditing((prev) => ({ ...prev, [rowKey]: false })); + setInlineError((prev) => ({ ...prev, [item.id]: null })); + }} + onSave={() => handleInlineSaveAndAdvance(item)} + onOpenActions={(e) => handleActionOpen(e, item)} + setLocationRef={(ref, key) => { + locationRefs.current[key] = ref; + }} + setQuantityRef={(ref, key) => { + quantityRefs.current[key] = ref; + }} + setSaveRef={(ref, key) => { + saveRefs.current[key] = ref; + }} + /> + ); + }; return ( @@ -1745,84 +1828,7 @@ const InventoryPage = () => { }> - {groupItems.map((item) => { - const rowKey = item.id.toString(); - const draft = inlineDrafts[item.id] ?? { - locationId: Number(item.locationId) || '', - quantity: Number(item.quantity) || 0, - }; - const originalLocationId = Number(item.locationId) || ''; - const originalQuantity = Number(item.quantity) || 0; - const draftLocationId = - typeof draft.locationId === 'string' - ? Number(draft.locationId) - : draft.locationId; - const saving = inlineSaving.has(item.id); - const errorText = inlineError[item.id]; - const draftQuantityNumber = Number(draft.quantity); - const isDirty = - draftLocationId !== originalLocationId || - draftQuantityNumber !== originalQuantity; - return ( - setInlineDraft(item.id, changes)} - onErrorChange={(message) => - setInlineError((prev) => ({ ...prev, [item.id]: message })) - } - onLocationInputChange={(value) => - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: value, - })) - } - onLocationFocus={() => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: true })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - onLocationBlur={(selectedName) => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: - selectedName ?? - allLocations.find((loc) => loc.id === draftLocationId)?.name ?? - '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: false })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - onSave={() => handleInlineSaveAndAdvance(item)} - onOpenActions={(e) => handleActionOpen(e, item)} - setLocationRef={(ref, key) => { - locationRefs.current[key] = ref; - }} - setQuantityRef={(ref, key) => { - quantityRefs.current[key] = ref; - }} - setSaveRef={(ref, key) => { - saveRefs.current[key] = ref; - }} - /> - ); - })} + {groupItems.map((item) => renderInlineRow(item))} ))} From 06f3008b1943d710cb4075875a0e631b71f4372d Mon Sep 17 00:00:00 2001 From: Demian Date: Fri, 19 Dec 2025 03:20:02 -0500 Subject: [PATCH 2/2] chore: fix typecheck unused import in editor-mode test --- frontend/src/pages/Inventory.editor-mode.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/Inventory.editor-mode.test.tsx b/frontend/src/pages/Inventory.editor-mode.test.tsx index 9f83eed..20c16eb 100644 --- a/frontend/src/pages/Inventory.editor-mode.test.tsx +++ b/frontend/src/pages/Inventory.editor-mode.test.tsx @@ -3,7 +3,6 @@ import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import InventoryPage from './Inventory'; import { locationCache } from '../services/locationCache'; import type { LocationRecord } from '../services/location.service'; -import { useMemoizedLocations } from '../hooks/useMemoizedLocations'; const mockUpdateItem = jest.fn(); const mockGetInventory = jest.fn();