23 Commits

Author SHA1 Message Date
Bas
14a5acd222 Merge pull request #13 from xtr-dev/dev
Dev
2025-10-03 19:57:05 +02:00
d3b8a8446e . 2025-10-03 19:56:55 +02:00
7dc17bc80a v0.0.17: Add enableCustomListView option 2025-10-03 19:53:01 +02:00
b642b653d0 Add enableCustomListView option
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 19:52:57 +02:00
Bas
1db434e701 Merge pull request #12 from xtr-dev/dev
Dev
2025-10-03 19:20:09 +02:00
7fd6194712 v0.0.16: Rebuild to sync dist with source 2025-10-03 19:19:41 +02:00
259599ddcc v0.0.15: Fix SSR warning in client components
Removes misleading warning that appeared during Next.js SSR:
- Client components are initially rendered on server where window is undefined
- This is expected behavior and doesn't require a warning
- Now silently falls back to relative URLs during SSR
- Properly uses window.location.origin once hydrated on client

The hooks now work seamlessly in:
- Pure client-side apps
- Next.js with SSR/SSG
- Server components (with explicit serverURL)
- Client components (auto-detects after hydration)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 18:48:46 +02:00
Bas
7f54d9a79f Merge pull request #11 from xtr-dev/dev
Fix: Client hooks webpack error - remove @payloadcms/ui dependency
2025-10-03 18:35:32 +02:00
9bb5f4ecc8 v0.0.14: Improve SSR support and fix race condition
Addresses critical issues identified in code review:

1. Server-Side Environment Handling:
   - Add warning when serverURL is not provided in SSR/SSG environments
   - Falls back to relative URLs with console warning
   - Prevents silent failures in server-side rendering

2. Race Condition Fix:
   - Use useRef for initialFlags to prevent re-creating fetchFlags on every render
   - Removes initialFlags from useCallback dependencies
   - Prevents excessive re-renders and potential infinite loops

These improvements ensure better stability and reliability in both
client-side and server-side environments.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 18:30:58 +02:00
4f802c8cc9 v0.0.13: Remove useConfig dependency from client hooks
Makes client hooks work outside of Payload Admin UI context by:
- Adding FeatureFlagOptions parameter to all hooks for configuration
- Using window.location.origin as default serverURL when in browser
- Removing @payloadcms/ui dependency from client hooks
- Allowing custom serverURL, apiPath, and collectionSlug configuration

This fixes the webpack error "_payloadcms_ui__WEBPACK_IMPORTED_MODULE_1__.b() is undefined"
when using the hooks in frontend applications.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 18:25:32 +02:00
Bas
f49a445e5a Merge pull request #10 from xtr-dev/dev
Dev
2025-10-03 18:00:46 +02:00
e26d895864 Fix race condition in fetchFlags useEffect
Addresses race condition where fetchFlags could cause memory leaks and state updates after component unmount:
- Convert fetchFlags to useCallback with AbortSignal support
- Add useEffect with AbortController for proper request cancellation
- Prevent state updates when requests are aborted
- Handle AbortError gracefully without showing error messages

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 17:54:50 +02:00
0c7c864248 v0.0.12: Type consistency and configuration improvements
Type System Enhancements:
- Introduced PayloadID helper type (string | number) for flexible ID handling
- Created shared types module (src/types/index.ts) for better type consistency
- Exported PayloadID and FeatureFlag types from main index for user access
- Fixed runtime issues with different Payload ID configurations

Configuration Improvements:
- Made API request limits configurable via maxFlags prop (default 100, max 1000)
- Added configurable collection slug support for custom collection names
- Enhanced URL construction to use config.routes.admin for proper path handling
- Improved server-side pagination with query parameter support

Code Quality:
- Centralized type definitions for better maintainability
- Enhanced type safety across client and server components
- Improved prop interfaces with better documentation
- Fixed potential number parsing edge cases with parseFloat

Developer Experience:
- Users can now configure collection slug, API limits, and admin paths
- Better TypeScript support with exported shared types
- Consistent handling of both string and numeric IDs
- More flexible plugin configuration options

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 17:36:53 +02:00
0a39d0631c v0.0.11: Accessibility and performance improvements
Accessibility Enhancements:
- Added role="alert" to error messages and read-only notices for screen readers
- Improved semantic HTML for better assistive technology support

Performance Optimizations:
- Implemented debounced search (300ms) to reduce re-renders during typing
- Added pagination support for large datasets (configurable limit up to 1000)
- Enhanced server-side data fetching with query parameter support

Input Improvements:
- Changed rollout percentage validation from parseInt to parseFloat for better decimal handling
- Made admin URL construction configurable using config.routes.admin
- Improved input validation with proper rounding for percentage values

