mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-08 00:13:23 +00:00
Add Payload Feature Flags plugin with custom endpoints and configurations
This commit is contained in:
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
24
.swcrc
Normal file
24
.swcrc
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
24
.vscode/launch.json
vendored
Normal file
24
.vscode/launch.json
vendored
Normal 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
40
.vscode/settings.json
vendored
Normal 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
510
README.md
Normal 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
2
dev/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal 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
|
||||
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
dev/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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
|
||||
9
dev/app/(payload)/admin/importMap.js
Normal file
9
dev/app/(payload)/admin/importMap.js
Normal 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,
|
||||
}
|
||||
19
dev/app/(payload)/api/[...slug]/route.ts
Normal file
19
dev/app/(payload)/api/[...slug]/route.ts
Normal 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)
|
||||
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal file
7
dev/app/(payload)/api/graphql-playground/route.ts
Normal 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)
|
||||
8
dev/app/(payload)/api/graphql/route.ts
Normal file
8
dev/app/(payload)/api/graphql/route.ts
Normal 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)
|
||||
0
dev/app/(payload)/custom.scss
Normal file
0
dev/app/(payload)/custom.scss
Normal file
32
dev/app/(payload)/layout.tsx
Normal file
32
dev/app/(payload)/layout.tsx
Normal 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
12
dev/app/my-route/route.ts
Normal 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
15
dev/e2e.spec.ts
Normal 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()
|
||||
})
|
||||
4
dev/helpers/credentials.ts
Normal file
4
dev/helpers/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const devUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
}
|
||||
38
dev/helpers/testEmailAdapter.ts
Normal file
38
dev/helpers/testEmailAdapter.ts
Normal 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
52
dev/int.spec.ts
Normal 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
5
dev/next-env.d.ts
vendored
Normal 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
21
dev/next.config.mjs
Normal 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
276
dev/payload-types.ts
Normal 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
75
dev/payload.config.ts
Normal 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
21
dev/seed.ts
Normal 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
35
dev/tsconfig.json
Normal 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
46
eslint.config.js
Normal 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
18003
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
120
package.json
Normal file
120
package.json
Normal 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
46
playwright.config.js
Normal 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',
|
||||
},
|
||||
})
|
||||
29
src/components/BeforeDashboardClient.tsx
Normal file
29
src/components/BeforeDashboardClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/components/BeforeDashboardServer.module.css
Normal file
5
src/components/BeforeDashboardServer.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
19
src/components/BeforeDashboardServer.tsx
Normal file
19
src/components/BeforeDashboardServer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/endpoints/customEndpointHandler.ts
Normal file
78
src/endpoints/customEndpointHandler.ts
Normal 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
1
src/exports/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
|
||||
12
src/exports/rsc.ts
Normal file
12
src/exports/rsc.ts
Normal 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
198
src/hooks/server.ts
Normal 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
234
src/index.ts
Normal 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
34
tsconfig.json
Normal 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
25
vitest.config.js
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user