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
6 changes: 4 additions & 2 deletions src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions src/components/payload/ExportDownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import { Button, useDocumentInfo, useTranslation } from "@payloadcms/ui";

/**
* Renders an always-available "Download" control for a saved Export doc.
*
* The exports collection denies update access to everyone by design
* (`access.update: () => false` in @payloadcms/plugin-import-export's
* getExportCollection.js — exports are immutable once generated). Payload's
* Edit view only mounts the `edit.SaveButton` slot when the viewer has
* update permission (see @payloadcms/ui's DocumentControls and
* @payloadcms/next's renderDocumentSlots), so on a saved doc that slot
* never renders at all — not merely disabled. That's why the plugin's own
* Download button (and our ExportSaveButtonFixed fork of it) disappears
* the moment you navigate away and back.
*
* `beforeDocumentControls` is rendered unconditionally, regardless of save
* permission, so it's the right slot for a control that only needs read
* access to the file already attached to the doc.
*/
export const ExportDownloadButton = () => {
const { t } = useTranslation();
const { id, savedDocumentData } = useDocumentInfo();
const url =
typeof savedDocumentData?.url === "string" ? savedDocumentData.url : undefined;
const filename =
typeof savedDocumentData?.filename === "string"
? savedDocumentData.filename
: undefined;

if (!id || !url) {
return null;
}

const handleClick = () => {
const a = document.createElement("a");
a.href = url;
a.download = filename ?? "";
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};

return (
<Button size="medium" type="button" onClick={handleClick}>
{t("upload:download")}
</Button>
);
};
140 changes: 140 additions & 0 deletions src/components/payload/ExportSaveButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"use client";

import {
Button,
SaveButton,
toast,
useConfig,
useField,
useForm,
useFormModified,
useTranslation,
} from "@payloadcms/ui";
import { formatAdminURL } from "payload/shared";
import React from "react";

/**
* Forks @payloadcms/plugin-import-export's ExportSaveButton to fix two
* issues with its Download button once an export has already been saved:
*
* 1. It's disabled via `disabled: !modified` — as soon as the doc is saved,
* the form is no longer "modified", so the button becomes permanently
* unclickable the next time the doc is opened.
* 2. Even when enabled, it always POSTs to `/exports/download` to
* regenerate a brand-new file from the current form fields rather than
* serving the file already attached to the doc.
*
* Here, whenever the doc already has a saved file, Download just opens that
* file directly. Regeneration is kept as a fallback for brand-new/edited
* exports that have no saved file yet.
*/
export const ExportSaveButtonFixed = () => {
const { t } = useTranslation();
const {
config: {
routes: { api },
},
getEntityConfig,
} = useConfig();
const { getData, setModified } = useForm();
const modified = useFormModified();
const { value: targetCollectionSlug } = useField({
path: "collectionSlug",
});
const targetCollectionConfig = getEntityConfig({
collectionSlug: targetCollectionSlug as string,
});
const targetPluginConfig = (
targetCollectionConfig?.admin?.custom as
| { ["plugin-import-export"]?: { disableSave?: boolean; disableDownload?: boolean } }
| undefined
)?.["plugin-import-export"];
const exportsCollectionConfig = getEntityConfig({
collectionSlug: "exports",
});
const exportsAdminCustom = exportsCollectionConfig?.admin?.custom as
| { disableSave?: boolean; disableDownload?: boolean }
| undefined;
const disableSave =
targetPluginConfig?.disableSave ?? exportsAdminCustom?.disableSave === true;
const disableDownload =
targetPluginConfig?.disableDownload ??
exportsAdminCustom?.disableDownload === true;
const label = t("general:save");

const downloadSavedFile = (url: string, filename?: string) => {
const a = document.createElement("a");
a.href = url;
a.download = filename ?? "";
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};

const regenerateAndDownload = async (data: Record<string, unknown>) => {
let timeoutID: ReturnType<typeof setTimeout> | null = null;
let toastID: string | number | null = null;
try {
setModified(false);
timeoutID = setTimeout(() => {
toastID = toast.success("Your export is being processed...");
}, 200);
const response = await fetch(
formatAdminURL({ apiRoute: api, path: "/exports/download" }),
{
body: JSON.stringify({ data }),
credentials: "include",
headers: { "Content-Type": "application/json" },
method: "POST",
},
);
if (timeoutID) {
clearTimeout(timeoutID);
}
if (toastID) {
toast.dismiss(toastID);
}
if (!response.ok) {
let errorMsg = "Failed to download file";
try {
const errorJson = await response.json();
if (errorJson?.errors?.[0]?.message) {
errorMsg = errorJson.errors[0].message;
}
} catch {
// Ignore JSON parse errors, fallback to generic message
}
throw new Error(errorMsg);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
downloadSavedFile(url, `${data.name}-${data.collectionSlug}.${data.format}`);
URL.revokeObjectURL(url);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Error downloading file");
}
};

const handleDownload = async () => {
const data = getData();

if (!modified && typeof data?.url === "string" && data.url) {
downloadSavedFile(data.url, typeof data.filename === "string" ? data.filename : undefined);
return;
}

await regenerateAndDownload(data);
};

return (
<React.Fragment>
{!disableSave && <SaveButton label={label} />}
{!disableDownload && (
<Button size="medium" type="button" onClick={handleDownload}>
{t("upload:download")}
</Button>
)}
</React.Fragment>
);
};
49 changes: 49 additions & 0 deletions src/lib/exportColumnLabels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { getTranslation } from "@payloadcms/translations";
import { CollectionSlug, PayloadRequest } from "payload";
import { ExportBeforeHook } from "@payloadcms/plugin-import-export/types";

const titleCase = (key: string): string =>
key
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase());

