payload-ab
Version:
Payload CMS plugin for A/B testing with PostHog
386 lines (385 loc) • 21.9 kB
JavaScript
/**
* Payload CMS plugin for A/B testing with PostHog
* Adds an optional abVariant field group to specified collections
*/ export const abTestingPlugin = (pluginOptions)=>(incomingConfig)=>{
// Create a copy of the incoming config with proper typing
const config = {
...incomingConfig
};
// --- Start: Define Shared Sanitize Object Function ---
const sanitizeObject = (obj)=>{
if (!obj || typeof obj !== 'object') {
return obj;
}
// Handle arrays
if (Array.isArray(obj)) {
return obj.map(sanitizeObject);
}
// Handle objects
const sanitized = {
...obj
};
delete sanitized.id;
delete sanitized._id;
delete sanitized.__v;
delete sanitized.createdAt;
delete sanitized.updatedAt;
// Recursively sanitize all properties
Object.keys(sanitized).forEach((key)=>{
if (typeof sanitized[key] === 'object' && sanitized[key] !== null) {
sanitized[key] = sanitizeObject(sanitized[key]);
}
});
return sanitized;
};
// --- End: Define Shared Sanitize Object Function ---
// Ensure collections exist
if (!config.collections) {
config.collections = [];
}
// If the plugin is disabled, return the config as is
if (pluginOptions.disabled) {
return config;
}
// Validate PostHog configuration if provided
if (pluginOptions.posthog?.apiKey) {
if (!pluginOptions.posthog.apiKey.startsWith('phc_')) {
throw new Error('Invalid PostHog API key format. PostHog API keys should start with "phc_"');
}
}
// Normalize collections config to object format
const collectionsConfig = {};
if (Array.isArray(pluginOptions.collections)) {
// If collections is an array, convert to object with default config
pluginOptions.collections.forEach((slug)=>{
collectionsConfig[slug] = {
enabled: true
};
});
} else {
// If collections is already an object, use it directly
Object.entries(pluginOptions.collections).forEach(([slug, config])=>{
collectionsConfig[slug] = {
enabled: true,
...config
};
});
}
// Track collection field mappings to use in hooks
const collectionFieldMappings = {};
// Map over the collections in the config
const modifiedCollections = config.collections.map((collection)=>{
// Get the collection config if it exists
const collectionConfig = collectionsConfig[collection.slug];
// Only modify collections that are in our config and enabled
if (collectionConfig && collectionConfig.enabled !== false) {
// Get all content fields from the collection to duplicate them in the variant
let contentFields = (collection.fields || []).filter((field)=>{
// Check if the field has a name property
return 'name' in field;
});
// If specific fields are provided, only include those
if (collectionConfig.fields && collectionConfig.fields.length > 0) {
contentFields = contentFields.filter((field)=>{
return 'name' in field && collectionConfig.fields?.includes(field.name);
});
} else {
// Otherwise, exclude system fields and any specified in excludeFields
const excludeFields = collectionConfig.excludeFields || [
'id',
'createdAt',
'updatedAt'
];
contentFields = contentFields.filter((field)=>{
return 'name' in field && !excludeFields.includes(field.name);
});
}
// Make sure all fields in the variant are nullable in the database
const variantFields = contentFields.map((field)=>{
// Clone original field and remove "required" constraint for variants
const fieldCopy = {
...field
};
fieldCopy.required = false;
// For any field that might contain an ID, add a custom validation hook
if (fieldCopy.type === 'relationship' || fieldCopy.type === 'upload' || fieldCopy.type === 'array' || fieldCopy.type === 'blocks' || fieldCopy.type === 'richText') {
// Add hooks to the field if they don't exist
if (!fieldCopy.hooks) {
fieldCopy.hooks = {};
}
// Add beforeValidate hook to sanitize any potential ID fields
if (!fieldCopy.hooks.beforeValidate) {
fieldCopy.hooks.beforeValidate = [];
}
// Add a hook to sanitize potential ID fields
fieldCopy.hooks.beforeValidate.push(({ value })=>{
if (!value) {
return value;
}
return sanitizeObject(value);
});
}
return fieldCopy;
});
// Store field names for this collection to use in hooks
if (collection.slug) {
collectionFieldMappings[collection.slug] = contentFields.filter((field)=>'name' in field).map((field)=>field.name);
}
// Add a toggle field to enable/disable A/B testing for this document
const enableABTestingField = {
name: 'enableABTesting',
type: 'checkbox',
admin: {
description: 'Check this box to create an A/B testing variant for this document',
position: 'sidebar'
},
defaultValue: false,
label: 'Enable A/B Testing'
};
// Create PostHog fields for feature flag integration
const posthogFields = [
{
name: 'posthogFeatureFlagKey',
type: 'text',
admin: {
condition: (data)=>data?.enableABTesting === true,
description: (args)=>args.data?.enableABTesting ? 'PostHog feature flag key for this experiment (auto-generated if left empty)' : 'Enable A/B testing above to configure PostHog integration',
position: 'sidebar'
},
label: 'PostHog Feature Flag Key',
required: false
},
{
name: 'posthogVariantName',
type: 'text',
admin: {
condition: (data)=>data?.enableABTesting === true,
description: (args)=>args.data?.enableABTesting ? 'Name of this variant in PostHog (defaults to "variant")' : 'Enable A/B testing above to configure PostHog integration',
position: 'sidebar'
},
defaultValue: 'variant',
label: 'Variant Name',
required: false
}
];
// Create a tabs field with an A/B Testing tab
const abTestingTab = {
type: 'tabs',
tabs: [
// Keep the original tabs/fields as they are
{
fields: collection.fields || [],
label: 'Content'
},
// Add a new tab for A/B Testing
{
description: 'Configure A/B testing variants for this content. Enable A/B testing to start the experiment.',
fields: [
enableABTestingField,
...posthogFields,
{
name: 'abVariant',
type: 'group',
admin: {
className: 'ab-variant-group',
condition: (data)=>data?.enableABTesting === true,
description: (args)=>args.data?.enableABTesting ? 'Configure your A/B testing variant content here' : 'Enable A/B testing above to start configuring your variant'
},
fields: variantFields,
hooks: {
// Add a hook to sanitize the variant data before it's saved
beforeValidate: [
({ value })=>{
// If the value is an object, ensure it doesn't have any system fields
if (value && typeof value === 'object') {
const sanitizedValue = sanitizeObject(value);
return sanitizedValue;
}
return value;
}
]
},
label: 'Variant Content',
localized: false,
nullable: true,
required: false,
unique: false
}
],
label: 'A/B Testing'
}
]
};
// Return the modified collection with tabs
return {
...collection,
admin: {
...collection.admin,
// Ensure we preserve any existing useAsTitle setting
useAsTitle: collection.admin?.useAsTitle || 'title'
},
fields: [
abTestingTab
]
};
}
return collection;
});
// Update the config with the modified collections
config.collections = modifiedCollections;
// Add hooks to copy content to variant when A/B testing is enabled
if (!config.hooks) {
config.hooks = {};
}
// Add global beforeChange hook
if (!config.hooks.beforeChange) {
config.hooks.beforeChange = [];
}
// Add collection-specific hooks instead of a global one
Object.keys(collectionsConfig).forEach((collectionSlug)=>{
// Skip if collection is not enabled
const collectionConfig = collectionsConfig[collectionSlug];
if (collectionConfig?.enabled === false) {
return;
}
// Find the collection to add the hook to
const collection = config.collections?.find((c)=>c.slug === collectionSlug);
if (!collection) {
return;
}
// Initialize hooks for this collection if needed
if (!collection.hooks) {
collection.hooks = {};
}
if (!collection.hooks.beforeChange) {
collection.hooks.beforeChange = [];
}
// Add the hook for this specific collection
const copyToVariantHook = (args)=>{
try {
console.log(`[A/B Plugin] copyToVariantHook fired for ${collectionSlug}`, {
enableABTesting: args.data.enableABTesting,
hasOriginalDoc: !!args.originalDoc
});
const { data, originalDoc } = args;
// Initialize abVariant if not already present
if (!data.abVariant || typeof data.abVariant !== 'object') {
data.abVariant = {};
}
// If A/B testing is disabled, clear the variant data and exit early
if (!data.enableABTesting) {
data.abVariant = {};
return Promise.resolve(data);
}
// Check if this is the first time A/B testing is being enabled
const wasABTestingEnabled = originalDoc?.enableABTesting === true;
const isABTestingEnabled = data.enableABTesting === true;
const isFirstTimeEnabling = isABTestingEnabled && !wasABTestingEnabled;
// Only copy content if this is the first time enabling A/B testing
if (isFirstTimeEnabling) {
console.log(`[A/B Plugin] First time enabling A/B testing for ${collectionSlug}, copying content to variant`);
// Get the explicitly defined fields to copy from the collection config
const fieldsToCopy = collectionFieldMappings[collectionSlug] || [];
console.log(`[A/B Plugin] fieldsToCopy for ${collectionSlug}:`, fieldsToCopy);
// Create a new object for the variant instead of modifying the existing one
const newVariant = {};
// Only copy the fields that are explicitly defined in the configuration
fieldsToCopy.forEach((fieldName)=>{
// Determine source value: new data overrides originalDoc
const sourceValue = data[fieldName] !== undefined ? data[fieldName] : originalDoc?.[fieldName];
if (sourceValue !== undefined) {
console.log(`[A/B Plugin] Copying field ${fieldName} to variant:`, typeof sourceValue === 'object' ? 'Complex object' : sourceValue);
// Special handling for blocks and complex fields
if (fieldName === 'content' || fieldName === 'callOut' || fieldName === 'callToAction' || fieldName === 'subTitle' || typeof sourceValue === 'object') {
console.log(`[A/B Plugin] Special handling for complex field: ${fieldName}`);
try {
// For blocks and complex objects, use a more careful approach
// First stringify to break references
const jsonString = JSON.stringify(sourceValue);
let parsed;
try {
parsed = JSON.parse(jsonString);
} catch (err) {
console.log(`[A/B Plugin] Error parsing JSON for ${fieldName}:`, err);
parsed = sourceValue // Fallback to original
;
}
// If we have blocks, ensure we handle them properly
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0] && (parsed[0].blockType || parsed[0].type || parsed[0].blockName)) {
console.log(`[A/B Plugin] Detected blocks in ${fieldName}, sanitizing...`);
// Process each block to remove problematic fields
const sanitizedBlocks = parsed.map((block)=>{
// 1. Get the original block type.
const type = block.blockType || block.type || block.blockName;
// 2. Create a copy of the block's content to modify.
// We will pass this to sanitizeObject.
const blockDataToSanitize = {
...block
};
// 3. Remove original top-level id, _id from this copy before full sanitization.
// Also remove the various type designators because we'll add the canonical `blockType` back.
// sanitizeObject (defined in the outer scope) will handle nested ids.
delete blockDataToSanitize.id;
delete blockDataToSanitize._id;
delete blockDataToSanitize.blockType // remove if it exists from the data payload
;
delete blockDataToSanitize.type // remove if it exists from the data payload
;
delete blockDataToSanitize.blockName // remove if it exists from the data payload
;
// 4. Recursively sanitize all remaining fields in the block data.
const sanitizedInternalFields = sanitizeObject(blockDataToSanitize);
// 5. Construct the new block with the correct blockType and sanitized fields.
return {
blockType: type,
...sanitizedInternalFields
};
});
newVariant[fieldName] = sanitizedBlocks;
} else {
// For other complex objects, use the recursive sanitizer
newVariant[fieldName] = sanitizeObject(parsed);
}
} catch (err) {
console.log(`[A/B Plugin] Error processing ${fieldName}:`, err);
// Last resort: try a shallow copy
const shallowCopy = Array.isArray(sourceValue) ? [
...sourceValue
] : {
...sourceValue
};
newVariant[fieldName] = sanitizeObject(shallowCopy);
}
} else {
// For primitive values, assign directly
newVariant[fieldName] = sourceValue;
}
}
});
// Preserve any PostHog-related fields
if (data.abVariant?.posthogVariantName) {
newVariant.posthogVariantName = data.abVariant.posthogVariantName;
}
if (data.abVariant?.posthogFeatureFlagKey) {
newVariant.posthogFeatureFlagKey = data.abVariant.posthogFeatureFlagKey;
}
// Replace the entire abVariant object with our new clean one
data.abVariant = newVariant;
console.log(`[A/B Plugin] Final variant fields:`, Object.keys(newVariant));
} else {
console.log(`[A/B Plugin] A/B testing already enabled for ${collectionSlug}, preserving existing variant content`);
}
return Promise.resolve(data);
} catch (error) {
console.error(`[A/B Plugin] Error in copyToVariantHook for ${collectionSlug}:`, error);
return Promise.resolve(args.data);
}
};
// Add the hook to this collection
collection.hooks.beforeChange.push(copyToVariantHook);
});
return config;
};
// For backward compatibility
export default abTestingPlugin;
//# sourceMappingURL=index.js.map