Skip to content

Commit 85e79bf

Browse files
authored
fix(frontend): streamline web ux (#916)
## Summary - streamline the conversion flow UI and strategy handling - normalize user-entered URLs before submit and auto-submit - improve access-token storage fallback cleanup for restricted session storage contexts ## Validation - make ready - frontend smoke at http://127.0.0.1:4001/ (initial + token-required states; action uniqueness checked)
1 parent 383ecc3 commit 85e79bf

24 files changed

Lines changed: 964 additions & 110 deletions

app/web/api/v1/create_feed.rb

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,46 @@ def build_create_params(params, account)
6161
# @param account [Hash]
6262
# @return [String]
6363
def validated_url(raw_url, account)
64-
url = raw_url.to_s.strip
64+
url = normalized_input_url(raw_url)
6565
raise Html2rss::Web::BadRequestError, 'URL parameter is required' if url.empty?
66-
raise Html2rss::Web::BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url)
66+
67+
url = UrlValidator.canonical_url(url)
68+
raise Html2rss::Web::BadRequestError, 'Invalid URL format' unless url
6769
unless UrlValidator.url_allowed?(account, url)
6870
raise Html2rss::Web::ForbiddenError, 'URL not allowed for this account'
6971
end
7072

7173
url
7274
end
7375

