Snapshot URL datasets locally on add; preview tabular data as a table

This commit is contained in:
2026-06-10 10:24:42 +03:00
parent eb5e7ac53a
commit 2410c6e965
23 changed files with 1239 additions and 148 deletions
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest';
import { RemoteFetchError } from '../infrastructure/remote-data';
import { remoteFetchErrorMessage } from './remote-data-errors';
describe('remoteFetchErrorMessage', () => {
test('a network failure hedges offline vs cross-origin and offers the inline fallback', () => {
const msg = remoteFetchErrorMessage(new RemoteFetchError('network', 'x'));
expect(msg).toMatch(/offline/i);
expect(msg).toMatch(/cross-origin/i);
expect(msg).toMatch(/paste the data inline/i);
});
test('an HTTP 404 explains "not found" without a raw error code', () => {
const msg = remoteFetchErrorMessage(new RemoteFetchError('http', 'x', 404));
expect(msg).toMatch(/couldn't find/i);
expect(msg).not.toMatch(/404/);
});
test('an HTTP 403 explains refused access', () => {
expect(remoteFetchErrorMessage(new RemoteFetchError('http', 'x', 403))).toMatch(
/refused access/i,
);
});
test('an empty body says it returned no data', () => {
expect(remoteFetchErrorMessage(new RemoteFetchError('empty', 'x'))).toMatch(/no data/i);
});
test('the retry recovery points at refreshing, not pasting inline', () => {
const msg = remoteFetchErrorMessage(new RemoteFetchError('timeout', 'x'), 'retry');
expect(msg).toMatch(/refreshing again/i);
expect(msg).not.toMatch(/inline/i);
});
test('an unknown error still produces a sensible fallback message', () => {
expect(remoteFetchErrorMessage(new Error('boom'))).toMatch(/couldn't fetch this url/i);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Translate a remote-data fetch failure (snapshot model — spec §05 → URL datasets)
* into the message shown when adding or refreshing a URL dataset.
*
* Pure and framework-free so the wording is unit-tested directly — the same split
* as `storage-errors`. Follows the recorded error-copy resolution (docs/architecture
* /10 → error copy): plain language, name what stopped, and — because every one of
* these is user-fixable — point at the next step. No error codes in the user-facing
* line (GOV.UK / NN/g #9). The recovery clause adapts to where the error shows: the
* create/edit form can fall back to pasting inline; Refresh can only retry.
*/
import { RemoteFetchError } from '../infrastructure/remote-data';
/** Where the error surfaces, which decides the recovery the copy points at. */
export type FetchRecovery = 'inline' | 'retry';
function nextStep(recovery: FetchRecovery): string {
return recovery === 'inline'
? 'Check the address, or paste the data inline instead.'
: 'Check the address and try refreshing again.';
}
function httpCause(status?: number): string {
if (status === 404) return "The server couldn't find anything at this URL.";
if (status === 401 || status === 403) return 'The server refused access to this URL.';
if (status && status >= 500) return 'The server hit an error serving this URL.';
return "The server couldn't return this URL.";
}
/**
* The user-facing message for a fetch failure. `recovery` defaults to `inline`
* (the add/edit form); pass `retry` for the Refresh action, which has no inline
* fallback.
*/
export function remoteFetchErrorMessage(err: unknown, recovery: FetchRecovery = 'inline'): string {
const next = nextStep(recovery);
if (err instanceof RemoteFetchError) {
switch (err.reason) {
case 'network':
return `Couldn't fetch this URL. Your device may be offline, or the host may block cross-origin requests. ${next}`;
case 'http':
return `${httpCause(err.status)} ${next}`;
case 'empty':
return `This URL returned no data. Make sure it points directly at a data file. ${next}`;
case 'timeout':
return `This URL took too long to respond. ${next}`;
}
}
return `Couldn't fetch this URL. ${next}`;
}