diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js
index 9416c1b4..6824fff4 100644
--- a/app/src/components/Archive.js
+++ b/app/src/components/Archive.js
@@ -630,6 +630,15 @@ export function DomainEditPage() {
};
if (!form.ready) {
+ if (form.error) {
+ return <>
+
+
+ Domain not found
+ {form.error}
+
+ >;
+ }
return Loading domain...;
}
diff --git a/app/src/components/DomainEditPage.test.js b/app/src/components/DomainEditPage.test.js
index c767d78b..0a8ac9ae 100644
--- a/app/src/components/DomainEditPage.test.js
+++ b/app/src/components/DomainEditPage.test.js
@@ -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();
- // 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();
+
+ 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({
diff --git a/app/src/hooks/useForm.js b/app/src/hooks/useForm.js
index ff76fa7b..0d205a84 100644
--- a/app/src/hooks/useForm.js
+++ b/app/src/hooks/useForm.js
@@ -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,
diff --git a/app/src/hooks/useForm.test.js b/app/src/hooks/useForm.test.js
index 5ea0e167..8433ed1a 100644
--- a/app/src/hooks/useForm.test.js
+++ b/app/src/hooks/useForm.test.js
@@ -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', () => {
diff --git a/app/src/test-utils.js b/app/src/test-utils.js
index 9966ad41..151f96eb 100644
--- a/app/src/test-utils.js
+++ b/app/src/test-utils.js
@@ -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) => {