Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/src/modules/org-inventory/org-inventory.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,28 @@ describe('OrgInventoryController', () => {
new BadRequestException('location_id must be an integer'),
);
});

it('throws a bad request for negative quantity filters', async () => {
await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
minQuantity: '-0.25',
}),
).rejects.toThrow(
new BadRequestException(
'min_quantity must be greater than or equal to 0',
),
);

await expect(
controller.list({ user: { userId: 7 } }, 42, {
gameId: '1',
maxQuantity: '-1',
}),
).rejects.toThrow(
new BadRequestException(
'max_quantity must be greater than or equal to 0',
),
);
});
});
6 changes: 6 additions & 0 deletions backend/src/modules/org-inventory/org-inventory.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,17 @@ export class OrgInventoryController {
query,
['min_quantity', 'minQuantity'],
'min_quantity',
{
min: 0,
},
),
maxQuantity: this.readOptionalNumber(
query,
['max_quantity', 'maxQuantity'],
'max_quantity',
{
min: 0,
},
),
};

Expand Down
63 changes: 51 additions & 12 deletions frontend/src/pages/Inventory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { OrgPermission, permissionsService } from '../services/permissions.servi

type InventoryRecord = InventoryItem | OrgInventoryItem;
type ActionMode = 'edit' | 'split' | 'share' | 'delete' | null;
type InlineDraft = { locationId: number | ''; quantity: number | '' };

const GAME_ID = 1;
const EDITOR_MODE_QUANTITY_MAX = 100000;
Expand Down Expand Up @@ -155,9 +156,9 @@ const InventoryPage = () => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(25);
const [density, setDensity] = useState<'standard' | 'compact'>(() => readStoredDensity());
const [inlineDrafts, setInlineDrafts] = useState<
Record<string, { locationId: number | ''; quantity: number | '' }>
>({});
const [inlineDrafts, setInlineDrafts] = useState<Record<string, InlineDraft>>(
{},
);
const [inlineSaving, setInlineSaving] = useState<Set<string>>(new Set());
const [inlineSaved, setInlineSaved] = useState<Set<string>>(new Set());
const [inlineError, setInlineError] = useState<Record<string, string | null>>({});
Expand Down Expand Up @@ -226,6 +227,18 @@ const InventoryPage = () => {
}
}
}, [orgOptions, selectedOrgId, viewMode]);

const inlineDraftFallbacks = useRef<Map<string, InlineDraft>>(new Map());

useEffect(() => {
const itemIds = new Set(items.map((item) => item.id.toString()));
inlineDraftFallbacks.current.forEach((_, key) => {
if (!itemIds.has(key)) {
inlineDraftFallbacks.current.delete(key);
}
});
}, [items]);

const debouncedNewItemSearch = useDebounce(newRowItemInput, 300);
const debouncedNewLocationSearch = useDebounce(newRowLocationInput, 200);
const getRowOrder = useCallback(
Expand Down Expand Up @@ -1236,11 +1249,40 @@ const InventoryPage = () => {
};
}, [debouncedNewItemSearch, isEditorMode]);

const getInlineDraft = useCallback(
(item: InventoryRecord): InlineDraft => {
const existingDraft = inlineDrafts[item.id];
if (existingDraft) {
return existingDraft;
}

const rowKey = item.id.toString();
const nextFallback: InlineDraft = {
locationId: Number(item.locationId) || '',
quantity: Number(item.quantity) || 0,
};
const cachedFallback = inlineDraftFallbacks.current.get(rowKey);

if (
cachedFallback &&
cachedFallback.locationId === nextFallback.locationId &&
cachedFallback.quantity === nextFallback.quantity
) {
return cachedFallback;
}

// Writing to a ref during render is intentional here. nextFallback is
// derived deterministically from item data, so concurrent/aborted renders
// always write the same value for a given key — no stale state is possible.
// Stale entries for removed items are pruned by the [items] effect above.
inlineDraftFallbacks.current.set(rowKey, nextFallback);
return nextFallback;
},
[inlineDrafts],
);

const handleInlineSave = useCallback(async (item: InventoryRecord) => {
const draft = inlineDrafts[item.id] ?? {
locationId: item.locationId ?? '',
quantity: Number(item.quantity) || 0,
};
const draft = getInlineDraft(item);
const parsedLocationId =
draft.locationId === '' ? NaN : Number(draft.locationId);
const parsedQuantity = Number(draft.quantity);
Expand Down Expand Up @@ -1327,7 +1369,7 @@ const InventoryPage = () => {
}
}, [
focusController,
inlineDrafts,
getInlineDraft,
inlineSaving,
items,
locationNameById,
Expand Down Expand Up @@ -1785,10 +1827,7 @@ 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 draft = getInlineDraft(item);
const originalLocationId = Number(item.locationId) || '';
const draftLocationId = normalizeDraftLocationId(draft.locationId);
const originalQuantity = Number(item.quantity) || 0;
Expand Down
Loading