mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 10:53:24 +00:00
v0.0.12: Type consistency and configuration improvements
Type System Enhancements: - Introduced PayloadID helper type (string | number) for flexible ID handling - Created shared types module (src/types/index.ts) for better type consistency - Exported PayloadID and FeatureFlag types from main index for user access - Fixed runtime issues with different Payload ID configurations Configuration Improvements: - Made API request limits configurable via maxFlags prop (default 100, max 1000) - Added configurable collection slug support for custom collection names - Enhanced URL construction to use config.routes.admin for proper path handling - Improved server-side pagination with query parameter support Code Quality: - Centralized type definitions for better maintainability - Enhanced type safety across client and server components - Improved prop interfaces with better documentation - Fixed potential number parsing edge cases with parseFloat Developer Experience: - Users can now configure collection slug, API limits, and admin paths - Better TypeScript support with exported shared types - Consistent handling of both string and numeric IDs - More flexible plugin configuration options 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/payload-feature-flags",
|
"name": "@xtr-dev/payload-feature-flags",
|
||||||
"version": "0.0.11",
|
"version": "0.0.12",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useConfig,
|
useConfig,
|
||||||
useTheme
|
useTheme
|
||||||
} from '@payloadcms/ui'
|
} from '@payloadcms/ui'
|
||||||
|
import type { PayloadID, FeatureFlag } from '../types/index.js'
|
||||||
|
|
||||||
// Simple debounce hook
|
// Simple debounce hook
|
||||||
function useDebounce<T>(value: T, delay: number): T {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
@@ -22,30 +23,19 @@ function useDebounce<T>(value: T, delay: number): T {
|
|||||||
return debouncedValue
|
return debouncedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeatureFlag {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
enabled: boolean
|
|
||||||
rolloutPercentage?: number
|
|
||||||
variants?: Array<{
|
|
||||||
name: string
|
|
||||||
weight: number
|
|
||||||
metadata?: any
|
|
||||||
}>
|
|
||||||
environment?: 'development' | 'staging' | 'production'
|
|
||||||
tags?: Array<{ tag: string }>
|
|
||||||
metadata?: any
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeatureFlagsClientProps {
|
interface FeatureFlagsClientProps {
|
||||||
initialFlags?: FeatureFlag[]
|
initialFlags?: FeatureFlag[]
|
||||||
canUpdate?: boolean
|
canUpdate?: boolean
|
||||||
|
maxFlags?: number // Configurable limit for API requests
|
||||||
|
collectionSlug?: string // Configurable collection slug for URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: FeatureFlagsClientProps) => {
|
const FeatureFlagsClientComponent = ({
|
||||||
|
initialFlags = [],
|
||||||
|
canUpdate = true,
|
||||||
|
maxFlags = 100,
|
||||||
|
collectionSlug = 'feature-flags'
|
||||||
|
}: FeatureFlagsClientProps) => {
|
||||||
const { config } = useConfig()
|
const { config } = useConfig()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [flags, setFlags] = useState<FeatureFlag[]>(initialFlags)
|
const [flags, setFlags] = useState<FeatureFlag[]>(initialFlags)
|
||||||
@@ -54,7 +44,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [sortField, setSortField] = useState<'name' | 'enabled' | 'rolloutPercentage' | 'updatedAt'>('name')
|
const [sortField, setSortField] = useState<'name' | 'enabled' | 'rolloutPercentage' | 'updatedAt'>('name')
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||||
const [saving, setSaving] = useState<string | null>(null)
|
const [saving, setSaving] = useState<PayloadID | null>(null)
|
||||||
const [successMessage, setSuccessMessage] = useState('')
|
const [successMessage, setSuccessMessage] = useState('')
|
||||||
|
|
||||||
// Debounce search to reduce re-renders
|
// Debounce search to reduce re-renders
|
||||||
@@ -65,9 +55,9 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
// Use a reasonable limit to prevent performance issues
|
// Use configurable limit, capped at 1000 for performance
|
||||||
const limit = Math.min(1000, 100)
|
const limit = Math.min(1000, maxFlags)
|
||||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=${limit}`, {
|
const response = await fetch(`${config.serverURL}${config.routes.api}/${collectionSlug}?limit=${limit}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
@@ -101,7 +91,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateFlag = useCallback(async (flagId: string, updates: Partial<FeatureFlag>) => {
|
const updateFlag = useCallback(async (flagId: PayloadID, updates: Partial<FeatureFlag>) => {
|
||||||
// Security check: Don't allow updates if user doesn't have permission
|
// Security check: Don't allow updates if user doesn't have permission
|
||||||
if (!canUpdate) {
|
if (!canUpdate) {
|
||||||
setError('You do not have permission to update feature flags')
|
setError('You do not have permission to update feature flags')
|
||||||
@@ -114,7 +104,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe
|
|||||||
setSuccessMessage('')
|
setSuccessMessage('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagId}`, {
|
const response = await fetch(`${config.serverURL}${config.routes.api}/${collectionSlug}/${flagId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -149,7 +139,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
}
|
}
|
||||||
}, [config.serverURL, config.routes.api, canUpdate])
|
}, [config.serverURL, config.routes.api, canUpdate, collectionSlug])
|
||||||
|
|
||||||
const handleSort = useCallback((field: typeof sortField) => {
|
const handleSort = useCallback((field: typeof sortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -512,7 +502,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe
|
|||||||
color: styles.text
|
color: styles.text
|
||||||
}}>
|
}}>
|
||||||
<a
|
<a
|
||||||
href={`${config.routes.admin}/collections/feature-flags/${flag.id}`}
|
href={`${config.routes.admin}/collections/${collectionSlug}/${flag.id}`}
|
||||||
style={{
|
style={{
|
||||||
color: styles.info,
|
color: styles.info,
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
|
|||||||
@@ -2,32 +2,16 @@ import type { AdminViewServerProps } from 'payload'
|
|||||||
import { DefaultTemplate } from '@payloadcms/next/templates'
|
import { DefaultTemplate } from '@payloadcms/next/templates'
|
||||||
import { Gutter } from '@payloadcms/ui'
|
import { Gutter } from '@payloadcms/ui'
|
||||||
import FeatureFlagsClient from './FeatureFlagsClient.js'
|
import FeatureFlagsClient from './FeatureFlagsClient.js'
|
||||||
|
import type { FeatureFlag } from '../types/index.js'
|
||||||
interface FeatureFlag {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
enabled: boolean
|
|
||||||
rolloutPercentage?: number
|
|
||||||
variants?: Array<{
|
|
||||||
name: string
|
|
||||||
weight: number
|
|
||||||
metadata?: any
|
|
||||||
}>
|
|
||||||
environment?: 'development' | 'staging' | 'production'
|
|
||||||
tags?: Array<{ tag: string }>
|
|
||||||
metadata?: any
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchInitialFlags(payload: any, searchParams?: Record<string, any>): Promise<FeatureFlag[]> {
|
async function fetchInitialFlags(payload: any, searchParams?: Record<string, any>): Promise<FeatureFlag[]> {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(1000, parseInt(searchParams?.limit as string) || 100)
|
const limit = Math.min(1000, parseInt(searchParams?.limit as string) || 100)
|
||||||
const page = Math.max(1, parseInt(searchParams?.page as string) || 1)
|
const page = Math.max(1, parseInt(searchParams?.page as string) || 1)
|
||||||
|
const collectionSlug = searchParams?.collectionSlug as string || 'feature-flags'
|
||||||
|
|
||||||
const result = await payload.find({
|
const result = await payload.find({
|
||||||
collection: 'feature-flags',
|
collection: collectionSlug,
|
||||||
limit,
|
limit,
|
||||||
page,
|
page,
|
||||||
sort: 'name',
|
sort: 'name',
|
||||||
@@ -100,7 +84,8 @@ export default async function FeatureFlagsView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Security check: User must have permissions to access feature-flags collection
|
// Security check: User must have permissions to access feature-flags collection
|
||||||
const canReadFeatureFlags = permissions?.collections?.['feature-flags']?.read
|
const collectionSlug = searchParams?.collectionSlug as string || 'feature-flags'
|
||||||
|
const canReadFeatureFlags = permissions?.collections?.[collectionSlug]?.read
|
||||||
if (!canReadFeatureFlags) {
|
if (!canReadFeatureFlags) {
|
||||||
return (
|
return (
|
||||||
<DefaultTemplate
|
<DefaultTemplate
|
||||||
@@ -141,8 +126,8 @@ export default async function FeatureFlagsView({
|
|||||||
// Fetch initial data server-side (only if user has access)
|
// Fetch initial data server-side (only if user has access)
|
||||||
const initialFlags = await fetchInitialFlags(initPageResult.req.payload, searchParams)
|
const initialFlags = await fetchInitialFlags(initPageResult.req.payload, searchParams)
|
||||||
|
|
||||||
// Check if user can update feature flags
|
// Check if user can update feature flags (use already defined collection slug)
|
||||||
const canUpdateFeatureFlags = permissions?.collections?.['feature-flags']?.update || false
|
const canUpdateFeatureFlags = permissions?.collections?.[collectionSlug]?.update || false
|
||||||
|
|
||||||
// Use DefaultTemplate with proper props structure from initPageResult
|
// Use DefaultTemplate with proper props structure from initPageResult
|
||||||
return (
|
return (
|
||||||
@@ -160,6 +145,8 @@ export default async function FeatureFlagsView({
|
|||||||
<FeatureFlagsClient
|
<FeatureFlagsClient
|
||||||
initialFlags={initialFlags}
|
initialFlags={initialFlags}
|
||||||
canUpdate={canUpdateFeatureFlags}
|
canUpdate={canUpdateFeatureFlags}
|
||||||
|
maxFlags={parseInt(searchParams?.maxFlags as string) || 100}
|
||||||
|
collectionSlug={collectionSlug}
|
||||||
/>
|
/>
|
||||||
</Gutter>
|
</Gutter>
|
||||||
</DefaultTemplate>
|
</DefaultTemplate>
|
||||||
|
|||||||
Reference in New Issue
Block a user