Replace redundant components with updated feature flag hooks and views. Add comprehensive documentation and ESLint config for improved development workflow.

This commit is contained in:
2025-09-12 15:35:44 +02:00
parent 453b9eac7c
commit 81780ab7a9
27 changed files with 13326 additions and 260 deletions

88
dev/README.md Normal file
View File

@@ -0,0 +1,88 @@
# Demo Environment
Simple demo for testing the feature flags plugin.
## Quick Start
```bash
pnpm install
pnpm dev
# Visit http://localhost:3000
```
**URLs:**
- **Home:** http://localhost:3000
- **Admin:** http://localhost:3000/admin
- **API:** http://localhost:3000/api/feature-flags
## Login
**Admin:** `dev@payloadcms.com` / `test` (full access)
**Editor:** `editor@payloadcms.com` / `test123` (limited access)
**User:** `user@payloadcms.com` / `test123` (read-only)
## What's included
**Homepage:** Shows feature flag status and demo content
**Admin Panel:** Manage feature flags and users
**Sample Data:** One test feature flag ready to use
## Testing the Plugin
1. **Check the homepage** - See the feature flag in action
2. **Login to admin** - Use admin credentials above
3. **Toggle the flag** - Go to Feature Flags collection
4. **Refresh homepage** - See content appear/disappear
## Plugin Config
```typescript
payloadFeatureFlags({
enableRollouts: true, // Percentage rollouts
enableVariants: true, // A/B testing
enableApi: true, // REST endpoints
defaultValue: false, // New flags start disabled
// + custom fields and permissions
})
```
## API Testing
```bash
# Get all flags
curl http://localhost:3000/api/feature-flags
# Get specific flag
curl http://localhost:3000/api/feature-flags/new-feature
```
## Database
Uses in-memory MongoDB - no setup needed! Data resets on each restart.
## Creating New Flags
1. Login to `/admin/collections/feature-flags`
2. Click "Create New"
3. Add name, description, and toggle enabled/disabled
4. Check the homepage to see it working
## Need Help?
- Check the console for error messages
- Make sure port 3000 is available
- Try logging in as admin user
## Next Steps
Ready to use this in your project?
1. **Add to your project:** Copy the plugin config
2. **Customize:** Add your own fields and permissions
3. **Go live:** Use a real MongoDB database
4. **Monitor:** Track how your flags perform
---
This demo gives you everything needed to test feature flags with zero setup.

99
dev/app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,99 @@
import React from 'react'
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Feature Flags Demo - Payload CMS Plugin</title>
</head>
<body style={{
margin: 0,
padding: 0,
fontFamily: 'system-ui, -apple-system, sans-serif',
backgroundColor: '#f8fafc'
}}>
<style dangerouslySetInnerHTML={{
__html: `
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
background-color: rgba(255,255,255,0.2);
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.3);
font-size: 0.9rem;
transition: all 0.2s ease;
}
.nav-link:hover {
background-color: rgba(255,255,255,0.3);
}
`
}} />
<nav style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '1rem 2rem',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)'
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h1 style={{
color: 'white',
margin: 0,
fontSize: '1.5rem',
fontWeight: '600'
}}>
🚩 Payload Feature Flags
</h1>
<p style={{
color: 'rgba(255,255,255,0.8)',
margin: '0.25rem 0 0 0',
fontSize: '0.9rem'
}}>
Development & Testing Environment
</p>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<a href="/admin" className="nav-link">
Admin Panel
</a>
</div>
</div>
</nav>
<main style={{ minHeight: 'calc(100vh - 100px)' }}>
{children}
</main>
<footer style={{
background: '#2d3748',
color: 'white',
padding: '1rem 2rem',
textAlign: 'center',
fontSize: '0.9rem'
}}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<p style={{ margin: 0 }}>
Built with <strong>@xtr-dev/payload-feature-flags</strong>
<a
href="https://payloadcms.com"
style={{ color: '#a0aec0', marginLeft: '0.5rem' }}
>
Powered by Payload CMS
</a>
</p>
</div>
</footer>
</body>
</html>
)
}

