Skip to content

Commit e6c9c95

Browse files
committed
feat(spa): add explicit workflow states and error taxonomy
1 parent 8b2da81 commit e6c9c95

6 files changed

Lines changed: 221 additions & 30 deletions

File tree

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { App } from '../components/App';
77
describe('App contract', () => {
88
const token = 'contract-token';
99

10+
beforeEach(() => {
11+
globalThis.history.replaceState({}, '', 'http://localhost:3000/create');
12+
globalThis.localStorage.clear();
13+
globalThis.sessionStorage.clear();
14+
});
15+
1016
const authenticate = () => {
1117
globalThis.localStorage.setItem('html2rss_access_token', token);
1218
};
@@ -66,9 +72,10 @@ describe('App contract', () => {
6672
await waitFor(() => {
6773
expect(screen.getByText('Feed ready')).toBeInTheDocument();
6874
expect(screen.getByText('Example Feed')).toBeInTheDocument();
75+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready');
6976
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
7077
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
71-
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
78+
expect(screen.getByRole('link', { name: 'Open feed' })).toHaveClass('btn--primary');
7279
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
7380
'href',
7481
'http://localhost:3000/api/v1/feeds/generated-token.json'

frontend/src/__tests__/App.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ describe('App', () => {
118118
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
119119
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
120120
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
121+
expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'idle');
121122
});
122123

123124
it('keeps the page url field permissive enough for hostname-only input', () => {
@@ -242,6 +243,7 @@ describe('App', () => {
242243

243244
expect(screen.getByText('Enter access token')).toBeInTheDocument();
244245
expect(globalThis.location.pathname).toBe('/token');
246+
expect(document.querySelector('.form-shell')).toHaveAttribute('data-state', 'token_required');
245247
expect(screen.getByLabelText('Page URL')).toBeDisabled();
246248
expect(screen.getByRole('combobox')).toBeDisabled();
247249
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
@@ -322,6 +324,7 @@ describe('App', () => {
322324

323325
render(<App />);
324326

327+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'failed');
325328
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
326329
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
327330
expect(screen.getByText('Example Feed')).toBeInTheDocument();

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,17 @@ describe('ResultDisplay', () => {
5050
render(
5151
<ResultDisplay
5252
result={mockResult}
53+
workflowState="ready"
5354
onCreateAnother={mockOnCreateAnother}
5455
onRetryReadiness={mockOnRetryReadiness}
5556
/>
5657
);
5758

59+
expect(document.querySelector('.result-shell')).toHaveAttribute('data-state', 'ready');
5860
expect(screen.getByText('Feed ready')).toBeInTheDocument();
5961
expect(screen.getByText('Test Feed')).toBeInTheDocument();
6062
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
61-
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
63+
expect(screen.getByRole('link', { name: 'Open feed' })).toHaveClass('btn--primary');
6264
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
6365
'href',
6466
'https://example.com/feed.json'
@@ -85,14 +87,17 @@ describe('ResultDisplay', () => {
8587
readinessPhase: 'feed_not_ready_yet',
8688
preview: { items: [], error: 'Preview unavailable right now.', isLoading: false },
8789
}}
90+
workflowState="warming"
8891
onCreateAnother={mockOnCreateAnother}
8992
onRetryReadiness={mockOnRetryReadiness}
9093
/>
9194
);
9295

9396
await waitFor(() => {
9497
expect(screen.getByText('Feed still warming up')).toBeInTheDocument();
95-
expect(screen.getByRole('button', { name: 'Try readiness check again' })).toBeInTheDocument();
98+
expect(screen.getByRole('button', { name: 'Try readiness check again' })).toHaveClass(
99+
'btn--primary'
100+
);
96101
expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument();
97102
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
98103
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
@@ -107,13 +112,15 @@ describe('ResultDisplay', () => {
107112
readinessPhase: 'link_created',
108113
preview: { items: [], error: undefined, isLoading: true },
109114
}}
115+
workflowState="warming"
110116
onCreateAnother={mockOnCreateAnother}
111117
onRetryReadiness={mockOnRetryReadiness}
112118
/>
113119
);
114120

115121
await waitFor(() => {
116122
expect(screen.getByText('Feed created')).toBeInTheDocument();
123+
expect(screen.getByRole('button', { name: 'Checking readiness…' })).toHaveClass('btn--primary');
117124
expect(screen.getByRole('button', { name: 'Checking readiness…' })).toBeDisabled();
118125
expect(screen.queryByRole('link', { name: 'Open feed' })).not.toBeInTheDocument();
119126
expect(screen.getByText('Verifying feed readiness…')).toBeInTheDocument();
@@ -127,6 +134,7 @@ describe('ResultDisplay', () => {
127134
...mockResult,
128135
retry: { automatic: true, from: 'faraday', to: 'browserless' },
129136
}}
137+
workflowState="ready"
130138
onCreateAnother={mockOnCreateAnother}
131139
onRetryReadiness={mockOnRetryReadiness}
132140
/>
@@ -143,6 +151,7 @@ describe('ResultDisplay', () => {
143151
render(
144152
<ResultDisplay
145153
result={mockResult}
154+
workflowState="ready"
146155
onCreateAnother={mockOnCreateAnother}
147156
onRetryReadiness={mockOnRetryReadiness}
148157
/>
@@ -157,6 +166,7 @@ describe('ResultDisplay', () => {
157166
render(
158167
<ResultDisplay
159168
result={{ ...mockResult, readinessPhase: 'feed_not_ready_yet' }}
169+
workflowState="warming"
160170
onCreateAnother={mockOnCreateAnother}
161171
onRetryReadiness={mockOnRetryReadiness}
162172
/>
@@ -170,6 +180,7 @@ describe('ResultDisplay', () => {
170180
render(
171181
<ResultDisplay
172182
result={mockResult}
183+
workflowState="ready"
173184
onCreateAnother={mockOnCreateAnother}
174185
onRetryReadiness={mockOnRetryReadiness}
175186
/>

frontend/src/components/App.tsx

Lines changed: 155 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
saveFeedDraftState,
1414
} from '../utils/feedSessionStorage';
1515
import { normalizeUserUrl } from '../utils/url';
16+
import type { WorkflowErrorKind, WorkflowState } from './AppPanels';
17+
import type { CreatedFeedResult } from '../api/contracts';
1618

1719
const EMPTY_FEED_ERRORS = { url: '', form: '' };
1820
const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true };
@@ -64,6 +66,102 @@ interface ConversionErrorWithMeta extends Error {
6466
manualRetryStrategy?: string;
6567
}
6668

69+
function classifyWorkflowError(message?: string): WorkflowErrorKind | undefined {
70+
if (!message) return undefined;
71+
72+
const normalized = message.toLowerCase();
73+
const mentionsToken = normalized.includes('access token') || normalized.includes('token');
74+
75+
if (
76+
normalized.includes('unauthorized') ||
77+
normalized.includes('invalid token') ||
78+
normalized.includes('token rejected') ||
79+
normalized.includes('authentication') ||
80+
(normalized.includes('forbidden') && mentionsToken)
81+
) {
82+
return 'auth';
83+
}
84+
85+
if (
86+
normalized.includes('still preparing') ||
87+
normalized.includes('warming') ||
88+
normalized.includes('not ready') ||
89+
normalized.includes('readiness') ||
90+
normalized.includes('preview unavailable')
91+
) {
92+
return 'readiness';
93+
}
94+
95+
if (
96+
normalized.includes('network') ||
97+
normalized.includes('fetch') ||
98+
normalized.includes('timeout') ||
99+
normalized.includes('offline') ||
100+
normalized.includes('failed to fetch')
101+
) {
102+
return 'network';
103+
}
104+
105+
if (
106+
normalized.includes('not allowed') ||
107+
normalized.includes('disabled') ||
108+
(normalized.includes('forbidden') && !mentionsToken) ||
109+
normalized.includes('access denied')
110+
) {
111+
return 'server';
112+
}
113+
114+
if (
115+
normalized.includes('url') ||
116+
normalized.includes('strategy') ||
117+
normalized.includes('bad request') ||
118+
normalized.includes('unsupported strategy') ||
119+
normalized.includes('invalid response format')
120+
) {
121+
return 'input';
122+
}
123+
124+
return 'server';
125+
}
126+
127+
function deriveWorkflowState({
128+
resultReadinessPhase,
129+
conversionError,
130+
feedFieldErrors,
131+
isConverting,
132+
missingResultRoute,
133+
routeKind,
134+
tokenError,
135+
tokenStateError,
136+
metadataError,
137+
}: {
138+
resultReadinessPhase?: CreatedFeedResult['readinessPhase'];
139+
conversionError?: string;
140+
feedFieldErrors: { url: string; form: string };
141+
isConverting: boolean;
142+
missingResultRoute: boolean;
143+
routeKind: string;
144+
tokenError: string;
145+
tokenStateError?: string;
146+
metadataError?: string;
147+
}): WorkflowState {
148+
if (missingResultRoute || tokenStateError || metadataError) return 'failed';
149+
if (routeKind === 'token' || classifyWorkflowError(tokenError) === 'auth') return 'token_required';
150+
if (resultReadinessPhase === 'feed_ready') return 'ready';
151+
if (resultReadinessPhase === 'link_created' || resultReadinessPhase === 'feed_not_ready_yet') {
152+
return 'warming';
153+
}
154+
if (resultReadinessPhase === 'preview_unavailable') return 'failed';
155+
if (isConverting) return 'submitting';
156+
if (feedFieldErrors.url || feedFieldErrors.form || classifyWorkflowError(conversionError) === 'input') {
157+
return 'validating';
158+
}
159+
160+
if (conversionError) return 'failed';
161+
162+
return 'idle';
163+
}
164+
67165
function BrandLockup({ onNavigateHome }: { onNavigateHome: () => void }) {
68166
return (
69167
<a
@@ -114,6 +212,7 @@ export function App() {
114212
const [tokenError, setTokenError] = useState('');
115213
const [manualRetryStrategy, setManualRetryStrategy] = useState('');
116214
const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0);
215+
const [resultRouteRecoveryAttempted, setResultRouteRecoveryAttempted] = useState(false);
117216
const autoSubmitUrlReference = useRef<string | undefined>(route.prefillUrl);
118217
const hasAutoSubmittedReference = useRef(false);
119218
const restoredFeedTokenReference = useRef<string | undefined>(undefined);
@@ -122,7 +221,25 @@ export function App() {
122221
const routeFeedToken = route.kind === 'result' ? route.feedToken : undefined;
123222
const activeResult =
124223
route.kind === 'result' && result?.feed.feed_token === route.feedToken ? result : undefined;
125-
const missingResultRoute = route.kind === 'result' && !activeResult && !isConverting;
224+
const resultRouteRestorePending = route.kind === 'result' && !activeResult && !resultRouteRecoveryAttempted;
225+
const missingResultRoute =
226+
route.kind === 'result' && resultRouteRecoveryAttempted && !activeResult && !isConverting;
227+
const visibleErrorMessage =
228+
tokenError || conversionError || feedFieldErrors.url || feedFieldErrors.form || metadataError || tokenStateError;
229+
const errorKind = classifyWorkflowError(visibleErrorMessage);
230+
const workflowState: WorkflowState = resultRouteRestorePending
231+
? 'validating'
232+
: deriveWorkflowState({
233+
resultReadinessPhase: activeResult?.readinessPhase,
234+
conversionError,
235+
feedFieldErrors,
236+
isConverting,
237+
missingResultRoute,
238+
routeKind: route.kind,
239+
tokenError,
240+
tokenStateError,
241+
metadataError,
242+
});
126243

127244
useEffect(() => {
128245
if (!route.prefillUrl) return;
@@ -267,16 +384,27 @@ export function App() {
267384
void attemptFeedCreation(token ?? '', manualRetryStrategy);
268385
};
269386

387+
useEffect(() => {
388+
setResultRouteRecoveryAttempted(false);
389+
}, [routeFeedToken]);
390+
270391
useEffect(() => {
271392
if (!routeFeedToken) return;
272393
if (result?.feed.feed_token === routeFeedToken) return;
273394
if (restoredFeedTokenReference.current === routeFeedToken) return;
274395

275396
const recoveredResult = loadFeedResultState(routeFeedToken);
276397
restoredFeedTokenReference.current = routeFeedToken;
398+
setResultRouteRecoveryAttempted(true);
277399
if (recoveredResult) restoreResult(recoveredResult);
278400
}, [result?.feed.feed_token, restoreResult, routeFeedToken]);
279401

402+
useEffect(() => {
403+
if (!routeFeedToken) return;
404+
if (result?.feed.feed_token !== routeFeedToken) return;
405+
setResultRouteRecoveryAttempted(true);
406+
}, [result?.feed.feed_token, routeFeedToken]);
407+
280408
useEffect(() => {
281409
const autoSubmitUrl = autoSubmitUrlReference.current;
282410
if (!autoSubmitUrl || hasAutoSubmittedReference.current) return;
@@ -336,34 +464,44 @@ export function App() {
336464
</section>
337465
)}
338466

339-
{activeResult ? (
467+
{resultRouteRestorePending ? (
468+
<section class="ui-card ui-card--notice ui-card--roomy notice" data-state="loading" aria-live="polite">
469+
<div class="notice__spinner" aria-hidden="true" />
470+
<div>
471+
<strong>Restoring saved result</strong>
472+
<p>Checking local session state.</p>
473+
</div>
474+
</section>
475+
) : activeResult ? (
340476
<ResultDisplay
341477
result={activeResult}
478+
workflowState={workflowState}
342479
onCreateAnother={handleCreateAnother}
343480
onRetryReadiness={retryReadinessCheck}
344481
/>
482+
) : missingResultRoute ? (
483+
<section class="ui-card ui-card--notice ui-card--padded notice" data-tone="error" role="alert">
484+
<div class="notice__title">Saved result unavailable</div>
485+
<p>We could not restore this feed result. Create a new feed link to continue.</p>
486+
<div class="notice__actions">
487+
<button
488+
type="button"
489+
class="btn btn--primary"
490+
onClick={() => navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined })}
491+
>
492+
Go to create
493+
</button>
494+
</div>
495+
</section>
345496
) : (
346497
<>
347-
{missingResultRoute && (
348-
<section class="ui-card ui-card--notice ui-card--padded notice" data-tone="error" role="alert">
349-
<div class="notice__title">Saved result unavailable</div>
350-
<p>We could not restore this feed result. Create a new feed link to continue.</p>
351-
<div class="notice__actions">
352-
<button
353-
type="button"
354-
class="btn btn--ghost"
355-
onClick={() => navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined })}
356-
>
357-
Go to create
358-
</button>
359-
</div>
360-
</section>
361-
)}
362498
<CreateFeedPanel
363499
focusComposerKey={focusCreateComposerKey}
500+
workflowState={workflowState}
364501
feedFormData={{ ...feedFormData, strategy: selectedStrategy }}
365502
feedFieldErrors={feedFieldErrors}
366503
conversionError={conversionError}
504+
errorKind={errorKind}
367505
isConverting={isConverting}
368506
submitDisabled={submitDisabled}
369507
strategies={strategies}

0 commit comments

Comments
 (0)