Skip to content

Commit 3b385ab

Browse files
committed
Simplify result display preview and cleanup result UI
1 parent a370f64 commit 3b385ab

7 files changed

Lines changed: 130 additions & 76 deletions

File tree

app.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ class App < Roda
4747
</html>
4848
HTML
4949
FRONTEND_DIST_PATH = 'frontend/dist'
50-
FRONTEND_INDEX_PATH = File.join(FRONTEND_DIST_PATH, 'index.html')
50+
FRONTEND_DIST_INDEX_PATH = File.join(FRONTEND_DIST_PATH, 'index.html')
51+
FRONTEND_SOURCE_INDEX_PATH = 'frontend/index.html'
5152
def self.development? = EnvironmentValidator.development?
5253

5354
def development? = self.class.development?
@@ -108,7 +109,16 @@ def development? = self.class.development?
108109

109110
def render_index_page(router)
110111
router.response['Content-Type'] = 'text/html'
111-
File.exist?(FRONTEND_INDEX_PATH) ? File.read(FRONTEND_INDEX_PATH) : FALLBACK_HTML
112+
index_path = index_page_path
113+
return File.read(index_path) if index_path
114+
115+
FALLBACK_HTML
116+
end
117+
118+
def index_page_path
119+
return FRONTEND_DIST_INDEX_PATH if File.exist?(FRONTEND_DIST_INDEX_PATH)
120+
121+
FRONTEND_SOURCE_INDEX_PATH if development? && File.exist?(FRONTEND_SOURCE_INDEX_PATH)
112122
end
113123
end
114124
end

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,42 @@ describe('ResultDisplay', () => {
3636
});
3737

