UNPKG

payload-ab

Version:

Payload CMS plugin for A/B testing with PostHog

245 lines (244 loc) 11.6 kB
/** * 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 }; // 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)=>{ // Create a copy of the field const fieldCopy = { ...field }; // For fields that can have a required property, make sure it's false if ('name' in fieldCopy && 'type' in fieldCopy) { // Only modify fields that can have a required property const fieldTypes = [ 'text', 'textarea', 'number', 'email', 'code', 'date', 'upload', 'relationship', 'select' ]; if (fieldTypes.includes(fieldCopy.type)) { // Type assertion to FieldWithRequired since we've verified it's a field type that can have required ; fieldCopy.required = false; } } 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, 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 { const { data } = args; // Initialize abVariant if not already present if (!data.abVariant) { data.abVariant = {}; } // If A/B testing is disabled, clear the variant data if (data.enableABTesting === false) { data.abVariant = {}; } return Promise.resolve(data); } catch (error) { // Log error but don't throw to prevent breaking the save operation 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