Skip to content

Commit 216352d

Browse files
committed
Refine feed creation workspace UX
1 parent 43d6452 commit 216352d

8 files changed

Lines changed: 271 additions & 118 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('App contract', () => {
3939

4040
render(<App />);
4141

42-
await screen.findByText('Generate a feed from a web page');
42+
await screen.findByText('Create a feed URL.');
4343

4444
const urlInput = screen.getByLabelText('Source URL') as HTMLInputElement;
4545
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
@@ -51,7 +51,7 @@ describe('App contract', () => {
5151
expect(resultRegion).not.toBeNull();
5252
const resultQueries = within(resultRegion!);
5353

54-
expect(screen.getByText('Feed ready')).toBeInTheDocument();
54+
expect(screen.getByText('Result')).toBeInTheDocument();
5555
expect(screen.getByText('Example Feed')).toBeInTheDocument();
5656
expect(resultQueries.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
5757
expect(resultQueries.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();

frontend/src/__tests__/App.test.tsx

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,17 @@ describe('App', () => {
8686
it('renders the streamlined hero and create section', () => {
8787
render(<App />);
8888

89-
expect(screen.getByText('Turn web pages into stable feeds.')).toBeInTheDocument();
90-
expect(screen.getByText('Create a feed')).toBeInTheDocument();
89+
expect(screen.getByText('Create a feed URL.')).toBeInTheDocument();
90+
expect(screen.getByLabelText('Source URL')).toBeInTheDocument();
9191
expect(screen.getByText('Run your own instance')).toBeInTheDocument();
9292
});
9393

94+
it('autofocuses the source url field', () => {
95+
render(<App />);
96+
97+
expect(document.activeElement).toBe(screen.getByLabelText('Source URL'));
98+
});
99+
94100
it('shows inline token prompt when submitting without a token', () => {
95101
render(<App />);
96102

@@ -99,7 +105,7 @@ describe('App', () => {
99105
});
100106
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
101107

102-
expect(screen.getByText('Save one token in this browser session to continue.')).toBeInTheDocument();
108+
expect(screen.getByText('Add access token')).toBeInTheDocument();
103109
expect(mockConvertFeed).not.toHaveBeenCalled();
104110
});
105111

@@ -126,7 +132,7 @@ describe('App', () => {
126132

127133
render(<App />);
128134

129-
expect(screen.getByText('Ready')).toBeInTheDocument();
135+
expect(screen.getByText('Result')).toBeInTheDocument();
130136
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
131137
expect(screen.queryByText('Run your own instance')).not.toBeInTheDocument();
132138

@@ -162,24 +168,57 @@ describe('App', () => {
162168

163169
render(<App />);
164170

165-
fireEvent.click(screen.getByRole('button', { name: 'Clear token' }));
171+
expect(screen.getByText('Utilities')).toBeInTheDocument();
172+
expect(screen.queryByText('Run your own instance')).not.toBeInTheDocument();
173+
fireEvent.click(screen.getByRole('button', { name: 'Clear saved token' }));
166174

167175
expect(mockClearToken).toHaveBeenCalled();
168176
});
169177

170-
it('saves access token from the inline prompt', async () => {
178+
it('saves access token and resumes feed creation from the inline prompt', async () => {
179+
render(<App />);
180+
181+
fireEvent.input(screen.getByLabelText('Source URL'), {
182+
target: { value: 'https://example.com/articles' },
183+
});
184+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
185+
const accessTokenInput = document.getElementById('access-token') as HTMLInputElement;
186+
fireEvent.input(accessTokenInput, { target: { value: 'token-123' } });
187+
fireEvent.click(screen.getByRole('button', { name: 'Save and continue' }));
188+
189+
await waitFor(() => {
190+
expect(mockSaveToken).toHaveBeenCalledWith('token-123');
191+
expect(mockConvertFeed).toHaveBeenCalledWith(
192+
'https://example.com/articles',
193+
'ssrf_filter',
194+
'token-123'
195+
);
196+
});
197+
});
198+
199+
it('submits the token prompt with Enter', async () => {
171200
render(<App />);
172201

173202
fireEvent.input(screen.getByLabelText('Source URL'), {
174203
target: { value: 'https://example.com/articles' },
175204
});
176205
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
206+
177207
const accessTokenInput = document.getElementById('access-token') as HTMLInputElement;
178208
fireEvent.input(accessTokenInput, { target: { value: 'token-123' } });
179-
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
209+
fireEvent.keyDown(accessTokenInput, { key: 'Enter' });
180210

181211
await waitFor(() => {
182212
expect(mockSaveToken).toHaveBeenCalledWith('token-123');
183213
});
184214
});
215+
216+
it('builds a bookmarklet that returns to the current frontend entry', () => {
217+
window.history.replaceState({}, '', 'http://localhost:3000/frontend/index.html');
218+
render(<App />);
219+
220+
const bookmarklet = screen.getByRole('link', { name: 'Convert page to feed' });
221+
expect(bookmarklet.getAttribute('href')).toContain('/frontend/index.html?url=');
222+
expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent');
223+
});
185224
});

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ describe('ResultDisplay', () => {
2525
it('renders utility actions and preview state', async () => {
2626
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
2727

28-
expect(screen.getByText('Ready')).toBeInTheDocument();
28+
expect(screen.getByText('Result')).toBeInTheDocument();
2929
expect(screen.getByText('Test Feed')).toBeInTheDocument();
30+
expect(
31+
screen.getByText('Copy the feed URL, then drop it into the reader or workflow you use.')
32+
).toBeInTheDocument();
3033
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
3134
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
3235

frontend/src/components/App.tsx

Lines changed: 66 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useFeedConversion } from '../hooks/useFeedConversion';
77
import { useStrategies } from '../hooks/useStrategies';
88

99
const EMPTY_FEED_ERRORS = { url: '', form: '' };
10+
const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true };
1011

1112
function BrandLockup() {
1213
return (
@@ -42,6 +43,7 @@ export function App() {
4243
const [showTokenPrompt, setShowTokenPrompt] = useState(false);
4344
const [tokenDraft, setTokenDraft] = useState('');
4445
const [tokenError, setTokenError] = useState('');
46+
const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0);
4547

4648
useEffect(() => {
4749
if (typeof window === 'undefined') return;
@@ -61,7 +63,7 @@ export function App() {
6163
if (!hasCurrentStrategy) setFeedFormData((prev) => ({ ...prev, strategy: nextStrategy }));
6264
}, [strategies, feedFormData.strategy]);
6365

64-
const feedCreation = metadata?.instance.feed_creation ?? { enabled: true, access_token_required: true };
66+
const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION;
6567

6668
const setFeedField = (key: 'url' | 'strategy', value: string) => {
6769
setFeedFormData((prev) => ({ ...prev, [key]: value }));
@@ -73,60 +75,68 @@ export function App() {
7375
};
7476

7577
const strategyHint = (strategy: Strategy) => {
76-
if (strategy.id === 'ssrf_filter') return 'Direct fetch for standard documents and static pages.';
77-
if (strategy.id === 'browserless')
78-
return 'Rendered browser pass for JavaScript-heavy pages, SPAs, and delayed content.';
78+
if (strategy.id === 'ssrf_filter') return 'Start here for most pages.';
79+
if (strategy.id === 'browserless') return 'Use this if the page loads content with JavaScript.';
7980
return strategy.name;
8081
};
8182

82-
const handleFeedSubmit = async (event: Event) => {
83-
event.preventDefault();
84-
setFeedFieldErrors(EMPTY_FEED_ERRORS);
85-
83+
const attemptFeedCreation = async (accessToken: string) => {
8684
if (!feedFormData.url.trim()) {
8785
setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: 'Source URL is required.' });
88-
return;
86+
return false;
8987
}
9088

9189
if (!feedCreation.enabled) {
9290
setFeedFieldErrors({
9391
...EMPTY_FEED_ERRORS,
9492
form: 'Custom feed generation is disabled for this instance.',
9593
});
96-
return;
94+
return false;
9795
}
9896

99-
if (feedCreation.access_token_required && !hasToken) {
97+
if (feedCreation.access_token_required && !accessToken) {
10098
setShowTokenPrompt(true);
101-
setTokenError('Add an access token to create a custom feed.');
102-
return;
99+
setTokenError('Paste an access token to keep going.');
100+
return false;
103101
}
104102

105103
try {
106-
await convertFeed(feedFormData.url, feedFormData.strategy, token ?? '');
104+
await convertFeed(feedFormData.url, feedFormData.strategy, accessToken);
105+
setShowTokenPrompt(false);
106+
setTokenError('');
107+
return true;
107108
} catch (submitError) {
108109
const message = submitError instanceof Error ? submitError.message : 'Unable to start feed generation.';
109110
if (message.toLowerCase().includes('url')) {
110111
setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: message });
111112
} else {
112113
setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: message });
113114
}
115+
return false;
114116
}
115117
};
116118

119+
const handleFeedSubmit = async (event: Event) => {
120+
event.preventDefault();
121+
setFeedFieldErrors(EMPTY_FEED_ERRORS);
122+
await attemptFeedCreation(token ?? '');
123+
};
124+
117125
const handleSaveToken = async () => {
118126
try {
119-
await saveToken(tokenDraft);
127+
const normalizedToken = tokenDraft.trim();
128+
await saveToken(normalizedToken);
120129
setTokenError('');
121-
setShowTokenPrompt(false);
122-
setTokenDraft('');
130+
const created = await attemptFeedCreation(normalizedToken);
131+
if (created) setTokenDraft('');
123132
} catch (error) {
124133
setTokenError(error instanceof Error ? error.message : 'Unable to save access token.');
125134
}
126135
};
127136

128137
const handleCreateAnother = () => {
129138
clearResult();
139+
setFocusCreateComposerKey((current) => current + 1);
130140
};
131141

132142
if (metadataLoading || tokenLoading) {
@@ -152,7 +162,7 @@ export function App() {
152162
</div>
153163
{!result && (
154164
<div class="workspace-frame__titleblock">
155-
<h1>Turn web pages into stable feeds.</h1>
165+
<h1>Create a feed URL.</h1>
156166
</div>
157167
)}
158168
</header>
@@ -167,43 +177,44 @@ export function App() {
167177
{result ? (
168178
<ResultDisplay result={result} onCreateAnother={handleCreateAnother} />
169179
) : (
170-
<CreateFeedPanel
171-
feedFormData={feedFormData}
172-
feedFieldErrors={feedFieldErrors}
173-
conversionError={conversionError}
174-
isConverting={isConverting}
175-
strategies={strategies}
176-
strategiesLoading={strategiesLoading}
177-
strategiesError={strategiesError}
178-
feedCreationEnabled={feedCreation.enabled}
179-
accessTokenRequired={feedCreation.access_token_required}
180-
hasAccessToken={hasToken}
181-
tokenDraft={tokenDraft}
182-
tokenError={tokenError}
183-
showTokenPrompt={showTokenPrompt}
184-
onFeedSubmit={handleFeedSubmit}
185-
onFeedFieldChange={setFeedField}
186-
onTokenDraftChange={(value) => {
187-
setTokenDraft(value);
188-
setTokenError('');
189-
}}
190-
onSaveToken={handleSaveToken}
191-
onCancelTokenPrompt={() => {
192-
setShowTokenPrompt(false);
193-
setTokenError('');
194-
}}
195-
strategyHint={strategyHint}
196-
/>
197-
)}
198-
199-
{!result && (
200-
<InstanceInfo
201-
hasAccessToken={hasToken}
202-
onClearToken={() => {
203-
clearToken();
204-
setShowTokenPrompt(false);
205-
}}
206-
/>
180+
<div class="workspace-grid">
181+
<CreateFeedPanel
182+
focusComposerKey={focusCreateComposerKey}
183+
feedFormData={feedFormData}
184+
feedFieldErrors={feedFieldErrors}
185+
conversionError={conversionError}
186+
isConverting={isConverting}
187+
strategies={strategies}
188+
strategiesLoading={strategiesLoading}
189+
strategiesError={strategiesError}
190+
feedCreationEnabled={feedCreation.enabled}
191+
accessTokenRequired={feedCreation.access_token_required}
192+
hasAccessToken={hasToken}
193+
tokenDraft={tokenDraft}
194+
tokenError={tokenError}
195+
showTokenPrompt={showTokenPrompt}
196+
onFeedSubmit={handleFeedSubmit}
197+
onFeedFieldChange={setFeedField}
198+
onTokenDraftChange={(value) => {
199+
setTokenDraft(value);
200+
setTokenError('');
201+
}}
202+
onSaveToken={handleSaveToken}
203+
onCancelTokenPrompt={() => {
204+
setShowTokenPrompt(false);
205+
setTokenError('');
206+
}}
207+
strategyHint={strategyHint}
208+
/>
209+
210+
<InstanceInfo
211+
hasAccessToken={hasToken}
212+
onClearToken={() => {
213+
clearToken();
214+
setShowTokenPrompt(false);
215+
}}
216+
/>
217+
</div>
207218
)}
208219
</section>
209220
);

0 commit comments

Comments
 (0)