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:
@@ -622,7 +622,12 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({
|
||||
sx={{ borderRadius: 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>
|
||||
|
||||
@@ -98,13 +98,28 @@ export function useManualExtraction() {
|
||||
}, [submitMutation]);
|
||||
|
||||
const jobData = pollQuery.data;
|
||||
const hasPollError = !!pollQuery.error;
|
||||
const status: JobStatus | 'idle' = !jobId
|
||||
? 'idle'
|
||||
: hasPollError
|
||||
? 'failed'
|
||||
: jobData?.status ?? 'pending';
|
||||
const progress = jobData?.progress ?? 0;
|
||||
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 {
|
||||
submit,
|
||||
|
||||
@@ -7,6 +7,23 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MaintenanceScheduleReviewScreen } from './MaintenanceScheduleReviewScreen';
|
||||
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
|
||||
const mockMutateAsync = jest.fn();
|
||||
jest.mock('../hooks/useCreateSchedulesFromExtraction', () => ({
|
||||
@@ -222,4 +239,70 @@ describe('MaintenanceScheduleReviewScreen', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,10 +117,10 @@ const InlineField: React.FC<InlineFieldProps> = ({ label, value, type = 'text',
|
||||
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 }} />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={handleCancel}>
|
||||
<IconButton size="small" onClick={handleCancel} sx={{ minWidth: 44, minHeight: 44 }}>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
@@ -134,6 +134,7 @@ const InlineField: React.FC<InlineFieldProps> = ({ label, value, type = 'text',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
cursor: 'pointer',
|
||||
minHeight: 44,
|
||||
'&:hover .edit-icon': { opacity: 1 },
|
||||
}}
|
||||
onClick={() => setIsEditing(true)}
|
||||
@@ -293,7 +294,7 @@ export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReview
|
||||
<Checkbox
|
||||
checked={item.selected}
|
||||
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}` }}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -379,7 +380,7 @@ export const MaintenanceScheduleReviewScreen: React.FC<MaintenanceScheduleReview
|
||||
onClick={handleCreate}
|
||||
disabled={selectedCount === 0 || createMutation.isPending}
|
||||
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
|
||||
? 'Creating...'
|
||||
|
||||
Reference in New Issue
Block a user