5 Commits
dev ... v0.0.9

Author SHA1 Message Date
Bas
263c355806 Merge pull request #8 from xtr-dev/dev
Temporarily disable test script for CI
2025-10-03 14:31:41 +02:00
Bas
33b39e3ced Merge pull request #7 from xtr-dev/dev
Remove custom API endpoints in favor of Payload's native REST API
2025-10-03 14:29:21 +02:00
Bas
a1943c23a6 Merge pull request #5 from xtr-dev/dev
Bump version to 0.0.7
2025-10-01 21:38:24 +02:00
Bas
e0e0046d21 Merge pull request #4 from xtr-dev/dev
Bump version to 0.0.6
2025-09-28 22:14:53 +02:00
Bas
adffe3aaa1 Merge pull request #3 from xtr-dev/dev
Bump version to 0.0.5
2025-09-28 18:20:28 +02:00
11 changed files with 1075 additions and 1253 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/payload-feature-flags",
"version": "0.0.20",
"version": "0.0.9",
"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.56.0",
"@payloadcms/db-postgres": "3.56.0",
"@payloadcms/db-sqlite": "3.56.0",
"@payloadcms/db-mongodb": "3.37.0",
"@payloadcms/db-postgres": "3.37.0",
"@payloadcms/db-sqlite": "3.37.0",
"@payloadcms/eslint-config": "3.9.0",
"@payloadcms/next": "3.56.0",
"@payloadcms/richtext-lexical": "3.56.0",
"@payloadcms/ui": "3.56.0",
"@payloadcms/next": "3.37.0",
"@payloadcms/richtext-lexical": "3.37.0",
"@payloadcms/ui": "3.37.0",
"@playwright/test": "^1.52.0",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.6.0",
@@ -74,7 +74,7 @@
"mongodb-memory-server": "10.1.4",
"next": "15.4.4",
"open": "^10.1.0",
"payload": "3.56.0",
"payload": "3.37.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.56.0"
"payload": "^3.37.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,5 +7,4 @@ export {
useRolloutCheck,
withFeatureFlag,
type FeatureFlag,
type FeatureFlagOptions,
} from '../hooks/client.js'

View File

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

View File

