mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 02:43:25 +00:00
Replace redundant components with updated feature flag hooks and views. Add comprehensive documentation and ESLint config for improved development workflow.
This commit is contained in:
@@ -1,29 +0,0 @@
|
||||
'use client'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const BeforeDashboardClient = () => {
|
||||
const { config } = useConfig()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/my-plugin-endpoint`)
|
||||
const result = await response.json()
|
||||
setMessage(result.message)
|
||||
}
|
||||
|
||||
void fetchMessage()
|
||||
}, [config.serverURL, config.routes.api])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Added by the plugin: Before Dashboard Client</h1>
|
||||
<div>
|
||||
Message from the endpoint:
|
||||
<div>{message || 'Loading...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ServerComponentProps } from 'payload'
|
||||
|
||||
import styles from './BeforeDashboardServer.module.css'
|
||||
|
||||
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
|
||||
const { payload } = props
|
||||
|
||||
const { docs } = await payload.find({ collection: 'plugin-collection' })
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h1>Added by the plugin: Before Dashboard Server</h1>
|
||||
Docs from Local API:
|
||||
{docs.map((doc) => (
|
||||
<div key={doc.id}>{doc.id}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { PayloadHandler } from 'payload'
|
||||
export const customEndpointHandler = (collectionSlug: string): PayloadHandler =>
|
||||
async (req) => {
|
||||
const { payload } = req
|
||||
const url = new URL(req.url)
|
||||
const url = new URL(req.url || '')
|
||||
const pathParts = url.pathname.split('/').filter(Boolean)
|
||||
const flagName = pathParts[pathParts.length - 1]
|
||||
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
|
||||
// Client-side hooks for React components
|
||||
export {
|
||||
useFeatureFlags,
|
||||
useFeatureFlag,
|
||||
useSpecificFeatureFlag,
|
||||
useVariantSelection,
|
||||
useRolloutCheck,
|
||||
withFeatureFlag,
|
||||
type FeatureFlag,
|
||||
} from '../hooks/client.js'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
|
||||
|
||||
// Server-side hooks for React Server Components
|
||||
export {
|
||||
getFeatureFlag,
|
||||
|
||||
2
src/exports/views.ts
Normal file
2
src/exports/views.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Custom admin views
|
||||
export { FeatureFlagsView } from '../views/FeatureFlagsView.js'
|
||||
238
src/hooks/client.ts
Normal file
238
src/hooks/client.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
enabled: boolean
|
||||
rolloutPercentage?: number
|
||||
variants?: Array<{
|
||||
name: string
|
||||
weight: number
|
||||
metadata?: any
|
||||
}>
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all active feature flags from the API
|
||||
*/
|
||||
export function useFeatureFlags(): {
|
||||
flags: Record<string, FeatureFlag> | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
} {
|
||||
const { config } = useConfig()
|
||||
const [flags, setFlags] = useState<Record<string, FeatureFlag> | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchFlags = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Feature flags API not enabled. Set enableApi: true in plugin config.')
|
||||
}
|
||||
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setFlags(result)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(errorMessage)
|
||||
setFlags(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [config.serverURL, config.routes.api])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchFlags()
|
||||
}, [fetchFlags])
|
||||
|
||||
return { flags, loading, error, refetch: fetchFlags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if a specific feature flag is enabled
|
||||
*/
|
||||
export function useFeatureFlag(flagName: string): {
|
||||
isEnabled: boolean
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { flags, loading, error } = useFeatureFlags()
|
||||
|
||||
const flag = flags?.[flagName] || null
|
||||
const isEnabled = flag?.enabled || false
|
||||
|
||||
return { isEnabled, flag, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a specific feature flag from the API
|
||||
*/
|
||||
export function useSpecificFeatureFlag(flagName: string): {
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refetch: () => Promise<void>
|
||||
} {
|
||||
const { config } = useConfig()
|
||||
const [flag, setFlag] = useState<FeatureFlag | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchFlag = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagName}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setFlag(null)
|
||||
setError(`Feature flag '${flagName}' not found`)
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to fetch feature flag: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setFlag(result)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
setError(errorMessage)
|
||||
setFlag(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [config.serverURL, config.routes.api, flagName])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchFlag()
|
||||
}, [fetchFlag])
|
||||
|
||||
return { flag, loading, error, refetch: fetchFlag }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility hook for A/B testing - selects a variant based on user ID
|
||||
*/
|
||||
export function useVariantSelection(
|
||||
flagName: string,
|
||||
userId: string
|
||||
): {
|
||||
variant: string | null
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
|
||||
|
||||
const variant = flag?.enabled && flag.variants
|
||||
? selectVariantForUser(userId, flag.variants)
|
||||
: null
|
||||
|
||||
return { variant, flag, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility hook to check if user is in rollout percentage
|
||||
*/
|
||||
export function useRolloutCheck(
|
||||
flagName: string,
|
||||
userId: string
|
||||
): {
|
||||
isInRollout: boolean
|
||||
flag: FeatureFlag | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
} {
|
||||
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
|
||||
|
||||
const isInRollout = flag?.enabled
|
||||
? checkUserInRollout(userId, flag.rolloutPercentage || 100)
|
||||
: false
|
||||
|
||||
return { isInRollout, flag, loading, error }
|
||||
}
|
||||
|
||||
// Utility functions for client-side feature flag evaluation
|
||||
|
||||
/**
|
||||
* Select variant for a user based on consistent hashing
|
||||
*/
|
||||
function selectVariantForUser(
|
||||
userId: string,
|
||||
variants: Array<{ name: string; weight: number }>
|
||||
): string | null {
|
||||
if (variants.length === 0) return null
|
||||
|
||||
// Simple hash function for consistent user bucketing
|
||||
const hash = Math.abs(userId.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0)
|
||||
}, 0))
|
||||
|
||||
const bucket = hash % 100
|
||||
let cumulative = 0
|
||||
|
||||
for (const variant of variants) {
|
||||
cumulative += variant.weight
|
||||
if (bucket < cumulative) {
|
||||
return variant.name
|
||||
}
|
||||
}
|
||||
|
||||
return variants[0]?.name || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is in rollout percentage
|
||||
*/
|
||||
function checkUserInRollout(userId: string, percentage: number): boolean {
|
||||
if (percentage >= 100) return true
|
||||
if (percentage <= 0) return false
|
||||
|
||||
// Simple hash function for consistent user bucketing
|
||||
const hash = userId.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0)
|
||||
}, 0)
|
||||
|
||||
return (Math.abs(hash) % 100) < percentage
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component for feature flag gating
|
||||
*/
|
||||
export function withFeatureFlag<P extends Record<string, any>>(
|
||||
flagName: string,
|
||||
FallbackComponent?: React.ComponentType<P>
|
||||
) {
|
||||
return function FeatureFlagWrapper(
|
||||
WrappedComponent: React.ComponentType<P>
|
||||
): React.ComponentType<P> {
|
||||
return function WithFeatureFlagComponent(props: P): React.ReactElement | null {
|
||||
const { isEnabled, loading } = useFeatureFlag(flagName)
|
||||
|
||||
if (loading) {
|
||||
return null // or a loading spinner
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
return FallbackComponent ? React.createElement(FallbackComponent, props) : null
|
||||
}
|
||||
|
||||
return React.createElement(WrappedComponent, props)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { Payload } from 'payload'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
@@ -14,27 +13,35 @@ export interface FeatureFlag {
|
||||
}
|
||||
|
||||
// Helper to get the collection slug from config
|
||||
async function getCollectionSlug(): Promise<string> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
// Look for the feature flags collection - it should have a 'name' field with unique constraint
|
||||
const collection = payload.config.collections?.find(col =>
|
||||
col.fields.some(field =>
|
||||
field.name === 'name' &&
|
||||
field.type === 'text' &&
|
||||
field.unique === true
|
||||
) &&
|
||||
col.fields.some(field => field.name === 'enabled' && field.type === 'checkbox')
|
||||
)
|
||||
return collection?.slug || 'feature-flags'
|
||||
function getCollectionSlug(payload: Payload): string {
|
||||
try {
|
||||
// Look for the feature flags collection - it should have a 'name' field with unique constraint
|
||||
const collection = payload.config.collections?.find(col =>
|
||||
col.fields.some((field: any) =>
|
||||
field.name === 'name' &&
|
||||
field.type === 'text' &&
|
||||
field.unique === true
|
||||
) &&
|
||||
col.fields.some((field: any) => field.name === 'enabled' && field.type === 'checkbox')
|
||||
)
|
||||
return collection?.slug || 'feature-flags'
|
||||
} catch {
|
||||
return 'feature-flags'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific feature flag by name (for use in React Server Components)
|
||||
*/
|
||||
export async function getFeatureFlag(flagName: string): Promise<FeatureFlag | null> {
|
||||
export async function getFeatureFlag(flagName: string, payload?: Payload): Promise<FeatureFlag | null> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
// If no payload provided, return null as these hooks should be used within Payload context
|
||||
if (!payload) {
|
||||
console.error('Payload instance not available. These hooks should be called within Payload server context or pass payload as parameter.')
|
||||
return null
|
||||
}
|
||||
|
||||
const collectionSlug = getCollectionSlug(payload)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
@@ -68,18 +75,23 @@ export async function getFeatureFlag(flagName: string): Promise<FeatureFlag | nu
|
||||
/**
|
||||
* Check if a feature flag is enabled (for use in React Server Components)
|
||||
*/
|
||||
export async function isFeatureEnabled(flagName: string): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
export async function isFeatureEnabled(flagName: string, payload?: Payload): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName, payload)
|
||||
return flag?.enabled ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active feature flags (for use in React Server Components)
|
||||
*/
|
||||
export async function getAllFeatureFlags(): Promise<Record<string, FeatureFlag>> {
|
||||
export async function getAllFeatureFlags(payload?: Payload): Promise<Record<string, FeatureFlag>> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
// If no payload provided, return empty object as these hooks should be used within Payload context
|
||||
if (!payload) {
|
||||
console.error('Payload instance not available. These hooks should be called within Payload server context or pass payload as parameter.')
|
||||
return {}
|
||||
}
|
||||
|
||||
const collectionSlug = getCollectionSlug(payload)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
@@ -115,9 +127,10 @@ export async function getAllFeatureFlags(): Promise<Record<string, FeatureFlag>>
|
||||
*/
|
||||
export async function isUserInRollout(
|
||||
flagName: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
payload?: Payload
|
||||
): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
const flag = await getFeatureFlag(flagName, payload)
|
||||
|
||||
if (!flag?.enabled) {
|
||||
return false
|
||||
@@ -140,9 +153,10 @@ export async function isUserInRollout(
|
||||
*/
|
||||
export async function getUserVariant(
|
||||
flagName: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
payload?: Payload
|
||||
): Promise<string | null> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
const flag = await getFeatureFlag(flagName, payload)
|
||||
|
||||
if (!flag?.enabled || !flag.variants || flag.variants.length === 0) {
|
||||
return null
|
||||
@@ -169,10 +183,15 @@ export async function getUserVariant(
|
||||
/**
|
||||
* Get feature flags by tags (for use in React Server Components)
|
||||
*/
|
||||
export async function getFeatureFlagsByTag(tag: string): Promise<FeatureFlag[]> {
|
||||
export async function getFeatureFlagsByTag(tag: string, payload?: Payload): Promise<FeatureFlag[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
// If no payload provided, return empty array as these hooks should be used within Payload context
|
||||
if (!payload) {
|
||||
console.error('Payload instance not available. These hooks should be called within Payload server context or pass payload as parameter.')
|
||||
return []
|
||||
}
|
||||
|
||||
const collectionSlug = getCollectionSlug(payload)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
|
||||
55
src/index.ts
55
src/index.ts
@@ -49,11 +49,10 @@ export const payloadFeatureFlags =
|
||||
enableRollouts = true,
|
||||
enableVariants = true,
|
||||
enableApi = false,
|
||||
collectionOverrides = {},
|
||||
collectionOverrides,
|
||||
} = pluginOptions
|
||||
|
||||
// Get collection slug from overrides or use default
|
||||
const collectionSlug = collectionOverrides.slug || 'feature-flags'
|
||||
|
||||
const collectionSlug = collectionOverrides?.slug || 'feature-flags'
|
||||
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
@@ -86,10 +85,9 @@ export const payloadFeatureFlags =
|
||||
description: 'Toggle this feature flag on or off',
|
||||
},
|
||||
},
|
||||
...(enableRollouts ? [
|
||||
{
|
||||
...(enableRollouts ? [{
|
||||
name: 'rolloutPercentage',
|
||||
type: 'number',
|
||||
type: 'number' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
defaultValue: 100,
|
||||
@@ -97,12 +95,10 @@ export const payloadFeatureFlags =
|
||||
description: 'Percentage of users who will see this feature (0-100)',
|
||||
condition: (data: any) => data?.enabled === true,
|
||||
},
|
||||
},
|
||||
] : []),
|
||||
...(enableVariants ? [
|
||||
{
|
||||
}] : []),
|
||||
...(enableVariants ? [{
|
||||
name: 'variants',
|
||||
type: 'array',
|
||||
type: 'array' as const,
|
||||
admin: {
|
||||
description: 'Define variants for A/B testing',
|
||||
condition: (data: any) => data?.enabled === true,
|
||||
@@ -110,7 +106,7 @@ export const payloadFeatureFlags =
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Variant identifier (e.g., control, variant-a)',
|
||||
@@ -118,7 +114,7 @@ export const payloadFeatureFlags =
|
||||
},
|
||||
{
|
||||
name: 'weight',
|
||||
type: 'number',
|
||||
type: 'number' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true,
|
||||
@@ -128,21 +124,20 @@ export const payloadFeatureFlags =
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
type: 'json' as const,
|
||||
admin: {
|
||||
description: 'Additional data for this variant',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] : []),
|
||||
}] : []),
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
type: 'array' as const,
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
type: 'text',
|
||||
type: 'text' as const,
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
@@ -159,12 +154,12 @@ export const payloadFeatureFlags =
|
||||
]
|
||||
|
||||
// Apply field overrides if provided
|
||||
const fields = collectionOverrides.fields
|
||||
const fields = collectionOverrides?.fields
|
||||
? collectionOverrides.fields({ defaultFields })
|
||||
: defaultFields
|
||||
|
||||
// Extract field overrides from collectionOverrides
|
||||
const { fields: _fieldsOverride, ...otherOverrides } = collectionOverrides
|
||||
const { fields: _fieldsOverride, ...otherOverrides } = collectionOverrides || {}
|
||||
|
||||
// Create the feature flags collection with overrides
|
||||
const featureFlagsCollection: CollectionConfig = {
|
||||
@@ -173,7 +168,7 @@ export const payloadFeatureFlags =
|
||||
useAsTitle: 'name',
|
||||
group: 'Configuration',
|
||||
description: 'Manage feature flags for your application',
|
||||
...(otherOverrides.admin || {}),
|
||||
...(collectionOverrides?.admin || {}),
|
||||
},
|
||||
fields,
|
||||
// Apply any other collection overrides
|
||||
@@ -202,16 +197,16 @@ export const payloadFeatureFlags =
|
||||
config.admin.components = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components.beforeDashboard) {
|
||||
config.admin.components.beforeDashboard = []
|
||||
if (!config.admin.components.views) {
|
||||
config.admin.components.views = {}
|
||||
}
|
||||
|
||||
// Add custom feature flags overview view
|
||||
config.admin.components.views['feature-flags-overview'] = {
|
||||
Component: 'payload-feature-flags/views#FeatureFlagsView',
|
||||
path: '/feature-flags-overview',
|
||||
}
|
||||
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`payload-feature-flags/client#BeforeDashboardClient`,
|
||||
)
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`payload-feature-flags/rsc#BeforeDashboardServer`,
|
||||
)
|
||||
|
||||
// Add API endpoints if enabled
|
||||
if (enableApi) {
|
||||
|
||||
418
src/views/FeatureFlagsView.tsx
Normal file
418
src/views/FeatureFlagsView.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
|
||||
interface FeatureFlag {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
rolloutPercentage?: number
|
||||
variants?: Array<{
|
||||
name: string
|
||||
weight: number
|
||||
metadata?: any
|
||||
}>
|
||||
environment?: 'development' | 'staging' | 'production'
|
||||
tags?: Array<{ tag: string }>
|
||||
metadata?: any
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const FeatureFlagsViewComponent = () => {
|
||||
const { config } = useConfig()
|
||||
const [flags, setFlags] = useState<FeatureFlag[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController()
|
||||
|
||||
const loadFlags = async () => {
|
||||
await fetchFlags(abortController.signal)
|
||||
}
|
||||
|
||||
loadFlags()
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
}, [config.serverURL])
|
||||
|
||||
const fetchFlags = async (signal?: AbortSignal) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags`, {
|
||||
credentials: 'include',
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('Feature flags API not enabled. Set enableApi: true in plugin config.')
|
||||
return
|
||||
}
|
||||
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Convert the result object to an array if it's not already
|
||||
const flagsArray = Array.isArray(result) ? result : Object.values(result || {})
|
||||
|
||||
// Only update state if the component is still mounted (signal not aborted)
|
||||
if (!signal?.aborted) {
|
||||
setFlags(flagsArray as FeatureFlag[])
|
||||
}
|
||||
} catch (err) {
|
||||
// Don't show error if request was aborted (component unmounting)
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return
|
||||
}
|
||||
console.error('Error fetching feature flags:', err)
|
||||
if (!signal?.aborted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch feature flags')
|
||||
}
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFlag = useCallback(async (flagId: string, enabled: boolean) => {
|
||||
// For now, just show a message that editing isn't available in the custom view
|
||||
setError('Toggle functionality coming soon. Please use the standard collection view to edit flags.')
|
||||
setTimeout(() => setError(''), 3000)
|
||||
}, [])
|
||||
|
||||
const filteredFlags = useMemo(() => {
|
||||
return flags.filter(flag => {
|
||||
const matchesFilter = filter === 'all' ||
|
||||
(filter === 'enabled' && flag.enabled) ||
|
||||
(filter === 'disabled' && !flag.enabled)
|
||||
|
||||
const matchesSearch = !search ||
|
||||
flag.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
flag.description?.toLowerCase().includes(search.toLowerCase())
|
||||
|
||||
return matchesFilter && matchesSearch
|
||||
})
|
||||
}, [flags, filter, search])
|
||||
|
||||
const getStatusColor = (flag: FeatureFlag) => {
|
||||
if (!flag.enabled) return '#ef4444'
|
||||
if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return '#f59e0b'
|
||||
return '#10b981'
|
||||
}
|
||||
|
||||
const getStatusText = (flag: FeatureFlag) => {
|
||||
if (!flag.enabled) return 'Disabled'
|
||||
if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return `${flag.rolloutPercentage}% Rollout`
|
||||
return 'Enabled'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.125rem', color: '#6b7280' }}>Loading feature flags...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{
|
||||
backgroundColor: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem',
|
||||
color: '#dc2626'
|
||||
}}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h1 style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: '0.5rem'
|
||||
}}>
|
||||
🚩 Feature Flags
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', fontSize: '1rem' }}>
|
||||
Manage feature toggles, A/B tests, and gradual rollouts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
marginBottom: '2rem',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search flags..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
minWidth: '200px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{(['all', 'enabled', 'disabled'] as const).map(filterType => (
|
||||
<button
|
||||
key={filterType}
|
||||
onClick={() => setFilter(filterType)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: filter === filterType ? '#3b82f6' : 'white',
|
||||
color: filter === filterType ? 'white' : '#374151',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize'
|
||||
}}
|
||||
>
|
||||
{filterType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => fetchFlags()}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: 'white',
|
||||
color: '#374151',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '2rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#111827' }}>
|
||||
{flags.length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Total Flags</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#10b981' }}>
|
||||
{flags.filter(f => f.enabled).length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Enabled</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#f59e0b' }}>
|
||||
{flags.filter(f => f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Rolling Out</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '1.5rem',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#8b5cf6' }}>
|
||||
{flags.filter(f => f.variants && f.variants.length > 0).length}
|
||||
</div>
|
||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>A/B Tests</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Flags List */}
|
||||
{filteredFlags.length === 0 ? (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '3rem',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div style={{ fontSize: '1.125rem', color: '#6b7280', marginBottom: '0.5rem' }}>
|
||||
{search || filter !== 'all' ? 'No flags match your criteria' : 'No feature flags yet'}
|
||||
</div>
|
||||
{(!search && filter === 'all') && (
|
||||
<div style={{ color: '#9ca3af', fontSize: '0.875rem' }}>
|
||||
Create your first feature flag to get started
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{filteredFlags.map(flag => (
|
||||
<div key={flag.id} style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* Flag Info */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.5rem' }}>
|
||||
<h3 style={{
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
margin: 0
|
||||
}}>
|
||||
{flag.name}
|
||||
</h3>
|
||||
|
||||
<div style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: getStatusColor(flag),
|
||||
color: 'white',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{getStatusText(flag)}
|
||||
</div>
|
||||
|
||||
{flag.environment && (
|
||||
<div style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#374151',
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'capitalize'
|
||||
}}>
|
||||
{flag.environment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flag.description && (
|
||||
<p style={{
|
||||
color: '#6b7280',
|
||||
fontSize: '0.875rem',
|
||||
margin: '0 0 0.75rem 0'
|
||||
}}>
|
||||
{flag.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||
{flag.variants && flag.variants.length > 0 && (
|
||||
<span>🧪 {flag.variants.length} variants</span>
|
||||
)}
|
||||
{flag.tags && flag.tags.length > 0 && (
|
||||
<span>🏷️ {flag.tags.map(t => t.tag).join(', ')}</span>
|
||||
)}
|
||||
<span>📅 {new Date(flag.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<label style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '60px',
|
||||
height: '34px'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flag.enabled}
|
||||
onChange={(e) => toggleFlag(flag.id, e.target.checked)}
|
||||
style={{ opacity: 0, width: 0, height: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
cursor: 'pointer',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: flag.enabled ? '#10b981' : '#ccc',
|
||||
borderRadius: '34px',
|
||||
transition: '0.4s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
height: '26px',
|
||||
width: '26px',
|
||||
left: flag.enabled ? '30px' : '4px',
|
||||
bottom: '4px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '50%',
|
||||
transition: '0.4s'
|
||||
}} />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeatureFlagsView = memo(FeatureFlagsViewComponent)
|
||||
Reference in New Issue
Block a user