Developer Experience:
- Added reusable useDebounce hook for performance optimization
- Better error handling for edge cases in numeric inputs
- Cleaner code organization with separated concerns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 17:21:17 +02:00
3696ff7641 v0.0.10: Server/client architecture with enhanced security and UX improvements
- Split feature flags view into server/client components for better performance
- Added comprehensive security checks for authentication and authorization
- Implemented read-only mode for users without update permissions
- Fixed checkbox state synchronization issues with server updates
- Improved UX: rollout percentage editable regardless of flag enabled state
- Added DefaultTemplate integration with proper PayloadCMS admin layout
- Enhanced error handling with specific messages for auth/permissions
- Removed debug logging for production readiness

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 17:12:16 +02:00
bca558fad3 Update Payload dependencies to 3.56.0 and implement DefaultTemplate layout
- Updated all @payloadcms/* dependencies from 3.37.0 to 3.56.0
- Implemented DefaultTemplate from @payloadcms/next/templates with proper props structure
- Fixed TypeScript compilation by adding proper Locale type import
- Feature flags admin interface now wrapped in PayloadCMS default admin layout with navigation sidebar
- Verified build process works with updated dependencies

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 16:38:31 +02:00
a267824239 Fix runtime error: payload undefined in custom view
Runtime Fix:
- Removed DefaultTemplate wrapper that was causing payload undefined error
- Simplified component to return content directly for Payload custom views
- Payload CMS automatically provides admin layout for custom views
- Removed unnecessary template prop dependencies

Component Structure:
- Custom views in Payload are rendered within the admin layout automatically
- No need for manual DefaultTemplate wrapper in custom view components
- Maintained all spreadsheet functionality and theme integration
- Simplified props interface to handle any view context

The feature flags view now renders correctly as a Payload custom view
without runtime errors, while preserving all functionality and admin layout.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 16:20:38 +02:00
98cab95411 Implement proper Payload CMS DefaultTemplate layout integration
Layout Implementation:
- Fixed import to use DefaultTemplate from '@payloadcms/next/templates'
- Added proper template props interface with i18n, locale, payload, etc.
- Restructured component to use DefaultTemplate wrapper correctly
- Created FeatureFlagsContent as child component for template

Template Structure:
- Component now receives standard Payload admin view props
- DefaultTemplate provides proper admin layout with sidebar navigation
- All template props (i18n, locale, params, payload, permissions, etc.) are passed through
- Maintains theme integration and responsive design within admin layout

The feature flags dashboard now properly integrates with Payload's admin
layout system, including the navigation sidebar and standard admin styling,
while preserving all spreadsheet functionality and inline editing capabilities.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 16:16:42 +02:00
4091141722 Fix admin layout integration for Payload CMS custom views
Layout Corrections:
- Removed unavailable DefaultTemplate import (not available in @payloadcms/ui)
- Created proper custom view layout that integrates with Payload's admin structure
- Added flexible props interface for future extensibility
- Optimized container structure for admin panel embedding
- Removed breadcrumbs (handled by Payload's navigation system)

Technical Improvements:
- Component now works as a proper Payload custom view
- Height and overflow handling for admin panel integration
- Maintained theme integration and responsive design
- Added proper TypeScript interfaces for props
- Ensured compatibility with Payload's rendering system

The view now properly integrates with Payload's admin panel as a custom view
while preserving all spreadsheet functionality and theme support.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 16:07:36 +02:00
fd848dcfe8 Wrap feature flags interface in admin layout structure
Layout Integration:
- Created custom admin layout wrapper with proper structure
- Added breadcrumb navigation (Dashboard › Feature Flags)
- Implemented consistent spacing and container max-width
- Added proper header hierarchy with title and description
- Ensured full-height layout with theme-aware backgrounds

Navigation Improvements:
- Added clickable breadcrumb back to Dashboard
- Maintained proper visual hierarchy with typography
- Added theme-consistent spacing and margins
- Improved responsive design with centered content container

The interface now feels like a native part of the Payload admin
panel while maintaining the spreadsheet functionality.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 15:47:30 +02:00
477f7f96eb Integrate Payload CMS theme system and add clickable flag names
Theme Integration:
- Added useTheme hook from @payloadcms/ui
- Replaced all hardcoded colors with CSS custom properties
- Created getThemeStyles() function for consistent theming
- Updated all UI elements to respect dark/light theme settings
- Added proper contrast for text, backgrounds, and borders

Navigation Enhancement:
- Made feature flag names clickable links
- Links navigate to /admin/collections/feature-flags/{id} for editing
- Added hover effects with underline on flag name links
- Used theme-aware link color (info blue)

The interface now properly adapts to Payload's admin panel theme,
supporting both dark and light modes seamlessly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 15:39:10 +02:00
b364fb9e8f Fix null reference errors in feature flags view
- Added null checks throughout filteredAndSortedFlags computation
- Filter out null/undefined entries before processing
- Added null checks in summary statistics calculations
- Enhanced API response filtering to remove invalid entries
- Added optional chaining for safer property access
- Improved error handling for malformed API responses

This resolves the "can't access property 'enabled', f is null" runtime error.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 15:28:02 +02:00
6d151d9e82 Replace feature flags overview with spreadsheet-style interface
- Complete rewrite of FeatureFlagsView component with table layout
- Added inline editing for enabled/disabled checkboxes
- Added inline editing for rollout percentages with validation (0-100)
- Implemented sortable columns (name, enabled, rollout %, last updated)
- Added real-time search functionality across name, description, and tags
- Added visual status indicators with color coding
- Implemented proper API integration with PATCH requests for updates
- Added loading states and success/error notifications
- Improved responsive design with sticky status column
- Added summary statistics at the bottom

The new interface provides a much more efficient way to manage multiple feature flags at once, similar to a spreadsheet application.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 15:25:51 +02:00
11 changed files with 1307 additions and 1077 deletions

View File

@@ -88,7 +88,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: string;
id: number;
title: string;
content?: {
root: {
@@ -151,7 +151,7 @@ export interface Post {
* via the `definition` "pages".
*/
export interface Page {
id: string;
id: number;
title: string;
slug: string;
content?: {
@@ -178,7 +178,7 @@ export interface Page {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
name?: string | null;
role?: ('admin' | 'editor' | 'user') | null;
updatedAt: string;
@@ -190,6 +190,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -197,7 +204,7 @@ export interface User {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
alt?: string | null;
updatedAt: string;
createdAt: string;
@@ -218,7 +225,7 @@ export interface Media {
* via the `definition` "feature-flags".
*/
export interface FeatureFlag {
id: string;
id: number;
/**
* Unique identifier for the feature flag
*/
@@ -291,7 +298,7 @@ export interface FeatureFlag {
/**
* Team member responsible for this feature flag
*/
owner?: (string | null) | User;
owner?: (number | null) | User;
/**
* Optional expiration date for temporary flags
*/
@@ -308,32 +315,32 @@ export interface FeatureFlag {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'posts';
value: string | Post;
value: number | Post;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null)
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'feature-flags';
value: string | FeatureFlag;
value: number | FeatureFlag;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -343,10 +350,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -366,7 +373,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -412,6 +419,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "payload-feature-flags",
"version": "1.0.0",
"version": "0.0.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "payload-feature-flags",
"version": "1.0.0",
"version": "0.0.17",
"license": "MIT",
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-feature-flags",
"version": "0.0.9",
"version": "0.0.17",
"description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts",
"license": "MIT",
"type": "module",
@@ -52,13 +52,13 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0",
"@payloadcms/db-mongodb": "3.56.0",
"@payloadcms/db-postgres": "3.56.0",
"@payloadcms/db-sqlite": "3.56.0",
"@payloadcms/eslint-config": "3.9.0",
"@payloadcms/next": "3.37.0",
"@payloadcms/richtext-lexical": "3.37.0",
"@payloadcms/ui": "3.37.0",
"@payloadcms/next": "3.56.0",
"@payloadcms/richtext-lexical": "3.56.0",
"@payloadcms/ui": "3.56.0",
"@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
@@ -74,7 +74,7 @@
"mongodb-memory-server": "10.1.4",
"next": "15.4.4",
"open": "^10.1.0",
"payload": "3.37.0",
"payload": "3.56.0",
"prettier": "^3.4.2",
"qs-esm": "7.0.2",
"react": "19.1.0",
@@ -87,7 +87,7 @@
"vitest": "^3.1.2"
},
"peerDependencies": {
"payload": "^3.37.0"
"payload": "^3.56.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0",

970
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,4 +7,5 @@ export {
useRolloutCheck,
withFeatureFlag,
type FeatureFlag,
type FeatureFlagOptions,
} from '../hooks/client.js'

View File

@@ -1,2 +1,2 @@
// Custom admin views
export { FeatureFlagsView } from '../views/FeatureFlagsView.js'
export { default as FeatureFlagsView } from '../views/FeatureFlagsView.js'

View File

@@ -1,6 +1,5 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useConfig } from '@payloadcms/ui'
import React, { useCallback, useEffect, useState, useRef } from 'react'
export interface FeatureFlag {
name: string
@@ -14,34 +13,78 @@ export interface FeatureFlag {
metadata?: any
}
export interface FeatureFlagOptions {
serverURL?: string
apiPath?: string
collectionSlug?: string
}
// Helper to get config from options or defaults
function getConfig(options?: FeatureFlagOptions) {
// Check if serverURL is explicitly provided
if (options?.serverURL) {
return {
serverURL: options.serverURL,
apiPath: options.apiPath || '/api',
collectionSlug: options.collectionSlug || 'feature-flags'
}
}
// In browser environment, use window.location.origin
if (typeof window !== 'undefined') {
return {
serverURL: window.location.origin,
apiPath: options?.apiPath || '/api',
collectionSlug: options?.collectionSlug || 'feature-flags'
}
}
// During SSR or in non-browser environments, use relative URL
// This will work for same-origin requests
return {
serverURL: '',
apiPath: options?.apiPath || '/api',
collectionSlug: options?.collectionSlug || 'feature-flags'
}
}
/**
* Hook to fetch all active feature flags from the API
*/
export function useFeatureFlags(
initialFlags: Partial<FeatureFlag>[]
initialFlags: Partial<FeatureFlag>[],
options?: FeatureFlagOptions
): {
flags: Partial<FeatureFlag>[]
loading: boolean
error: string | null
refetch: () => Promise<void>
} {
const { config } = useConfig()
const { serverURL, apiPath, collectionSlug } = getConfig(options)
const [flags, setFlags] = useState<Partial<FeatureFlag>[]>(initialFlags)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Use ref to store initialFlags to avoid re-creating fetchFlags on every render
const initialFlagsRef = useRef(initialFlags)
// Update ref when initialFlags changes (but won't trigger re-fetch)
useEffect(() => {
initialFlagsRef.current = initialFlags
}, [initialFlags])
const fetchFlags = useCallback(async () => {
try {
setLoading(true)
setError(null)
// Use Payload's native collection API
const names = initialFlags.map(f => f.name).filter(Boolean)
const names = initialFlagsRef.current.map(f => f.name).filter(Boolean)
const query = names.length > 0
? `?where[name][in]=${names.join(',')}&limit=1000`
: '?limit=1000'
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags${query}`)
const response = await fetch(`${serverURL}${apiPath}/${collectionSlug}${query}`)
if (!response.ok) {
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
@@ -64,7 +107,7 @@ export function useFeatureFlags(
}
// Sort flags based on the order of names in initialFlags
const sortedFlags = initialFlags.map(initialFlag => {
const sortedFlags = initialFlagsRef.current.map(initialFlag => {
const fetchedFlag = fetchedFlagsMap.get(initialFlag.name!)
// Use fetched flag if available, otherwise keep the initial flag
return fetchedFlag || initialFlag
@@ -77,7 +120,7 @@ export function useFeatureFlags(
} finally {
setLoading(false)
}
}, [config.serverURL, config.routes.api, initialFlags])
}, [serverURL, apiPath, collectionSlug]) // Remove initialFlags from dependencies
useEffect(() => {
void fetchFlags()
@@ -89,13 +132,16 @@ export function useFeatureFlags(
/**
* Hook to check if a specific feature flag is enabled
*/
export function useFeatureFlag(flagName: string): {
export function useFeatureFlag(
flagName: string,
options?: FeatureFlagOptions
): {
isEnabled: boolean
flag: Partial<FeatureFlag> | null
loading: boolean
error: string | null
} {
const { flags, loading, error } = useFeatureFlags([{ name: flagName }])
const { flags, loading, error } = useFeatureFlags([{ name: flagName }], options)
const flag = flags.find(f => f.name === flagName) || null
const isEnabled = flag?.enabled || false
@@ -106,13 +152,16 @@ export function useFeatureFlag(flagName: string): {
/**
* Hook to fetch a specific feature flag from the API
*/
export function useSpecificFeatureFlag(flagName: string): {
export function useSpecificFeatureFlag(
flagName: string,
options?: FeatureFlagOptions
): {
flag: FeatureFlag | null
loading: boolean
error: string | null
refetch: () => Promise<void>
} {
const { config } = useConfig()
const { serverURL, apiPath, collectionSlug } = getConfig(options)
const [flag, setFlag] = useState<FeatureFlag | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -124,7 +173,7 @@ export function useSpecificFeatureFlag(flagName: string): {
// Use Payload's native collection API with query filter
const response = await fetch(
`${config.serverURL}${config.routes.api}/feature-flags?where[name][equals]=${flagName}&limit=1`
`${serverURL}${apiPath}/${collectionSlug}?where[name][equals]=${flagName}&limit=1`
)
if (!response.ok) {
@@ -153,7 +202,7 @@ export function useSpecificFeatureFlag(flagName: string): {
} finally {
setLoading(false)
}
}, [config.serverURL, config.routes.api, flagName])
}, [serverURL, apiPath, collectionSlug, flagName])
useEffect(() => {
void fetchFlag()
@@ -167,14 +216,15 @@ export function useSpecificFeatureFlag(flagName: string): {
*/
export function useVariantSelection(
flagName: string,
userId: string
userId: string,
options?: FeatureFlagOptions
): {
variant: string | null
flag: FeatureFlag | null
loading: boolean
error: string | null
} {
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
const { flag, loading, error } = useSpecificFeatureFlag(flagName, options)
const variant = flag?.enabled && flag.variants
? selectVariantForUser(userId, flag.variants)
@@ -188,14 +238,15 @@ export function useVariantSelection(
*/
export function useRolloutCheck(
flagName: string,
userId: string
userId: string,
options?: FeatureFlagOptions
): {
isInRollout: boolean
flag: FeatureFlag | null
loading: boolean
error: string | null
} {
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
const { flag, loading, error } = useSpecificFeatureFlag(flagName, options)
const isInRollout = flag?.enabled
? checkUserInRollout(userId, flag.rolloutPercentage || 100)
@@ -253,13 +304,14 @@ function checkUserInRollout(userId: string, percentage: number): boolean {
*/
export function withFeatureFlag<P extends Record<string, any>>(
flagName: string,
FallbackComponent?: React.ComponentType<P>
FallbackComponent?: React.ComponentType<P>,
options?: FeatureFlagOptions
) {
return function FeatureFlagWrapper(
WrappedComponent: React.ComponentType<P>
): React.ComponentType<P> {
return function WithFeatureFlagComponent(props: P): React.ReactElement | null {
const { isEnabled, loading } = useFeatureFlag(flagName)
const { isEnabled, loading } = useFeatureFlag(flagName, options)
if (loading) {
return null // or a loading spinner

View File

@@ -6,6 +6,9 @@ export type CollectionOverrides = Partial<
fields?: (args: { defaultFields: Field[] }) => Field[]
}
// Export shared types for users of the plugin
export type { PayloadID, FeatureFlag } from './types/index.js'
export type PayloadFeatureFlagsConfig = {
/**
* Enable/disable the plugin
@@ -31,6 +34,11 @@ export type PayloadFeatureFlagsConfig = {
* Override collection configuration
*/
collectionOverrides?: CollectionOverrides
/**
* Enable custom list view for feature flags
* @default false
*/
enableCustomListView?: boolean
}
export const payloadFeatureFlags =
@@ -41,6 +49,7 @@ export const payloadFeatureFlags =
defaultValue = false,
enableRollouts = true,
enableVariants = true,
enableCustomListView = false,
collectionOverrides,
} = pluginOptions
@@ -160,6 +169,15 @@ export const payloadFeatureFlags =
useAsTitle: 'name',
group: 'Configuration',
description: 'Manage feature flags for your application',
components: enableCustomListView ? {
...collectionOverrides?.admin?.components,
views: {
...collectionOverrides?.admin?.components?.views,
list: {
Component: '@xtr-dev/payload-feature-flags/views#FeatureFlagsView'
}
}
} : collectionOverrides?.admin?.components || {},
...(collectionOverrides?.admin || {}),
},
fields,

24
src/types/index.ts Normal file
View File

@@ -0,0 +1,24 @@
// Shared types for the feature flags plugin
// Helper type for flexible ID handling - supports both string and number IDs
// This allows the plugin to work with different Payload ID configurations
export type PayloadID = string | number
// Common interface for feature flags used across the plugin
export interface FeatureFlag {
id: PayloadID
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
}

View File

@@ -0,0 +1,667 @@
'use client'
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
import {
useConfig,
useTheme
} from '@payloadcms/ui'
import type { PayloadID, FeatureFlag } from '../types/index.js'
// Simple debounce hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
interface FeatureFlagsClientProps {
initialFlags?: FeatureFlag[]
canUpdate?: boolean
maxFlags?: number // Configurable limit for API requests
collectionSlug?: string // Configurable collection slug for URLs
}
const FeatureFlagsClientComponent = ({
initialFlags = [],
canUpdate = true,
maxFlags = 100,
collectionSlug = 'feature-flags'
}: FeatureFlagsClientProps) => {
const { config } = useConfig()
const { theme } = useTheme()
const [flags, setFlags] = useState<FeatureFlag[]>(initialFlags)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [sortField, setSortField] = useState<'name' | 'enabled' | 'rolloutPercentage' | 'updatedAt'>('name')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
const [saving, setSaving] = useState<PayloadID | null>(null)
const [successMessage, setSuccessMessage] = useState('')
// Debounce search to reduce re-renders
const debouncedSearch = useDebounce(search, 300)
const fetchFlags = useCallback(async (signal?: AbortSignal) => {
try {
setLoading(true)
setError('')
// Use configurable limit, capped at 1000 for performance
const limit = Math.min(1000, maxFlags)
const response = await fetch(`${config.serverURL}${config.routes.api}/${collectionSlug}?limit=${limit}`, {
credentials: 'include',
signal,
})
if (!response.ok) {
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
}
const result = await response.json()
// Extract docs array from Payload API response and filter out null/invalid entries
const flagsArray = (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
// 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)
}
}
}, [config.serverURL, config.routes.api, collectionSlug, maxFlags])
useEffect(() => {
const abortController = new AbortController()
const loadFlags = async () => {
await fetchFlags(abortController.signal)
}
loadFlags()
return () => {
abortController.abort()
}
}, [fetchFlags]) // Re-fetch if fetchFlags function changes
const updateFlag = useCallback(async (flagId: PayloadID, updates: Partial<FeatureFlag>) => {
// Security check: Don't allow updates if user doesn't have permission
if (!canUpdate) {
setError('You do not have permission to update feature flags')
setTimeout(() => setError(''), 5000)
return
}
setSaving(flagId)
setError('')
setSuccessMessage('')
try {
const response = await fetch(`${config.serverURL}${config.routes.api}/${collectionSlug}/${flagId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
if (response.status === 403) {
throw new Error('Access denied: You do not have permission to update this feature flag')
}
if (response.status === 401) {
throw new Error('Authentication required: Please log in again')
}
throw new Error(`Failed to update feature flag: ${response.statusText}`)
}
const updatedFlag = await response.json()
// Update local state - merge only the specific updates we sent
setFlags(prev => prev.map(flag =>
flag.id === flagId ? { ...flag, ...updates, updatedAt: updatedFlag.updatedAt || new Date().toISOString() } : flag
))
setSuccessMessage('✓ Saved')
setTimeout(() => setSuccessMessage(''), 2000)
} catch (err) {
console.error('Error updating feature flag:', err)
setError(err instanceof Error ? err.message : 'Failed to update feature flag')
setTimeout(() => setError(''), 5000)
} finally {
setSaving(null)
}
}, [config.serverURL, config.routes.api, canUpdate, collectionSlug])
const handleSort = useCallback((field: typeof sortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}, [sortField])
const filteredAndSortedFlags = useMemo(() => {
// Filter out null/undefined entries first
let filtered = flags.filter(flag => flag && flag.name)
// Filter by debounced search
if (debouncedSearch) {
const searchLower = debouncedSearch.toLowerCase()
filtered = filtered.filter(flag =>
flag.name?.toLowerCase().includes(searchLower) ||
flag.description?.toLowerCase().includes(searchLower) ||
flag.tags?.some(t => t.tag?.toLowerCase().includes(searchLower))
)
}
// Sort
filtered.sort((a, b) => {
let aVal: any = a[sortField]
let bVal: any = b[sortField]
if (sortField === 'updatedAt') {
aVal = new Date(aVal || 0).getTime()
bVal = new Date(bVal || 0).getTime()
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
return 0
})
return filtered
}, [flags, debouncedSearch, sortField, sortDirection])
const SortIcon = ({ field }: { field: typeof sortField }) => {
if (sortField !== field) {
return <span style={{ opacity: 0.3 }}></span>
}
return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>
}
// Theme-aware styles
const getThemeStyles = () => ({
background: 'var(--theme-bg)',
surface: 'var(--theme-elevation-50)',
surfaceHover: 'var(--theme-elevation-100)',
border: 'var(--theme-elevation-150)',
text: 'var(--theme-text)',
textMuted: 'var(--theme-text-400)',
textSubdued: 'var(--theme-text-600)',
primary: 'var(--theme-success-500)',
warning: 'var(--theme-warning-500)',
error: 'var(--theme-error-500)',
info: 'var(--theme-info-500)',
inputBg: 'var(--theme-elevation-0)',
inputBorder: 'var(--theme-elevation-250)',
headerBg: 'var(--theme-elevation-100)',
})
const styles = getThemeStyles()
return (
<div style={{
padding: '2rem',
maxWidth: '100%'
}}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: '700',
color: styles.text,
margin: '0 0 0.5rem 0'
}}>
Feature Flags Dashboard
</h1>
<p style={{
color: styles.textMuted,
fontSize: '1rem',
margin: '0 0 1rem 0'
}}>
Manage all feature flags in a spreadsheet view with inline editing capabilities
</p>
{!canUpdate && (
<div
role="alert"
style={{
backgroundColor: styles.info + '20',
border: `1px solid ${styles.info}`,
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1rem',
color: styles.info,
fontSize: '0.875rem'
}}
>
<strong>Read-Only Access:</strong> You can view feature flags but cannot edit them. Contact your administrator to request update permissions.
</div>
)}
</div>
{loading ? (
<div style={{
padding: '2rem',
textAlign: 'center',
minHeight: '400px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{ fontSize: '1.125rem', color: styles.textMuted }}>Loading feature flags...</div>
</div>
) : (
<>
{/* Success/Error Messages */}
{successMessage && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
backgroundColor: styles.primary,
color: 'white',
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
zIndex: 1000,
}}>
{successMessage}
</div>
)}
{error && (
<div
role="alert"
style={{
marginBottom: '1rem',
backgroundColor: styles.error + '20',
border: `1px solid ${styles.error}`,
borderRadius: '0.5rem',
padding: '1rem',
color: styles.error
}}
>
<strong>Error:</strong> {error}
</div>
)}
{/* Controls */}
<div style={{
display: 'flex',
gap: '1rem',
marginBottom: '2rem',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<input
type="text"
placeholder="Search flags by name, description, or tags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
padding: '0.5rem 1rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.5rem',
fontSize: '0.875rem',
width: '300px',
backgroundColor: styles.inputBg,
color: styles.text
}}
/>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ fontSize: '0.875rem', color: styles.textMuted }}>
{filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
</div>
<button
onClick={() => fetchFlags()}
style={{
padding: '0.5rem 1rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.5rem',
backgroundColor: styles.surface,
color: styles.text,
fontSize: '0.875rem',
cursor: 'pointer'
}}
>
🔄 Refresh
</button>
</div>
</div>
{/* Spreadsheet Table */}
<div style={{
backgroundColor: styles.surface,
border: `1px solid ${styles.border}`,
borderRadius: '0.75rem',
overflow: 'hidden',
marginBottom: '2rem'
}}>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.875rem'
}}>
<thead>
<tr style={{ backgroundColor: styles.headerBg }}>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
position: 'sticky',
left: 0,
backgroundColor: styles.headerBg,
minWidth: '50px'
}}>
Status
</th>
<th
onClick={() => handleSort('name')}
style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '200px',
userSelect: 'none'
}}
>
Name <SortIcon field="name" />
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '300px'
}}>
Description
</th>
<th
onClick={() => handleSort('enabled')}
style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '80px',
userSelect: 'none'
}}
>
Enabled <SortIcon field="enabled" />
</th>
<th
onClick={() => handleSort('rolloutPercentage')}
style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '120px',
userSelect: 'none'
}}
>
Rollout % <SortIcon field="rolloutPercentage" />
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '100px'
}}>
Variants
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '150px'
}}>
Tags
</th>
<th
onClick={() => handleSort('updatedAt')}
style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '150px',
userSelect: 'none'
}}
>
Last Updated <SortIcon field="updatedAt" />
</th>
</tr>
</thead>
<tbody>
{filteredAndSortedFlags.length === 0 ? (
<tr>
<td colSpan={8} style={{
padding: '2rem',
textAlign: 'center',
color: styles.textMuted
}}>
{debouncedSearch ? 'No flags match your search' : 'No feature flags yet'}
</td>
</tr>
) : (
filteredAndSortedFlags.map(flag => (
<tr key={flag.id} style={{
borderBottom: `1px solid ${styles.border}`,
transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = styles.surfaceHover}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
>
<td style={{
padding: '0.75rem 1rem',
position: 'sticky',
left: 0,
backgroundColor: 'inherit'
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: flag.enabled ?
(flag.rolloutPercentage && flag.rolloutPercentage < 100 ? styles.warning : styles.primary)
: styles.error
}} />
</td>
<td style={{
padding: '0.75rem 1rem',
fontWeight: '500',
color: styles.text
}}>
<a
href={`${config.routes.admin}/collections/${collectionSlug}/${flag.id}`}
style={{
color: styles.info,
textDecoration: 'none',
cursor: 'pointer'
}}
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
>
{flag.name}
</a>
</td>
<td style={{
padding: '0.75rem 1rem',
color: styles.textMuted
}}>
{flag.description || '-'}
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center'
}}>
<input
type="checkbox"
checked={flag.enabled}
onChange={(e) => updateFlag(flag.id, { enabled: e.target.checked })}
disabled={!canUpdate || saving === flag.id}
style={{
width: '18px',
height: '18px',
cursor: (!canUpdate || saving === flag.id) ? 'not-allowed' : 'pointer',
accentColor: styles.primary,
opacity: canUpdate ? 1 : 0.6
}}
/>
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
<input
type="number"
value={flag.rolloutPercentage || 100}
onChange={(e) => {
const value = Math.min(100, Math.max(0, parseFloat(e.target.value) || 0))
updateFlag(flag.id, { rolloutPercentage: Math.round(value) })
}}
disabled={!canUpdate || saving === flag.id}
min="0"
max="100"
style={{
width: '60px',
padding: '0.25rem 0.5rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.25rem',
fontSize: '0.875rem',
textAlign: 'center',
cursor: (!canUpdate || saving === flag.id) ? 'not-allowed' : 'text',
opacity: canUpdate ? 1 : 0.5,
backgroundColor: styles.inputBg,
color: styles.text
}}
/>
<span style={{ color: styles.textMuted }}>%</span>
</div>
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center',
color: styles.textMuted
}}>
{flag.variants && flag.variants.length > 0 ? (
<span style={{
backgroundColor: styles.surface,
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{flag.variants.length} variants
</span>
) : '-'}
</td>
<td style={{
padding: '0.75rem 1rem'
}}>
{flag.tags && flag.tags.length > 0 ? (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{flag.tags.map((t, i) => (
<span key={i} style={{
backgroundColor: styles.info + '20',
color: styles.info,
padding: '0.125rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{t.tag}
</span>
))}
</div>
) : '-'}
</td>
<td style={{
padding: '0.75rem 1rem',
color: styles.textMuted,
fontSize: '0.75rem'
}}>
{new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Summary Stats */}
<div style={{
marginTop: '2rem',
display: 'flex',
gap: '2rem',
fontSize: '0.875rem',
color: styles.textMuted
}}>
<div>
<span style={{ fontWeight: '600' }}>Total:</span> {flags.filter(f => f && f.name).length} flags
</div>
<div>
<span style={{ fontWeight: '600' }}>Enabled:</span> {flags.filter(f => f && f.enabled).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>Disabled:</span> {flags.filter(f => f && !f.enabled).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>Rolling out:</span> {flags.filter(f => f && f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>A/B Tests:</span> {flags.filter(f => f && f.variants && f.variants.length > 0).length}
</div>
</div>
</>
)}
</div>
)
}
export const FeatureFlagsClient = memo(FeatureFlagsClientComponent)
export default FeatureFlagsClient

View File

@@ -1,414 +1,154 @@
'use client'
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
import { useConfig } from '@payloadcms/ui'
import type { AdminViewServerProps } from 'payload'
import { DefaultTemplate } from '@payloadcms/next/templates'
import { Gutter } from '@payloadcms/ui'
import FeatureFlagsClient from './FeatureFlagsClient.js'
import type { FeatureFlag } from '../types/index.js'
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
async function fetchInitialFlags(payload: any, searchParams?: Record<string, any>): Promise<FeatureFlag[]> {
try {
const limit = Math.min(1000, parseInt(searchParams?.limit as string) || 100)
const page = Math.max(1, parseInt(searchParams?.page as string) || 1)
const collectionSlug = searchParams?.collectionSlug as string || 'feature-flags'
const result = await payload.find({
collection: collectionSlug,
limit,
page,
sort: 'name',
})
return (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
} catch (error) {
console.error('Error fetching initial feature flags:', error)
return []
}
}
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('')
export default async function FeatureFlagsView({
initPageResult,
params,
searchParams,
}: AdminViewServerProps) {
const {
req: { user },
permissions,
} = initPageResult
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) {
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
}
const result = await response.json()
// Extract docs array from Payload API response
const flagsArray = result.docs || []
// 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) {
// Security check: User must be logged in
if (!user) {
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',
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={undefined}
visibleEntities={initPageResult.visibleEntities}
>
<Gutter>
<div style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--theme-error-500)',
backgroundColor: 'var(--theme-error-50)',
border: '1px solid var(--theme-error-200)',
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)}
margin: '2rem 0'
}}>
<h2 style={{ marginBottom: '1rem', color: 'var(--theme-error-600)' }}>
Authentication Required
</h2>
<p style={{ marginBottom: '1rem' }}>
You must be logged in to view the Feature Flags Dashboard.
</p>
<a
href="/admin/login"
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'
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: 'var(--theme-error-500)',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontWeight: '500'
}}
>
{filterType}
</button>
))}
</div>
Go to Login
</a>
</div>
</Gutter>
</DefaultTemplate>
)
}
<button
onClick={() => fetchFlags()}
style={{
padding: '0.5rem 1rem',
border: '1px solid #d1d5db',
// Security check: User must have permissions to access feature-flags collection
const collectionSlug = searchParams?.collectionSlug as string || 'feature-flags'
const canReadFeatureFlags = permissions?.collections?.[collectionSlug]?.read
if (!canReadFeatureFlags) {
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={initPageResult.req.user || undefined}
visibleEntities={initPageResult.visibleEntities}
>
<Gutter>
<div style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--theme-warning-600)',
backgroundColor: 'var(--theme-warning-50)',
border: '1px solid var(--theme-warning-200)',
borderRadius: '0.5rem',
backgroundColor: 'white',
color: '#374151',
fontSize: '0.875rem',
cursor: 'pointer'
}}
>
🔄 Refresh
</button>
</div>
margin: '2rem 0'
}}>
<h2 style={{ marginBottom: '1rem', color: 'var(--theme-warning-700)' }}>
Access Denied
</h2>
<p style={{ marginBottom: '1rem' }}>
You don't have permission to access the Feature Flags Dashboard.
</p>
<p style={{ fontSize: '0.875rem', color: 'var(--theme-warning-600)' }}>
Contact your administrator to request access to the feature-flags collection.
</p>
</div>
</Gutter>
</DefaultTemplate>
)
}
{/* 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>
// Fetch initial data server-side (only if user has access)
const initialFlags = await fetchInitialFlags(initPageResult.req.payload, searchParams)
{/* 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>
// Check if user can update feature flags (use already defined collection slug)
const canUpdateFeatureFlags = permissions?.collections?.[collectionSlug]?.update || false
// Use DefaultTemplate with proper props structure from initPageResult
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={initPageResult.req.user || undefined}
visibleEntities={initPageResult.visibleEntities}
>
<Gutter>
<FeatureFlagsClient
initialFlags={initialFlags}
canUpdate={canUpdateFeatureFlags}
maxFlags={parseInt(searchParams?.maxFlags as string) || 100}
collectionSlug={collectionSlug}
/>
</Gutter>
</DefaultTemplate>
)
}
export const FeatureFlagsView = memo(FeatureFlagsViewComponent)
}