Skip to content

Commit fb70150

Browse files
feat(bulk-exporter): rename to Content Exporter, add select-all-matching
Rebrands the app display name from Entry/Bulk Exporter to Content Exporter across the config screen, page, README, and app definition. Also adds a Gmail-style select-all-across-pages flow to the results table: once every row on the current page is checked, a banner offers to select all entries matching the active search, and export reuses the existing filtered-query export path so it scales beyond a single fetched page instead of collecting every ID client-side.
1 parent 1688b81 commit fb70150

6 files changed

Lines changed: 95 additions & 14 deletions

File tree

apps/bulk-exporter/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Entry Exporter for Contentful
1+
# Content Exporter for Contentful
22

33
A Contentful App that allows you to export unlimited entries from any content type to **5 different formats** (CSV, JSON, XLSX, XML, YAML), bypassing the 40-entry limitation of the Contentful web interface.
44

@@ -144,7 +144,7 @@ npm run deploy -- --organization-id YOUR_ORG_ID --definition-id YOUR_APP_DEF_ID
144144
### Search & Preview
145145

146146
1. Navigate to **Apps** in the Contentful web UI main menu
147-
2. Select **Entry Exporter**
147+
2. Select **Content Exporter**
148148
3. Use the tabbed interface to build your query:
149149

150150
#### Filter Tab

