Skip to content

Commit 9b2fe4c

Browse files
feat(drive-integration): View button opens entries created modal with titles and status badges
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d728407 commit 9b2fe4c

3 files changed

Lines changed: 229 additions & 89 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { useEffect, useState } from 'react';
2+
import {
3+
Badge,
4+
Button,
5+
Flex,
6+
Modal,
7+
Spinner,
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableRow,
12+
Text,
13+
TextLink,
14+
} from '@contentful/f36-components';
15+
import { PageAppSDK } from '@contentful/app-sdk';
16+
17+
interface EntryRow {
18+
id: string;
19+
title: string;
20+
contentTypeName: string;
21+
status: 'Draft' | 'Published' | 'Changed';
22+
}
23+
24+
interface EntriesCreatedModalProps {
25+
isOpen: boolean;
26+
onClose: () => void;
27+
sdk: PageAppSDK;
28+
spaceId: string;
29+
webappHost: string;
30+
entryIds: string[];
31+
}
32+
33+
async function fetchEntryRows(
34+
sdk: PageAppSDK,
35+
spaceId: string,
36+
entryIds: string[]
37+
): Promise<EntryRow[]> {
38+
const environmentId = sdk.ids.environmentAlias ?? sdk.ids.environment;
39+
40+
const entries = await Promise.all(
41+
entryIds.map((id) =>
42+
sdk.cma.entry.get({ entryId: id, spaceId, environmentId }).catch(() => null)
43+
)
44+
);
45+
46+
const contentTypeIds = [
47+
...new Set(entries.filter(Boolean).map((e) => e!.sys.contentType.sys.id)),
48+
];
49+
const contentTypes = await Promise.all(
50+
contentTypeIds.map((ctId) =>
51+
sdk.cma.contentType.get({ contentTypeId: ctId, spaceId, environmentId }).catch(() => null)
52+
)
53+
);
54+
const ctMap = new Map(contentTypes.filter(Boolean).map((ct) => [ct!.sys.id, ct!]));
55+
56+
return entries
57+
.map((entry, i) => {
58+
if (!entry) return null;
59+
const ct = ctMap.get(entry.sys.contentType.sys.id);
60+
const displayField = ct?.displayField;
61+
const locale = sdk.locales.default;
62+
const title =
63+
(displayField && String(entry.fields[displayField]?.[locale] ?? '')) || 'Untitled';
64+
const contentTypeName = ct?.name ?? entry.sys.contentType.sys.id;
65+
66+
const isPublished = !!entry.sys.publishedAt;
67+
const isDraft = !isPublished;
68+
const isChanged = isPublished && entry.sys.version > (entry.sys.publishedVersion ?? 0) + 1;
69+
const status: EntryRow['status'] = isChanged ? 'Changed' : isDraft ? 'Draft' : 'Published';
70+
71+
return { id: entryIds[i], title, contentTypeName, status };
72+
})
73+
.filter((r): r is EntryRow => r !== null);
74+
}
75+
76+
const STATUS_STYLES: Record<EntryRow['status'], React.CSSProperties> = {
77+
Draft: { background: '#FEF3C7', color: '#92400E', border: 'none' },
78+
Published: { background: '#D1FAE5', color: '#065F46', border: 'none' },
79+
Changed: { background: '#DBEAFE', color: '#1E40AF', border: 'none' },
80+
};
81+
82+
export function EntriesCreatedModal({
83+
isOpen,
84+
onClose,
85+
sdk,
86+
spaceId,
87+
webappHost,
88+
entryIds,
89+
}: EntriesCreatedModalProps) {
90+
const [rows, setRows] = useState<EntryRow[]>([]);
91+
const [isLoading, setIsLoading] = useState(false);
92+
93+
useEffect(() => {
94+
if (!isOpen || entryIds.length === 0) return;
95+
setIsLoading(true);
96+
fetchEntryRows(sdk, spaceId, entryIds)
97+
.then(setRows)
98+
.catch(() => setRows([]))
99+
.finally(() => setIsLoading(false));
100+
}, [isOpen, entryIds.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
101+
102+
return (
103+
<Modal isShown={isOpen} onClose={onClose} size="large">
104+
{() => (
105+
<>
106+
<Modal.Header title="Entries created" onClose={onClose} />
107+
<Modal.Content>
108+
{isLoading ? (
109+
<Flex justifyContent="center" padding="spacingL">
110+
<Spinner size="large" />
111+
</Flex>
112+
) : (
113+
<Table>
114+
<TableBody>
115+
{rows.map((row) => (
116+
<TableRow key={row.id}>
117+
<TableCell>
118+
<TextLink
119+
href={`https://${webappHost}/spaces/${spaceId}/entries/${row.id}`}
120+
target="_blank"
121+
rel="noopener noreferrer">
122+
<Text fontWeight="fontWeightMedium">{row.title}</Text>
123+
</TextLink>
124+
</TableCell>
125+
<TableCell style={{ width: '140px', verticalAlign: 'middle' }}>
126+
<Badge variant="secondary" style={STATUS_STYLES[row.status]}>
127+
{row.status}
128+
</Badge>
129+
</TableCell>
130+
</TableRow>
131+
))}
132+
</TableBody>
133+
</Table>
134+
)}
135+
</Modal.Content>
136+
<Modal.Controls>
137+
<Button variant="secondary" onClick={onClose}>
138+
Done
139+
</Button>
140+
</Modal.Controls>
141+
</>
142+
)}
143+
</Modal>
144+
);
145+
}

apps/drive-integration/src/locations/Page/components/runs/RunRow.tsx

Lines changed: 83 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import {
66
TableCell,
77
TableRow,
88
Text,
9-
TextLink,
109
Tooltip,
1110
} from '@contentful/f36-components';
11+
import { PageAppSDK } from '@contentful/app-sdk';
1212
import { DisplayStatus } from '../../../../types/runs';
1313
import type { RunWithStatus } from '../../../../types/runs';
14+
import { EntriesCreatedModal } from './EntriesCreatedModal';
1415

1516
interface RunRowProps {
1617
run: RunWithStatus;
18+
sdk: PageAppSDK;
1719
spaceId: string;
1820
webappHost: string;
1921
onReview: (runId: string) => void;
@@ -99,8 +101,9 @@ function StatusBadge({
99101
}
100102
}
101103

102-
export function RunRow({ run, spaceId, webappHost, onReview, onRetry }: RunRowProps) {
104+
export function RunRow({ run, sdk, spaceId, webappHost, onReview, onRetry }: RunRowProps) {
103105
const [isRetrying, setIsRetrying] = useState(false);
106+
const [isModalOpen, setIsModalOpen] = useState(false);
104107

105108
const handleRetry = async () => {
106109
setIsRetrying(true);
@@ -112,98 +115,89 @@ export function RunRow({ run, spaceId, webappHost, onReview, onRetry }: RunRowPr
112115
};
113116

114117
return (
115-
<TableRow>
116-
{/* Name */}
117-
<TableCell>
118-
<Text fontWeight="fontWeightMedium">{run.documentTitle}</Text>
118+
<>
119+
<TableRow>
120+
{/* Name */}
121+
<TableCell>
122+
<Text fontWeight="fontWeightMedium">{run.documentTitle}</Text>
123+
</TableCell>
119124

120-
{/* Entry links for completed runs */}
121-
{run.displayStatus === DisplayStatus.COMPLETED &&
122-
run.createdEntryIds &&
123-
run.createdEntryIds.length > 0 && (
124-
<Flex gap="spacingXs" flexWrap="wrap" marginTop="spacing2Xs">
125-
{run.createdEntryIds.map((entryId, index) => (
126-
<TextLink
127-
key={entryId}
128-
href={`https://${webappHost}/spaces/${spaceId}/entries/${entryId}`}
129-
target="_blank"
130-
rel="noopener noreferrer"
131-
style={{ fontSize: '12px' }}>
132-
{run.createdEntryIds!.length === 1 ? 'View entry' : `Entry ${index + 1}`}
133-
</TextLink>
125+
{/* Created date */}
126+
<TableCell>
127+
<Text fontSize="fontSizeS" fontColor="gray600">
128+
{formatDate(run.startedAt)}
129+
</Text>
130+
</TableCell>
131+
132+
{/* Status badge */}
133+
<TableCell style={{ verticalAlign: 'middle' }}>
134+
<StatusBadge
135+
status={run.displayStatus}
136+
errorMessage={run.errorMessage}
137+
entryCount={run.createdEntryIds?.length}
138+
/>
139+
</TableCell>
140+
141+
{/* Action */}
142+
<TableCell style={{ verticalAlign: 'middle' }}>
143+
{run.displayStatus === DisplayStatus.RUNNING && (
144+
<Flex gap="spacing2Xs" alignItems="center">
145+
{[0, 1, 2].map((i) => (
146+
<div
147+
key={i}
148+
style={{
149+
width: '5px',
150+
height: '5px',
151+
borderRadius: '50%',
152+
background: '#536171',
153+
animation: `driveDotBounce 1.2s ease-in-out ${i * 0.2}s infinite`,
154+
}}
155+
/>
134156
))}
157+
<style>{`
158+
@keyframes driveDotBounce {
159+
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
160+
40% { transform: translateY(-5px); opacity: 1; }
161+
}
162+
`}</style>
135163
</Flex>
136164
)}
137-
</TableCell>
138-
139-
{/* Created date */}
140-
<TableCell>
141-
<Text fontSize="fontSizeS" fontColor="gray600">
142-
{formatDate(run.startedAt)}
143-
</Text>
144-
</TableCell>
165+
{run.displayStatus === DisplayStatus.NEEDS_REVIEW && (
166+
<Button variant="secondary" size="small" onClick={() => onReview(run.runId)}>
167+
Review
168+
</Button>
169+
)}
170+
{run.displayStatus === DisplayStatus.COMPLETED &&
171+
run.createdEntryIds &&
172+
run.createdEntryIds.length > 0 && (
173+
<Button variant="secondary" size="small" onClick={() => setIsModalOpen(true)}>
174+
View
175+
</Button>
176+
)}
177+
{(run.displayStatus === DisplayStatus.FAILED ||
178+
run.displayStatus === DisplayStatus.EXPIRED) && (
179+
<Button
180+
variant="secondary"
181+
size="small"
182+
onClick={() => void handleRetry()}
183+
isLoading={isRetrying}
184+
isDisabled={isRetrying}>
185+
Retry
186+
</Button>
187+
)}
188+
</TableCell>
189+
</TableRow>
145190

146-
{/* Status badge */}
147-
<TableCell style={{ verticalAlign: 'middle' }}>
148-
<StatusBadge
149-
status={run.displayStatus}
150-
errorMessage={run.errorMessage}
151-
entryCount={run.createdEntryIds?.length}
191+
{run.createdEntryIds && run.createdEntryIds.length > 0 && (
192+
<EntriesCreatedModal
193+
isOpen={isModalOpen}
194+
onClose={() => setIsModalOpen(false)}
195+
sdk={sdk}
196+
spaceId={spaceId}
197+
webappHost={webappHost}
198+
entryIds={run.createdEntryIds}
152199
/>
153-
</TableCell>
154-
155-
{/* Action */}
156-
<TableCell style={{ verticalAlign: 'middle' }}>
157-
{run.displayStatus === DisplayStatus.RUNNING && (
158-
<Flex gap="spacing2Xs" alignItems="center">
159-
{[0, 1, 2].map((i) => (
160-
<div
161-
key={i}
162-
style={{
163-
width: '5px',
164-
height: '5px',
165-
borderRadius: '50%',
166-
background: '#536171',
167-
animation: `driveDotBounce 1.2s ease-in-out ${i * 0.2}s infinite`,
168-
}}
169-
/>
170-
))}
171-
<style>{`
172-
@keyframes driveDotBounce {
173-
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
174-
40% { transform: translateY(-5px); opacity: 1; }
175-
}
176-
`}</style>
177-
</Flex>
178-
)}
179-
{run.displayStatus === DisplayStatus.NEEDS_REVIEW && (
180-
<Button variant="secondary" size="small" onClick={() => onReview(run.runId)}>
181-
Review
182-
</Button>
183-
)}
184-
{run.displayStatus === DisplayStatus.COMPLETED && run.createdEntryIds?.length === 1 && (
185-
<Button
186-
as="a"
187-
variant="secondary"
188-
size="small"
189-
href={`https://${webappHost}/spaces/${spaceId}/entries/${run.createdEntryIds[0]}`}
190-
target="_blank"
191-
rel="noopener noreferrer">
192-
View
193-
</Button>
194-
)}
195-
{(run.displayStatus === DisplayStatus.FAILED ||
196-
run.displayStatus === DisplayStatus.EXPIRED) && (
197-
<Button
198-
variant="secondary"
199-
size="small"
200-
onClick={() => void handleRetry()}
201-
isLoading={isRetrying}
202-
isDisabled={isRetrying}>
203-
Retry
204-
</Button>
205-
)}
206-
</TableCell>
207-
</TableRow>
200+
)}
201+
</>
208202
);
209203
}

apps/drive-integration/src/locations/Page/components/runs/RunsPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export function RunsPage({
285285
<RunRow
286286
key={run.runId}
287287
run={run}
288+
sdk={sdk}
288289
spaceId={spaceId}
289290
webappHost={webappHost}
290291
onReview={onReviewRun}

0 commit comments

Comments
 (0)