127
dev/app/(app)/page.tsx Normal file
View File

@@ -0,0 +1,127 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { getAllFeatureFlags, isFeatureEnabled } from 'payload-feature-flags/rsc'
export default async function HomePage() {
const payload = await getPayload({ config })
const allFlags = await getAllFeatureFlags(payload)
const activeCount = Object.keys(allFlags).length
const isNewFeatureEnabled = await isFeatureEnabled('new-feature', payload)
return (
<div style={{
padding: '2rem',
maxWidth: '800px',
margin: '0 auto',
fontFamily: 'system-ui, -apple-system, sans-serif'
}}>
<h1 style={{
fontSize: '2.5rem',
fontWeight: '700',
color: '#1e293b',
marginBottom: '1rem',
textAlign: 'center'
}}>
Feature Flags Demo
</h1>
<p style={{
fontSize: '1.1rem',
color: '#64748b',
textAlign: 'center',
marginBottom: '2rem'
}}>
Simple demonstration of the Payload CMS Feature Flags plugin
</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '1rem',
marginBottom: '2rem'
}}>
<div style={{
background: 'white',
padding: '1.5rem',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
textAlign: 'center'
}}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>🚩</div>
<div style={{ fontSize: '1.5rem', fontWeight: '600', color: '#1e293b' }}>
{activeCount}
</div>
<div style={{ fontSize: '0.9rem', color: '#64748b' }}>
Active Flags
</div>
</div>
<div style={{
background: 'white',
padding: '1.5rem',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid #e2e8f0',
textAlign: 'center'
}}>
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>
{isNewFeatureEnabled ? '✅' : '❌'}
</div>
<div style={{ fontSize: '1rem', fontWeight: '600', color: '#1e293b' }}>
New Feature
</div>
<div style={{ fontSize: '0.9rem', color: '#64748b' }}>
{isNewFeatureEnabled ? 'Enabled' : 'Disabled'}
</div>
</div>
</div>
{isNewFeatureEnabled && (
<div style={{
background: '#f0f9ff',
border: '1px solid #0ea5e9',
borderRadius: '8px',
padding: '1rem',
marginBottom: '2rem'
}}>
<h3 style={{ margin: '0 0 0.5rem 0', color: '#0369a1' }}>
🎉 Feature Flag Active
</h3>
<p style={{ margin: 0, color: '#0369a1' }}>
The "new-feature" flag is enabled! This content is only visible when the flag is active.
</p>
</div>
)}
<div style={{
background: '#f8fafc',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #e2e8f0'
}}>
<h3 style={{ margin: '0 0 1rem 0', color: '#1e293b' }}>
Manage Feature Flags
</h3>
<p style={{ margin: '0 0 1rem 0', color: '#64748b' }}>
Use the Payload admin panel to create and manage feature flags.
</p>
<a
href="/admin/collections/feature-flags"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
background: '#3b82f6',
color: 'white',
textDecoration: 'none',
borderRadius: '6px',
fontWeight: '500'
}}
>
Open Admin Panel
</a>
</div>
</div>
)
}

View File

@@ -1,9 +1,51 @@
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'payload-feature-flags/client'
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'payload-feature-flags/rsc'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { FeatureFlagsView as FeatureFlagsView_ff9c56e3f2e2e2932e770d38b1114030 } from 'payload-feature-flags/views'
export const importMap = {
'payload-feature-flags/client#BeforeDashboardClient':
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
'payload-feature-flags/rsc#BeforeDashboardServer':
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"payload-feature-flags/views#FeatureFlagsView": FeatureFlagsView_ff9c56e3f2e2e2932e770d38b1114030
}

View File

@@ -1,4 +1,21 @@
export const devUser = {
email: 'dev@payloadcms.com',
password: 'test',
name: 'Development Admin',
role: 'admin' as const,
}
export const testUsers = [
{
email: 'editor@payloadcms.com',
password: 'test123',
name: 'Content Editor',
role: 'editor' as const,
},
{
email: 'user@payloadcms.com',
password: 'test123',
name: 'Regular User',
role: 'user' as const,
},
]

