Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/bulk-exporter/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Entry Exporter for Contentful
# Content Exporter for Contentful

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.

Expand Down Expand Up @@ -144,7 +144,7 @@ npm run deploy -- --organization-id YOUR_ORG_ID --definition-id YOUR_APP_DEF_ID
### Search & Preview

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

#### Filter Tab
Expand Down
2 changes: 1 addition & 1 deletion apps/bulk-exporter/contentful-app.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "1Oz4Ttx1lCCwdkiS4cEe11",
"name": "Entry Exporter",
"name": "Content Exporter",
"locations": [
{
"location": "app-config"
Expand Down
2 changes: 1 addition & 1 deletion apps/bulk-exporter/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bulk Entry Exporter</title>
<title>Content Exporter</title>
<link rel="preconnect" href="https://cdn.f36.contentful.com" />
<link rel="stylesheet" href="https://cdn.f36.contentful.com/font/geist/geist.css" />
<style>
Expand Down
82 changes: 75 additions & 7 deletions apps/bulk-exporter/src/components/ResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ export interface ResultsListProps {
format: 'csv' | 'json' | 'xlsx' | 'xml' | 'yaml',
filename: string
) => void;
/** True once the user has clicked "Select all N entries matching this search" */
selectAllMatching?: boolean;
onSelectAllMatchingChange?: (value: boolean) => void;
/** Exports every entry matching the current search filters, not just the fetched page(s) */
onExportAllMatching?: (
format: 'csv' | 'json' | 'xlsx' | 'xml' | 'yaml',
filename: string
) => void;
contentTypeMap?: ContentTypeMap;
userMap?: UserMap;
spaceId?: string;
Expand Down Expand Up @@ -338,6 +346,9 @@ export function ResultsList({
selectedIds = [],
onSelectionChange,
onExportSelected,
selectAllMatching = false,
onSelectAllMatchingChange,
onExportAllMatching,
contentTypeMap = {},
userMap = {},
spaceId = '',
Expand Down Expand Up @@ -503,6 +514,12 @@ export function ResultsList({

const handleSelectAll = () => {
if (!onSelectionChange) return;
if (selectAllMatching) {
// Unchecking while every matching entry is selected drops back to no selection.
onSelectAllMatchingChange?.(false);
onSelectionChange([]);
return;
}
if (allSelected) {
onSelectionChange(selectedIds.filter((id) => !allCurrentIds.includes(id)));
} else {
Expand All @@ -513,13 +530,31 @@ export function ResultsList({

const handleSelectOne = (id: string) => {
if (!onSelectionChange) return;
if (selectAllMatching) {
// Deselecting a single row while every matching entry is selected falls back
// to page-level selection (minus that row) rather than tracking exclusions.
onSelectAllMatchingChange?.(false);
onSelectionChange(allCurrentIds.filter((currentId) => currentId !== id));
return;
}
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter((selectedId) => selectedId !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};

const handleClearSelection = () => {
onSelectAllMatchingChange?.(false);
onSelectionChange?.([]);
};

const showSelectAllBanner =
!selectAllMatching &&
allSelected &&
totalCount !== undefined &&
totalCount > allCurrentIds.length;

const getTitle = (entry: SearchResult): string => {
if (!entry.fields) return entry.sys.id;
const contentTypeId = entry.sys.contentType.sys.id;
Expand Down Expand Up @@ -606,18 +641,40 @@ export function ResultsList({
</Flex>

{/* Selection bar */}
{selectedIds.length > 0 && (
{(selectedIds.length > 0 || selectAllMatching) && (
<div style={{ padding: '0 24px' }}>
<Flex
alignItems="center"
gap="spacingS"
flexWrap="wrap"
style={{
padding: '12px 0',
borderTop: `1px solid ${tokens.gray200}`,
borderBottom: `1px solid ${tokens.gray200}`,
}}>
<Text fontSize="fontSizeS" fontColor="gray700">
{selectedIds.length} {selectedIds.length === 1 ? 'entry' : 'entries'} selected:
<Text fontSize="fontSizeM" fontColor="gray700">
{selectAllMatching ? (
<>
All {(totalCount ?? selectedIds.length).toLocaleString()}{' '}
{(totalCount ?? selectedIds.length) === 1 ? 'entry' : 'entries'} matching this
search are selected.{' '}
<TextLink as="button" onClick={handleClearSelection}>
Clear selection
</TextLink>
</>
) : showSelectAllBanner ? (
<>
All {allCurrentIds.length} {allCurrentIds.length === 1 ? 'entry' : 'entries'} on
this page are selected.{' '}
<TextLink as="button" onClick={() => onSelectAllMatchingChange?.(true)}>
Select all {totalCount?.toLocaleString()} entries matching this search
</TextLink>
</>
) : (
`${selectedIds.length} ${
selectedIds.length === 1 ? 'entry' : 'entries'
} selected:`
)}
</Text>
{onExportSelected && (
<Button
Expand All @@ -635,7 +692,10 @@ export function ResultsList({
{/* Table — horizontal scroll when field columns overflow.
tableLayout: fixed lets us set exact column widths so sticky
left offsets are reliable pixel values. */}
<div style={{ padding: `${selectedIds.length > 0 ? '24px' : '0'} 24px 24px` }}>
<div
style={{
padding: `${selectedIds.length > 0 || selectAllMatching ? '24px' : '0'} 24px 24px`,
}}>
<div style={{ border: '1px solid #E7EBEE', borderRadius: '6px', overflow: 'hidden' }}>
<div style={{ overflowX: 'auto', width: '100%' }}>
<Table
Expand Down Expand Up @@ -672,8 +732,8 @@ export function ResultsList({
}}>
{onSelectionChange && (
<Checkbox
isChecked={allSelected}
isIndeterminate={someSelected}
isChecked={allSelected || selectAllMatching}
isIndeterminate={someSelected && !selectAllMatching}
onChange={handleSelectAll}
aria-label="Select all entries on this page"
/>
Expand Down Expand Up @@ -900,7 +960,10 @@ export function ResultsList({
<div style={{ padding: '16px 24px 0' }}>
{isExporting ? (
<Text>
{exportProgress?.message || `Exporting ${selectedIds.length} selected entries`}
{exportProgress?.message ||
(selectAllMatching
? `Exporting ${(totalCount ?? 0).toLocaleString()} matching entries`
: `Exporting ${selectedIds.length} selected entries`)}
</Text>
) : (
<>
Expand Down Expand Up @@ -944,6 +1007,11 @@ export function ResultsList({
isDisabled={isExporting}
onClick={() => {
const today = new Date().toISOString().split('T')[0];
if (selectAllMatching) {
const resolvedFilename = exportFilename.trim() || `all-matching-${today}`;
onExportAllMatching?.(exportFormat, resolvedFilename);
return;
}
const resolvedFilename =
exportFilename.trim() || `selected-${selectedIds.length}-entries-${today}`;
onExportSelected?.(selectedIds, exportFormat, resolvedFilename);
Expand Down
8 changes: 4 additions & 4 deletions apps/bulk-exporter/src/locations/ConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ const ConfigScreen = () => {
<Box padding="spacingXl" style={{ maxWidth: '900px', margin: '0 auto' }}>
<Flex flexDirection="column" gap="spacingXl" alignItems="stretch">
<Flex flexDirection="column" gap="spacingS" alignItems="flex-start" style={fullWidth}>
<Heading>Bulk Exporter</Heading>
<Heading>Content Exporter</Heading>
<Paragraph>
Export entries from Contentful with filters, saved field selections, and multiple file
formats. No additional configuration is required before installation.
</Paragraph>
</Flex>

<Note variant="positive" title="Ready to install">
Bulk Exporter adds a page to the Apps menu. After installation, users with access to this
space can open the page and export entries from the content types they are allowed to
Content Exporter adds a page to the Apps menu. After installation, users with access to
this space can open the page and export entries from the content types they are allowed to
read.
</Note>

Expand Down Expand Up @@ -100,7 +100,7 @@ const ConfigScreen = () => {
<Note variant="primary" title="Permissions">
<Flex flexDirection="column" gap="spacingXs" alignItems="flex-start" style={fullWidth}>
<Text>
Bulk Exporter can only export entries, tags, locales, and taxonomy data that the
Content Exporter can only export entries, tags, locales, and taxonomy data that the
current user is allowed to access.
</Text>
<Text>
Expand Down
19 changes: 18 additions & 1 deletion apps/bulk-exporter/src/locations/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const Page = () => {
const ITEMS_PER_PAGE = 50;
const [lastFormData, setLastFormData] = useState<ExportFormData | null>(null);
const [selectedEntryIds, setSelectedEntryIds] = useState<string[]>([]);
const [selectAllMatching, setSelectAllMatching] = useState(false);
const [contentTypeMap, setContentTypeMap] = useState<
Record<string, { name: string; displayField?: string }>
>({});
Expand Down Expand Up @@ -231,6 +232,7 @@ const Page = () => {
setIsSearching(true);
setSearchResults([]);
setSelectedEntryIds([]);
setSelectAllMatching(false);
setActivePage(0);
setLastFormData(data);

Expand Down Expand Up @@ -340,6 +342,7 @@ const Page = () => {
setIsSearching(true);
setActivePage(page);
setSelectedEntryIds([]);
setSelectAllMatching(false);

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

// Exports every entry matching the current filters (not just the fetched page),
// reusing the same filtered query the main "Export" flow uses so it scales to
// any result size instead of requiring every ID to be collected client-side.
const handleExportAllMatching = (
format: 'csv' | 'json' | 'xlsx' | 'xml' | 'yaml',
filename: string
) => {
if (!lastFormData) return;
handleExport({ ...lastFormData, format, customFilename: filename });
};

if (loading) {
return (
<Flex
Expand All @@ -449,7 +463,7 @@ const Page = () => {
<Box style={{ maxWidth: '1400px', margin: '0 auto', width: '100%' }}>
<Flex flexDirection="column" alignItems="stretch" gap="spacingL" padding="spacingL">
<Box style={{ width: '100%', maxWidth: '1040px' }}>
<Heading marginBottom="spacingS">Entry Exporter</Heading>
<Heading marginBottom="spacingS">Content Exporter</Heading>
<Paragraph marginBottom="none" style={{ maxWidth: '700px' }}>
Search, preview, and export Contentful entries across one content type or the whole
space. Use filters to narrow the result set, then export matching or selected entries.
Expand Down Expand Up @@ -509,6 +523,9 @@ const Page = () => {
selectedIds={selectedEntryIds}
onSelectionChange={setSelectedEntryIds}
onExportSelected={handleExportSelected}
selectAllMatching={selectAllMatching}
onSelectAllMatchingChange={setSelectAllMatching}
onExportAllMatching={handleExportAllMatching}
contentTypeMap={contentTypeMap}
userMap={userMap}
spaceId={sdk.ids.space}
Expand Down
Loading