mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-08 00:13:23 +00:00
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:
105
CLAUDE.md
Normal file
105
CLAUDE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a Payload CMS v3 plugin for feature flags (@xtr-dev/payload-feature-flags). The plugin enables feature toggles, A/B testing, and gradual rollouts through a dedicated collection and server-side hooks.
|
||||
|
||||
## Key Commands
|
||||
|
||||
### Development
|
||||
- `pnpm dev` - Start Next.js dev server with Payload CMS admin panel
|
||||
- `pnpm dev:payload` - Run Payload CLI commands for development
|
||||
- `pnpm dev:generate-types` - Generate TypeScript types from Payload config
|
||||
- `pnpm dev:generate-importmap` - Generate import map for admin panel
|
||||
|
||||
### Building
|
||||
- `pnpm build` - Full production build (copies files, builds types, compiles with SWC)
|
||||
- `pnpm build:types` - Generate TypeScript declarations only
|
||||
- `pnpm build:swc` - Compile TypeScript to JavaScript using SWC
|
||||
- `pnpm clean` - Remove dist directory and build artifacts
|
||||
- `pnpm copyfiles` - Copy non-TS assets to dist
|
||||
|
||||
### Testing & Quality
|
||||
- `pnpm test` - Run all tests (integration + e2e)
|
||||
- `pnpm test:int` - Run integration tests with Vitest
|
||||
- `pnpm test:e2e` - Run end-to-end tests with Playwright
|
||||
- `pnpm lint` - Run ESLint
|
||||
- `pnpm lint:fix` - Run ESLint with auto-fix
|
||||
|
||||
### Publishing
|
||||
- `pnpm prepublishOnly` - Clean and build before publishing (runs automatically)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin Structure
|
||||
The plugin follows Payload's plugin architecture with multiple exports:
|
||||
|
||||
- **Main export (`src/index.ts`)**: Core plugin configuration function
|
||||
- **RSC export (`src/exports/rsc.ts`)**: Server-side hooks for React Server Components
|
||||
- **Client export (`src/exports/client.ts`)**: Client-side components (currently placeholder)
|
||||
|
||||
### Core Components
|
||||
|
||||
#### Plugin Configuration (`src/index.ts`)
|
||||
- `PayloadFeatureFlagsConfig`: Main configuration type
|
||||
- `payloadFeatureFlags()`: Plugin factory function that creates a Payload collection and optional API endpoints
|
||||
- Collection overrides support for full customization
|
||||
- Feature toggles for rollouts, variants, and API endpoints
|
||||
|
||||
#### Server Hooks (`src/hooks/server.ts`)
|
||||
- `getFeatureFlag()`: Fetch individual flag data
|
||||
- `isFeatureEnabled()`: Simple boolean check
|
||||
- `getAllFeatureFlags()`: Get all active flags
|
||||
- `isUserInRollout()`: Check percentage-based rollouts with consistent hashing
|
||||
- `getUserVariant()`: A/B testing variant selection
|
||||
- `getFeatureFlagsByTag()`: Query flags by tags
|
||||
|
||||
#### API Endpoints (`src/endpoints/customEndpointHandler.ts`)
|
||||
- `GET /api/feature-flags` - List all active feature flags
|
||||
- `GET /api/feature-flags/:flag` - Get specific flag data
|
||||
- Only created when `enableApi: true`
|
||||
|
||||
### Collection Schema
|
||||
The plugin creates a feature flags collection with these key fields:
|
||||
- `name` (unique): Flag identifier
|
||||
- `enabled`: On/off toggle
|
||||
- `rolloutPercentage`: 0-100% rollout control
|
||||
- `variants`: Array for A/B testing with weights
|
||||
- `tags`: Organization and filtering
|
||||
- `metadata`: Additional JSON data
|
||||
|
||||
### Development Environment
|
||||
- Uses MongoDB Memory Server for testing
|
||||
- Next.js for admin panel development
|
||||
- SWC for fast compilation
|
||||
- Vitest for integration testing
|
||||
- Playwright for E2E testing
|
||||
|
||||
### Export Strategy
|
||||
The plugin publishes with different entry points:
|
||||
- **Development**: Points to TypeScript sources (`src/`)
|
||||
- **Production**: Points to compiled JavaScript (`dist/`)
|
||||
- Supports both CommonJS and ESM through package.json exports
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Plugin Integration
|
||||
The plugin integrates with Payload by:
|
||||
1. Creating a feature flags collection with configurable slug and fields
|
||||
2. Adding optional REST API endpoints
|
||||
3. Providing server-side hooks that work with any collection slug
|
||||
4. Supporting full collection customization through `collectionOverrides`
|
||||
|
||||
### Security Considerations
|
||||
- REST API endpoints are disabled by default (`enableApi: false`)
|
||||
- Server-side hooks are the preferred method for accessing feature flags
|
||||
- Collection access can be restricted through `collectionOverrides.access`
|
||||
|
||||
### Testing Setup
|
||||
The development configuration (`dev/payload.config.ts`) includes:
|
||||
- MongoDB Memory Server for isolated testing
|
||||
- Test collections (posts, media)
|
||||
- Example plugin configuration with collection overrides
|
||||
- Seeding functionality for development data
|
||||
98
README.md
98
README.md
@@ -1,13 +1,17 @@
|
||||
# @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.
|
||||
Feature flags plugin for Payload CMS v3. Manage feature toggles, A/B tests, and rollouts from your admin panel.
|
||||
|
||||
⚠️ **Pre-release Warning**: This package is currently in active development (v0.0.x). Breaking changes may occur before v1.0.0. Not recommended for production use.
|
||||
|
||||
## 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
|
||||
- 🚀 **Easy setup** - Add to your Payload config and you're done
|
||||
- 🎛️ **Admin dashboard** - Manage flags from your Payload admin panel
|
||||
- 🔄 **Gradual rollouts** - Roll out features to a percentage of users
|
||||
- 🧪 **A/B testing** - Test different versions of features
|
||||
- 🛣️ **REST API** - Check flag status via API endpoints
|
||||
- 🗃️ **Quick demo** - Try it instantly with no database setup
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -15,21 +19,17 @@ A powerful feature flags plugin for Payload CMS v3 that enables you to manage fe
|
||||
npm install @xtr-dev/payload-feature-flags
|
||||
```
|
||||
|
||||
Or using pnpm:
|
||||
|
||||
```bash
|
||||
# or with pnpm
|
||||
pnpm add @xtr-dev/payload-feature-flags
|
||||
```
|
||||
|
||||
Or using yarn:
|
||||
|
||||
```bash
|
||||
# or with yarn
|
||||
yarn add @xtr-dev/payload-feature-flags
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Payload CMS v3.37.0 or higher
|
||||
- Payload CMS v3.37.0+
|
||||
- Node.js 18.20.2+ or 20.9.0+
|
||||
- React 19.1.0+
|
||||
|
||||
@@ -47,12 +47,12 @@ 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
|
||||
// All options are optional - these are the defaults
|
||||
defaultValue: false, // New flags start disabled
|
||||
enableRollouts: true, // Allow percentage rollouts
|
||||
enableVariants: true, // Allow A/B testing
|
||||
enableApi: false, // REST API endpoints
|
||||
disabled: false, // Plugin enabled
|
||||
}),
|
||||
],
|
||||
})
|
||||
@@ -60,7 +60,7 @@ export default buildConfig({
|
||||
|
||||
### Configuration Options
|
||||
|
||||
The plugin accepts the following configuration options:
|
||||
Available plugin options:
|
||||
|
||||
```typescript
|
||||
export type PayloadFeatureFlagsConfig = {
|
||||
@@ -113,9 +113,9 @@ export type PayloadFeatureFlagsConfig = {
|
||||
}
|
||||
```
|
||||
|
||||
### Collection Overrides
|
||||
### Custom Fields and Access
|
||||
|
||||
You can customize the feature flags collection using `collectionOverrides`:
|
||||
Add custom fields or change permissions using `collectionOverrides`:
|
||||
|
||||
```typescript
|
||||
payloadFeatureFlags({
|
||||
@@ -171,9 +171,29 @@ payloadFeatureFlags({
|
||||
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
|
||||
2. **Provides a clean admin interface** - Manage flags directly from the Payload admin panel
|
||||
3. **Adds a custom dashboard view** - Enhanced UI for managing flags at `/admin/feature-flags-overview`
|
||||
4. **Exposes REST API endpoints** - Simple endpoints for checking flag states
|
||||
5. **Keeps your data clean** - No modifications to your existing collections
|
||||
|
||||
### Admin Interface
|
||||
|
||||
The plugin provides a custom admin view with enhanced UI for managing feature flags:
|
||||
|
||||
**📍 Access:** `/admin/feature-flags-overview`
|
||||
|
||||
**Features:**
|
||||
- 📊 **Dashboard Overview** - Visual stats showing total, enabled, and rolling out flags
|
||||
- 🔍 **Search & Filter** - Find flags by name/description and filter by status
|
||||
- 🎛️ **Quick Toggle** - Enable/disable flags with visual toggle switches
|
||||
- 🏷️ **Smart Labels** - Visual indicators for rollout percentages, A/B tests, and environments
|
||||
- 📱 **Responsive Design** - Works seamlessly on desktop and mobile devices
|
||||
|
||||
The custom view provides a more user-friendly interface compared to the standard collection view, with:
|
||||
- Real-time status indicators
|
||||
- One-click flag toggling
|
||||
- Better visual organization
|
||||
- Advanced filtering capabilities
|
||||
|
||||
### Using Feature Flags in React Server Components
|
||||
|
||||
@@ -390,6 +410,24 @@ payloadFeatureFlags({
|
||||
|
||||
## Development
|
||||
|
||||
### Try the Demo
|
||||
|
||||
Test the plugin with zero setup:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xtr-dev/payload-feature-flags
|
||||
cd payload-feature-flags
|
||||
pnpm install
|
||||
pnpm dev
|
||||
# Visit http://localhost:3000
|
||||
```
|
||||
|
||||
**What you get:**
|
||||
- 🗃️ **No database needed** - Uses in-memory MongoDB
|
||||
- 🎯 **Sample data included** - Ready-to-test feature flag
|
||||
- 🔑 **Auto-login** - Use `dev@payloadcms.com / test`
|
||||
- 📱 **Working dashboard** - See flags in action
|
||||
|
||||
### Building the Plugin
|
||||
|
||||
```bash
|
||||
@@ -406,20 +444,10 @@ pnpm test
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run integration tests
|
||||
pnpm test:int
|
||||
|
||||
# Run E2E tests
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
# Start development server with hot reload
|
||||
pnpm dev
|
||||
|
||||
# Generate types
|
||||
|
||||
88
dev/README.md
Normal file
88
dev/README.md
Normal 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
99
dev/app/(app)/layout.tsx
Normal 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
127
dev/app/(app)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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'),
|
||||
|
||||
131
dev/seed.ts
131
dev/seed.ts
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
],
|
||||
"payload-feature-flags/rsc": [
|
||||
"../src/exports/rsc.ts"
|
||||
],
|
||||
"payload-feature-flags/views": [
|
||||
"../src/exports/views.ts"
|
||||
]
|
||||
},
|
||||
"noEmit": true,
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
// @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/',
|
||||
]
|
||||
import js from '@eslint/js'
|
||||
|
||||
export default [
|
||||
...payloadEsLintConfig,
|
||||
{
|
||||
rules: {
|
||||
'no-restricted-exports': 'off',
|
||||
},
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/build/**',
|
||||
'**/temp/**',
|
||||
'**/*.d.ts',
|
||||
'**/payload-types.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
...js.configs.recommended,
|
||||
rules: {
|
||||
'no-unused-vars': 'warn',
|
||||
'no-console': '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,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
console: 'readonly',
|
||||
process: 'readonly',
|
||||
fetch: 'readonly',
|
||||
URL: 'readonly',
|
||||
Response: 'readonly',
|
||||
Request: 'readonly',
|
||||
FormData: 'readonly',
|
||||
Headers: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
global: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
]
|
||||
46
eslint.config.payload.js
Normal file
46
eslint.config.payload.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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
13
package.json
13
package.json
@@ -19,6 +19,11 @@
|
||||
"import": "./src/exports/rsc.ts",
|
||||
"types": "./src/exports/rsc.ts",
|
||||
"default": "./src/exports/rsc.ts"
|
||||
},
|
||||
"./views": {
|
||||
"import": "./src/exports/views.ts",
|
||||
"types": "./src/exports/views.ts",
|
||||
"default": "./src/exports/views.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
@@ -57,6 +62,7 @@
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.6.0",
|
||||
"@swc/core": "^1.13.5",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
@@ -103,6 +109,11 @@
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
},
|
||||
"./views": {
|
||||
"import": "./dist/exports/views.js",
|
||||
"types": "./dist/exports/views.d.ts",
|
||||
"default": "./dist/exports/views.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
@@ -116,5 +127,5 @@
|
||||
]
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"dependencies": {}
|
||||
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
|
||||
}
|
||||
|
||||
11348
pnpm-lock.yaml
generated
Normal file
11348
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { PayloadHandler } from 'payload'
|
||||
export const customEndpointHandler = (collectionSlug: string): PayloadHandler =>
|
||||
async (req) => {
|
||||
const { payload } = req
|
||||
const url = new URL(req.url)
|
||||
const url = new URL(req.url || '')
|
||||
const pathParts = url.pathname.split('/').filter(Boolean)
|
||||
const flagName = pathParts[pathParts.length - 1]
|
||||
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
|
||||
// Client-side hooks for React components
|
||||
export {
|
||||
useFeatureFlags,
|
||||
useFeatureFlag,
|
||||
useSpecificFeatureFlag,
|
||||
useVariantSelection,
|
||||
useRolloutCheck,
|
||||
withFeatureFlag,
|
||||
type FeatureFlag,
|
||||
} from '../hooks/client.js'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
|
||||
|
||||
// Server-side hooks for React Server Components
|
||||
export {
|
||||
getFeatureFlag,
|
||||
|
||||
2
src/exports/views.ts
Normal file
2
src/exports/views.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Custom admin views
|
||||
export { FeatureFlagsView } from '../views/FeatureFlagsView.js'
|
||||
238
src/hooks/client.ts
Normal file
238
src/hooks/client.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
enabled: boolean
|
||||
rolloutPercentage?: number
|
||||
variants?: Array<{
|
||||
name: string
|
||||
weight: number
|
||||
metadata?: any
|
||||
}>
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all active feature flags from the API
|
||||
*/
|
||||
export function useFeatureFlags(): {
|
||||
flags: Record<string, FeatureFlag> | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
} {
|
||||
const { config } = useConfig()
|
||||
const [flags, setFlags] = useState<Record<string, FeatureFlag> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchFlags = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Feature flags API not enabled. Set enableApi: true in plugin config.')
|
||||
}
|
||||
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setFlags(result)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(errorMessage)
|
||||
setFlags(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [config.serverURL, config.routes.api])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchFlags()
|
||||
}, [fetchFlags])
|
||||
|
||||
return { flags, loading, error, refetch: fetchFlags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific feature flag is enabled
|
||||
*/
|
||||
export function useFeatureFlag(flagName: string): {
|
||||
isEnabled: boolean
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { flags, loading, error } = useFeatureFlags()
|
||||
|
||||
const flag = flags?.[flagName] || null
|
||||
const isEnabled = flag?.enabled || false
|
||||
|
||||
return { isEnabled, flag, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a specific feature flag from the API
|
||||
*/
|
||||
export function useSpecificFeatureFlag(flagName: string): {
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
} {
|
||||
const { config } = useConfig()
|
||||
const [flag, setFlag] = useState<FeatureFlag | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchFlag = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagName}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setFlag(null)
|
||||
setError(`Feature flag '${flagName}' not found`)
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to fetch feature flag: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setFlag(result)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(errorMessage)
|
||||
setFlag(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [config.serverURL, config.routes.api, flagName])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchFlag()
|
||||
}, [fetchFlag])
|
||||
|
||||
return { flag, loading, error, refetch: fetchFlag }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility hook for A/B testing - selects a variant based on user ID
|
||||
*/
|
||||
export function useVariantSelection(
|
||||
flagName: string,
|
||||
userId: string
|
||||
): {
|
||||
variant: string | null
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
|
||||
|
||||
const variant = flag?.enabled && flag.variants
|
||||
? selectVariantForUser(userId, flag.variants)
|
||||
: null
|
||||
|
||||
return { variant, flag, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility hook to check if user is in rollout percentage
|
||||
*/
|
||||
export function useRolloutCheck(
|
||||
flagName: string,
|
||||
userId: string
|
||||
): {
|
||||
isInRollout: boolean
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
|
||||
|
||||
const isInRollout = flag?.enabled
|
||||
? checkUserInRollout(userId, flag.rolloutPercentage || 100)
|
||||
: false
|
||||
|
||||
return { isInRollout, flag, loading, error }
|
||||
}
|
||||
|
||||
// Utility functions for client-side feature flag evaluation
|
||||
|
||||
/**
|
||||
* Select variant for a user based on consistent hashing
|
||||
*/
|
||||
function selectVariantForUser(
|
||||
userId: string,
|
||||
variants: Array<{ name: string; weight: number }>
|
||||
): string | null {
|
||||
if (variants.length === 0) return null
|
||||
|
||||
// Simple hash function for consistent user bucketing
|
||||
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 || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is in rollout percentage
|
||||
*/
|
||||
function checkUserInRollout(userId: string, percentage: number): boolean {
|
||||
if (percentage >= 100) return true
|
||||
if (percentage <= 0) return false
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component for feature flag gating
|
||||
*/
|
||||
export function withFeatureFlag<P extends Record<string, any>>(
|
||||
flagName: string,
|
||||
FallbackComponent?: React.ComponentType<P>
|
||||
) {
|
||||
return function FeatureFlagWrapper(
|
||||
WrappedComponent: React.ComponentType<P>
|
||||
): React.ComponentType<P> {
|
||||
return function WithFeatureFlagComponent(props: P): React.ReactElement | null {
|
||||
const { isEnabled, loading } = useFeatureFlag(flagName)
|
||||
|
||||
if (loading) {
|
||||
return null // or a loading spinner
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
return FallbackComponent ? React.createElement(FallbackComponent, props) : null
|
||||
}
|
||||
|
||||
return React.createElement(WrappedComponent, props)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { Payload } from 'payload'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
@@ -14,27 +13,35 @@ export interface FeatureFlag {
|
||||
}
|
||||
|
||||
// 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'
|
||||
function getCollectionSlug(payload: Payload): string {
|
||||
try {
|
||||
// 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: any) =>
|
||||
field.name === 'name' &&
|
||||
field.type === 'text' &&
|
||||
field.unique === true
|
||||
) &&
|
||||
col.fields.some((field: any) => field.name === 'enabled' && field.type === 'checkbox')
|
||||
)
|
||||
return collection?.slug || 'feature-flags'
|
||||
} catch {
|
||||
return 'feature-flags'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific feature flag by name (for use in React Server Components)
|
||||
*/
|
||||
export async function getFeatureFlag(flagName: string): Promise<FeatureFlag | null> {
|
||||
export async function getFeatureFlag(flagName: string, payload?: Payload): Promise<FeatureFlag | null> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
// If no payload provided, return null as these hooks should be used within Payload context
|
||||
if (!payload) {
|
||||
console.error('Payload instance not available. These hooks should be called within Payload server context or pass payload as parameter.')
|
||||
return null
|
||||
}
|
||||
|
||||
const collectionSlug = getCollectionSlug(payload)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
@@ -68,18 +75,23 @@ export async function getFeatureFlag(flagName: string): Promise<FeatureFlag | nu
|
||||
/**
|
||||
* 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)
|
||||
export async function isFeatureEnabled(flagName: string, payload?: Payload): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName, payload)
|
||||
return flag?.enabled ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active feature flags (for use in React Server Components)
|
||||
*/
|
||||
export async function getAllFeatureFlags(): Promise<Record<string, FeatureFlag>> {
|
||||
export async function getAllFeatureFlags(payload?: Payload): Promise<Record<string, FeatureFlag>> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
// If no payload provided, return empty object as these hooks should be used within Payload context
|
||||
if (!payload) {
|
||||
console.error('Payload instance not available. These hooks should be called within Payload server context or pass payload as parameter.')
|
||||
return {}
|
||||
}
|
||||
|
||||
const collectionSlug = getCollectionSlug(payload)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
@@ -115,9 +127,10 @@ export async function getAllFeatureFlags(): Promise<Record<string, FeatureFlag>>
|
||||
*/
|
||||
export async function isUserInRollout(
|
||||
flagName: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
payload?: Payload
|
||||
): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
const flag = await getFeatureFlag(flagName, payload)
|
||||
|
||||
if (!flag?.enabled) {
|
||||
return false
|
||||
@@ -140,9 +153,10 @@ export async function isUserInRollout(
|
||||
*/
|
||||
export async function getUserVariant(
|
||||
flagName: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
payload?: Payload
|
||||
): Promise<string | null> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
const flag = await getFeatureFlag(flagName, payload)
|
||||
|
||||
if (!flag?.enabled || !flag.variants || flag.variants.length === 0) {
|
||||
return null
|
||||
@@ -169,10 +183,15 @@ export async function getUserVariant(
|
||||
/**
|
||||
* Get feature flags by tags (for use in React Server Components)
|
||||
*/
|
||||
export async function getFeatureFlagsByTag(tag: string): Promise<FeatureFlag[]> {
|
||||
export async function getFeatureFlagsByTag(tag: string, payload?: Payload): Promise<FeatureFlag[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
// If no payload provided, return empty array as these hooks should be used within Payload context
|
||||
if (!payload) {
|
||||
console.error('Payload instance not available. These hooks should be called within Payload server context or pass payload as parameter.')
|
||||
return []
|
||||
}
|
||||
|
||||
const collectionSlug = getCollectionSlug(payload)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
|
||||
55
src/index.ts
55
src/index.ts
@@ -49,11 +49,10 @@ export const payloadFeatureFlags =
|
||||
enableRollouts = true,
|
||||
enableVariants = true,
|
||||
enableApi = false,
|
||||
collectionOverrides = {},
|
||||
collectionOverrides,
|
||||
} = pluginOptions
|
||||
|
||||
// Get collection slug from overrides or use default
|
||||
const collectionSlug = collectionOverrides.slug || 'feature-flags'
|
||||
|
||||
const collectionSlug = collectionOverrides?.slug || 'feature-flags'
|
||||
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
@@ -86,10 +85,9 @@ export const payloadFeatureFlags =
|
||||
description: 'Toggle this feature flag on or off',
|
||||
},
|
||||
},
|
||||
...(enableRollouts ? [
|
||||
{
|
||||
...(enableRollouts ? [{
|
||||
name: 'rolloutPercentage',
|
||||
type: 'number',
|
||||
type: 'number' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
defaultValue: 100,
|
||||
@@ -97,12 +95,10 @@ export const payloadFeatureFlags =
|
||||
description: 'Percentage of users who will see this feature (0-100)',
|
||||
condition: (data: any) => data?.enabled === true,
|
||||
},
|
||||
},
|
||||
] : []),
|
||||
...(enableVariants ? [
|
||||
{
|
||||
}] : []),
|
||||
...(enableVariants ? [{
|
||||
name: 'variants',
|
||||
type: 'array',
|
||||
type: 'array' as const,
|
||||
admin: {
|
||||
description: 'Define variants for A/B testing',
|
||||
condition: (data: any) => data?.enabled === true,
|
||||
@@ -110,7 +106,7 @@ export const payloadFeatureFlags =
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Variant identifier (e.g., control, variant-a)',
|
||||
@@ -118,7 +114,7 @@ export const payloadFeatureFlags =
|
||||
},
|
||||
{
|
||||
name: 'weight',
|
||||
type: 'number',
|
||||
type: 'number' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true,
|
||||
@@ -128,21 +124,20 @@ export const payloadFeatureFlags =
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
type: 'json' as const,
|
||||
admin: {
|
||||
description: 'Additional data for this variant',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] : []),
|
||||
}] : []),
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
type: 'array' as const,
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
@@ -159,12 +154,12 @@ export const payloadFeatureFlags =
|
||||
]
|
||||
|
||||
// Apply field overrides if provided
|
||||
const fields = collectionOverrides.fields
|
||||
const fields = collectionOverrides?.fields
|
||||
? collectionOverrides.fields({ defaultFields })
|
||||
: defaultFields
|
||||
|
||||
// Extract field overrides from collectionOverrides
|
||||
const { fields: _fieldsOverride, ...otherOverrides } = collectionOverrides
|
||||
const { fields: _fieldsOverride, ...otherOverrides } = collectionOverrides || {}
|
||||
|
||||
// Create the feature flags collection with overrides
|
||||
const featureFlagsCollection: CollectionConfig = {
|
||||
@@ -173,7 +168,7 @@ export const payloadFeatureFlags =
|
||||
useAsTitle: 'name',
|
||||
group: 'Configuration',
|
||||
description: 'Manage feature flags for your application',
|
||||
...(otherOverrides.admin || {}),
|
||||
...(collectionOverrides?.admin || {}),
|
||||
},
|
||||
fields,
|
||||
// Apply any other collection overrides
|
||||
@@ -202,16 +197,16 @@ export const payloadFeatureFlags =
|
||||
config.admin.components = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components.beforeDashboard) {
|
||||
config.admin.components.beforeDashboard = []
|
||||
if (!config.admin.components.views) {
|
||||
config.admin.components.views = {}
|
||||
}
|
||||
|
||||
// Add custom feature flags overview view
|
||||
config.admin.components.views['feature-flags-overview'] = {
|
||||
Component: 'payload-feature-flags/views#FeatureFlagsView',
|
||||
path: '/feature-flags-overview',
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
418
src/views/FeatureFlagsView.tsx
Normal file
418
src/views/FeatureFlagsView.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
|
||||
interface FeatureFlag {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
rolloutPercentage?: number
|
||||
variants?: Array<{
|
||||
name: string
|
||||
weight: number
|
||||
metadata?: any
|
||||
}>
|
||||
environment?: 'development' | 'staging' | 'production'
|
||||
tags?: Array<{ tag: string }>
|
||||
metadata?: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const FeatureFlagsViewComponent = () => {
|
||||
const { config } = useConfig()
|
||||
const [flags, setFlags] = useState<FeatureFlag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
const loadFlags = async () => {
|
||||
await fetchFlags(abortController.signal)
|
||||
}
|
||||
|
||||
loadFlags()
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [config.serverURL])
|
||||
|
||||
const fetchFlags = async (signal?: AbortSignal) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags`, {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('Feature flags API not enabled. Set enableApi: true in plugin config.')
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Convert the result object to an array if it's not already
|
||||
const flagsArray = Array.isArray(result) ? result : Object.values(result || {})
|
||||
|
||||
// Only update state if the component is still mounted (signal not aborted)
|
||||
if (!signal?.aborted) {
|
||||
setFlags(flagsArray as FeatureFlag[])
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't show error if request was aborted (component unmounting)
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
console.error('Error fetching feature flags:', err)
|
||||
if (!signal?.aborted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch feature flags')
|
||||
}
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFlag = useCallback(async (flagId: string, enabled: boolean) => {
|
||||
// For now, just show a message that editing isn't available in the custom view
|
||||
setError('Toggle functionality coming soon. Please use the standard collection view to edit flags.')
|
||||
setTimeout(() => setError(''), 3000)
|
||||
}, [])
|
||||
|
||||
const filteredFlags = useMemo(() => {
|
||||
return flags.filter(flag => {
|
||||
const matchesFilter = filter === 'all' ||
|
||||
(filter === 'enabled' && flag.enabled) ||
|
||||
(filter === 'disabled' && !flag.enabled)
|
||||
|
||||
const matchesSearch = !search ||
|
||||
flag.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
flag.description?.toLowerCase().includes(search.toLowerCase())
|
||||
|
||||
return matchesFilter && matchesSearch
|
||||
})
|
||||
}, [flags, filter, search])
|
||||
|
||||
const getStatusColor = (flag: FeatureFlag) => {
|
||||
if (!flag.enabled) return '#ef4444'
|
||||
if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return '#f59e0b'
|
||||
return '#10b981'
|
||||
}
|
||||
|
||||
const getStatusText = (flag: FeatureFlag) => {
|
||||
if (!flag.enabled) return 'Disabled'
|
||||
if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return `${flag.rolloutPercentage}% Rollout`
|
||||
return 'Enabled'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.125rem', color: '#6b7280' }}>Loading feature flags...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h1 style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: '0.5rem'
|
||||
}}>
|
||||
🚩 Feature Flags
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', fontSize: '1rem' }}>
|
||||
Manage feature toggles, A/B tests, and gradual rollouts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
marginBottom: '2rem',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search flags..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
minWidth: '200px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{(['all', 'enabled', 'disabled'] as const).map(filterType => (
|
||||
<button
|
||||
key={filterType}
|
||||
onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: filter === filterType ? '#3b82f6' : 'white',
|
||||
color: filter === filterType ? 'white' : '#374151',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize'
|
||||
}}
|
||||
>
|
||||
{filterType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => fetchFlags()}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: 'white',
|
||||
color: '#374151',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#111827' }}>
|
||||
{flags.length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Total Flags</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#10b981' }}>
|
||||
{flags.filter(f => f.enabled).length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Enabled</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#f59e0b' }}>
|
||||
{flags.filter(f => f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Rolling Out</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#8b5cf6' }}>
|
||||
{flags.filter(f => f.variants && f.variants.length > 0).length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>A/B Tests</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Flags List */}
|
||||
{filteredFlags.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div style={{ fontSize: '1.125rem', color: '#6b7280', marginBottom: '0.5rem' }}>
|
||||
{search || filter !== 'all' ? 'No flags match your criteria' : 'No feature flags yet'}
|
||||
</div>
|
||||
{(!search && filter === 'all') && (
|
||||
<div style={{ color: '#9ca3af', fontSize: '0.875rem' }}>
|
||||
Create your first feature flag to get started
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{filteredFlags.map(flag => (
|
||||
<div key={flag.id} style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* Flag Info */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.5rem' }}>
|
||||
<h3 style={{
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
margin: 0
|
||||
}}>
|
||||
{flag.name}
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: getStatusColor(flag),
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{getStatusText(flag)}
|
||||
</div>
|
||||
|
||||
{flag.environment && (
|
||||
<div style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#374151',
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{flag.environment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flag.description && (
|
||||
<p style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '0.875rem',
|
||||
margin: '0 0 0.75rem 0'
|
||||
}}>
|
||||
{flag.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||
{flag.variants && flag.variants.length > 0 && (
|
||||
<span>🧪 {flag.variants.length} variants</span>
|
||||
)}
|
||||
{flag.tags && flag.tags.length > 0 && (
|
||||
<span>🏷️ {flag.tags.map(t => t.tag).join(', ')}</span>
|
||||
)}
|
||||
<span>📅 {new Date(flag.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<label style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '60px',
|
||||
height: '34px'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flag.enabled}
|
||||
onChange={(e) => toggleFlag(flag.id, e.target.checked)}
|
||||
style={{ opacity: 0, width: 0, height: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
cursor: 'pointer',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: flag.enabled ? '#10b981' : '#ccc',
|
||||
borderRadius: '34px',
|
||||
transition: '0.4s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
height: '26px',
|
||||
width: '26px',
|
||||
left: flag.enabled ? '30px' : '4px',
|
||||
bottom: '4px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '50%',
|
||||
transition: '0.4s'
|
||||
}} />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeatureFlagsView = memo(FeatureFlagsViewComponent)
|
||||
Reference in New Issue
Block a user