View File

@@ -1,6 +1,7 @@
import { withPayload } from '@payloadcms/next/withPayload'
import { fileURLToPath } from 'url'
import path from 'path'
import { fileURLToPath } from 'url'
const dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -13,6 +14,15 @@ const nextConfig = {
'.mjs': ['.mts', '.mjs'],
}
// Add webpack aliases for local plugin development
webpackConfig.resolve.alias = {
...webpackConfig.resolve.alias,
'payload-feature-flags/views': path.resolve(dirname, '../src/exports/views.ts'),
'payload-feature-flags/client': path.resolve(dirname, '../src/exports/client.ts'),
'payload-feature-flags/rsc': path.resolve(dirname, '../src/exports/rsc.ts'),
'payload-feature-flags': path.resolve(dirname, '../src/index.ts'),
}
return webpackConfig
},
serverExternalPackages: ['mongodb-memory-server'],

View File

@@ -6,15 +6,72 @@
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
posts: Post;
media: Media;
'plugin-collection': PluginCollection;
pages: Page;
users: User;
media: Media;
'feature-flags': FeatureFlag;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -22,9 +79,10 @@ export interface Config {
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'feature-flags': FeatureFlagsSelect<false> | FeatureFlagsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -67,34 +125,51 @@ export interface UserAuthOperations {
*/
export interface Post {
id: string;
addedByPlugin?: string | null;
title: string;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
status?: ('draft' | 'published') | null;
publishedAt?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
* via the `definition` "pages".
*/
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 {
export interface Page {
id: string;
title: string;
slug: string;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
layout?: ('default' | 'landing' | 'sidebar') | null;
updatedAt: string;
createdAt: string;
}
@@ -104,6 +179,8 @@ export interface PluginCollection {
*/
export interface User {
id: string;
name?: string | null;
role?: ('admin' | 'editor' | 'user') | null;
updatedAt: string;
createdAt: string;
email: string;
@@ -115,6 +192,117 @@ export interface User {
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
alt?: string | null;
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;
}
/**
* Manage feature flags for the development environment
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "feature-flags".
*/
export interface FeatureFlag {
id: string;
/**
* Unique identifier for the feature flag
*/
name: string;
/**
* Describe what this feature flag controls
*/
description?: string | null;
/**
* Toggle this feature flag on or off
*/
enabled: boolean;
/**
* Percentage of users who will see this feature (0-100)
*/
rolloutPercentage?: number | null;
/**
* Define variants for A/B testing
*/
variants?:
| {
/**
* Variant identifier (e.g., control, variant-a)
*/
name: string;
/**
* Weight for this variant (all weights should sum to 100)
*/
weight: number;
/**
* Additional data for this variant
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
id?: string | null;
}[]
| null;
/**
* Tags for organizing feature flags
*/
tags?:
| {
tag?: string | null;
id?: string | null;
}[]
| null;
/**
* Additional metadata for this feature flag
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Which environment this flag applies to
*/
environment: 'development' | 'staging' | 'production';
/**
* Team member responsible for this feature flag
*/
owner?: (string | null) | User;
/**
* Optional expiration date for temporary flags
*/
expiresAt?: string | null;
/**
* Related JIRA ticket or issue number
*/
jiraTicket?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -127,16 +315,20 @@ export interface PayloadLockedDocument {
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'plugin-collection';
value: string | PluginCollection;
relationTo: 'pages';
value: string | Page;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'feature-flags';
value: string | FeatureFlag;
} | null);
globalSlug?: string | null;
user: {
@@ -185,15 +377,48 @@ export interface PayloadMigration {
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
addedByPlugin?: T;
title?: T;
content?: T;
status?: T;
publishedAt?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
title?: T;
slug?: T;
content?: T;
layout?: 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> {
name?: T;
role?: T;
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` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
@@ -208,28 +433,35 @@ export interface MediaSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "plugin-collection_select".
* via the `definition` "feature-flags_select".
*/
export interface PluginCollectionSelect<T extends boolean = true> {
id?: T;
export interface FeatureFlagsSelect<T extends boolean = true> {
name?: T;
description?: T;
enabled?: T;
rolloutPercentage?: T;
variants?:
| T
| {
name?: T;
weight?: T;
metadata?: T;
id?: T;
};
tags?:
| T
| {
tag?: T;
id?: T;
};
metadata?: T;
environment?: T;
owner?: T;
expiresAt?: T;
jiraTicket?: 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".

View File

@@ -3,7 +3,7 @@ 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 { payloadFeatureFlags } from '../src/index.js'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
@@ -18,15 +18,19 @@ if (!process.env.ROOT_DIR) {
}
const buildConfigWithMemoryDB = async () => {
if (process.env.NODE_ENV === 'test') {
// Use in-memory MongoDB for both test and development
if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV !== 'production') {
console.log('🗃️ Starting MongoDB Memory Server...')
const memoryDB = await MongoMemoryReplSet.create({
replSet: {
count: 3,
dbName: 'payloadmemory',
count: 1,
dbName: 'payload-feature-flags-dev',
},
})
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
const uri = memoryDB.getUri()
process.env.DATABASE_URI = `${uri}&retryWrites=true`
console.log('✅ MongoDB Memory Server started successfully')
}
return buildConfig({
@@ -38,11 +42,87 @@ const buildConfigWithMemoryDB = async () => {
collections: [
{
slug: 'posts',
fields: [],
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'content',
type: 'richText',
},
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
defaultValue: 'draft',
},
{
name: 'publishedAt',
type: 'date',
},
],
},
{
slug: 'pages',
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
},
{
name: 'content',
type: 'richText',
},
{
name: 'layout',
type: 'select',
options: ['default', 'landing', 'sidebar'],
defaultValue: 'default',
},
],
},
{
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
{
name: 'name',
type: 'text',
},
{
name: 'role',
type: 'select',
options: ['admin', 'editor', 'user'],
defaultValue: 'user',
},
],
},
{
slug: 'media',
fields: [],
fields: [
{
name: 'alt',
type: 'text',
},
],
upload: {
staticDir: path.resolve(dirname, 'media'),
},
@@ -50,7 +130,7 @@ const buildConfigWithMemoryDB = async () => {
],
db: mongooseAdapter({
ensureIndexes: true,
url: process.env.DATABASE_URI || '',
url: process.env.DATABASE_URI || 'mongodb://localhost/payload-feature-flags-dev',
}),
editor: lexicalEditor(),
email: testEmailAdapter,
@@ -59,12 +139,94 @@ const buildConfigWithMemoryDB = async () => {
},
plugins: [
payloadFeatureFlags({
collections: {
posts: true,
// Enable all features
enableRollouts: true,
enableVariants: true,
enableApi: true,
defaultValue: false,
// Custom collection configuration
collectionOverrides: {
admin: {
useAsTitle: 'name',
group: 'Configuration',
description: 'Manage feature flags for the development environment',
},
access: {
// Only authenticated users can read/manage feature flags
read: ({ req: { user } }) => !!user,
create: ({ req: { user } }) => !!user,
update: ({ req: { user } }) => user?.role === 'admin',
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'environment',
type: 'select',
options: [
{ label: 'Development', value: 'development' },
{ label: 'Staging', value: 'staging' },
{ label: 'Production', value: 'production' },
],
required: true,
defaultValue: 'development',
admin: {
description: 'Which environment this flag applies to',
},
},
{
name: 'owner',
type: 'relationship',
relationTo: 'users',
admin: {
description: 'Team member responsible for this feature flag',
},
},
{
name: 'expiresAt',
type: 'date',
admin: {
description: 'Optional expiration date for temporary flags',
},
},
{
name: 'jiraTicket',
type: 'text',
admin: {
description: 'Related JIRA ticket or issue number',
},
},
],
hooks: {
beforeChange: [
async ({ data, req, operation }) => {
// Auto-assign current user as owner for new flags
if (operation === 'create' && !data.owner && req.user) {
data.owner = req.user.id
}
// Log flag changes for audit trail
if (req.user) {
console.log(`Feature flag "${data.name}" ${operation} by ${req.user.email}`)
}
return data
},
],
afterChange: [
async ({ doc, req, operation }) => {
// Send notification for critical flag changes
if (doc.environment === 'production' && req.user) {
console.log(`🚨 Production feature flag "${doc.name}" was ${operation === 'create' ? 'created' : 'modified'} by ${req.user.email}`)
}
},
],
},
},
}),
],
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
secret: process.env.PAYLOAD_SECRET || 'dev-secret-key-change-in-production',
sharp,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),

View File

@@ -1,9 +1,10 @@
import type { Payload } from 'payload'
import { devUser } from './helpers/credentials.js'
import { devUser, testUsers } from './helpers/credentials.js'
export const seed = async (payload: Payload) => {
const { totalDocs } = await payload.count({
// Create admin user
const { totalDocs: adminExists } = await payload.count({
collection: 'users',
where: {
email: {
@@ -12,10 +13,132 @@ export const seed = async (payload: Payload) => {
},
})
if (!totalDocs) {
await payload.create({
let adminUser
if (!adminExists) {
adminUser = await payload.create({
collection: 'users',
data: devUser,
})
console.log(`✅ Created admin user: ${devUser.email}`)
} else {
const adminResult = await payload.find({
collection: 'users',
where: {
email: {
equals: devUser.email,
},
},
})
adminUser = adminResult.docs[0]
}
// Create test users
for (const user of testUsers) {
const { totalDocs: userExists } = await payload.count({
collection: 'users',
where: {
email: {
equals: user.email,
},
},
})
if (!userExists) {
await payload.create({
collection: 'users',
data: user,
})
console.log(`✅ Created user: ${user.email}`)
}
}
// Create sample posts
const { totalDocs: postsExist } = await payload.count({
collection: 'posts',
})
if (postsExist === 0) {
const samplePosts = [
{
title: 'Welcome to Feature Flag Testing',
status: 'published' as const,
publishedAt: new Date().toISOString(),
},
{
title: 'Beta Feature Showcase',
status: 'draft' as const,
},
{
title: 'A/B Test Content',
status: 'published' as const,
publishedAt: new Date().toISOString(),
},
]
for (const post of samplePosts) {
await payload.create({
collection: 'posts',
data: post,
})
}
console.log('✅ Created sample posts')
}
// Create sample pages
const { totalDocs: pagesExist } = await payload.count({
collection: 'pages',
})
if (pagesExist === 0) {
const samplePages = [
{
title: 'Home',
slug: 'home',
layout: 'landing' as const,
},
{
title: 'About',
slug: 'about',
layout: 'default' as const,
},
{
title: 'Beta Dashboard',
slug: 'beta-dashboard',
layout: 'sidebar' as const,
},
]
for (const page of samplePages) {
await payload.create({
collection: 'pages',
data: page,
})
}
console.log('✅ Created sample pages')
}
// Create simple feature flag for testing
const { totalDocs: flagsExist } = await payload.count({
collection: 'feature-flags',
})
if (flagsExist === 0) {
await payload.create({
collection: 'feature-flags',
data: {
name: 'new-feature',
description: 'A simple test feature flag',
enabled: true,
environment: 'development' as const,
owner: adminUser.id,
},
})
console.log('✅ Created simple feature flag for testing')
}
console.log('🎯 Development environment seeded successfully!')
console.log('📧 Login with:')
console.log(` Admin: ${devUser.email} / ${devUser.password}`)
console.log(` Editor: ${testUsers[0].email} / ${testUsers[0].password}`)
console.log(` User: ${testUsers[1].email} / ${testUsers[1].password}`)
}

View File

@@ -27,6 +27,9 @@
],
"payload-feature-flags/rsc": [
"../src/exports/rsc.ts"
],
"payload-feature-flags/views": [
"../src/exports/views.ts"
]
},
"noEmit": true,