Add Payload Feature Flags plugin with custom endpoints and configurations

This commit is contained in:
2025-09-12 11:45:33 +02:00
commit 453b9eac7c
41 changed files with 20187 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
/.idea/*
!/.idea/runConfigurations
# testing
/coverage
# next.js
.next/
/out/
# production
/build
/dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
.env
/dev/media
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}

24
.swcrc Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}

24
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}"
}
]
}

40
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,40 @@
{
"npm.packageManager": "pnpm",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"editor.formatOnSaveMode": "file",
"typescript.tsdk": "node_modules/typescript/lib",
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}

510
README.md Normal file
View File

@@ -0,0 +1,510 @@
# @xtr-dev/payload-feature-flags
A powerful feature flags plugin for Payload CMS v3 that enables you to manage feature toggles, A/B testing, and gradual rollouts directly from your Payload admin panel.
## Features
- 🚀 **Easy Integration** - Drop-in plugin with minimal configuration
- 🔄 **Gradual Rollouts** - Percentage-based feature deployment
- 🧪 **A/B Testing** - Built-in variant support
- 🛣️ **REST API** - Simple flag state endpoints
## Installation
```bash
npm install @xtr-dev/payload-feature-flags
```
Or using pnpm:
```bash
pnpm add @xtr-dev/payload-feature-flags
```
Or using yarn:
```bash
yarn add @xtr-dev/payload-feature-flags
```
## Requirements
- Payload CMS v3.37.0 or higher
- Node.js 18.20.2+ or 20.9.0+
- React 19.1.0+
## Quick Start
### Basic Setup
Add the plugin to your Payload config:
```typescript
import { buildConfig } from 'payload'
import { payloadFeatureFlags } from '@xtr-dev/payload-feature-flags'
export default buildConfig({
// ... your existing config
plugins: [
payloadFeatureFlags({
// All options are optional
defaultValue: false, // Default state for new flags
enableRollouts: true, // Enable percentage-based rollouts
enableVariants: true, // Enable A/B testing variants
enableApi: false, // Enable REST API endpoints
disabled: false, // Disable plugin if needed
}),
],
})
```
### Configuration Options
The plugin accepts the following configuration options:
```typescript
export type PayloadFeatureFlagsConfig = {
/**
* Default value for new feature flags
* @default false
*/
defaultValue?: boolean
/**
* Enable percentage-based rollouts
* @default true
*/
enableRollouts?: boolean
/**
* Enable variant/experiment support (A/B testing)
* @default true
*/
enableVariants?: boolean
/**
* Enable REST API endpoints for feature flags
* @default false
*/
enableApi?: boolean
/**
* Disable the plugin while keeping the database schema intact
* @default false
*/
disabled?: boolean
/**
* Override collection configuration
*/
collectionOverrides?: {
// Override any collection config
slug?: string // @default 'feature-flags'
access?: CollectionConfig['access']
admin?: CollectionConfig['admin']
hooks?: CollectionConfig['hooks']
timestamps?: CollectionConfig['timestamps']
versions?: CollectionConfig['versions']
// ... any other collection config
// Customize fields
fields?: (args: { defaultFields: Field[] }) => Field[]
}
}
```
### Collection Overrides
You can customize the feature flags collection using `collectionOverrides`:
```typescript
payloadFeatureFlags({
collectionOverrides: {
// Custom collection slug
slug: 'my-feature-flags',
// Custom access control
access: {
read: ({ req: { user } }) => !!user, // authenticated users only
update: ({ req: { user } }) => user?.role === 'admin', // admins only
},
// Add custom fields
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'environment',
type: 'select',
options: ['development', 'staging', 'production'],
required: true,
},
{
name: 'expiresAt',
type: 'date',
admin: {
description: 'Auto-disable this flag after this date',
},
},
],
// Disable versioning (enabled by default)
versions: false,
// Add hooks
hooks: {
beforeChange: [
async ({ data, req }) => {
// Add audit log, validation, etc.
console.log(`Flag ${data.name} changed by ${req.user?.email}`)
return data
},
],
},
},
})
```
## Usage
### Managing Feature Flags
Once installed, the plugin automatically:
1. **Creates a dedicated collection** - A `feature-flags` collection (or custom name) for managing all flags
2. **Provides a clean admin interface** - Manage flags directly from the Payload admin panel
3. **Exposes REST API endpoints** - Simple endpoints for checking flag states
4. **Keeps your data clean** - No modifications to your existing collections
### Using Feature Flags in React Server Components
The plugin provides server-side hooks for React Server Components:
```tsx
import {
isFeatureEnabled,
getFeatureFlag,
isUserInRollout,
getUserVariant
} from '@xtr-dev/payload-feature-flags/rsc'
// Simple feature check
export default async function HomePage() {
const showNewDesign = await isFeatureEnabled('new-homepage-design')
return showNewDesign ? <NewHomePage /> : <LegacyHomePage />
}
// Percentage-based rollout
export default async function Dashboard({ userId }: { userId: string }) {
const inRollout = await isUserInRollout('beta-dashboard', userId)
return inRollout ? <BetaDashboard /> : <ClassicDashboard />
}
// A/B testing with variants
export default async function ProductPage({ userId }: { userId: string }) {
const variant = await getUserVariant('product-page-test', userId)
switch(variant) {
case 'layout-a':
return <ProductLayoutA />
case 'layout-b':
return <ProductLayoutB />
default:
return <DefaultProductLayout />
}
}
```
### Using Feature Flags via REST API
If you have `enableApi: true`, you can use the REST API endpoints:
```typescript
// Check if a specific feature is enabled
const response = await fetch('/api/feature-flags/new-dashboard')
const flag = await response.json()
if (flag.enabled) {
// Show new dashboard
}
// Get all active feature flags
const allFlags = await fetch('/api/feature-flags')
const flags = await allFlags.json()
```
**Note**: REST API endpoints are disabled by default (`enableApi: false`). Set `enableApi: true` if you need REST endpoints.
### API Endpoints
When `enableApi: true`, the plugin exposes the following endpoints:
#### Get All Active Feature Flags
```http
GET /api/feature-flags
```
Returns all enabled feature flags:
```json
{
"new-dashboard": {
"enabled": true,
"rolloutPercentage": 50,
"variants": null,
"metadata": {}
},
"beta-feature": {
"enabled": true,
"rolloutPercentage": 100,
"variants": [
{ "name": "control", "weight": 50, "metadata": {} },
{ "name": "variant-a", "weight": 50, "metadata": {} }
],
"metadata": {}
}
}
```
#### Get Specific Feature Flag
```http
GET /api/feature-flags/:flagName
```
Returns a specific feature flag:
```json
{
"name": "new-dashboard",
"enabled": true,
"rolloutPercentage": 50,
"variants": null,
"metadata": {}
}
```
### Feature Flag Schema
The plugin creates a collection with the following fields:
- **`name`** (required, unique) - Unique identifier for the feature flag
- **`description`** - Description of what the flag controls
- **`enabled`** (required) - Toggle the flag on/off
- **`rolloutPercentage`** - Percentage of users (0-100) who see this feature
- **`variants`** - Array of variants for A/B testing
- `name` - Variant identifier
- `weight` - Distribution weight (all weights should sum to 100)
- `metadata` - Additional variant data
- **`tags`** - Array of tags for organization
- **`metadata`** - JSON field for additional flag data
## Advanced Usage
### Conditional Feature Rendering
```typescript
// Example: Check feature flag from your frontend
async function checkFeature(flagName: string): Promise<boolean> {
try {
const response = await fetch(`/api/feature-flags/${flagName}`)
if (!response.ok) return false
const flag = await response.json()
return flag.enabled
} catch {
return false // Default to disabled on error
}
}
// Usage in your application
if (await checkFeature('new-dashboard')) {
// Show new dashboard
} else {
// Show legacy dashboard
}
```
### Implementing Gradual Rollouts
```typescript
// Example: Hash-based rollout
function isUserInRollout(userId: string, percentage: number): boolean {
// Simple hash function for consistent user bucketing
const hash = userId.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0)
}, 0)
return (Math.abs(hash) % 100) < percentage
}
// Check if user should see the feature
const flag = await fetch('/api/feature-flags/new-feature').then(r => r.json())
if (flag.enabled && isUserInRollout(userId, flag.rolloutPercentage)) {
// Show feature to this user
}
```
### A/B Testing with Variants
```typescript
// Example: Select variant based on user
function selectVariant(userId: string, variants: Array<{name: string, weight: number}>) {
const hash = Math.abs(userId.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0)
}, 0))
const bucket = hash % 100
let cumulative = 0
for (const variant of variants) {
cumulative += variant.weight
if (bucket < cumulative) {
return variant.name
}
}
return variants[0]?.name || 'control'
}
// Usage
const flag = await fetch('/api/feature-flags/homepage-test').then(r => r.json())
if (flag.enabled && flag.variants) {
const variant = selectVariant(userId, flag.variants)
// Render based on variant
}
```
## Migration
### Disabling the Plugin
If you need to temporarily disable the plugin (e.g., during migrations), set `disabled: true` in the configuration. This keeps the database schema intact while disabling plugin functionality.
```typescript
payloadFeatureFlags({
disabled: true, // Plugin functionality disabled, schema preserved
})
```
## Development
### Building the Plugin
```bash
# Install dependencies
pnpm install
# Build the plugin
pnpm build
# Run tests
pnpm test
# Run linting
pnpm lint
```
### Testing
```bash
# Run integration tests
pnpm test:int
# Run E2E tests
pnpm test:e2e
```
### Development Mode
```bash
# Start development server
pnpm dev
# Generate types
pnpm generate:types
# Generate import map
pnpm generate:importmap
```
## API Reference
### Main Plugin Export
```typescript
import { payloadFeatureFlags } from '@xtr-dev/payload-feature-flags'
```
- `payloadFeatureFlags`: Main plugin configuration function
- `PayloadFeatureFlagsConfig`: TypeScript type for configuration options
### Server Component Hooks (RSC Export)
```typescript
import {
getFeatureFlag,
isFeatureEnabled,
getAllFeatureFlags,
isUserInRollout,
getUserVariant,
getFeatureFlagsByTag
} from '@xtr-dev/payload-feature-flags/rsc'
```
#### Available Functions:
- `getFeatureFlag(flagName: string)` - Get complete flag data
- `isFeatureEnabled(flagName: string)` - Simple boolean check
- `getAllFeatureFlags()` - Get all active flags
- `isUserInRollout(flagName: string, userId: string)` - Check rollout percentage
- `getUserVariant(flagName: string, userId: string)` - Get A/B test variant
- `getFeatureFlagsByTag(tag: string)` - Get flags by tag
## Troubleshooting
### Common Issues
**Plugin not loading:**
- Ensure Payload CMS v3.37.0+ is installed
- Check that the plugin is properly added to the `plugins` array in your Payload config
**Feature flags not appearing:**
- Verify that collections are specified in the plugin configuration
- Check that the plugin is not disabled (`disabled: false`)
**TypeScript errors:**
- Ensure all peer dependencies are installed
- Run `pnpm generate:types` to regenerate type definitions
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Support
For issues, questions, or suggestions, please [open an issue](https://github.com/xtr-dev/payload-feature-flags/issues) on GitHub.
## Changelog
See [CHANGELOG.md](./CHANGELOG.md) for a list of changes.
## Authors
- XTR Development Team
## Acknowledgments
- Built for [Payload CMS](https://payloadcms.com/)
- Inspired by modern feature flag management systems

2
dev/.env.example Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template
PAYLOAD_SECRET=YOUR_SECRET_HERE

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams })
export default NotFound

View File

@@ -0,0 +1,25 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams })
export default Page

View File

@@ -0,0 +1,9 @@
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'payload-feature-flags/client'
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'payload-feature-flags/rsc'
export const importMap = {
'payload-feature-flags/client#BeforeDashboardClient':
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
'payload-feature-flags/rsc#BeforeDashboardServer':
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
}

View File

@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

View File

@@ -0,0 +1,32 @@
import type { ServerFunctionClient } from 'payload'
import '@payloadcms/next/css'
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

12
dev/app/my-route/route.ts Normal file
View File

@@ -0,0 +1,12 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
export const GET = async (request: Request) => {
const payload = await getPayload({
config: configPromise,
})
return Response.json({
message: 'This is an example of a custom route.',
})
}

15
dev/e2e.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { expect, test } from '@playwright/test'
// this is an example Playwright e2e test
test('should render admin panel logo', async ({ page }) => {
await page.goto('/admin')
// login
await page.fill('#field-email', 'dev@payloadcms.com')
await page.fill('#field-password', 'test')
await page.click('.form-submit button')
// should show dashboard
await expect(page).toHaveTitle(/Dashboard/)
await expect(page.locator('.graphic-icon')).toBeVisible()
})

View File

@@ -0,0 +1,4 @@
export const devUser = {
email: 'dev@payloadcms.com',
password: 'test',
}

View File

@@ -0,0 +1,38 @@
import type { EmailAdapter, SendEmailOptions } from 'payload'
/**
* Logs all emails to stdout
*/
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
name: 'test-email-adapter',
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload Test',
sendEmail: async (message) => {
const stringifiedTo = getStringifiedToAddress(message)
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
payload.logger.info({ content: message, msg: res })
return Promise.resolve()
},
})
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
let stringifiedTo: string | undefined
if (typeof message.to === 'string') {
stringifiedTo = message.to
} else if (Array.isArray(message.to)) {
stringifiedTo = message.to
.map((to: { address: string } | string) => {
if (typeof to === 'string') {
return to
} else if (to.address) {
return to.address
}
return ''
})
.join(', ')
} else if (message.to?.address) {
stringifiedTo = message.to.address
}
return stringifiedTo
}

52
dev/int.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Payload } from 'payload'
import config from '@payload-config'
import { createPayloadRequest, getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js'
let payload: Payload
afterAll(async () => {
await payload.destroy()
})
beforeAll(async () => {
payload = await getPayload({ config })
})
describe('Plugin integration tests', () => {
test('should query custom endpoint added by plugin', async () => {
const request = new Request('http://localhost:3000/api/my-plugin-endpoint', {
method: 'GET',
})
const payloadRequest = await createPayloadRequest({ config, request })
const response = await customEndpointHandler(payloadRequest)
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toMatchObject({
message: 'Hello from custom endpoint',
})
})
test('can create post with custom text field added by plugin', async () => {
const post = await payload.create({
collection: 'posts',
data: {
addedByPlugin: 'added by plugin',
},
})
expect(post.addedByPlugin).toBe('added by plugin')
})
test('plugin creates and seeds plugin-collection', async () => {
expect(payload.collections['plugin-collection']).toBeDefined()
const { docs } = await payload.find({ collection: 'plugin-collection' })
expect(docs).toHaveLength(1)
})
})

5
dev/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

21
dev/next.config.mjs Normal file
View File

@@ -0,0 +1,21 @@
import { withPayload } from '@payloadcms/next/withPayload'
import { fileURLToPath } from 'url'
import path from 'path'
const dirname = path.dirname(fileURLToPath(import.meta.url))
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
serverExternalPackages: ['mongodb-memory-server'],
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

276
dev/payload-types.ts Normal file
View File

@@ -0,0 +1,276 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
media: Media;
'plugin-collection': PluginCollection;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
addedByPlugin?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-collection".
*/
export interface PluginCollection {
id: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'plugin-collection';
value: string | PluginCollection;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
addedByPlugin?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-collection_select".
*/
export interface PluginCollectionSelect<T extends boolean = true> {
id?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

75
dev/payload.config.ts Normal file
View File

@@ -0,0 +1,75 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import { buildConfig } from 'payload'
import { payloadFeatureFlags } from 'payload-feature-flags'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
if (!process.env.ROOT_DIR) {
process.env.ROOT_DIR = dirname
}
const buildConfigWithMemoryDB = async () => {
if (process.env.NODE_ENV === 'test') {
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
},
})
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
}
return buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
{
slug: 'posts',
fields: [],
},
{
slug: 'media',
fields: [],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
},
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
onInit: async (payload) => {
await seed(payload)
},
plugins: [
payloadFeatureFlags({
collections: {
posts: true,
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
}
export default buildConfigWithMemoryDB()

21
dev/seed.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.js'
export const seed = async (payload: Payload) => {
const { totalDocs } = await payload.count({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
if (!totalDocs) {
await payload.create({
collection: 'users',
data: devUser,
})
}
}

35
dev/tsconfig.json Normal file
View File

@@ -0,0 +1,35 @@
{
"extends": "../tsconfig.json",
"exclude": [],
"include": [
"**/*.js",
"**/*.jsx",
"**/*.mjs",
"**/*.cjs",
"**/*.ts",
"**/*.tsx",
"../src/**/*.ts",
"../src/**/*.tsx",
"next.config.mjs",
".next/types/**/*.ts"
],
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@payload-config": [
"./payload.config.ts"
],
"payload-feature-flags": [
"../src/index.ts"
],
"payload-feature-flags/client": [
"../src/exports/client.ts"
],
"payload-feature-flags/rsc": [
"../src/exports/rsc.ts"
]
},
"noEmit": true,
"emitDeclarationOnly": false,
}
}

46
eslint.config.js Normal file
View File

@@ -0,0 +1,46 @@
// @ts-check
import payloadEsLintConfig from '@payloadcms/eslint-config'
export const defaultESLintIgnores = [
'**/.temp',
'**/.*', // ignore all dotfiles
'**/.git',
'**/.hg',
'**/.pnp.*',
'**/.svn',
'**/playwright.config.ts',
'**/vitest.config.js',
'**/tsconfig.tsbuildinfo',
'**/README.md',
'**/eslint.config.js',
'**/payload-types.ts',
'**/dist/',
'**/.yarn/',
'**/build/',
'**/node_modules/',
'**/temp/',
]
export default [
...payloadEsLintConfig,
{
rules: {
'no-restricted-exports': 'off',
},
},
{
languageOptions: {
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
projectService: {
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'],
},
// projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
]

18003
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

120
package.json Normal file
View File

@@ -0,0 +1,120 @@
{
"name": "@xtr-dev/payload-feature-flags",
"version": "0.0.1",
"description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./client": {
"import": "./src/exports/client.ts",
"types": "./src/exports/client.ts",
"default": "./src/exports/client.ts"
},
"./rsc": {
"import": "./src/exports/rsc.ts",
"types": "./src/exports/rsc.ts",
"default": "./src/exports/rsc.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --outDir dist --rootDir ./src",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"dev": "next dev dev --turbo",
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
"dev:generate-types": "pnpm dev:payload generate:types",
"dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
"generate:importmap": "pnpm dev:generate-importmap",
"generate:types": "pnpm dev:generate-types",
"lint": "eslint",
"lint:fix": "eslint ./src --fix",
"prepublishOnly": "pnpm clean && pnpm build",
"test": "pnpm test:int && pnpm test:e2e",
"test:e2e": "playwright test",
"test:int": "vitest"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0",
"@payloadcms/eslint-config": "3.9.0",
"@payloadcms/next": "3.37.0",
"@payloadcms/richtext-lexical": "3.37.0",
"@payloadcms/ui": "3.37.0",
"@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
"@types/node": "^22.5.4",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"copyfiles": "2.4.1",
"cross-env": "^7.0.3",
"eslint": "^9.23.0",
"eslint-config-next": "15.4.4",
"graphql": "^16.8.1",
"mongodb-memory-server": "10.1.4",
"next": "15.4.4",
"open": "^10.1.0",
"payload": "3.37.0",
"prettier": "^3.4.2",
"qs-esm": "7.0.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"rimraf": "3.0.2",
"sharp": "0.34.2",
"sort-package-json": "^2.10.0",
"typescript": "5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.2"
},
"peerDependencies": {
"payload": "^3.37.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^9 || ^10"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"import": "./dist/exports/client.js",
"types": "./dist/exports/client.d.ts",
"default": "./dist/exports/client.js"
},
"./rsc": {
"import": "./dist/exports/rsc.js",
"types": "./dist/exports/rsc.d.ts",
"default": "./dist/exports/rsc.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"pnpm": {
"onlyBuiltDependencies": [
"sharp",
"esbuild",
"unrs-resolver"
]
},
"registry": "https://registry.npmjs.org/",
"dependencies": {}
}

46
playwright.config.js Normal file
View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './dev',
testMatch: '**/e2e.spec.{ts,js}',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
reuseExistingServer: true,
url: 'http://localhost:3000/admin',
},
})

View File

@@ -0,0 +1,29 @@
'use client'
import { useConfig } from '@payloadcms/ui'
import { useEffect, useState } from 'react'
export const BeforeDashboardClient = () => {
const { config } = useConfig()
const [message, setMessage] = useState('')
useEffect(() => {
const fetchMessage = async () => {
const response = await fetch(`${config.serverURL}${config.routes.api}/my-plugin-endpoint`)
const result = await response.json()
setMessage(result.message)
}
void fetchMessage()
}, [config.serverURL, config.routes.api])
return (
<div>
<h1>Added by the plugin: Before Dashboard Client</h1>
<div>
Message from the endpoint:
<div>{message || 'Loading...'}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
.wrapper {
display: flex;
gap: 5px;
flex-direction: column;
}

View File

@@ -0,0 +1,19 @@
import type { ServerComponentProps } from 'payload'
import styles from './BeforeDashboardServer.module.css'
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
const { payload } = props
const { docs } = await payload.find({ collection: 'plugin-collection' })
return (
<div className={styles.wrapper}>
<h1>Added by the plugin: Before Dashboard Server</h1>
Docs from Local API:
{docs.map((doc) => (
<div key={doc.id}>{doc.id}</div>
))}
</div>
)
}

View File

@@ -0,0 +1,78 @@
import type { PayloadHandler } from 'payload'
export const customEndpointHandler = (collectionSlug: string): PayloadHandler =>
async (req) => {
const { payload } = req
const url = new URL(req.url)
const pathParts = url.pathname.split('/').filter(Boolean)
const flagName = pathParts[pathParts.length - 1]
// Check if we're fetching a specific flag
if (flagName && flagName !== 'feature-flags') {
try {
const result = await payload.find({
collection: collectionSlug,
where: {
name: {
equals: flagName,
},
},
limit: 1,
})
if (result.docs.length === 0) {
return Response.json(
{ error: 'Feature flag not found' },
{ status: 404 }
)
}
const flag = result.docs[0]
// Return simplified flag data
return Response.json({
name: flag.name,
enabled: flag.enabled,
rolloutPercentage: flag.rolloutPercentage,
variants: flag.variants,
metadata: flag.metadata,
})
} catch (error) {
return Response.json(
{ error: 'Failed to fetch feature flag' },
{ status: 500 }
)
}
}
// Fetch all feature flags
try {
const result = await payload.find({
collection: collectionSlug,
limit: 1000, // Adjust as needed
where: {
enabled: {
equals: true,
},
},
})
// Return simplified flag data
const flags = result.docs.reduce((acc: any, flag: any) => {
acc[flag.name] = {
enabled: flag.enabled,
rolloutPercentage: flag.rolloutPercentage,
variants: flag.variants,
metadata: flag.metadata,
}
return acc
}, {})
return Response.json(flags)
} catch (error) {
return Response.json(
{ error: 'Failed to fetch feature flags' },
{ status: 500 }
)
}
}

1
src/exports/client.ts Normal file
View File

@@ -0,0 +1 @@
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'

12
src/exports/rsc.ts Normal file
View File

@@ -0,0 +1,12 @@
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
// Server-side hooks for React Server Components
export {
getFeatureFlag,
isFeatureEnabled,
getAllFeatureFlags,
isUserInRollout,
getUserVariant,
getFeatureFlagsByTag,
type FeatureFlag,
} from '../hooks/server.js'

198
src/hooks/server.ts Normal file
View File

@@ -0,0 +1,198 @@
import { getPayload } from 'payload'
import configPromise from '@payload-config'
export interface FeatureFlag {
name: string
enabled: boolean
rolloutPercentage?: number
variants?: Array<{
name: string
weight: number
metadata?: any
}>
metadata?: any
}
// Helper to get the collection slug from config
async function getCollectionSlug(): Promise<string> {
const payload = await getPayload({ config: configPromise })
// Look for the feature flags collection - it should have a 'name' field with unique constraint
const collection = payload.config.collections?.find(col =>
col.fields.some(field =>
field.name === 'name' &&
field.type === 'text' &&
field.unique === true
) &&
col.fields.some(field => field.name === 'enabled' && field.type === 'checkbox')
)
return collection?.slug || 'feature-flags'
}
/**
* Get a specific feature flag by name (for use in React Server Components)
*/
export async function getFeatureFlag(flagName: string): Promise<FeatureFlag | null> {
try {
const payload = await getPayload({ config: configPromise })
const collectionSlug = await getCollectionSlug()
const result = await payload.find({
collection: collectionSlug,
where: {
name: {
equals: flagName,
},
},
limit: 1,
})
if (result.docs.length === 0) {
return null
}
const flag = result.docs[0]
return {
name: flag.name as string,
enabled: flag.enabled as boolean,
rolloutPercentage: flag.rolloutPercentage as number | undefined,
variants: flag.variants as any,
metadata: flag.metadata,
}
} catch (error) {
console.error(`Failed to fetch feature flag ${flagName}:`, error)
return null
}
}
/**
* Check if a feature flag is enabled (for use in React Server Components)
*/
export async function isFeatureEnabled(flagName: string): Promise<boolean> {
const flag = await getFeatureFlag(flagName)
return flag?.enabled ?? false
}
/**
* Get all active feature flags (for use in React Server Components)
*/
export async function getAllFeatureFlags(): Promise<Record<string, FeatureFlag>> {
try {
const payload = await getPayload({ config: configPromise })
const collectionSlug = await getCollectionSlug()
const result = await payload.find({
collection: collectionSlug,
where: {
enabled: {
equals: true,
},
},
limit: 1000,
})
const flags: Record<string, FeatureFlag> = {}
for (const doc of result.docs) {
flags[doc.name as string] = {
name: doc.name as string,
enabled: doc.enabled as boolean,
rolloutPercentage: doc.rolloutPercentage as number | undefined,
variants: doc.variants as any,
metadata: doc.metadata,
}
}
return flags
} catch (error) {
console.error('Failed to fetch feature flags:', error)
return {}
}
}
/**
* Check if a user is in a feature rollout (for use in React Server Components)
*/
export async function isUserInRollout(
flagName: string,
userId: string
): Promise<boolean> {
const flag = await getFeatureFlag(flagName)
if (!flag?.enabled) {
return false
}
if (!flag.rolloutPercentage || flag.rolloutPercentage === 100) {
return true
}
// Simple hash function for consistent user bucketing
const hash = userId.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0)
}, 0)
return (Math.abs(hash) % 100) < flag.rolloutPercentage
}
/**
* Get the variant for a user in an A/B test (for use in React Server Components)
*/
export async function getUserVariant(
flagName: string,
userId: string
): Promise<string | null> {
const flag = await getFeatureFlag(flagName)
if (!flag?.enabled || !flag.variants || flag.variants.length === 0) {
return null
}
// Hash the user ID for consistent variant assignment
const hash = Math.abs(userId.split('').reduce((acc, char) => {
return ((acc << 5) - acc) + char.charCodeAt(0)
}, 0))
const bucket = hash % 100
let cumulative = 0
for (const variant of flag.variants) {
cumulative += variant.weight
if (bucket < cumulative) {
return variant.name
}
}
return flag.variants[0]?.name || null
}
/**
* Get feature flags by tags (for use in React Server Components)
*/
export async function getFeatureFlagsByTag(tag: string): Promise<FeatureFlag[]> {
try {
const payload = await getPayload({ config: configPromise })
const collectionSlug = await getCollectionSlug()
const result = await payload.find({
collection: collectionSlug,
where: {
'tags.tag': {
equals: tag,
},
},
limit: 1000,
})
return result.docs.map(doc => ({
name: doc.name as string,
enabled: doc.enabled as boolean,
rolloutPercentage: doc.rolloutPercentage as number | undefined,
variants: doc.variants as any,
metadata: doc.metadata,
}))
} catch (error) {
console.error(`Failed to fetch feature flags with tag ${tag}:`, error)
return []
}
}

234
src/index.ts Normal file
View File

@@ -0,0 +1,234 @@
import type { Config, CollectionConfig, Field } from 'payload'
import { customEndpointHandler } from './endpoints/customEndpointHandler.js'
export type CollectionOverrides = Partial<
Omit<CollectionConfig, 'fields'>
> & {
fields?: (args: { defaultFields: Field[] }) => Field[]
}
export type PayloadFeatureFlagsConfig = {
/**
* Enable/disable the plugin
* @default false
*/
disabled?: boolean
/**
* Default value for new feature flags
* @default false
*/
defaultValue?: boolean
/**
* Enable percentage-based rollouts
* @default true
*/
enableRollouts?: boolean
/**
* Enable variant/experiment support (A/B testing)
* @default true
*/
enableVariants?: boolean
/**
* Enable REST API endpoints for feature flags
* @default false
*/
enableApi?: boolean
/**
* Override collection configuration
*/
collectionOverrides?: CollectionOverrides
}
export const payloadFeatureFlags =
(pluginOptions: PayloadFeatureFlagsConfig = {}) =>
(config: Config): Config => {
const {
disabled = false,
defaultValue = false,
enableRollouts = true,
enableVariants = true,
enableApi = false,
collectionOverrides = {},
} = pluginOptions
// Get collection slug from overrides or use default
const collectionSlug = collectionOverrides.slug || 'feature-flags'
if (!config.collections) {
config.collections = []
}
// Define default fields
const defaultFields: Field[] = [
{
name: 'name',
type: 'text',
required: true,
unique: true,
admin: {
description: 'Unique identifier for the feature flag',
},
},
{
name: 'description',
type: 'textarea',
admin: {
description: 'Describe what this feature flag controls',
},
},
{
name: 'enabled',
type: 'checkbox',
defaultValue: defaultValue,
required: true,
admin: {
description: 'Toggle this feature flag on or off',
},
},
...(enableRollouts ? [
{
name: 'rolloutPercentage',
type: 'number',
min: 0,
max: 100,
defaultValue: 100,
admin: {
description: 'Percentage of users who will see this feature (0-100)',
condition: (data: any) => data?.enabled === true,
},
},
] : []),
...(enableVariants ? [
{
name: 'variants',
type: 'array',
admin: {
description: 'Define variants for A/B testing',
condition: (data: any) => data?.enabled === true,
},
fields: [
{
name: 'name',
type: 'text',
required: true,
admin: {
description: 'Variant identifier (e.g., control, variant-a)',
},
},
{
name: 'weight',
type: 'number',
min: 0,
max: 100,
required: true,
admin: {
description: 'Weight for this variant (all weights should sum to 100)',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional data for this variant',
},
},
],
},
] : []),
{
name: 'tags',
type: 'array',
fields: [
{
name: 'tag',
type: 'text',
},
],
admin: {
description: 'Tags for organizing feature flags',
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'Additional metadata for this feature flag',
},
},
]
// Apply field overrides if provided
const fields = collectionOverrides.fields
? collectionOverrides.fields({ defaultFields })
: defaultFields
// Extract field overrides from collectionOverrides
const { fields: _fieldsOverride, ...otherOverrides } = collectionOverrides
// Create the feature flags collection with overrides
const featureFlagsCollection: CollectionConfig = {
slug: collectionSlug,
admin: {
useAsTitle: 'name',
group: 'Configuration',
description: 'Manage feature flags for your application',
...(otherOverrides.admin || {}),
},
fields,
// Apply any other collection overrides
...otherOverrides,
}
config.collections.push(featureFlagsCollection)
/**
* If the plugin is disabled, we still want to keep the collection
* so the database schema is consistent which is important for migrations.
*/
if (disabled) {
return config
}
if (!config.endpoints) {
config.endpoints = []
}
if (!config.admin) {
config.admin = {}
}
if (!config.admin.components) {
config.admin.components = {}
}
if (!config.admin.components.beforeDashboard) {
config.admin.components.beforeDashboard = []
}
config.admin.components.beforeDashboard.push(
`payload-feature-flags/client#BeforeDashboardClient`,
)
config.admin.components.beforeDashboard.push(
`payload-feature-flags/rsc#BeforeDashboardServer`,
)
// Add API endpoints if enabled
if (enableApi) {
// Add API endpoint for fetching feature flags
config.endpoints.push({
handler: customEndpointHandler(collectionSlug),
method: 'get',
path: '/feature-flags',
})
// Add endpoint for checking a specific feature flag
config.endpoints.push({
handler: customEndpointHandler(collectionSlug),
method: 'get',
path: '/feature-flags/:flag',
})
}
return config
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"rootDir": "./",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "nodenext",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"emitDeclarationOnly": true,
"target": "ES2022",
"composite": true,
"plugins": [
{
"name": "next"
}
],
},
"include": [
"./src/**/*.ts",
"./src/**/*.tsx",
"./dev/next-env.d.ts",
],
}

25
vitest.config.js Normal file
View File

@@ -0,0 +1,25 @@
import path from 'path'
import { loadEnv } from 'payload/node'
import { fileURLToPath } from 'url'
import tsconfigPaths from 'vite-tsconfig-paths'
import { defineConfig } from 'vitest/config'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default defineConfig(() => {
loadEnv(path.resolve(dirname, './dev'))
return {
plugins: [
tsconfigPaths({
ignoreConfigErrors: true,
}),
],
test: {
environment: 'node',
hookTimeout: 30_000,
testTimeout: 30_000,
},
}
})