apps/bulk-exporter/contentful-app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"id": "1Oz4Ttx1lCCwdkiS4cEe11",
3-
"name": "Entry Exporter",
3+
"name": "Content Exporter",
44
"locations": [
55
{
66
"location": "app-config"

apps/bulk-exporter/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>Bulk Entry Exporter</title>
6+
<title>Content Exporter</title>
77
<link rel="preconnect" href="https://cdn.f36.contentful.com" />
88
<link rel="stylesheet" href="https://cdn.f36.contentful.com/font/geist/geist.css" />
99
<style>

apps/bulk-exporter/src/components/ResultsList.tsx

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ export interface ResultsListProps {
149149
format: 'csv' | 'json' | 'xlsx' | 'xml' | 'yaml',
150150
filename: string
151151
) => void;
152+
/** True once the user has clicked "Select all N entries matching this search" */
153+
selectAllMatching?: boolean;
154+
onSelectAllMatchingChange?: (value: boolean) => void;
155+
/** Exports every entry matching the current search filters, not just the fetched page(s) */
156+
onExportAllMatching?: (
157+
format: 'csv' | 'json' | 'xlsx' | 'xml' | 'yaml',
158+
filename: string
159+
) => void;
152160
contentTypeMap?: ContentTypeMap;
153161
userMap?: UserMap;
154162
spaceId?: string;
@@ -338,6 +346,9 @@ export function ResultsList({
338346
selectedIds = [],
339347
onSelectionChange,
340348
onExportSelected,
349+
selectAllMatching = false,
350+
onSelectAllMatchingChange,
351+
onExportAllMatching,
341352
contentTypeMap = {},
342353
userMap = {},
343354
spaceId = '',
@@ -503,6 +514,12 @@ export function ResultsList({
503514

504515
const handleSelectAll = () => {
505516
if (!onSelectionChange) return;
517+
if (selectAllMatching) {
518+
// Unchecking while every matching entry is selected drops back to no selection.
519+
onSelectAllMatchingChange?.(false);
520+
onSelectionChange([]);
521+
return;
522+
}
506523
if (allSelected) {
507524
onSelectionChange(selectedIds.filter((id) => !allCurrentIds.includes(id)));
508525
} else {
@@ -513,13 +530,31 @@ export function ResultsList({
513530

514531
const handleSelectOne = (id: string) => {
515532
if (!onSelectionChange) return;
533+
if (selectAllMatching) {
534+
// Deselecting a single row while every matching entry is selected falls back
535+
// to page-level selection (minus that row) rather than tracking exclusions.
536+
onSelectAllMatchingChange?.(false);
537+
onSelectionChange(allCurrentIds.filter((currentId) => currentId !== id));
538+
return;
539+
}
516540
if (selectedIds.includes(id)) {
517541
onSelectionChange(selectedIds.filter((selectedId) => selectedId !== id));
518542
} else {
519543
onSelectionChange([...selectedIds, id]);
520544
}
521545
};
522546

547+
const handleClearSelection = () => {
548+
onSelectAllMatchingChange?.(false);
549+
onSelectionChange?.([]);
550+
};
551+
552+
const showSelectAllBanner =
553+
!selectAllMatching &&
554+
allSelected &&
555+
totalCount !== undefined &&
556+
totalCount > allCurrentIds.length;
557+
523558
const getTitle = (entry: SearchResult): string => {
524559
if (!entry.fields) return entry.sys.id;
525560
const contentTypeId = entry.sys.contentType.sys.id;
@@ -606,18 +641,38 @@ export function ResultsList({
606641
</Flex>
607642

608643
{/* Selection bar */}
609-
{selectedIds.length > 0 && (
644+
{(selectedIds.length > 0 || selectAllMatching) && (
610645
<div style={{ padding: '0 24px' }}>
611646
<Flex
612647
alignItems="center"
613648
gap="spacingS"
649+
flexWrap="wrap"
614650
style={{
615651
padding: '12px 0',
616652
borderTop: `1px solid ${tokens.gray200}`,
617653
borderBottom: `1px solid ${tokens.gray200}`,
618654
}}>
619655
<Text fontSize="fontSizeS" fontColor="gray700">
620-
{selectedIds.length} {selectedIds.length === 1 ? 'entry' : 'entries'} selected:
656+
{selectAllMatching ? (
657+
<>
658+
All {(totalCount ?? selectedIds.length).toLocaleString()}{' '}
659+
{(totalCount ?? selectedIds.length) === 1 ? 'entry' : 'entries'} matching this
660+
search are selected.{' '}
661+
<TextLink as="button" onClick={handleClearSelection}>
662+
Clear selection
663+
</TextLink>
664+
</>
665+
) : showSelectAllBanner ? (
666+
<>
667+
All {allCurrentIds.length} {allCurrentIds.length === 1 ? 'entry' : 'entries'} on
668+
this page are selected.{' '}
669+
<TextLink as="button" onClick={() => onSelectAllMatchingChange?.(true)}>
670+
Select all {totalCount?.toLocaleString()} entries matching this search
671+
</TextLink>
672+
</>
673+
) : (
674+
`${selectedIds.length} ${selectedIds.length === 1 ? 'entry' : 'entries'} selected:`
675+
)}
621676
</Text>
622677
{onExportSelected && (
623678
<Button
@@ -635,7 +690,8 @@ export function ResultsList({
635690
{/* Table — horizontal scroll when field columns overflow.
636691
tableLayout: fixed lets us set exact column widths so sticky
637692
left offsets are reliable pixel values. */}
638-
<div style={{ padding: `${selectedIds.length > 0 ? '24px' : '0'} 24px 24px` }}>
693+
<div
694+
style={{ padding: `${selectedIds.length > 0 || selectAllMatching ? '24px' : '0'} 24px 24px` }}>
639695
<div style={{ border: '1px solid #E7EBEE', borderRadius: '6px', overflow: 'hidden' }}>
640696
<div style={{ overflowX: 'auto', width: '100%' }}>
641697
<Table
@@ -672,8 +728,8 @@ export function ResultsList({
672728
}}>
673729
{onSelectionChange && (
674730
<Checkbox
675-
isChecked={allSelected}
676-
isIndeterminate={someSelected}
731+
isChecked={allSelected || selectAllMatching}
732+
isIndeterminate={someSelected && !selectAllMatching}
677733
onChange={handleSelectAll}
678734
aria-label="Select all entries on this page"
679735
/>
@@ -900,7 +956,10 @@ export function ResultsList({
900956
<div style={{ padding: '16px 24px 0' }}>
901957
{isExporting ? (
902958
<Text>
903-
{exportProgress?.message || `Exporting ${selectedIds.length} selected entries`}
959+
{exportProgress?.message ||
960+
(selectAllMatching
961+
? `Exporting ${(totalCount ?? 0).toLocaleString()} matching entries`
962+
: `Exporting ${selectedIds.length} selected entries`)}
904963
</Text>
905964
) : (
906965
<>
@@ -944,6 +1003,11 @@ export function ResultsList({
9441003
isDisabled={isExporting}
9451004
onClick={() => {
9461005
const today = new Date().toISOString().split('T')[0];
1006+
if (selectAllMatching) {
1007+
const resolvedFilename = exportFilename.trim() || `all-matching-${today}`;
1008+
onExportAllMatching?.(exportFormat, resolvedFilename);
1009+
return;
1010+
}
9471011
const resolvedFilename =
9481012
exportFilename.trim() || `selected-${selectedIds.length}-entries-${today}`;
9491013
onExportSelected?.(selectedIds, exportFormat, resolvedFilename);

apps/bulk-exporter/src/locations/ConfigScreen.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,15 @@ const ConfigScreen = () => {
4040
<Box padding="spacingXl" style={{ maxWidth: '900px', margin: '0 auto' }}>
4141
<Flex flexDirection="column" gap="spacingXl" alignItems="stretch">
4242
<Flex flexDirection="column" gap="spacingS" alignItems="flex-start" style={fullWidth}>
43-
<Heading>Bulk Exporter</Heading>
43+
<Heading>Content Exporter</Heading>
4444
<Paragraph>
4545
Export entries from Contentful with filters, saved field selections, and multiple file
4646
formats. No additional configuration is required before installation.
4747
</Paragraph>
4848
</Flex>
4949

5050
<Note variant="positive" title="Ready to install">
51-
Bulk Exporter adds a page to the Apps menu. After installation, users with access to this
51+
Content Exporter adds a page to the Apps menu. After installation, users with access to this
5252
space can open the page and export entries from the content types they are allowed to
5353
read.
5454
</Note>
@@ -100,7 +100,7 @@ const ConfigScreen = () => {
100100
<Note variant="primary" title="Permissions">
101101
<Flex flexDirection="column" gap="spacingXs" alignItems="flex-start" style={fullWidth}>
102102
<Text>
103-
Bulk Exporter can only export entries, tags, locales, and taxonomy data that the
103+
Content Exporter can only export entries, tags, locales, and taxonomy data that the
104104
current user is allowed to access.
105105
</Text>
106106
<Text>

apps/bulk-exporter/src/locations/Page.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const Page = () => {
7070
const ITEMS_PER_PAGE = 50;
7171
const [lastFormData, setLastFormData] = useState<ExportFormData | null>(null);
7272
const [selectedEntryIds, setSelectedEntryIds] = useState<string[]>([]);
73+
const [selectAllMatching, setSelectAllMatching] = useState(false);
7374
const [contentTypeMap, setContentTypeMap] = useState<
7475
Record<string, { name: string; displayField?: string }>
7576
>({});
@@ -231,6 +232,7 @@ const Page = () => {
231232
setIsSearching(true);
232233
setSearchResults([]);
233234
setSelectedEntryIds([]);
235+
setSelectAllMatching(false);
234236
setActivePage(0);
235237
setLastFormData(data);
236238

@@ -340,6 +342,7 @@ const Page = () => {
340342
setIsSearching(true);
341343
setActivePage(page);
342344
setSelectedEntryIds([]);
345+
setSelectAllMatching(false);
343346

344347
const response = await sdk.cma.entry.getMany({
345348
query: { ...lastSearchQuery, limit: ITEMS_PER_PAGE, skip: page * ITEMS_PER_PAGE },
@@ -431,6 +434,17 @@ const Page = () => {
431434
}
432435
};
433436

437+
// Exports every entry matching the current filters (not just the fetched page),
438+
// reusing the same filtered query the main "Export" flow uses so it scales to
439+
// any result size instead of requiring every ID to be collected client-side.
440+
const handleExportAllMatching = (
441+
format: 'csv' | 'json' | 'xlsx' | 'xml' | 'yaml',
442+
filename: string
443+
) => {
444+
if (!lastFormData) return;
445+
handleExport({ ...lastFormData, format, customFilename: filename });
446+
};
447+
434448
if (loading) {
435449
return (
436450
<Flex
@@ -449,7 +463,7 @@ const Page = () => {
449463
<Box style={{ maxWidth: '1400px', margin: '0 auto', width: '100%' }}>
450464
<Flex flexDirection="column" alignItems="stretch" gap="spacingL" padding="spacingL">
451465
<Box style={{ width: '100%', maxWidth: '1040px' }}>
452-
<Heading marginBottom="spacingS">Entry Exporter</Heading>
466+
<Heading marginBottom="spacingS">Content Exporter</Heading>
453467
<Paragraph marginBottom="none" style={{ maxWidth: '700px' }}>
454468
Search, preview, and export Contentful entries across one content type or the whole
455469
space. Use filters to narrow the result set, then export matching or selected entries.
@@ -509,6 +523,9 @@ const Page = () => {
509523
selectedIds={selectedEntryIds}
510524
onSelectionChange={setSelectedEntryIds}
511525
onExportSelected={handleExportSelected}
526+
selectAllMatching={selectAllMatching}
527+
onSelectAllMatchingChange={setSelectAllMatching}
528+
onExportAllMatching={handleExportAllMatching}
512529
contentTypeMap={contentTypeMap}
513530
userMap={userMap}
514531
spaceId={sdk.ids.space}

0 commit comments

Comments
 (0)