@@ -1,5 +1,6 @@
'use client'
import React, { useCallback, useEffect, useState, useRef } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useConfig } from '@payloadcms/ui'
export interface FeatureFlag {
name: string
@@ -13,78 +14,34 @@ 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>[],
options?: FeatureFlagOptions
initialFlags: Partial<FeatureFlag>[]
): {
flags: Partial<FeatureFlag>[]
loading: boolean
error: string | null
refetch: () => Promise<void>
} {
const { serverURL, apiPath, collectionSlug } = getConfig(options)
const { config } = useConfig()
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 = initialFlagsRef.current.map(f => f.name).filter(Boolean)
const names = initialFlags.map(f => f.name).filter(Boolean)
const query = names.length > 0
? `?where[name][in]=${names.join(',')}&limit=1000`
: '?limit=1000'
const response = await fetch(`${serverURL}${apiPath}/${collectionSlug}${query}`)
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags${query}`)
if (!response.ok) {
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
@@ -107,7 +64,7 @@ export function useFeatureFlags(
}
// Sort flags based on the order of names in initialFlags
const sortedFlags = initialFlagsRef.current.map(initialFlag => {
const sortedFlags = initialFlags.map(initialFlag => {
const fetchedFlag = fetchedFlagsMap.get(initialFlag.name!)
// Use fetched flag if available, otherwise keep the initial flag
return fetchedFlag || initialFlag
@@ -120,7 +77,7 @@ export function useFeatureFlags(
} finally {
setLoading(false)
}
}, [serverURL, apiPath, collectionSlug]) // Remove initialFlags from dependencies
}, [config.serverURL, config.routes.api, initialFlags])
useEffect(() => {
void fetchFlags()
@@ -132,16 +89,13 @@ export function useFeatureFlags(
/**
* Hook to check if a specific feature flag is enabled
*/
export function useFeatureFlag(
flagName: string,
options?: FeatureFlagOptions
): {
export function useFeatureFlag(flagName: string): {
isEnabled: boolean
flag: Partial<FeatureFlag> | null
loading: boolean
error: string | null
} {
const { flags, loading, error } = useFeatureFlags([{ name: flagName }], options)
const { flags, loading, error } = useFeatureFlags([{ name: flagName }])
const flag = flags.find(f => f.name === flagName) || null
const isEnabled = flag?.enabled || false
@@ -152,16 +106,13 @@ export function useFeatureFlag(
/**
* Hook to fetch a specific feature flag from the API
*/
export function useSpecificFeatureFlag(
flagName: string,
options?: FeatureFlagOptions
): {
export function useSpecificFeatureFlag(flagName: string): {
flag: FeatureFlag | null
loading: boolean
error: string | null
refetch: () => Promise<void>
} {
const { serverURL, apiPath, collectionSlug } = getConfig(options)
const { config } = useConfig()
const [flag, setFlag] = useState<FeatureFlag | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -173,7 +124,7 @@ export function useSpecificFeatureFlag(
// Use Payload's native collection API with query filter
const response = await fetch(
`${serverURL}${apiPath}/${collectionSlug}?where[name][equals]=${flagName}&limit=1`
`${config.serverURL}${config.routes.api}/feature-flags?where[name][equals]=${flagName}&limit=1`
)
if (!response.ok) {
@@ -202,7 +153,7 @@ export function useSpecificFeatureFlag(
} finally {
setLoading(false)
}
}, [serverURL, apiPath, collectionSlug, flagName])
}, [config.serverURL, config.routes.api, flagName])
useEffect(() => {
void fetchFlag()
@@ -216,15 +167,14 @@ export function useSpecificFeatureFlag(
*/
export function useVariantSelection(
flagName: string,
userId: string,
options?: FeatureFlagOptions
userId: string
): {
variant: string | null
flag: FeatureFlag | null
loading: boolean
error: string | null
} {
const { flag, loading, error } = useSpecificFeatureFlag(flagName, options)
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
const variant = flag?.enabled && flag.variants
? selectVariantForUser(userId, flag.variants)
@@ -238,15 +188,14 @@ export function useVariantSelection(
*/
export function useRolloutCheck(
flagName: string,
userId: string,
options?: FeatureFlagOptions
userId: string
): {
isInRollout: boolean
flag: FeatureFlag | null
loading: boolean
error: string | null
} {
const { flag, loading, error } = useSpecificFeatureFlag(flagName, options)
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
const isInRollout = flag?.enabled
? checkUserInRollout(userId, flag.rolloutPercentage || 100)
@@ -304,14 +253,13 @@ function checkUserInRollout(userId: string, percentage: number): boolean {
*/
export function withFeatureFlag<P extends Record<string, any>>(
flagName: string,
FallbackComponent?: React.ComponentType<P>,
options?: FeatureFlagOptions
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, options)
const { isEnabled, loading } = useFeatureFlag(flagName)
if (loading) {
return null // or a loading spinner

View File

@@ -6,9 +6,6 @@ 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
@@ -34,11 +31,6 @@ export type PayloadFeatureFlagsConfig = {
* Override collection configuration
*/
collectionOverrides?: CollectionOverrides
/**
* Enable custom list view for feature flags
* @default false
*/
enableCustomListView?: boolean
}
export const payloadFeatureFlags =
@@ -49,7 +41,6 @@ export const payloadFeatureFlags =
defaultValue = false,
enableRollouts = true,
enableVariants = true,
enableCustomListView = false,
collectionOverrides,
} = pluginOptions
@@ -169,15 +160,6 @@ 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,

View File

@@ -1,24 +0,0 @@
// 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

@@ -1,669 +0,0 @@
'use client'
import React from 'react'
import type { ListViewClientProps } from 'payload'
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
</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,100 +1,414 @@
import React from 'react'
import type { ListViewServerProps } from 'payload'
import FeatureFlagsClient from './FeatureFlagsClient.js'
import type { FeatureFlag } from '../types/index.js'
'use client'
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
import { useConfig } from '@payloadcms/ui'
async function fetchInitialFlags(payload: any, collectionSlug: string): Promise<FeatureFlag[]> {
try {
const result = await payload.find({
collection: collectionSlug,
limit: 1000,
sort: 'name',
})
return (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
} catch (error) {
console.error('Error fetching initial feature flags:', error)
return []
}
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
}
export default async function FeatureFlagsView(props: ListViewServerProps) {
const { collectionConfig, user, permissions, payload } = props
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('')
// Security check: User must be logged in
if (!user) {
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) {
return (
<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',
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={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: 'var(--theme-error-500)',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontWeight: '500'
}}
>
Go to Login
</a>
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div style={{ fontSize: '1.125rem', color: '#6b7280' }}>Loading feature flags...</div>
</div>
)
}
// Security check: User must have permissions to access the collection
const canReadFeatureFlags = permissions?.collections?.[collectionConfig.slug]?.read
if (!canReadFeatureFlags) {
if (error) {
return (
<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',
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 {collectionConfig.slug} collection.
</p>
<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>
)
}
// Fetch initial data server-side (only if user has access)
const initialFlags = await fetchInitialFlags(payload, collectionConfig.slug)
// Check if user can update feature flags
const canUpdateFeatureFlags = permissions?.collections?.[collectionConfig.slug]?.update || false
return (
<FeatureFlagsClient
initialFlags={initialFlags}
canUpdate={canUpdateFeatureFlags}
maxFlags={100}
collectionSlug={collectionConfig.slug}
/>
<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)