import { useCallback, useEffect, useState } from "react";
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
Button,
SectionMessage,
Tabs,
useToast,
ToastType,
} from "@pantheon-systems/pds-toolkit-react";
import { apiClient } from "../api/client";
import { getErrorMessage } from "../lib/errors";
interface AcfMapping {
post_type: string;
acf_field: string;
cpub_field: string;
}
interface AcfField {
key: string;
label: string;
name: string;
type: string;
group: string;
}
interface AcfMappingsResponse {
acf_active: boolean;
mappings: AcfMapping[];
user_match_by: "login" | "email";
errors: string[];
post_types_with_fields: PostTypeOption[];
}
interface FieldRowProps {
field: AcfField;
cpubValue: string;
onChange: (_acfFieldName: string, _cpubField: string) => void;
}
function FieldRow({ field, cpubValue, onChange }: FieldRowProps) {
return (
{field.label || field.name}
{field.key}
{ onChange(field.name, e.target.value); }}
placeholder={`e.g. ${field.label || field.name}`}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
);
}
interface FieldTableProps {
postType: string;
cpubValues: Record;
onChangeField: (_acfName: string, _cpubField: string) => void;
onFieldsLoaded: (_postType: string, _fields: AcfField[]) => void;
}
function FieldTable({ postType, cpubValues, onChangeField, onFieldsLoaded }: FieldTableProps) {
const { data: fields = [], isLoading } = useQuery({
queryKey: ["acf-fields", postType],
queryFn: async () => {
const res = await apiClient.get<{ fields: AcfField[] }>(
`/acf-fields?post_type=${encodeURIComponent(postType)}`
);
return res.data.fields;
},
enabled: Boolean(postType),
});
// Notify parent of loaded fields for user-type detection
useEffect(() => {
if (fields.length > 0) {
onFieldsLoaded(postType, fields);
}
}, [fields, onFieldsLoaded, postType]);
if (isLoading) {
return Loading ACF fields…
;
}
if (fields.length === 0) {
return (
No ACF fields found for post type {postType}. Create an ACF
field group assigned to this post type first.
);
}
return (
Advanced Custom Fields
Content Publisher Metadata Field
{fields.map((field) => (
))}
);
}
export default function AcfMappings() {
const [addToast] = useToast();
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ["acf-mappings"],
queryFn: async () => {
const res = await apiClient.get("/acf-mappings");
return res.data;
},
});
const saveMutation = useMutation({
mutationFn: async (payload: { mappings: AcfMapping[]; user_match_by: "login" | "email" }) => {
const res = await apiClient.put("/acf-mappings", payload);
return res.data;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["acf-mappings"] });
},
});
const acfActive = window.CPUB_BOOTSTRAP.acf_active;
const postTypesWithFields = data?.post_types_with_fields ?? [];
// Local state: post_type → acf_field_name → cpub_field
const [localMappings, setLocalMappings] = useState>>({});
const [userMatchBy, setUserMatchBy] = useState<"login" | "email">(
data?.user_match_by ?? "login"
);
// Track loaded ACF fields per post type for user-type detection
const [loadedFieldsByType, setLoadedFieldsByType] = useState>({});
// Sync remote data into local state once loaded
const [syncedFromServer, setSyncedFromServer] = useState(false);
useEffect(() => {
if (data && !syncedFromServer) {
setSyncedFromServer(true);
setUserMatchBy(data.user_match_by);
const grouped: Record> = {};
data.mappings.forEach((mapping) => {
grouped[mapping.post_type] ??= {};
grouped[mapping.post_type][mapping.acf_field] = mapping.cpub_field;
});
setLocalMappings((prev) => {
const merged: Record> = { ...prev };
for (const [pt, fields] of Object.entries(grouped)) {
const existing = merged[pt] ?? {};
merged[pt] = { ...existing, ...fields };
}
return merged;
});
}
}, [data, syncedFromServer]);
const handleFieldChange = useCallback((postType: string, acfName: string, cpubField: string) => {
setLocalMappings((prev) => {
const existing = prev[postType] ?? {};
return { ...prev, [postType]: { ...existing, [acfName]: cpubField } };
});
}, []);
const handleFieldsLoaded = useCallback((postType: string, fields: AcfField[]) => {
setLoadedFieldsByType((prev) => ({ ...prev, [postType]: fields }));
}, []);
const handleSave = () => {
// Build flat mappings array across all post types, omitting empty cpub fields
const mappings: AcfMapping[] = [];
for (const [postType, fields] of Object.entries(localMappings)) {
for (const [acfField, cpubField] of Object.entries(fields)) {
if (cpubField.trim()) {
mappings.push({
post_type: postType,
acf_field: acfField,
cpub_field: cpubField.trim(),
});
}
}
}
saveMutation.mutate(
{ mappings, user_match_by: userMatchBy },
{
onSuccess: () => {
addToast(ToastType.Success, "Metadata mapping saved.");
},
onError: (err) => {
addToast(
ToastType.Critical,
getErrorMessage(err, "Failed to save mappings.") ?? "Failed to save mappings."
);
},
}
);
};
if (isLoading) {
return Loading…
;
}
if (error) {
return (
);
}
// ACF not active: show install prompt
if (!acfActive) {
return (
Advanced Custom Fields
Metadata fields defined in the Pantheon Content Publisher collection
can be synced to Advanced Custom Fields.
);
}
const hasUserFields = Object.values(loadedFieldsByType).some(
(fields) => fields.some((f) => f.type === "user")
);
const renderFieldTable = (pt: PostTypeOption) => (
{ handleFieldChange(pt.name, acfName, cpubField); }}
onFieldsLoaded={handleFieldsLoaded}
/>
);
return (
Advanced Custom Fields
Metadata fields defined in the Pantheon Content Publisher collection
can be synced to Advanced Custom Fields.
{/* Recent sync errors from server */}
{data?.errors && data.errors.length > 0 && (
`• ${e}`).join("\n")
}
/>
)}
{/* Field mapping tables per post type */}
{postTypesWithFields.length === 0 ? (
No post types have ACF field groups configured. Create an ACF field
group and assign it to a post type first.
) : (
({
tabLabel: pt.label,
panelContent: renderFieldTable(pt),
}))}
/>
)}
{/* User field matching — only when user-type ACF fields exist */}
{hasUserFields && (
)}
Enter the metadata field name exactly as defined in your PCC collection.
Leave blank to skip that field. Field names are case-sensitive. Mappings
are applied on every publish; renaming a field requires updating the
mapping manually.
);
}