'use client' import { useState, useEffect, useCallback, useMemo, memo } from 'react' import { useConfig, useTheme } from '@payloadcms/ui' import { DefaultTemplate } from '@payloadcms/next/templates' import type { Locale } from 'payload' 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 } interface FeatureFlagsViewProps { i18n?: any locale?: Locale params?: Record payload?: any permissions?: any searchParams?: Record user?: any visibleEntities?: any [key: string]: any } const FeatureFlagsViewComponent = (props: FeatureFlagsViewProps = {}) => { const { config } = useConfig() const { theme } = useTheme() const [flags, setFlags] = useState([]) const [loading, setLoading] = useState(true) 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(null) const [successMessage, setSuccessMessage] = useState('') useEffect(() => { const abortController = new AbortController() const loadFlags = async () => { await fetchFlags(abortController.signal) } loadFlags() return () => { abortController.abort() } }, [config.serverURL]) const fetchFlags = async (signal?: AbortSignal) => { try { setLoading(true) setError('') const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=1000`, { 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) } } } const updateFlag = useCallback(async (flagId: string, updates: Partial) => { setSaving(flagId) setError('') setSuccessMessage('') try { const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagId}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(updates), }) if (!response.ok) { throw new Error(`Failed to update feature flag: ${response.statusText}`) } const updatedFlag = await response.json() // Update local state setFlags(prev => prev.map(flag => flag.id === flagId ? { ...flag, ...updatedFlag } : 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]) 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 search if (search) { filtered = filtered.filter(flag => flag.name?.toLowerCase().includes(search.toLowerCase()) || flag.description?.toLowerCase().includes(search.toLowerCase()) || flag.tags?.some(t => t.tag?.toLowerCase().includes(search.toLowerCase())) ) } // 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, search, sortField, sortDirection]) const SortIcon = ({ field }: { field: typeof sortField }) => { if (sortField !== field) { return } return {sortDirection === 'asc' ? '↑' : '↓'} } // 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() const FeatureFlagsContent = () => (
{/* Header */}

Feature Flags Dashboard

Manage all feature flags in a spreadsheet view with inline editing capabilities

{loading ? (
Loading feature flags...
) : ( <> {/* Success/Error Messages */} {successMessage && (
{successMessage}
)} {error && (
Error: {error}
)} {/* Controls */}
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 }} />
{filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
{/* Spreadsheet Table */}
{filteredAndSortedFlags.length === 0 ? ( ) : ( filteredAndSortedFlags.map(flag => ( e.currentTarget.style.backgroundColor = styles.surfaceHover} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''} > )) )}
Status 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 Description 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 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 % Variants Tags 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
{search ? 'No flags match your search' : 'No feature flags yet'}
e.currentTarget.style.textDecoration = 'underline'} onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'} > {flag.name} {flag.description || '-'} updateFlag(flag.id, { enabled: e.target.checked })} disabled={saving === flag.id} style={{ width: '18px', height: '18px', cursor: saving === flag.id ? 'wait' : 'pointer', accentColor: styles.primary }} />
{ const value = Math.min(100, Math.max(0, parseInt(e.target.value) || 0)) updateFlag(flag.id, { rolloutPercentage: value }) }} disabled={!flag.enabled || 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: saving === flag.id ? 'wait' : 'text', opacity: flag.enabled ? 1 : 0.5, backgroundColor: styles.inputBg, color: styles.text }} /> %
{flag.variants && flag.variants.length > 0 ? ( {flag.variants.length} variants ) : '-'} {flag.tags && flag.tags.length > 0 ? (
{flag.tags.map((t, i) => ( {t.tag} ))}
) : '-'}
{new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{/* Summary Stats */}
Total: {flags.filter(f => f && f.name).length} flags
Enabled: {flags.filter(f => f && f.enabled).length}
Disabled: {flags.filter(f => f && !f.enabled).length}
Rolling out: {flags.filter(f => f && f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
A/B Tests: {flags.filter(f => f && f.variants && f.variants.length > 0).length}
)}
) // Use DefaultTemplate with proper props structure return ( ) } export const FeatureFlagsView = memo(FeatureFlagsViewComponent) export default FeatureFlagsView