feat: add 410 error handling, progress messages, touch targets, and tests (refs #145)

- Handle poll errors including 410 Gone in useManualExtraction hook
- Add specific progress stage messages (Preparing/Processing/Mapping/Complete)
- Enforce 44px minimum touch targets on all interactive elements
- Add tests for inline editing, mobile fullscreen, and desktop modal layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-11 15:12:29 -06:00
parent ca33f8ad9d
commit 11f52258db
5 changed files with 143 additions and 865 deletions

File diff suppressed because one or more lines are too long

View File

@@ -622,7 +622,12 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
sx={{ borderRadius: 1 }} sx={{ borderRadius: 1 }}
/> />
<div className="text-xs text-slate-500 dark:text-titanio mt-1"> <div className="text-xs text-slate-500 dark:text-titanio mt-1">
{extraction.progress > 0 ? `${extraction.progress}% complete` : 'Starting extraction...'} {extraction.progress >= 100 ? '100% - Complete' :
extraction.progress >= 95 ? `${extraction.progress}% - Mapping maintenance schedules...` :
extraction.progress >= 50 ? `${extraction.progress}% - Processing maintenance data...` :
extraction.progress >= 10 ? `${extraction.progress}% - Preparing document...` :
extraction.progress > 0 ? `${extraction.progress}% complete` :
'Starting extraction...'}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -98,13 +98,28 @@ export function useManualExtraction() {
}, [submitMutation]); }, [submitMutation]);
const jobData = pollQuery.data; const jobData = pollQuery.data;
const hasPollError = !!pollQuery.error;
const status: JobStatus | 'idle' = !jobId const status: JobStatus | 'idle' = !jobId
? 'idle' ? 'idle'
: hasPollError
? 'failed'
: jobData?.status ?? 'pending'; : jobData?.status ?? 'pending';
const progress = jobData?.progress ?? 0; const progress = jobData?.progress ?? 0;
const result = jobData?.result ?? null; const result = jobData?.result ?? null;
const error = jobData?.error
?? (submitMutation.error ? String((submitMutation.error as Error).message || submitMutation.error) : null); let error: string | null = null;
if (jobData?.error) {
error = jobData.error;
} else if (pollQuery.error) {
const err = pollQuery.error as any;
if (err.response?.status === 410) {
error = 'Job expired. Please resubmit the document.';
} else {
error = String((err as Error).message || err);
}
} else if (submitMutation.error) {
error = String((submitMutation.error as Error).message || submitMutation.error);
}
return { return {
submit, submit,

View File

@@ -7,6 +7,23 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { MaintenanceScheduleReviewScreen } from './MaintenanceScheduleReviewScreen'; import { MaintenanceScheduleReviewScreen } from './MaintenanceScheduleReviewScreen';
import type { MaintenanceScheduleItem } from '../../documents/hooks/useManualExtraction'; import type { MaintenanceScheduleItem } from '../../documents/hooks/useManualExtraction';
// Mock matchMedia for responsive tests
function mockMatchMedia(matches: boolean) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
// Mock the create hook // Mock the create hook
const mockMutateAsync = jest.fn(); const mockMutateAsync = jest.fn();
jest.mock('../hooks/useCreateSchedulesFromExtraction', () => ({ jest.mock('../hooks/useCreateSchedulesFromExtraction', () => ({
@@ -222,4 +239,70 @@ describe('MaintenanceScheduleReviewScreen', () => {
await screen.findByText('Network error'); await screen.findByText('Network error');
}); });
}); });
describe('Editing', () => {
it('should update item data via inline editing', async () => {
mockMutateAsync.mockResolvedValue([{ id: '1' }]);
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
// Click on the Months field of the third item (unique value: 12 mo)
const monthsField = screen.getByText('12 mo');
fireEvent.click(monthsField);
// Find the input that appeared and change its value
const monthsInput = screen.getByDisplayValue('12');
fireEvent.change(monthsInput, { target: { value: '24' } });
fireEvent.keyDown(monthsInput, { key: 'Enter' });
// Deselect items 1 and 2 to only create the edited item
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[0]);
fireEvent.click(checkboxes[1]);
// Create the schedule and verify updated value is used
fireEvent.click(screen.getByRole('button', { name: /create 1 schedule$/i }));
expect(mockMutateAsync).toHaveBeenCalledWith({
vehicleId: 'vehicle-123',
items: [expect.objectContaining({
service: 'Cabin Air Filter Replacement',
intervalMonths: 24,
})],
});
});
});
describe('Responsive layout', () => {
afterEach(() => {
// Reset matchMedia after each test
mockMatchMedia(false);
});
it('should render in fullscreen mode on mobile viewports', () => {
// Simulate mobile: breakpoints.down('sm') returns true
mockMatchMedia(true);
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
// On mobile, dialog renders with fullScreen prop - check that the MuiDialog-paperFullScreen
// class is applied. MUI renders the dialog in a portal, so query from document.
const paper = document.querySelector('.MuiDialog-paperFullScreen');
expect(paper).not.toBeNull();
});
it('should render as modal dialog on desktop viewports', () => {
// Simulate desktop: breakpoints.down('sm') returns false
mockMatchMedia(false);
render(<MaintenanceScheduleReviewScreen {...defaultProps} />);
// On desktop, dialog should NOT have fullScreen class
const fullScreenPaper = document.querySelector('.MuiDialog-paperFullScreen');
expect(fullScreenPaper).toBeNull();
// But the dialog should still render
expect(screen.getByText('Extracted Maintenance Schedules')).toBeInTheDocument();
});
});
}); });

