Skip to content

Commit 3464ffb

Browse files
feat(bulk-exporter): rename to Content Exporter, add select-all-matching [] (#11277)
* 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. * fix(bulk-exporter): match selection banner font sizes Bump the selection bar sentence text from fontSizeS to fontSizeM so it matches the TextLink action text's default size, fixing a visual mismatch between the two. * style(bulk-exporter): fix prettier formatting CI's prettier check flagged these files; run prettier --write to resolve. * style(bulk-exporter): reformat with repo's pinned Prettier 2.8.8 Previous fix used an auto-installed Prettier 3 (different default formatting), which didn't match the repo's pinned 2.8.8 + root .prettierrc used by CI.
1 parent 1688b81 commit 3464ffb

6 files changed

Lines changed: 101 additions & 16 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: 75 additions & 7 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,40 @@ 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
}}>
619-
<Text fontSize="fontSizeS" fontColor="gray700">
620-
{selectedIds.length} {selectedIds.length === 1 ? 'entry' : 'entries'} selected:
655+
<Text fontSize="fontSizeM" fontColor="gray700">
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} ${
675+
selectedIds.length === 1 ? 'entry' : 'entries'
676+
} selected:`
677+
)}
621678
</Text>
622679
{onExportSelected && (
623680
<Button
@@ -635,7 +692,10 @@ export function ResultsList({
635692
{/* Table — horizontal scroll when field columns overflow.
636693
tableLayout: fixed lets us set exact column widths so sticky
637694
left offsets are reliable pixel values. */}
638-
<div style={{ padding: `${selectedIds.length > 0 ? '24px' : '0'} 24px 24px` }}>
695+
<div
696+
style={{
697+
padding: `${selectedIds.length > 0 || selectAllMatching ? '24px' : '0'} 24px 24px`,
698+
}}>
639699
<div style={{ border: '1px solid #E7EBEE', borderRadius: '6px', overflow: 'hidden' }}>
640700
<div style={{ overflowX: 'auto', width: '100%' }}>
641701
<Table
@@ -672,8 +732,8 @@ export function ResultsList({
672732
}}>
673733
{onSelectionChange && (
674734
<Checkbox
675-
isChecked={allSelected}
676-
isIndeterminate={someSelected}
735+
isChecked={allSelected || selectAllMatching}
736+
isIndeterminate={someSelected && !selectAllMatching}
677737
onChange={handleSelectAll}
678738
aria-label="Select all entries on this page"
679739
/>
@@ -900,7 +960,10 @@ export function ResultsList({
900960
<div style={{ padding: '16px 24px 0' }}>
901961
{isExporting ? (
902962
<Text>
903-
{exportProgress?.message || `Exporting ${selectedIds.length} selected entries`}
963+
{exportProgress?.message ||
964+
(selectAllMatching
965+
? `Exporting ${(totalCount ?? 0).toLocaleString()} matching entries`
966+
: `Exporting ${selectedIds.length} selected entries`)}
904967
</Text>
905968
) : (
906969
<>
@@ -944,6 +1007,11 @@ export function ResultsList({
9441007
isDisabled={isExporting}
9451008
onClick={() => {
9461009
const today = new Date().toISOString().split('T')[0];
1010+
if (selectAllMatching) {
1011+
const resolvedFilename = exportFilename.trim() || `all-matching-${today}`;
1012+
onExportAllMatching?.(exportFormat, resolvedFilename);
1013+
return;
1014+
}
9471015
const resolvedFilename =
9481016
exportFilename.trim() || `selected-${selectedIds.length}-entries-${today}`;
9491017
onExportSelected?.(selectedIds, exportFormat, resolvedFilename);

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,16 @@ 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
52-
space can open the page and export entries from the content types they are allowed to
51+
Content Exporter adds a page to the Apps menu. After installation, users with access to
52+
this space can open the page and export entries from the content types they are allowed to
5353
read.
5454
</Note>
5555

@@ -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)