3838
it('renders ready feed actions and preview cards', async () => {
39+
const resultWithMultiplePreviewItems = {
40+
...mockResult,
41+
preview: {
42+
items: [
43+
{
44+
title: 'Item One',
45+
excerpt: 'First preview item with markup.',
46+
url: 'https://example.com/item-one',
47+
publishedLabel: 'Jan 1, 2024',
48+
},
49+
{
50+
title: 'Item Two',
51+
excerpt: 'Second preview item with markup.',
52+
url: 'https://example.com/item-two',
53+
publishedLabel: 'Jan 2, 2024',
54+
},
55+
{
56+
title: 'Item Three',
57+
excerpt: 'Third preview item with markup.',
58+
url: 'https://example.com/item-three',
59+
publishedLabel: 'Jan 3, 2024',
60+
},
61+
{
62+
title: 'Item Four',
63+
excerpt: 'Fourth preview item with markup.',
64+
url: 'https://example.com/item-four',
65+
publishedLabel: 'Jan 4, 2024',
66+
},
67+
],
68+
isLoading: false,
69+
},
70+
};
71+
3972
render(
4073
<ResultDisplay
41-
result={mockResult}
74+
result={resultWithMultiplePreviewItems}
4275
workflowState="ready"
4376
onCreateAnother={mockOnCreateAnother}
4477
onRetryPreview={mockOnRetryPreview}
@@ -54,8 +87,11 @@ describe('ResultDisplay', () => {
5487
);
5588
await waitFor(() => {
5689
expect(screen.getByText('Item One')).toBeInTheDocument();
90+
expect(screen.getByText('Item Four')).toBeInTheDocument();
5791
expect(screen.getByText('Latest items from this feed')).toBeInTheDocument();
5892
});
93+
expect(screen.queryByRole('button', { name: /show all .* items/i })).not.toBeInTheDocument();
94+
expect(screen.queryByRole('button', { name: 'Show fewer items' })).not.toBeInTheDocument();
5995
});
6096

6197
it('renders preview loading as frontend-owned progress', () => {

frontend/src/components/App.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef, useState } from 'preact/hooks';
22
import type { JSX } from 'preact';
33
import { ResultDisplay } from './ResultDisplay';
4+
import { ResultHero } from './ResultHero';
45
import { CreateFeedPanel, UtilityStrip } from './AppPanels';
56
import { useAccessToken } from '../hooks/useAccessToken';
67
import { useApiMetadata } from '../hooks/useApiMetadata';
@@ -328,26 +329,19 @@ export function App() {
328329
} else if (missingResultRoute) {
329330
bodyContent = (
330331
<section class="result-shell result-recovery layout-stack" data-state="failed" role="alert">
331-
<div class="result-hero ui-card ui-card--roomy ui-hero layout-rail-reading layout-stack">
332-
<div class="result-hero__masthead ui-hero__masthead">
333-
<div class="result-hero__icon-wrap ui-hero__icon-wrap" aria-hidden="true">
334-
<img class="result-hero__icon ui-hero__icon" src="/feed.svg" alt="" />
335-
</div>
336-
<div class="layout-stack layout-stack--tight">
337-
<h1 class="result-title ui-display-title">Saved result unavailable</h1>
338-
<p class="field-help">Create a new feed link to continue.</p>
339-
</div>
340-
</div>
341-
<div class="result-hero__actions ui-hero__actions">
332+
<ResultHero
333+
title="Saved result unavailable"
334+
body={<p class="field-help">Create a new feed link to continue.</p>}
335+
actions={
342336
<button
343337
type="button"
344338
class="btn btn--primary"
345339
onClick={() => navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined })}
346340
>
347341
Go to create
348342
</button>
349-
</div>
350-
</div>
343+
}
344+
/>
351345
</section>
352346
);
353347
} else {

frontend/src/components/ResultDisplay.tsx

Lines changed: 39 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { ComponentChildren } from 'preact';
12
import { useEffect, useRef, useState } from 'preact/hooks';
23
import type { CreatedFeedResult } from '../api/contracts';
34
import type { WorkflowState } from './AppPanels';
45
import { DominantField } from './DominantField';
6+
import { ResultHero } from './ResultHero';
57

68
interface ResultDisplayProperties {
79
result: CreatedFeedResult;
@@ -10,14 +12,31 @@ interface ResultDisplayProperties {
1012
onRetryPreview: () => void;
1113
}
1214

15+
interface PreviewSectionProperties {
16+
ariaLabel: string;
17+
intro?: string;
18+
children: ComponentChildren;
19+
}
20+
21+
function PreviewSection({ ariaLabel, intro, children }: PreviewSectionProperties) {
22+
return (
23+
<section class="result-preview layout-rail-reading layout-stack" aria-label={ariaLabel}>
24+
<div class="result-preview__header layout-stack layout-stack--tight">
25+
<p class="result-preview__label ui-eyebrow">Preview</p>
26+
{intro && <p class="result-preview__intro">{intro}</p>}
27+
</div>
28+
{children}
29+
</section>
30+
);
31+
}
32+
1333
export function ResultDisplay({
1434
result,
1535
workflowState,
1636
onCreateAnother,
1737
onRetryPreview,
1838
}: ResultDisplayProperties) {
1939
const [copyNotice, setCopyNotice] = useState('');
20-
const [showAllPreviewItems, setShowAllPreviewItems] = useState(false);
2140
const copyResetReference = useRef<ReturnType<typeof globalThis.setTimeout> | undefined>(undefined);
2241
const { feed, preview, workflowState: previewWorkflowState, warnings } = result;
2342

@@ -32,8 +51,6 @@ export function ResultDisplay({
3251
const canManuallyRetryPreview =
3352
previewWorkflowState === 'preview_failed' && warnings.some((warning) => warning.retryable);
3453
const isPreviewCheckInProgress = preview.isLoading;
35-
const previewItems = showAllPreviewItems ? preview.items : preview.items.slice(0, 3);
36-
const hasMorePreviewItems = preview.items.length > 3;
3754
const statusTitle = {
3855
created: 'Feed created',
3956
preview_loading: 'Checking preview',
@@ -63,10 +80,6 @@ export function ResultDisplay({
6380
};
6481
}, []);
6582

66-
useEffect(() => {
67-
setShowAllPreviewItems(false);
68-
}, [feed.feed_token]);
69-
7083
const copyToClipboard = async (text: string) => {
7184
try {
7285
await navigator.clipboard.writeText(text);
@@ -80,25 +93,19 @@ export function ResultDisplay({
8093

8194
return (
8295
<section class="result-shell layout-stack" aria-live="polite" data-state={workflowState}>
83-
<header
84-
class="result-hero ui-card ui-card--roomy ui-hero layout-rail-reading layout-stack"
85-
style={{ '--stack-gap': 'var(--space-3)' }}
86-
>
87-
<div class="result-hero__masthead ui-hero__masthead">
88-
<div class="result-hero__icon-wrap ui-hero__icon-wrap" aria-hidden="true">
89-
<img class="result-hero__icon ui-hero__icon" src="/feed.svg" alt="" />
90-
</div>
91-
<div class="layout-stack layout-stack--tight">
92-
<h1 class="result-title ui-display-title">{statusTitle}</h1>
96+
<ResultHero
97+
title={statusTitle}
98+
body={
99+
<>
93100
<p class="result-meta layout-rail-copy">{feed.name}</p>
94101
{statusMessage && <p class="field-help">{statusMessage}</p>}
95102
{showResultStatusNote && (
96103
<p class="result-status-note field-help field-help--warning">{previewMessage}</p>
97104
)}
98-
</div>
99-
</div>
100-
<div class="result-hero__actions ui-hero__actions">
101-
{canManuallyRetryPreview && (
105+
</>
106+
}
107+
actions={
108+
canManuallyRetryPreview && (
102109
<button
103110
type="button"
104111
class="btn btn--primary"
@@ -108,9 +115,9 @@ export function ResultDisplay({
108115
>
109116
{isPreviewCheckInProgress ? 'Checking...' : 'Check preview again'}
110117
</button>
111-
)}
112-
</div>
113-
</header>
118+
)
119+
}
120+
/>
114121

115122
<DominantField
116123
className="layout-rail-reading"
@@ -146,22 +153,15 @@ export function ResultDisplay({
146153
</div>
147154

148155
{preview.isLoading && (
149-
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview status">
150-
<div class="result-preview__header layout-stack layout-stack--tight">
151-
<p class="result-preview__label ui-eyebrow">Preview</p>
152-
</div>
156+
<PreviewSection ariaLabel="Feed preview status">
153157
<p class="field-help">{previewMessage}</p>
154-
</section>
158+
</PreviewSection>
155159
)}
156160

157161
{!preview.isLoading && hasPreviewItems && (
158-
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview">
159-
<div class="result-preview__header layout-stack layout-stack--tight">
160-
<p class="result-preview__label ui-eyebrow">Preview</p>
161-
<p class="result-preview__intro">Latest items from this feed</p>
162-
</div>
162+
<PreviewSection ariaLabel="Feed preview" intro="Latest items from this feed">
163163
<ul class="result-preview__list" role="list">
164-
{previewItems.map((item) => (
164+
{preview.items.map((item) => (
165165
<li key={`${item.title}-${item.publishedLabel || 'undated'}`}>
166166
<article class="preview-card ui-card layout-stack layout-stack--tight">
167167
<h2 class="preview-card__title">{item.title}</h2>
@@ -178,31 +178,13 @@ export function ResultDisplay({
178178
</li>
179179
))}
180180
</ul>
181-
{hasMorePreviewItems && (
182-
<button
183-
type="button"
184-
class="btn btn--quiet btn--linkish"
185-
onClick={() => setShowAllPreviewItems((current) => !current)}
186-
>
187-
{showAllPreviewItems ? 'Show fewer items' : `Show all ${preview.items.length} items`}
188-
</button>
189-
)}
190-
</section>
181+
</PreviewSection>
191182
)}
192183

193184
{showPreviewStatusOnly && (
194-
<section class="result-preview layout-rail-reading layout-stack" aria-label="Feed preview status">
195-
<div class="result-preview__header layout-stack layout-stack--tight">
196-
<p class="result-preview__label ui-eyebrow">Preview</p>
197-
</div>
198-
<p
199-
class={
200-
previewWorkflowState === 'preview_failed' ? 'field-help field-help--warning' : 'field-help'
201-
}
202-
>
203-
{previewMessage}
204-
</p>
205-
</section>
185+
<PreviewSection ariaLabel="Feed preview status">
186+
<p class="field-help field-help--warning">{previewMessage}</p>
187+
</PreviewSection>
206188
)}
207189

208190
{copyNotice && (
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { ComponentChildren } from 'preact';
2+
3+
interface ResultHeroProperties {
4+
title: string;
5+
body: ComponentChildren;
6+
actions?: ComponentChildren;
7+
}
8+
9+
export function ResultHero({ title, body, actions }: ResultHeroProperties) {
10+
return (
11+
<header
12+
class="result-hero ui-card ui-card--roomy ui-hero layout-rail-reading layout-stack"
13+
style={{ '--stack-gap': 'var(--space-3)' }}
14+
>
15+
<div class="result-hero__masthead ui-hero__masthead">
16+
<div class="result-hero__icon-wrap ui-hero__icon-wrap" aria-hidden="true">
17+
<img class="result-hero__icon ui-hero__icon" src="/feed.svg" alt="" />
18+
</div>
19+
<div class="layout-stack layout-stack--tight">
20+
<h1 class="result-title ui-display-title">{title}</h1>
21+
{body}
22+
</div>
23+
</div>
24+
<div class="result-hero__actions ui-hero__actions">{actions}</div>
25+
</header>
26+
);
27+
}

frontend/src/styles/main.css

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,8 +563,6 @@ a:focus-visible {
563563
padding: 0;
564564
width: 100%;
565565
list-style: none;
566-
display: grid;
567-
gap: 1rem;
568566
}
569567

570568
.preview-card {
@@ -575,6 +573,7 @@ a:focus-visible {
575573
linear-gradient(180deg, rgb(var(--color-rgb-white) / 4%), transparent 80%),
576574
rgb(var(--color-rgb-white) / 2%);
577575
box-shadow: inset 0 1px 0 rgb(var(--color-rgb-white) / 4%);
576+
margin-bottom: 1rem;
578577
}
579578

580579
.preview-card__title {
@@ -598,6 +597,8 @@ a:focus-visible {
598597
margin: 0;
599598
color: var(--text-body);
600599
font-size: var(--font-size-0);
600+
text-overflow: ellipsis;
601+
overflow: hidden;
601602
}
602603

603604
.preview-card__actions {

spec/html2rss/web/app_spec.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,17 @@ def app = described_class
9797
expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000')
9898
end
9999

100-
it 'serves the same root page in development', :aggregate_failures do
100+
it 'serves the SPA shell in development when built assets are absent', :aggregate_failures do
101+
allow(File).to receive(:exist?).and_call_original
102+
allow(File).to receive(:exist?).with(Html2rss::Web::App::FRONTEND_DIST_INDEX_PATH).and_return(false)
103+
101104
ClimateControl.modify('RACK_ENV' => 'development') do
102105
get '/'
103106
end
104107

105108
expect(last_response).to be_ok
106109
expect(last_response.body).to include('<div id="app"></div>')
110+
expect(last_response.body).to include('<script type="module" src="/src/main.tsx"></script>')
107111
end
108112

109113
it 'does not render SPA app routes as backend paths' do

0 commit comments

Comments
 (0)