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,