mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 10:53:24 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be24beeaa5 | ||
| 05952e3e72 | |||
|
|
e6f56535ca | ||
| eefe9bdaf3 | |||
| 49c10c7091 | |||
|
|
fe3da4fe52 | ||
| e82bf9c6d4 | |||
| 460d627d92 | |||
|
|
14a5acd222 | ||
| d3b8a8446e | |||
| 7dc17bc80a | |||
| b642b653d0 | |||
|
|
1db434e701 | ||
| 7fd6194712 | |||
| 259599ddcc | |||
|
|
7f54d9a79f | ||
| 9bb5f4ecc8 | |||
| 4f802c8cc9 | |||
|
|
f49a445e5a | ||
| e26d895864 | |||
| 0c7c864248 | |||
| 0a39d0631c | |||
| 3696ff7641 | |||
| bca558fad3 | |||
| a267824239 | |||
| 98cab95411 | |||
| 4091141722 | |||
| fd848dcfe8 | |||
| 477f7f96eb | |||
| b364fb9e8f | |||
| 6d151d9e82 |
@@ -88,7 +88,7 @@ export interface Config {
|
|||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
db: {
|
db: {
|
||||||
defaultIDType: string;
|
defaultIDType: number;
|
||||||
};
|
};
|
||||||
globals: {};
|
globals: {};
|
||||||
globalsSelect: {};
|
globalsSelect: {};
|
||||||
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
|
|||||||
* via the `definition` "posts".
|
* via the `definition` "posts".
|
||||||
*/
|
*/
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content?: {
|
content?: {
|
||||||
root: {
|
root: {
|
||||||
@@ -151,7 +151,7 @@ export interface Post {
|
|||||||
* via the `definition` "pages".
|
* via the `definition` "pages".
|
||||||
*/
|
*/
|
||||||
export interface Page {
|
export interface Page {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
content?: {
|
content?: {
|
||||||
@@ -178,7 +178,7 @@ export interface Page {
|
|||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
*/
|
*/
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
role?: ('admin' | 'editor' | 'user') | null;
|
role?: ('admin' | 'editor' | 'user') | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -190,6 +190,13 @@ export interface User {
|
|||||||
hash?: string | null;
|
hash?: string | null;
|
||||||
loginAttempts?: number | null;
|
loginAttempts?: number | null;
|
||||||
lockUntil?: string | null;
|
lockUntil?: string | null;
|
||||||
|
sessions?:
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -197,7 +204,7 @@ export interface User {
|
|||||||
* via the `definition` "media".
|
* via the `definition` "media".
|
||||||
*/
|
*/
|
||||||
export interface Media {
|
export interface Media {
|
||||||
id: string;
|
id: number;
|
||||||
alt?: string | null;
|
alt?: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -218,7 +225,7 @@ export interface Media {
|
|||||||
* via the `definition` "feature-flags".
|
* via the `definition` "feature-flags".
|
||||||
*/
|
*/
|
||||||
export interface FeatureFlag {
|
export interface FeatureFlag {
|
||||||
id: string;
|
id: number;
|
||||||
/**
|
/**
|
||||||
* Unique identifier for the feature flag
|
* Unique identifier for the feature flag
|
||||||
*/
|
*/
|
||||||
@@ -291,7 +298,7 @@ export interface FeatureFlag {
|
|||||||
/**
|
/**
|
||||||
* Team member responsible for this feature flag
|
* Team member responsible for this feature flag
|
||||||
*/
|
*/
|
||||||
owner?: (string | null) | User;
|
owner?: (number | null) | User;
|
||||||
/**
|
/**
|
||||||
* Optional expiration date for temporary flags
|
* Optional expiration date for temporary flags
|
||||||
*/
|
*/
|
||||||
@@ -308,32 +315,32 @@ export interface FeatureFlag {
|
|||||||
* via the `definition` "payload-locked-documents".
|
* via the `definition` "payload-locked-documents".
|
||||||
*/
|
*/
|
||||||
export interface PayloadLockedDocument {
|
export interface PayloadLockedDocument {
|
||||||
id: string;
|
id: number;
|
||||||
document?:
|
document?:
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'posts';
|
relationTo: 'posts';
|
||||||
value: string | Post;
|
value: number | Post;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'pages';
|
relationTo: 'pages';
|
||||||
value: string | Page;
|
value: number | Page;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: number | User;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'media';
|
relationTo: 'media';
|
||||||
value: string | Media;
|
value: number | Media;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'feature-flags';
|
relationTo: 'feature-flags';
|
||||||
value: string | FeatureFlag;
|
value: number | FeatureFlag;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: number | User;
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -343,10 +350,10 @@ export interface PayloadLockedDocument {
|
|||||||
* via the `definition` "payload-preferences".
|
* via the `definition` "payload-preferences".
|
||||||
*/
|
*/
|
||||||
export interface PayloadPreference {
|
export interface PayloadPreference {
|
||||||
id: string;
|
id: number;
|
||||||
user: {
|
user: {
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: string | User;
|
value: number | User;
|
||||||
};
|
};
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
value?:
|
value?:
|
||||||
@@ -366,7 +373,7 @@ export interface PayloadPreference {
|
|||||||
* via the `definition` "payload-migrations".
|
* via the `definition` "payload-migrations".
|
||||||
*/
|
*/
|
||||||
export interface PayloadMigration {
|
export interface PayloadMigration {
|
||||||
id: string;
|
id: number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
batch?: number | null;
|
batch?: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -412,6 +419,13 @@ export interface UsersSelect<T extends boolean = true> {
|
|||||||
hash?: T;
|
hash?: T;
|
||||||
loginAttempts?: T;
|
loginAttempts?: T;
|
||||||
lockUntil?: T;
|
lockUntil?: T;
|
||||||
|
sessions?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
id?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
expiresAt?: T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "payload-feature-flags",
|
"name": "payload-feature-flags",
|
||||||
"version": "1.0.0",
|
"version": "0.0.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "payload-feature-flags",
|
"name": "payload-feature-flags",
|
||||||
"version": "1.0.0",
|
"version": "0.0.19",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-feature-flags",
|
"name": "@xtr-dev/payload-feature-flags",
|
||||||
"version": "0.0.9",
|
"version": "0.0.20",
|
||||||
"description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts",
|
"description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -52,13 +52,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@payloadcms/db-mongodb": "3.37.0",
|
"@payloadcms/db-mongodb": "3.56.0",
|
||||||
"@payloadcms/db-postgres": "3.37.0",
|
"@payloadcms/db-postgres": "3.56.0",
|
||||||
"@payloadcms/db-sqlite": "3.37.0",
|
"@payloadcms/db-sqlite": "3.56.0",
|
||||||
"@payloadcms/eslint-config": "3.9.0",
|
"@payloadcms/eslint-config": "3.9.0",
|
||||||
"@payloadcms/next": "3.37.0",
|
"@payloadcms/next": "3.56.0",
|
||||||
"@payloadcms/richtext-lexical": "3.37.0",
|
"@payloadcms/richtext-lexical": "3.56.0",
|
||||||
"@payloadcms/ui": "3.37.0",
|
"@payloadcms/ui": "3.56.0",
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.52.0",
|
||||||
"@swc-node/register": "1.10.9",
|
"@swc-node/register": "1.10.9",
|
||||||
"@swc/cli": "0.6.0",
|
"@swc/cli": "0.6.0",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"mongodb-memory-server": "10.1.4",
|
"mongodb-memory-server": "10.1.4",
|
||||||
"next": "15.4.4",
|
"next": "15.4.4",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"payload": "3.37.0",
|
"payload": "3.56.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"qs-esm": "7.0.2",
|
"qs-esm": "7.0.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"vitest": "^3.1.2"
|
"vitest": "^3.1.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"payload": "^3.37.0"
|
"payload": "^3.56.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.20.2 || >=20.9.0",
|
"node": "^18.20.2 || >=20.9.0",
|
||||||
|
|||||||
970
pnpm-lock.yaml
generated
970
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -7,4 +7,5 @@ export {
|
|||||||
useRolloutCheck,
|
useRolloutCheck,
|
||||||
withFeatureFlag,
|
withFeatureFlag,
|
||||||
type FeatureFlag,
|
type FeatureFlag,
|
||||||
|
type FeatureFlagOptions,
|
||||||
} from '../hooks/client.js'
|
} from '../hooks/client.js'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Custom admin views
|
// Custom admin views
|
||||||
export { FeatureFlagsView } from '../views/FeatureFlagsView.js'
|
export { default as FeatureFlagsView } from '../views/FeatureFlagsView.js'
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import { useConfig } from '@payloadcms/ui'
|
|
||||||
|
|
||||||
export interface FeatureFlag {
|
export interface FeatureFlag {
|
||||||
name: string
|
name: string
|
||||||
@@ -14,34 +13,78 @@ export interface FeatureFlag {
|
|||||||
metadata?: any
|
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
|
* Hook to fetch all active feature flags from the API
|
||||||
*/
|
*/
|
||||||
export function useFeatureFlags(
|
export function useFeatureFlags(
|
||||||
initialFlags: Partial<FeatureFlag>[]
|
initialFlags: Partial<FeatureFlag>[],
|
||||||
|
options?: FeatureFlagOptions
|
||||||
): {
|
): {
|
||||||
flags: Partial<FeatureFlag>[]
|
flags: Partial<FeatureFlag>[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
refetch: () => Promise<void>
|
refetch: () => Promise<void>
|
||||||
} {
|
} {
|
||||||
const { config } = useConfig()
|
const { serverURL, apiPath, collectionSlug } = getConfig(options)
|
||||||
const [flags, setFlags] = useState<Partial<FeatureFlag>[]>(initialFlags)
|
const [flags, setFlags] = useState<Partial<FeatureFlag>[]>(initialFlags)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 () => {
|
const fetchFlags = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
// Use Payload's native collection API
|
// 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
|
const query = names.length > 0
|
||||||
? `?where[name][in]=${names.join(',')}&limit=1000`
|
? `?where[name][in]=${names.join(',')}&limit=1000`
|
||||||
: '?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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
|
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
|
// 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!)
|
const fetchedFlag = fetchedFlagsMap.get(initialFlag.name!)
|
||||||
// Use fetched flag if available, otherwise keep the initial flag
|
// Use fetched flag if available, otherwise keep the initial flag
|
||||||
return fetchedFlag || initialFlag
|
return fetchedFlag || initialFlag
|
||||||
@@ -77,7 +120,7 @@ export function useFeatureFlags(
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [config.serverURL, config.routes.api, initialFlags])
|
}, [serverURL, apiPath, collectionSlug]) // Remove initialFlags from dependencies
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchFlags()
|
void fetchFlags()
|
||||||
@@ -89,13 +132,16 @@ export function useFeatureFlags(
|
|||||||
/**
|
/**
|
||||||
* Hook to check if a specific feature flag is enabled
|
* Hook to check if a specific feature flag is enabled
|
||||||
*/
|
*/
|
||||||
export function useFeatureFlag(flagName: string): {
|
export function useFeatureFlag(
|
||||||
|
flagName: string,
|
||||||
|
options?: FeatureFlagOptions
|
||||||
|
): {
|
||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
flag: Partial<FeatureFlag> | null
|
flag: Partial<FeatureFlag> | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
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 flag = flags.find(f => f.name === flagName) || null
|
||||||
const isEnabled = flag?.enabled || false
|
const isEnabled = flag?.enabled || false
|
||||||
@@ -106,13 +152,16 @@ export function useFeatureFlag(flagName: string): {
|
|||||||
/**
|
/**
|
||||||
* Hook to fetch a specific feature flag from the API
|
* 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
|
flag: FeatureFlag | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
refetch: () => Promise<void>
|
refetch: () => Promise<void>
|
||||||
} {
|
} {
|
||||||
const { config } = useConfig()
|
const { serverURL, apiPath, collectionSlug } = getConfig(options)
|
||||||
const [flag, setFlag] = useState<FeatureFlag | null>(null)
|
const [flag, setFlag] = useState<FeatureFlag | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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
|
// Use Payload's native collection API with query filter
|
||||||
const response = await fetch(
|
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) {
|
if (!response.ok) {
|
||||||
@@ -153,7 +202,7 @@ export function useSpecificFeatureFlag(flagName: string): {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [config.serverURL, config.routes.api, flagName])
|
}, [serverURL, apiPath, collectionSlug, flagName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchFlag()
|
void fetchFlag()
|
||||||
@@ -167,14 +216,15 @@ export function useSpecificFeatureFlag(flagName: string): {
|
|||||||
*/
|
*/
|
||||||
export function useVariantSelection(
|
export function useVariantSelection(
|
||||||
flagName: string,
|
flagName: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
options?: FeatureFlagOptions
|
||||||
): {
|
): {
|
||||||
variant: string | null
|
variant: string | null
|
||||||
flag: FeatureFlag | null
|
flag: FeatureFlag | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
} {
|
} {
|
||||||
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
|
const { flag, loading, error } = useSpecificFeatureFlag(flagName, options)
|
||||||
|
|
||||||
const variant = flag?.enabled && flag.variants
|
const variant = flag?.enabled && flag.variants
|
||||||
? selectVariantForUser(userId, flag.variants)
|
? selectVariantForUser(userId, flag.variants)
|
||||||
@@ -188,14 +238,15 @@ export function useVariantSelection(
|
|||||||
*/
|
*/
|
||||||
export function useRolloutCheck(
|
export function useRolloutCheck(
|
||||||
flagName: string,
|
flagName: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
options?: FeatureFlagOptions
|
||||||
): {
|
): {
|
||||||
isInRollout: boolean
|
isInRollout: boolean
|
||||||
flag: FeatureFlag | null
|
flag: FeatureFlag | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
} {
|
} {
|
||||||
const { flag, loading, error } = useSpecificFeatureFlag(flagName)
|
const { flag, loading, error } = useSpecificFeatureFlag(flagName, options)
|
||||||
|
|
||||||
const isInRollout = flag?.enabled
|
const isInRollout = flag?.enabled
|
||||||
? checkUserInRollout(userId, flag.rolloutPercentage || 100)
|
? checkUserInRollout(userId, flag.rolloutPercentage || 100)
|
||||||
@@ -253,13 +304,14 @@ function checkUserInRollout(userId: string, percentage: number): boolean {
|
|||||||
*/
|
*/
|
||||||
export function withFeatureFlag<P extends Record<string, any>>(
|
export function withFeatureFlag<P extends Record<string, any>>(
|
||||||
flagName: string,
|
flagName: string,
|
||||||
FallbackComponent?: React.ComponentType<P>
|
FallbackComponent?: React.ComponentType<P>,
|
||||||
|
options?: FeatureFlagOptions
|
||||||
) {
|
) {
|
||||||
return function FeatureFlagWrapper(
|
return function FeatureFlagWrapper(
|
||||||
WrappedComponent: React.ComponentType<P>
|
WrappedComponent: React.ComponentType<P>
|
||||||
): React.ComponentType<P> {
|
): React.ComponentType<P> {
|
||||||
return function WithFeatureFlagComponent(props: P): React.ReactElement | null {
|
return function WithFeatureFlagComponent(props: P): React.ReactElement | null {
|
||||||
const { isEnabled, loading } = useFeatureFlag(flagName)
|
const { isEnabled, loading } = useFeatureFlag(flagName, options)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null // or a loading spinner
|
return null // or a loading spinner
|
||||||
|
|||||||
18
src/index.ts
18
src/index.ts
@@ -6,6 +6,9 @@ export type CollectionOverrides = Partial<
|
|||||||
fields?: (args: { defaultFields: Field[] }) => Field[]
|
fields?: (args: { defaultFields: Field[] }) => Field[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export shared types for users of the plugin
|
||||||
|
export type { PayloadID, FeatureFlag } from './types/index.js'
|
||||||
|
|
||||||
export type PayloadFeatureFlagsConfig = {
|
export type PayloadFeatureFlagsConfig = {
|
||||||
/**
|
/**
|
||||||
* Enable/disable the plugin
|
* Enable/disable the plugin
|
||||||
@@ -31,6 +34,11 @@ export type PayloadFeatureFlagsConfig = {
|
|||||||
* Override collection configuration
|
* Override collection configuration
|
||||||
*/
|
*/
|
||||||
collectionOverrides?: CollectionOverrides
|
collectionOverrides?: CollectionOverrides
|
||||||
|
/**
|
||||||
|
* Enable custom list view for feature flags
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
enableCustomListView?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const payloadFeatureFlags =
|
export const payloadFeatureFlags =
|
||||||
@@ -41,6 +49,7 @@ export const payloadFeatureFlags =
|
|||||||
defaultValue = false,
|
defaultValue = false,
|
||||||
enableRollouts = true,
|
enableRollouts = true,
|
||||||
enableVariants = true,
|
enableVariants = true,
|
||||||
|
enableCustomListView = false,
|
||||||
collectionOverrides,
|
collectionOverrides,
|
||||||
} = pluginOptions
|
} = pluginOptions
|
||||||
|
|
||||||
@@ -160,6 +169,15 @@ export const payloadFeatureFlags =
|
|||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
group: 'Configuration',
|
group: 'Configuration',
|
||||||
description: 'Manage feature flags for your application',
|
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 || {}),
|
...(collectionOverrides?.admin || {}),
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
24
src/types/index.ts
Normal file
24
src/types/index.ts
Normal 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
|
||||||
|
}
|
||||||
669
src/views/FeatureFlagsClient.tsx
Normal file
669
src/views/FeatureFlagsClient.tsx
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
'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
|
||||||
@@ -1,414 +1,100 @@
|
|||||||
'use client'
|
import React from 'react'
|
||||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
|
import type { ListViewServerProps } from 'payload'
|
||||||
import { useConfig } from '@payloadcms/ui'
|
import FeatureFlagsClient from './FeatureFlagsClient.js'
|
||||||
|
import type { FeatureFlag } from '../types/index.js'
|
||||||
|
|
||||||
interface FeatureFlag {
|
async function fetchInitialFlags(payload: any, collectionSlug: string): Promise<FeatureFlag[]> {
|
||||||
id: string
|
try {
|
||||||
name: string
|
const result = await payload.find({
|
||||||
description?: string
|
collection: collectionSlug,
|
||||||
enabled: boolean
|
limit: 1000,
|
||||||
rolloutPercentage?: number
|
sort: 'name',
|
||||||
variants?: Array<{
|
})
|
||||||
name: string
|
|
||||||
weight: number
|
return (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
|
||||||
metadata?: any
|
} catch (error) {
|
||||||
}>
|
console.error('Error fetching initial feature flags:', error)
|
||||||
environment?: 'development' | 'staging' | 'production'
|
return []
|
||||||
tags?: Array<{ tag: string }>
|
}
|
||||||
metadata?: any
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureFlagsViewComponent = () => {
|
export default async function FeatureFlagsView(props: ListViewServerProps) {
|
||||||
const { config } = useConfig()
|
const { collectionConfig, user, permissions, payload } = props
|
||||||
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(() => {
|
// Security check: User must be logged in
|
||||||
const abortController = new AbortController()
|
if (!user) {
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{
|
||||||
<div style={{ fontSize: '1.125rem', color: '#6b7280' }}>Loading feature flags...</div>
|
padding: '2rem',
|
||||||
</div>
|
textAlign: 'center',
|
||||||
)
|
color: 'var(--theme-error-500)',
|
||||||
}
|
backgroundColor: 'var(--theme-error-50)',
|
||||||
|
border: '1px solid var(--theme-error-200)',
|
||||||
if (error) {
|
borderRadius: '0.5rem',
|
||||||
return (
|
margin: '2rem 0'
|
||||||
<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
|
<h2 style={{ marginBottom: '1rem', color: 'var(--theme-error-600)' }}>
|
||||||
type="text"
|
Authentication Required
|
||||||
placeholder="Search flags..."
|
</h2>
|
||||||
value={search}
|
<p style={{ marginBottom: '1rem' }}>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
You must be logged in to view the Feature Flags Dashboard.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/admin/login"
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 1rem',
|
display: 'inline-block',
|
||||||
border: '1px solid #d1d5db',
|
padding: '0.75rem 1.5rem',
|
||||||
borderRadius: '0.5rem',
|
backgroundColor: 'var(--theme-error-500)',
|
||||||
fontSize: '0.875rem',
|
color: 'white',
|
||||||
minWidth: '200px'
|
textDecoration: 'none',
|
||||||
}}
|
borderRadius: '0.375rem',
|
||||||
/>
|
fontWeight: '500'
|
||||||
|
|
||||||
<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
|
Go to Login
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Stats */}
|
// Security check: User must have permissions to access the collection
|
||||||
<div style={{
|
const canReadFeatureFlags = permissions?.collections?.[collectionConfig.slug]?.read
|
||||||
display: 'grid',
|
if (!canReadFeatureFlags) {
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
return (
|
||||||
gap: '1rem',
|
<div style={{
|
||||||
marginBottom: '2rem'
|
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'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<h2 style={{ marginBottom: '1rem', color: 'var(--theme-warning-700)' }}>
|
||||||
backgroundColor: 'white',
|
Access Denied
|
||||||
padding: '1.5rem',
|
</h2>
|
||||||
borderRadius: '0.75rem',
|
<p style={{ marginBottom: '1rem' }}>
|
||||||
border: '1px solid #e5e7eb',
|
You don't have permission to access the Feature Flags Dashboard.
|
||||||
textAlign: 'center'
|
</p>
|
||||||
}}>
|
<p style={{ fontSize: '0.875rem', color: 'var(--theme-warning-600)' }}>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#111827' }}>
|
Contact your administrator to request access to the {collectionConfig.slug} collection.
|
||||||
{flags.length}
|
</p>
|
||||||
</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>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Feature Flags List */}
|
// Fetch initial data server-side (only if user has access)
|
||||||
{filteredFlags.length === 0 ? (
|
const initialFlags = await fetchInitialFlags(payload, collectionConfig.slug)
|
||||||
<div style={{
|
|
||||||
textAlign: 'center',
|
// Check if user can update feature flags
|
||||||
padding: '3rem',
|
const canUpdateFeatureFlags = permissions?.collections?.[collectionConfig.slug]?.update || false
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '0.75rem',
|
return (
|
||||||
border: '1px solid #e5e7eb'
|
<FeatureFlagsClient
|
||||||
}}>
|
initialFlags={initialFlags}
|
||||||
<div style={{ fontSize: '1.125rem', color: '#6b7280', marginBottom: '0.5rem' }}>
|
canUpdate={canUpdateFeatureFlags}
|
||||||
{search || filter !== 'all' ? 'No flags match your criteria' : 'No feature flags yet'}
|
maxFlags={100}
|
||||||
</div>
|
collectionSlug={collectionConfig.slug}
|
||||||
{(!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