Skip to content

Commit 7e736db

Browse files
authored
Merge pull request #89 from GitAddRemote/feature/ISSUE-68
fix: editor mode validation and keyboard tests
2 parents 70976e4 + 67c5218 commit 7e736db

3 files changed

Lines changed: 265 additions & 14 deletions

File tree

frontend/src/components/inventory/InventoryInlineRow.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import type { InventoryItem, OrgInventoryItem } from '../../services/inventory.s
1515
import type { FocusController } from '../../utils/focusController';
1616
import { useMemoizedLocations } from '../../hooks/useMemoizedLocations';
1717

18+
const EDITOR_MODE_QUANTITY_MAX = 100000;
19+
1820
export type InventoryRecord = InventoryItem | OrgInventoryItem;
1921

2022
interface LocationOption {
@@ -234,7 +236,11 @@ export const InventoryInlineRow = ({
234236
return;
235237
}
236238
const numeric = Number(raw);
237-
onDraftChange({ quantity: Number.isNaN(numeric) ? '' : numeric });
239+
if (Number.isNaN(numeric)) {
240+
onDraftChange({ quantity: '' });
241+
} else {
242+
onDraftChange({ quantity: Math.min(numeric, EDITOR_MODE_QUANTITY_MAX) });
243+
}
238244
if (!Number.isInteger(numeric) || numeric <= 0) {
239245
onErrorChange('Quantity must be an integer greater than 0');
240246
} else {
@@ -276,7 +282,7 @@ export const InventoryInlineRow = ({
276282
<Typography variant="body2">
277283
{new Date(item.dateModified || item.dateAdded || '').toLocaleDateString()}
278284
</Typography>
279-
{Number.isFinite(draftQuantityNumber) && draftQuantityNumber > 100000 && (
285+
{Number.isFinite(draftQuantityNumber) && draftQuantityNumber >= EDITOR_MODE_QUANTITY_MAX && (
280286
<Typography variant="caption" sx={{ color: 'warning.main' }}>
281287
Large quantity entered - verify value.
282288
</Typography>

frontend/src/pages/Inventory.editor-mode.test.tsx

Lines changed: 250 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MemoryRouter } from 'react-router-dom';
22
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import InventoryPage from './Inventory';
45
import { locationCache } from '../services/locationCache';
56
import type { LocationRecord } from '../services/location.service';
@@ -85,16 +86,36 @@ describe('Inventory editor mode inline controls', () => {
8586
offset: 0,
8687
});
8788
const mockedLocationCache = locationCache as jest.Mocked<typeof locationCache>;
88-
const mockLocation: LocationRecord = {
89-
id: '200',
90-
gameId: 1,
91-
locationType: 'city',
92-
displayName: 'Test Location',
93-
shortName: 'Test Loc',
94-
isAvailable: true,
95-
hierarchyPath: {},
96-
};
97-
mockedLocationCache.getAllLocations.mockResolvedValue([mockLocation]);
89+
const mockLocations: LocationRecord[] = [
90+
{
91+
id: '200',
92+
gameId: 1,
93+
locationType: 'city',
94+
displayName: 'Test Location',
95+
shortName: 'Test Loc',
96+
isAvailable: true,
97+
hierarchyPath: {},
98+
},
99+
{
100+
id: '201',
101+
gameId: 1,
102+
locationType: 'outpost',
103+
displayName: 'Alpha Base',
104+
shortName: 'Alpha',
105+
isAvailable: true,
106+
hierarchyPath: {},
107+
},
108+
{
109+
id: '202',
110+
gameId: 1,
111+
locationType: 'station',
112+
displayName: 'Beta Port',
113+
shortName: 'Beta',
114+
isAvailable: true,
115+
hierarchyPath: {},
116+
},
117+
];
118+
mockedLocationCache.getAllLocations.mockResolvedValue(mockLocations);
98119
// minimal profile fetch
99120
global.fetch = jest.fn().mockResolvedValue({
100121
ok: true,
@@ -454,6 +475,225 @@ describe('Inventory editor mode inline controls', () => {
454475
await waitFor(() => expect(document.activeElement).toBe(saveButton));
455476
});
456477

478+
it('debounces location filtering in the new row combobox', async () => {
479+
const user = userEvent.setup();
480+
render(
481+
<MemoryRouter initialEntries={['/inventory']}>
482+
<InventoryPage />
483+
</MemoryRouter>,
484+
);
485+
486+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
487+
const viewModeSelect = screen.getByLabelText('View mode');
488+
fireEvent.mouseDown(viewModeSelect);
489+
const editorOption = await screen.findByText('Editor Mode');
490+
fireEvent.click(editorOption);
491+
492+
const locationInput = await screen.findByTestId('new-row-location-input');
493+
await user.click(locationInput);
494+
expect(await screen.findByText('Test Location')).toBeInTheDocument();
495+
496+
await user.type(locationInput, 'Al');
497+
498+
await waitFor(() => expect(screen.queryByText('Beta Port')).not.toBeInTheDocument());
499+
});
500+
501+
it('selects a location via keyboard navigation in the new row combobox', async () => {
502+
const user = userEvent.setup();
503+
render(
504+
<MemoryRouter initialEntries={['/inventory']}>
505+
<InventoryPage />
506+
</MemoryRouter>,
507+
);
508+
509+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
510+
const viewModeSelect = screen.getByLabelText('View mode');
511+
fireEvent.mouseDown(viewModeSelect);
512+
const editorOption = await screen.findByText('Editor Mode');
513+
fireEvent.click(editorOption);
514+
515+
const locationInput = await screen.findByTestId('new-row-location-input');
516+
await user.click(locationInput);
517+
await user.keyboard('{ArrowDown}{Enter}');
518+
519+
expect(locationInput).toHaveValue('Alpha Base');
520+
});
521+
522+
it('blocks save and shows an error when location has no matches', async () => {
523+
const user = userEvent.setup();
524+
render(
525+
<MemoryRouter initialEntries={['/inventory']}>
526+
<InventoryPage />
527+
</MemoryRouter>,
528+
);
529+
530+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
531+
const viewModeSelect = screen.getByLabelText('View mode');
532+
fireEvent.mouseDown(viewModeSelect);
533+
const editorOption = await screen.findByText('Editor Mode');
534+
fireEvent.click(editorOption);
535+
536+
const itemInput = await screen.findByTestId('new-row-item-input');
537+
await user.type(itemInput, 'New');
538+
await user.click(await screen.findByText('New Catalog Item'));
539+
540+
const locationInput = await screen.findByTestId('new-row-location-input');
541+
await user.click(locationInput);
542+
await user.type(locationInput, 'Nowhere');
543+
544+
await waitFor(() => expect(screen.queryByText('Beta Port')).not.toBeInTheDocument());
545+
await user.keyboard('{Enter}');
546+
547+
await waitFor(() => expect(screen.getByText('No matches found')).toBeInTheDocument());
548+
549+
const quantityInput = await screen.findByTestId('new-row-quantity');
550+
await user.type(quantityInput, '4');
551+
await user.click(screen.getByTestId('new-row-save'));
552+
553+
await waitFor(() => expect(screen.getByText('Select a valid location')).toBeInTheDocument());
554+
expect(mockCreateItem).not.toHaveBeenCalled();
555+
});
556+
557+
it('warns on large quantities and caps the value in editor mode', async () => {
558+
render(
559+
<MemoryRouter initialEntries={['/inventory']}>
560+
<InventoryPage />
561+
</MemoryRouter>,
562+
);
563+
564+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
565+
const viewModeSelect = screen.getByLabelText('View mode');
566+
fireEvent.mouseDown(viewModeSelect);
567+
const editorOption = await screen.findByText('Editor Mode');
568+
fireEvent.click(editorOption);
569+
570+
const quantityInput = await screen.findByTestId('new-row-quantity');
571+
fireEvent.change(quantityInput, { target: { value: '100000' } });
572+
expect(screen.getByText('Large quantity entered - verify value.')).toBeInTheDocument();
573+
574+
fireEvent.change(quantityInput, { target: { value: '1000000' } });
575+
expect(quantityInput).toHaveValue('100000');
576+
});
577+
578+
it('advances focus across inline row fields and into the next row', async () => {
579+
const user = userEvent.setup();
580+
const secondItem = {
581+
...mockItem,
582+
id: 'item-2',
583+
itemName: 'Second Item',
584+
locationId: 201,
585+
locationName: 'Alpha Base',
586+
};
587+
mockGetInventory.mockResolvedValue({
588+
items: [mockItem, secondItem],
589+
total: 2,
590+
limit: 25,
591+
offset: 0,
592+
});
593+
594+
render(
595+
<MemoryRouter initialEntries={['/inventory']}>
596+
<InventoryPage />
597+
</MemoryRouter>,
598+
);
599+
600+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
601+
const viewModeSelect = screen.getByLabelText('View mode');
602+
fireEvent.mouseDown(viewModeSelect);
603+
const editorOption = await screen.findByText('Editor Mode');
604+
fireEvent.click(editorOption);
605+
606+
const locationInput = await screen.findByTestId('inline-location-item-1');
607+
await user.click(locationInput);
608+
await user.keyboard('{Enter}');
609+
610+
const quantityInput = await screen.findByTestId('inline-quantity-item-1');
611+
await waitFor(() => expect(document.activeElement).toBe(quantityInput));
612+
613+
await user.keyboard('{Enter}');
614+
const saveButton = await screen.findByTestId('inline-save-item-1');
615+
await waitFor(() => expect(document.activeElement).toBe(saveButton));
616+
617+
await user.keyboard('{Enter}');
618+
const nextLocationInput = await screen.findByTestId('inline-location-item-2');
619+
await waitFor(() => expect(document.activeElement).toBe(nextLocationInput));
620+
});
621+
622+
it('moves focus to the next page after saving the last row', async () => {
623+
const user = userEvent.setup();
624+
const pageTwoItem = {
625+
...mockItem,
626+
id: 'item-2',
627+
itemName: 'Page Two Item',
628+
locationId: 201,
629+
locationName: 'Alpha Base',
630+
};
631+
mockGetInventory.mockImplementation((params?: { offset?: number; limit?: number }) => {
632+
if (params?.offset === 25) {
633+
return Promise.resolve({
634+
items: [pageTwoItem],
635+
total: 26,
636+
limit: 25,
637+
offset: 25,
638+
});
639+
}
640+
return Promise.resolve({
641+
items: [mockItem],
642+
total: 26,
643+
limit: 25,
644+
offset: 0,
645+
});
646+
});
647+
648+
render(
649+
<MemoryRouter initialEntries={['/inventory']}>
650+
<InventoryPage />
651+
</MemoryRouter>,
652+
);
653+
654+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
655+
const viewModeSelect = screen.getByLabelText('View mode');
656+
fireEvent.mouseDown(viewModeSelect);
657+
const editorOption = await screen.findByText('Editor Mode');
658+
fireEvent.click(editorOption);
659+
660+
const saveButton = await screen.findByTestId('inline-save-item-1');
661+
await user.click(saveButton);
662+
663+
const nextLocationInput = await screen.findByTestId('inline-location-item-2');
664+
await waitFor(() => expect(document.activeElement).toBe(nextLocationInput));
665+
});
666+
667+
it('keeps editor mode inputs labeled with combobox and tab order intact', async () => {
668+
const user = userEvent.setup();
669+
render(
670+
<MemoryRouter initialEntries={['/inventory']}>
671+
<InventoryPage />
672+
</MemoryRouter>,
673+
);
674+
675+
await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument());
676+
const viewModeSelect = screen.getByLabelText('View mode');
677+
fireEvent.mouseDown(viewModeSelect);
678+
const editorOption = await screen.findByText('Editor Mode');
679+
fireEvent.click(editorOption);
680+
681+
const comboboxes = screen.getAllByRole('combobox', { name: /location/i });
682+
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
683+
684+
const itemInput = await screen.findByTestId('new-row-item-input');
685+
itemInput.focus();
686+
687+
await user.tab();
688+
expect(document.activeElement).toBe(await screen.findByTestId('new-row-location-input'));
689+
690+
await user.tab();
691+
expect(document.activeElement).toBe(await screen.findByTestId('new-row-quantity'));
692+
693+
await user.tab();
694+
expect(document.activeElement).toBe(await screen.findByTestId('new-row-save'));
695+
});
696+
457697
it('memoizes location filtering for inline rows', () => {
458698
const { useMemoizedLocations: mockedHook } = jest.requireMock('../hooks/useMemoizedLocations');
459699
render(

frontend/src/pages/Inventory.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type InventoryRecord = InventoryItem | OrgInventoryItem;
5959
type ActionMode = 'edit' | 'split' | 'share' | 'delete' | null;
6060

6161
const GAME_ID = 1;
62+
const EDITOR_MODE_QUANTITY_MAX = 100000;
6263

6364
const valueText = (value: number) => `${value.toLocaleString()} qty`;
6465

@@ -1715,7 +1716,8 @@ const InventoryPage = () => {
17151716
saving={newRowSaving}
17161717
orgBlocked={newRowOrgBlocked}
17171718
showQuantityWarning={
1718-
Number.isFinite(newRowQuantityNumber) && newRowQuantityNumber > 100000
1719+
Number.isFinite(newRowQuantityNumber) &&
1720+
newRowQuantityNumber >= EDITOR_MODE_QUANTITY_MAX
17191721
}
17201722
onItemInputChange={(value, reason) => {
17211723
setNewRowItemInput(value);
@@ -1789,9 +1791,12 @@ const InventoryPage = () => {
17891791
return;
17901792
}
17911793
const numeric = Number(raw);
1794+
const nextQuantity = Number.isNaN(numeric)
1795+
? ''
1796+
: Math.min(numeric, EDITOR_MODE_QUANTITY_MAX);
17921797
setNewRowDraft((prev) => ({
17931798
...prev,
1794-
quantity: Number.isNaN(numeric) ? '' : numeric,
1799+
quantity: nextQuantity,
17951800
}));
17961801
if (!Number.isInteger(numeric) || numeric <= 0) {
17971802
setNewRowErrors((prev) => ({

0 commit comments

Comments
 (0)