76+
# @param raw_url [String, nil]
77+
# @return [String]
78+
def normalized_input_url(raw_url)
79+
url = raw_url.to_s.strip
80+
return url if url.empty?
81+
return "https:#{url}" if url.start_with?('//')
82+
return url if absolute_url?(url)
83+
84+
hostname_input?(url) ? "https://#{url}" : url
85+
end
86+
87+
# @param url [String]
88+
# @return [Boolean]
89+
def absolute_url?(url)
90+
url.match?(%r{\A[a-z][a-z0-9+\-.]*://}i)
91+
end
92+
93+
# @param url [String]
94+
# @return [Boolean]
95+
def hostname_input?(url)
96+
%r{
97+
\A
98+
(localhost(?::\d+)?|(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?|(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?)
99+
(?:[/?#].*)?
100+
\z
101+
}ix.match?(url)
102+
end
103+
74104
# @param raw_strategy [String, nil]
75105
# @return [String]
76106
def normalize_strategy(raw_strategy)

app/web/security/url_validator.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@ module UrlValidator
1010
MAX_URL_LENGTH = 2048
1111

1212
class << self
13+
# @param url [String]
14+
# @return [String, nil]
15+
def canonical_url(url)
16+
normalize_url(url)
17+
end
18+
1319
# @param url [String]
1420
# @return [Boolean]
1521
def valid_url?(url)
16-
!normalize_url(url).nil?
22+
!canonical_url(url).nil?
1723
end
1824

1925
# @param account [Hash]
@@ -23,7 +29,7 @@ def url_allowed?(account, url)
2329
return false unless account && url
2430

2531
allowed_urls = Array(account[:allowed_urls])
26-
return false unless (normalized_url = normalize_url(url))
32+
return false unless (normalized_url = canonical_url(url))
2733

2834
return false if allowed_urls.empty?
2935

@@ -37,7 +43,7 @@ def url_allowed?(account, url)
3743
def match_exact?(pattern, normalized_url)
3844
return true if pattern == normalized_url
3945

40-
normalized_pattern = normalize_url(pattern)
46+
normalized_pattern = canonical_url(pattern)
4147
normalized_pattern ? normalized_pattern == normalized_url : false
4248
end
4349

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ describe('App contract', () => {
88
const token = 'contract-token';
99

1010
const authenticate = () => {
11-
window.sessionStorage.setItem('html2rss_access_token', token);
11+
window.localStorage.setItem('html2rss_access_token', token);
1212
};
1313

1414
it('shows feed result when API responds with success', async () => {
@@ -18,7 +18,7 @@ describe('App contract', () => {
1818
http.post('/api/v1/feeds', async ({ request }) => {
1919
const body = (await request.json()) as { url: string; strategy: string };
2020

21-
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'browserless' });
21+
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'faraday' });
2222
expect(request.headers.get('authorization')).toBe(`Bearer ${token}`);
2323

2424
return HttpResponse.json(
@@ -55,7 +55,7 @@ describe('App contract', () => {
5555

5656
await screen.findByLabelText('Page URL');
5757
await waitFor(() => {
58-
expect(screen.getByRole('combobox')).toHaveValue('browserless');
58+
expect(screen.getByRole('combobox')).toHaveValue('faraday');
5959
});
6060

6161
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
@@ -149,7 +149,7 @@ describe('App contract', () => {
149149

150150
await screen.findByLabelText('Page URL');
151151
await waitFor(() => {
152-
expect(screen.getByRole('combobox')).toHaveValue('browserless');
152+
expect(screen.getByRole('combobox')).toHaveValue('faraday');
153153
});
154154

155155
fireEvent.input(screen.getByLabelText('Page URL'), {
@@ -161,6 +161,6 @@ describe('App contract', () => {
161161

162162
expect(screen.getByText('Add access token')).toBeInTheDocument();
163163
expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument();
164-
expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull();
164+
expect(window.localStorage.getItem('html2rss_access_token')).toBeNull();
165165
});
166166
});

frontend/src/__tests__/App.test.tsx

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,22 @@ describe('App', () => {
9191
render(<App />);
9292

9393
expect(screen.getByLabelText('html2rss')).toBeInTheDocument();
94+
expect(screen.getByRole('link', { name: 'html2rss' })).toHaveAttribute('href', '/');
9495
expect(screen.getByLabelText('Page URL')).toBeInTheDocument();
9596
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
9697
expect(screen.queryByRole('link', { name: 'Bookmarklet' })).not.toBeInTheDocument();
9798
});
9899

100+
it('keeps the page url field permissive enough for hostname-only input', () => {
101+
render(<App />);
102+
103+
const urlInput = screen.getByLabelText('Page URL');
104+
105+
expect(urlInput).toHaveAttribute('type', 'text');
106+
expect(urlInput).toHaveAttribute('inputmode', 'url');
107+
expect(urlInput).toHaveAttribute('autocapitalize', 'off');
108+
});
109+
99110
it('autofocuses the source url field', async () => {
100111
render(<App />);
101112

@@ -104,11 +115,11 @@ describe('App', () => {
104115
});
105116
});
106117

107-
it('prefers browserless as the default strategy when available', () => {
118+
it('prefers faraday as the default strategy when available', () => {
108119
render(<App />);
109120

110121
return waitFor(() => {
111-
expect(screen.getByRole('combobox')).toHaveValue('browserless');
122+
expect(screen.getByRole('combobox')).toHaveValue('faraday');
112123
});
113124
});
114125

@@ -140,11 +151,7 @@ describe('App', () => {
140151
render(<App />);
141152

142153
await waitFor(() => {
143-
expect(mockConvertFeed).toHaveBeenCalledWith(
144-
'https://example.com/articles',
145-
'browserless',
146-
'saved-token'
147-
);
154+
expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'saved-token');
148155
});
149156
});
150157

@@ -221,7 +228,9 @@ describe('App', () => {
221228
preview: {
222229
items: [],
223230
error: 'Preview unavailable right now.',
231+
isLoading: false,
224232
},
233+
retry: null,
225234
},
226235
error: null,
227236
convertFeed: mockConvertFeed,
@@ -243,6 +252,7 @@ describe('App', () => {
243252
result: null,
244253
error: 'Access denied',
245254
convertFeed: mockConvertFeed,
255+
clearError: mockClearConversionError,
246256
clearResult: mockClearResult,
247257
});
248258

@@ -331,11 +341,7 @@ describe('App', () => {
331341

332342
await waitFor(() => {
333343
expect(mockSaveToken).toHaveBeenCalledWith('token-123');
334-
expect(mockConvertFeed).toHaveBeenCalledWith(
335-
'https://example.com/articles',
336-
'browserless',
337-
'token-123'
338-
);
344+
expect(mockConvertFeed).toHaveBeenCalledWith('https://example.com/articles', 'faraday', 'token-123');
339345
});
340346
});
341347

@@ -419,13 +425,87 @@ describe('App', () => {
419425
expect(bookmarklet.getAttribute('href')).not.toContain('%27+encodeURIComponent');
420426
});
421427

428+
it('opens token entry immediately for bookmarklet urls when no token is saved', async () => {
429+
window.history.replaceState({}, '', 'http://localhost:3000/?url=example.com%2Farticles');
430+
431+
render(<App />);
432+
433+
await screen.findByText('Add access token');
434+
expect(screen.getByLabelText('Page URL')).toHaveValue('https://example.com/articles');
435+
expect(mockConvertFeed).not.toHaveBeenCalled();
436+
});
437+
438+
it('offers a direct alternate strategy retry after conversion failure', async () => {
439+
mockUseAccessToken.mockReturnValue({
440+
token: 'saved-token',
441+
hasToken: true,
442+
saveToken: mockSaveToken,
443+
clearToken: mockClearToken,
444+
isLoading: false,
445+
error: null,
446+
});
447+
mockConvertFeed
448+
.mockRejectedValueOnce(
449+
Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), {
450+
manualRetryStrategy: 'browserless',
451+
})
452+
)
453+
.mockResolvedValueOnce(undefined);
454+
455+
render(<App />);
456+
457+
fireEvent.input(screen.getByLabelText('Page URL'), {
458+
target: { value: 'https://example.com/articles' },
459+
});
460+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
461+
462+
await screen.findByRole('button', { name: 'Try browserless instead' });
463+
fireEvent.click(screen.getByRole('button', { name: 'Try browserless instead' }));
464+
465+
await waitFor(() => {
466+
expect(mockConvertFeed).toHaveBeenLastCalledWith(
467+
'https://example.com/articles',
468+
'browserless',
469+
'saved-token'
470+
);
471+
});
472+
});
473+
474+
it('does not offer a duplicate retry action after automatic fallback already failed', async () => {
475+
mockUseAccessToken.mockReturnValue({
476+
token: 'saved-token',
477+
hasToken: true,
478+
saveToken: mockSaveToken,
479+
clearToken: mockClearToken,
480+
isLoading: false,
481+
error: null,
482+
});
483+
mockConvertFeed.mockRejectedValueOnce(
484+
Object.assign(new Error('Tried faraday first, then browserless. Browserless failed.'), {
485+
manualRetryStrategy: '',
486+
})
487+
);
488+
489+
render(<App />);
490+
491+
fireEvent.input(screen.getByLabelText('Page URL'), {
492+
target: { value: 'https://example.com/articles' },
493+
});
494+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
495+
496+
await screen.findByText('Tried faraday first, then browserless. Browserless failed.');
497+
expect(screen.queryByRole('button', { name: /Try .* instead/ })).not.toBeInTheDocument();
498+
});
499+
422500
it('shows the utility links in a user-focused order', () => {
423501
window.history.replaceState({}, '', 'http://localhost:3000/#result');
424502
render(<App />);
425503

426504
fireEvent.click(screen.getByRole('button', { name: 'More' }));
427505

428-
const utilityLinks = screen.getAllByRole('link').map((link) => link.textContent);
506+
const utilityLinks = Array.from(
507+
screen.getByLabelText('Utilities').querySelectorAll('.utility-strip__items > a')
508+
).map((link) => link.textContent);
429509
expect(utilityLinks).toEqual([
430510
'Try included feeds',
431511
'Bookmarklet',

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ describe('ResultDisplay', () => {
3636
},
3737
],
3838
error: null,
39+
isLoading: false,
3940
},
41+
retry: null,
4042
};
4143

4244
beforeEach(() => {
@@ -49,6 +51,10 @@ describe('ResultDisplay', () => {
4951
expect(screen.getByText('Your feed is ready')).toBeInTheDocument();
5052
expect(screen.getByText('Test Feed')).toBeInTheDocument();
5153
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
54+
expect(screen.getByRole('link', { name: 'Subscribe in reader' })).toHaveAttribute(
55+
'href',
56+
'feed:https://example.com/feed.xml'
57+
);
5258
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
5359
expect(screen.getByRole('link', { name: 'Open JSON Feed' })).toHaveAttribute(
5460
'href',
@@ -67,7 +73,10 @@ describe('ResultDisplay', () => {
6773
it('surfaces preview failures as a result-state message', async () => {
6874
render(
6975
<ResultDisplay
70-
result={{ ...mockResult, preview: { items: [], error: 'Preview unavailable right now.' } }}
76+
result={{
77+
...mockResult,
78+
preview: { items: [], error: 'Preview unavailable right now.', isLoading: false },
79+
}}
7180
onCreateAnother={mockOnCreateAnother}
7281
/>
7382
);
@@ -78,6 +87,39 @@ describe('ResultDisplay', () => {
7887
});
7988
});
8089

90+
it('keeps the result state visible while preview is still loading', async () => {
91+
render(
92+
<ResultDisplay
93+
result={{ ...mockResult, preview: { items: [], error: null, isLoading: true } }}
94+
onCreateAnother={mockOnCreateAnother}
95+
/>
96+
);
97+
98+
await waitFor(() => {
99+
expect(screen.getByText('Your feed is ready')).toBeInTheDocument();
100+
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
101+
expect(screen.getByText('Loading preview…')).toBeInTheDocument();
102+
});
103+
});
104+
105+
it('shows an automatic retry notice when fallback strategy succeeded', async () => {
106+
render(
107+
<ResultDisplay
108+
result={{
109+
...mockResult,
110+
retry: { automatic: true, from: 'faraday', to: 'browserless' },
111+
}}
112+
onCreateAnother={mockOnCreateAnother}
113+
/>
114+
);
115+
116+
await waitFor(() => {
117+
expect(
118+
screen.getByText('Retried automatically with browserless after faraday could not finish the page.')
119+
).toBeInTheDocument();
120+
});
121+
});
122+
81123
it('calls onCreateAnother when the reset button is clicked', () => {
82124
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
83125

0 commit comments

Comments
 (0)