mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
app: handle form fetch failures without unhandled rejection
Catch useForm fetcher errors and surface them so DomainEditPage can show a not-found message instead of hanging on the loader (and so the webpack overlay no longer flakes Cypress).
This commit is contained in:
parent
f5615440e5
commit
0850b6a7fe
5 changed files with 104 additions and 10 deletions
|
|
@ -630,6 +630,15 @@ export function DomainEditPage() {
|
|||
};
|
||||
|
||||
if (!form.ready) {
|
||||
if (form.error) {
|
||||
return <>
|
||||
<BackButton/>
|
||||
<Message error>
|
||||
<Message.Header>Domain not found</Message.Header>
|
||||
<p>{form.error}</p>
|
||||
</Message>
|
||||
</>;
|
||||
}
|
||||
return <Loader active>Loading domain...</Loader>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -154,10 +154,13 @@ describe('DomainEditPage', () => {
|
|||
});
|
||||
|
||||
describe('Error States', () => {
|
||||
it('shows loader when form is not ready (fetch fails)', () => {
|
||||
// When form.ready is false (e.g., fetch failed), show loader
|
||||
it('shows error message when domain fetch fails', () => {
|
||||
const form = createTestForm({}, {
|
||||
overrides: {ready: false, loading: false, error: new Error('Domain not found')}
|
||||
overrides: {
|
||||
ready: false,
|
||||
loading: false,
|
||||
error: 'Domain collection with ID 999 not found',
|
||||
}
|
||||
});
|
||||
|
||||
mockUseDomain.mockReturnValue({
|
||||
|
|
@ -168,10 +171,26 @@ describe('DomainEditPage', () => {
|
|||
|
||||
render(<DomainEditPage/>);
|
||||
|
||||
// Should show loading screen when not ready
|
||||
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/domain not found/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Domain collection with ID 999 not found/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/loading domain/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Form should not be rendered
|
||||
it('shows loader when form is not ready and there is no error', () => {
|
||||
const form = createTestForm({}, {
|
||||
overrides: {ready: false, loading: false, error: null}
|
||||
});
|
||||
|
||||
mockUseDomain.mockReturnValue({
|
||||
domain: null,
|
||||
form,
|
||||
fetchDomain: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DomainEditPage/>);
|
||||
|
||||
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -182,7 +201,7 @@ describe('DomainEditPage', () => {
|
|||
});
|
||||
|
||||
const form = createTestForm(mockDomain, {
|
||||
overrides: {ready: true, loading: false, error: new Error('Update failed')}
|
||||
overrides: {ready: true, loading: false, error: 'Update failed'}
|
||||
});
|
||||
|
||||
mockUseDomain.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export function useForm({
|
|||
const [loading, setLoading] = React.useState(false);
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [dirty, setDirty] = React.useState(false);
|
||||
// Form-level error (e.g. initial fetch failed). Distinct from field-level `errors`.
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
const [errors, setErrors] = React.useState({});
|
||||
const [validators, setValidators] = React.useState({});
|
||||
|
|
@ -47,6 +49,12 @@ export function useForm({
|
|||
// Form is dirty if it has deviated from the default.
|
||||
setDirty(!_.isEqual(defaultFormData, memoizedFormData));
|
||||
|
||||
// Fetch/load failures keep the form not ready so pages can show error UI.
|
||||
if (error) {
|
||||
console.debug('Form invalid because of form-level error');
|
||||
setReady(false);
|
||||
return
|
||||
}
|
||||
const errorValues = Object.values(errors).filter(i => !!i);
|
||||
if (errorValues.length > 0) {
|
||||
console.debug('Form invalid because it has errors');
|
||||
|
|
@ -66,12 +74,21 @@ export function useForm({
|
|||
return
|
||||
}
|
||||
setReady(true);
|
||||
}, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires]);
|
||||
}, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires, error]);
|
||||
|
||||
const localFetch = async () => {
|
||||
if (fetcher) {
|
||||
const result = await fetcher();
|
||||
setFormData(result);
|
||||
try {
|
||||
const result = await fetcher();
|
||||
setFormData(result);
|
||||
setError(null);
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Catch so callers (useEffect, page refresh) do not get an unhandled rejection
|
||||
// (which would show the webpack-dev-server error overlay and break Cypress).
|
||||
console.error('Failed to fetch form data:', e);
|
||||
setError(e?.message || String(e) || 'Failed to load');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +295,7 @@ export function useForm({
|
|||
return {
|
||||
dirty,
|
||||
disabled,
|
||||
error,
|
||||
fetcher: localFetch,
|
||||
formData,
|
||||
getCustomProps,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ describe('useForm', () => {
|
|||
expect(result.current.dirty).toBe(false);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.disabled).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
// Ready starts true when there are no validators or required fields
|
||||
expect(result.current.ready).toBe(true);
|
||||
});
|
||||
|
|
@ -91,6 +92,52 @@ describe('useForm', () => {
|
|||
await waitFor(() => {
|
||||
expect(result.current.formData).toEqual(fetchedData);
|
||||
});
|
||||
expect(result.current.error).toBe(null);
|
||||
});
|
||||
|
||||
it('sets form error and stays not ready when fetcher rejects', async () => {
|
||||
const fetcher = jest.fn().mockRejectedValue(new Error('Domain not found'));
|
||||
|
||||
const {result} = renderHook(() => useForm({
|
||||
fetcher,
|
||||
defaultFormData: {},
|
||||
submitter: jest.fn(),
|
||||
}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Domain not found');
|
||||
});
|
||||
expect(result.current.ready).toBe(false);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to fetch form data:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears form error when a subsequent fetch succeeds', async () => {
|
||||
const fetcher = jest.fn()
|
||||
.mockRejectedValueOnce(new Error('Domain not found'))
|
||||
.mockResolvedValueOnce({name: 'recovered'});
|
||||
|
||||
const {result} = renderHook(() => useForm({
|
||||
fetcher,
|
||||
defaultFormData: {},
|
||||
submitter: jest.fn(),
|
||||
}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Domain not found');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetcher();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe(null);
|
||||
expect(result.current.formData).toEqual({name: 'recovered'});
|
||||
});
|
||||
expect(result.current.ready).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes with empty object when no defaultFormData provided', () => {
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ export function createTestForm(initialData = {}, config = {}) {
|
|||
loading: false,
|
||||
disabled: false,
|
||||
dirty: false,
|
||||
error: null,
|
||||
errors: {},
|
||||
// Methods that update formData directly
|
||||
patchFormData: jest.fn((updates) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue