mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 02:43:25 +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