const getFieldLabelMap = (
req: PayloadRequest,
collectionSlug: CollectionSlug,
): Map<string, string> => {
const collectionConfig = req.payload.collections[collectionSlug]?.config;
const map = new Map<string, string>();

for (const field of collectionConfig?.flattenedFields ?? []) {
if (!("name" in field) || typeof field.name !== "string") {
continue;
}

const label = "label" in field ? field.label : undefined;
map.set(
field.name,
label ? String(getTranslation(label, req.i18n)) : titleCase(field.name),
);
}

return map;
};

/**
* Renames export row keys from raw field names (e.g. "tenantName") to their
* configured admin labels (e.g. "Tenant Name") so downloaded CSV/JSON exports
* are human-readable rather than showing internal field names.
*/
export const createExportHeaderHook =
(collectionSlug: CollectionSlug): ExportBeforeHook =>
({ data, req }) => {
const labelMap = getFieldLabelMap(req, collectionSlug);

return data.map((row) => {
const relabeledRow: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
relabeledRow[labelMap.get(key) ?? titleCase(key)] = value;
}
return relabeledRow;
});
};
32 changes: 32 additions & 0 deletions src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { multiTenantPlugin } from "@payloadcms/plugin-multi-tenant";
import { importExportPlugin } from "@payloadcms/plugin-import-export";
import { Config } from "@/payload-types";
import { capitalizeFirstLetter, isProd } from "@/utils/utils";
import { createExportHeaderHook } from "@/lib/exportColumnLabels";
import { s3Storage } from "@payloadcms/storage-s3";
import { seoPlugin } from "@payloadcms/plugin-seo";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
Expand All @@ -21,11 +22,19 @@ export const plugins: Plugin[] = [
collections: [
{
slug: "promises",
export: {
hooks: {
before: createExportHeaderHook("promises"),
},
},
},
{
slug: "ai-extraction-export-rows",
export: {
format: "csv",
hooks: {
before: createExportHeaderHook("ai-extraction-export-rows"),
},
},
import: false,
},
Expand All @@ -39,6 +48,26 @@ export const plugins: Plugin[] = [
},
admin: {
...collection.admin,
components: {
...collection.admin?.components,
edit: {
...collection.admin?.components?.edit,
// The plugin's default Download button is disabled once the doc
// is saved and, even when enabled, regenerates a fresh export
// instead of serving the saved file. This fork keeps it enabled
// and downloads the already-saved file when there is one.
SaveButton: "@/components/payload/ExportSaveButton#ExportSaveButtonFixed",
// The exports collection denies update access to everyone (it's
// immutable once generated), so Payload never mounts the
// edit.SaveButton slot above once a doc is saved — the "Fixed"
// button vanishes entirely on revisit, not just disabled.
// beforeDocumentControls renders unconditionally, so it's the
// only reliable place for an always-available redownload button.
beforeDocumentControls: [
"@/components/payload/ExportDownloadButton#ExportDownloadButton",
],
},
},
group: {
en: "Documents",
fr: "Documents",
Expand Down Expand Up @@ -67,6 +96,9 @@ export const plugins: Plugin[] = [
s3Storage({
collections: {
media: true,
// Saved exports (Documents > Exports) must survive container
// restarts/redeploys, so they're durable enough to re-download later.
exports: true,
},
bucket,
config: {
Expand Down
4 changes: 2 additions & 2 deletions src/tasks/createPoliticalEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
} from "@/lib/airtable";
import { TaskConfig } from "payload";
import {
computeMediaChecksum,
downloadFile,
removeDownloadedFile,
sha256File,
} from "@/utils/files";
import { formatSlug } from "@/fields/slug/formatSlug";
import type { Media } from "@/payload-types";
Expand Down Expand Up @@ -254,7 +254,7 @@ export const CreatePoliticalEntity: TaskConfig = {
): Promise<string> => {
const filePath = await downloadFile(imageUrl, { fileName: alt });
try {
const checksum = await sha256File(filePath);
const checksum = await computeMediaChecksum(filePath);
const existingMedia = await findMediaByChecksum(payload, checksum);

if (existingMedia) {
Expand Down
4 changes: 2 additions & 2 deletions src/tasks/downloadDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
normalizeMediaSourceUrl,
} from "@/lib/mediaUrl";
import {
computeMediaChecksum,
downloadFile,
removeDownloadedFile,
sha256File,
} from "@/utils/files";
import { getTaskLogger, withTaskTracing, type TaskInput } from "./utils";

Expand Down Expand Up @@ -410,7 +410,7 @@ export const DownloadDocuments: TaskConfig<"downloadDocuments"> = {
filePath = await downloadFile(fileUrl, {
fileName: doc.title ?? undefined,
});
const checksum = await sha256File(filePath);
const checksum = await computeMediaChecksum(filePath);
const existingMedia = await findMediaByChecksum(
payload,
checksum,
Expand Down
Loading
Loading