From e3a2a918fe034380ac619b95efa4431d25b7e4bd Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Fri, 7 Aug 2026 09:13:03 +0200 Subject: [PATCH] refactor(frontend): type-safe routing and decompose CreateFeedPanel Unifies route state propagation by passing the typed AppRoute object directly to deriveAppViewModel, removing manual string mappings in useAppPresenter. Consolidates CreateFeedPanel by decomposing it into UrlEntrySection, TokenGateSection, and ActionFeedback sub-components, and isolates computed view state via getPanelViewState. --- frontend/src/__tests__/appViewModel.test.ts | 93 ++++- frontend/src/appViewModel.ts | 48 ++- frontend/src/components/AppPanels.tsx | 356 ++++++++++++-------- frontend/src/hooks/useAppPresenter.ts | 10 +- 4 files changed, 351 insertions(+), 156 deletions(-) diff --git a/frontend/src/__tests__/appViewModel.test.ts b/frontend/src/__tests__/appViewModel.test.ts index bcd0ad90..760b2a7c 100644 --- a/frontend/src/__tests__/appViewModel.test.ts +++ b/frontend/src/__tests__/appViewModel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { deriveAppViewModel } from '../appViewModel'; +import { deriveAppViewModel, getPanelViewState } from '../appViewModel'; const emptyErrors = { url: '', form: '' }; @@ -9,7 +9,7 @@ describe('deriveAppViewModel', () => { deriveAppViewModel({ feedFieldErrors: emptyErrors, isConverting: false, - routeKind: 'create', + route: { kind: 'create' }, tokenError: '', }) ).toEqual({ kind: 'create' }); @@ -19,7 +19,7 @@ describe('deriveAppViewModel', () => { const viewModel = deriveAppViewModel({ feedFieldErrors: emptyErrors, isConverting: false, - routeKind: 'result', + route: { kind: 'result', feedToken: 'token' }, tokenError: '', result: { feed: { @@ -48,7 +48,7 @@ describe('deriveAppViewModel', () => { deriveAppViewModel({ feedFieldErrors: emptyErrors, isConverting: false, - routeKind: 'create', + route: { kind: 'create' }, tokenError: '', conversionError: { kind: 'auth', @@ -70,7 +70,7 @@ describe('deriveAppViewModel', () => { deriveAppViewModel({ feedFieldErrors: emptyErrors, isConverting: true, - routeKind: 'create', + route: { kind: 'create' }, tokenError: '', }) ).toEqual({ kind: 'submitting' }); @@ -79,7 +79,7 @@ describe('deriveAppViewModel', () => { deriveAppViewModel({ feedFieldErrors: { url: '', form: 'Bad url' }, isConverting: false, - routeKind: 'create', + route: { kind: 'create' }, tokenError: '', conversionError: { kind: 'input', @@ -98,9 +98,88 @@ describe('deriveAppViewModel', () => { deriveAppViewModel({ feedFieldErrors: emptyErrors, isConverting: true, - routeKind: 'token', + route: { kind: 'token' }, tokenError: '', }) ).toEqual({ kind: 'submitting' }); }); }); + +describe('getPanelViewState', () => { + it('derives values for create state', () => { + const state = getPanelViewState({ kind: 'create' }, emptyErrors); + expect(state).toEqual({ + isTokenPrompt: false, + isConverting: false, + tokenError: '', + errorKind: undefined, + failureMessage: '', + isShowRetryButton: false, + }); + }); + + it('derives values for submitting state', () => { + const state = getPanelViewState({ kind: 'submitting' }, emptyErrors); + expect(state).toEqual({ + isTokenPrompt: false, + isConverting: true, + tokenError: '', + errorKind: undefined, + failureMessage: '', + isShowRetryButton: false, + }); + }); + + it('derives values for token_prompt state', () => { + const state = getPanelViewState( + { + kind: 'token_prompt', + tokenError: 'Invalid token', + error: { + kind: 'auth', + code: 'UNAUTHORIZED', + retryable: false, + nextAction: 'enter_token', + retryAction: 'none', + message: 'Access denied', + }, + }, + emptyErrors + ); + expect(state).toEqual({ + isTokenPrompt: true, + isConverting: false, + tokenError: 'Invalid token', + errorKind: 'auth', + failureMessage: 'Access denied', + isShowRetryButton: false, + }); + }); + + it('derives values for error state', () => { + const state = getPanelViewState( + { + kind: 'error', + message: 'Something went wrong', + errorKind: 'network', + error: { + kind: 'network', + code: 'TIMEOUT', + retryable: true, + nextAction: 'retry', + retryAction: 'recreate', + message: 'Something went wrong', + }, + }, + emptyErrors + ); + expect(state).toEqual({ + isTokenPrompt: false, + isConverting: false, + tokenError: '', + errorKind: 'network', + failureMessage: 'Something went wrong', + isShowRetryButton: true, + }); + }); +}); diff --git a/frontend/src/appViewModel.ts b/frontend/src/appViewModel.ts index 7de89db0..47b0687d 100644 --- a/frontend/src/appViewModel.ts +++ b/frontend/src/appViewModel.ts @@ -5,6 +5,7 @@ import type { FeedPreviewWarning, FeedRecord, } from './api/contracts'; +import type { AppRoute } from './routes/appRoute'; export type AppViewModel = | { kind: 'create' } @@ -27,7 +28,7 @@ export function deriveAppViewModel({ conversionError, feedFieldErrors, isConverting, - routeKind, + route, tokenError, tokenStateError, metadataError, @@ -36,7 +37,7 @@ export function deriveAppViewModel({ conversionError?: FeedCreationError; feedFieldErrors: { url: string; form: string }; isConverting: boolean; - routeKind: 'create' | 'token' | 'result'; + route: AppRoute; tokenError: string; tokenStateError?: string; metadataError?: string; @@ -49,7 +50,7 @@ export function deriveAppViewModel({ }; } - if (routeKind === 'result' && result) { + if (route.kind === 'result' && result && result.feed.feed_token === route.feedToken) { return { kind: 'result', feed: result.feed, @@ -60,7 +61,7 @@ export function deriveAppViewModel({ if (isConverting) return { kind: 'submitting' }; - if (routeKind === 'token' || tokenError) { + if (route.kind === 'token' || tokenError) { return { kind: 'token_prompt', tokenError, error: conversionError }; } @@ -88,3 +89,42 @@ export function deriveAppViewModel({ return { kind: 'create' }; } + +export interface PanelViewState { + isTokenPrompt: boolean; + isConverting: boolean; + tokenError: string; + errorKind?: FeedCreationError['kind']; + failureMessage: string; + isShowRetryButton: boolean; +} + +export function getPanelViewState( + viewModel: AppViewModel, + feedFieldErrors: { form: string } +): PanelViewState { + const isTokenPrompt = viewModel.kind === 'token_prompt'; + const isConverting = viewModel.kind === 'submitting'; + const conversionError = + viewModel.kind === 'error' || viewModel.kind === 'token_prompt' ? viewModel.error : undefined; + const tokenError = viewModel.kind === 'token_prompt' ? viewModel.tokenError : ''; + const errorKind = viewModel.kind === 'error' ? viewModel.errorKind : conversionError?.kind; + + const failureMessage = + (viewModel.kind === 'error' ? viewModel.message : undefined) || + conversionError?.message || + feedFieldErrors.form; + + const isShowRetryButton = Boolean( + conversionError && conversionError.nextAction === 'retry' && conversionError.retryAction !== 'none' + ); + + return { + isTokenPrompt, + isConverting, + tokenError, + errorKind, + failureMessage, + isShowRetryButton, + }; +} diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index 4b6f7f71..7675d3ff 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -1,7 +1,9 @@ import { useLayoutEffect, useRef } from 'preact/hooks'; +import type { RefObject } from 'preact'; import { Bookmarklet } from './Bookmarklet'; import { DominantField } from './DominantField'; import { Notice } from './Notice'; +import { getPanelViewState } from '../appViewModel'; import type { AppViewModel } from '../appViewModel'; export interface FeedFormData { @@ -32,6 +34,200 @@ interface CreateFeedPanelProperties { onRetryCreate: () => void; } +interface UrlEntrySectionProperties { + url: string; + disabled: boolean; + error: string; + isConverting: boolean; + feedCreationEnabled: boolean; + featuredFeeds: Array<{ path: string; title: string; description: string }>; + inputRef: RefObject; + onInput: (value: string) => void; +} + +function UrlEntrySection({ + url, + disabled, + error, + isConverting, + feedCreationEnabled, + featuredFeeds, + inputRef, + onInput, +}: UrlEntrySectionProperties) { + return ( + <> + '} + disabled={disabled} + error={error} + onInput={(event) => onInput((event.target as HTMLInputElement).value)} + /> + + {!feedCreationEnabled && ( + <> +

Feed creation is disabled on this instance.

+ {featuredFeeds.length > 0 && ( + +

Start with a ready-made feed from this instance.

+ +

+ + Learn how included configs work. + +

+
+ )} + + )} + + ); +} + +interface TokenGateSectionProperties { + tokenDraft: string; + tokenError: string; + inputRef: RefObject; + onInput?: (value: string) => void; + onTokenDraftChange: (value: string) => void; + onSaveToken: () => void; + onCancelTokenPrompt: () => void; +} + +function TokenGateSection({ + tokenDraft, + tokenError, + inputRef, + onTokenDraftChange, + onSaveToken, + onCancelTokenPrompt, +}: TokenGateSectionProperties) { + return ( +
+
+

Enter access token

+

Required by this instance.

+
+ + + Set up your own instance with Docker. + +
+ +
+
+ +
+
+ ); +} + +interface ActionFeedbackProperties { + failureMessage: string; + isConverting: boolean; + isShowRetryButton: boolean; + onRetryCreate: () => void; +} + +function ActionFeedback({ + failureMessage, + isConverting, + isShowRetryButton, + onRetryCreate, +}: ActionFeedbackProperties) { + return ( + <> + {failureMessage && ( + + Try again + + ) + } + > +

{failureMessage}

+
+ )} + + {isConverting && ( + +

Preparing preview.

+
+ )} + + ); +} + export function CreateFeedPanel({ focusComposerKey, viewModel, @@ -50,19 +246,9 @@ export function CreateFeedPanel({ }: CreateFeedPanelProperties) { const urlInputReference = useRef(undefined as never); const tokenInputReference = useRef(undefined as never); - const isTokenPrompt = viewModel.kind === 'token_prompt'; - const isConverting = viewModel.kind === 'submitting'; - const conversionError = - viewModel.kind === 'error' || viewModel.kind === 'token_prompt' ? viewModel.error : undefined; - const tokenError = viewModel.kind === 'token_prompt' ? viewModel.tokenError : ''; - const errorKind = viewModel.kind === 'error' ? viewModel.errorKind : conversionError?.kind; - const failureMessage = - (viewModel.kind === 'error' ? viewModel.message : undefined) || - conversionError?.message || - feedFieldErrors.form; - const isShowRetryButton = Boolean( - conversionError && conversionError.nextAction === 'retry' && conversionError.retryAction !== 'none' - ); + + const { isTokenPrompt, isConverting, tokenError, errorKind, failureMessage, isShowRetryButton } = + getPanelViewState(viewModel, feedFieldErrors); useLayoutEffect(() => { if (!urlInputReference.current || globalThis.window === undefined) return; @@ -96,137 +282,35 @@ export function CreateFeedPanel({ data-error-kind={errorKind} >
- '} + onFeedFieldChange('url', (event.target as HTMLInputElement).value)} + isConverting={isConverting} + feedCreationEnabled={feedCreationEnabled} + featuredFeeds={featuredFeeds} + inputRef={urlInputReference} + onInput={(value) => onFeedFieldChange('url', value)} /> - - {!feedCreationEnabled && ( - <> -

Feed creation is disabled on this instance.

- {featuredFeeds.length > 0 && ( - -

Start with a ready-made feed from this instance.

- -

- - Learn how included configs work. - -

-
- )} - - )}
{isTokenPrompt && ( -
-
-

Enter access token

-

Required by this instance.

-
- - - Set up your own instance with Docker. - -
- -
-
- -
-
- )} - - {failureMessage && ( - - Try again - - ) - } - > -

{failureMessage}

-
+ )} - {isConverting && ( - -

Preparing preview.

-
- )} + ); } diff --git a/frontend/src/hooks/useAppPresenter.ts b/frontend/src/hooks/useAppPresenter.ts index f9acca15..6717581e 100644 --- a/frontend/src/hooks/useAppPresenter.ts +++ b/frontend/src/hooks/useAppPresenter.ts @@ -55,22 +55,14 @@ export function useAppPresenter() { navigate, }); - const isTokenRoute = route.kind === 'token'; const activeResult = route.kind === 'result' && result?.feed.feed_token === route.feedToken ? result : undefined; - let visibleRouteKind: 'create' | 'token' | 'result' = 'create'; - if (activeResult) { - visibleRouteKind = 'result'; - } else if (isTokenRoute) { - visibleRouteKind = 'token'; - } - const viewModel = deriveAppViewModel({ conversionError, feedFieldErrors, isConverting, - routeKind: visibleRouteKind, + route, tokenError, tokenStateError, metadataError,