View File

@@ -117,10 +117,10 @@ const InlineField: React.FC<InlineFieldProps> = ({ label, value, type = 'text',
if (e.key === 'Escape') handleCancel(); if (e.key === 'Escape') handleCancel();
}} }}
/> />
<IconButton size="small" onClick={handleSave} color="primary"> <IconButton size="small" onClick={handleSave} color="primary" sx={{ minWidth: 44, minHeight: 44 }}>
<CheckIcon sx={{ fontSize: 16 }} /> <CheckIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
<IconButton size="small" onClick={handleCancel}> <IconButton size="small" onClick={handleCancel} sx={{ minWidth: 44, minHeight: 44 }}>
<CloseIcon sx={{ fontSize: 16 }} /> <CloseIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</Box> </Box>
@@ -134,6 +134,7 @@ const InlineField: React.FC<InlineFieldProps> = ({ label, value, type = 'text',
alignItems: 'center', alignItems: 'center',
gap: 0.5, gap: 0.5,
cursor: 'pointer', cursor: 'pointer',
minHeight: 44,
'&:hover .edit-icon': { opacity: 1 }, '&:hover .edit-icon': { opacity: 1 },
}} }}
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
@@ -293,7 +294,7 @@ export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReview
<Checkbox <Checkbox
checked={item.selected} checked={item.selected}
onChange={() => handleToggle(index)} onChange={() => handleToggle(index)}
sx={{ mt: -0.5, mr: 1 }} sx={{ mt: -0.5, mr: 1, '& .MuiSvgIcon-root': { fontSize: 24 }, minWidth: 44, minHeight: 44 }}
inputProps={{ 'aria-label': `Select ${item.service}` }} inputProps={{ 'aria-label': `Select ${item.service}` }}
/> />
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
@@ -379,7 +380,7 @@ export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReview
onClick={handleCreate} onClick={handleCreate}
disabled={selectedCount === 0 || createMutation.isPending} disabled={selectedCount === 0 || createMutation.isPending}
startIcon={createMutation.isPending ? <CircularProgress size={16} /> : <CheckIcon />} startIcon={createMutation.isPending ? <CircularProgress size={16} /> : <CheckIcon />}
sx={{ order: isMobile ? 1 : 2, width: isMobile ? '100%' : 'auto' }} sx={{ minHeight: 44, order: isMobile ? 1 : 2, width: isMobile ? '100%' : 'auto' }}
> >
{createMutation.isPending {createMutation.isPending
? 'Creating...' ? 'Creating...'