diff --git a/package.json b/package.json index 1a1e35f..cea4473 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-feature-flags", - "version": "0.0.9", + "version": "0.0.10", "description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts", "license": "MIT", "type": "module", diff --git a/src/exports/views.ts b/src/exports/views.ts index 473bec0..c3e68e6 100644 --- a/src/exports/views.ts +++ b/src/exports/views.ts @@ -1,2 +1,2 @@ // Custom admin views -export { FeatureFlagsView } from '../views/FeatureFlagsView.js' \ No newline at end of file +export { default as FeatureFlagsView } from '../views/FeatureFlagsView.js' \ No newline at end of file diff --git a/src/views/FeatureFlagsClient.tsx b/src/views/FeatureFlagsClient.tsx new file mode 100644 index 0000000..df5a5db --- /dev/null +++ b/src/views/FeatureFlagsClient.tsx @@ -0,0 +1,634 @@ +'use client' +import { useState, useEffect, useCallback, useMemo, memo } from 'react' +import { + useConfig, + useTheme +} 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 +} + +interface FeatureFlagsClientProps { + initialFlags?: FeatureFlag[] + canUpdate?: boolean +} + +const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: FeatureFlagsClientProps) => { + const { config } = useConfig() + const { theme } = useTheme() + const [flags, setFlags] = useState(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(null) + const [successMessage, setSuccessMessage] = useState('') + + const fetchFlags = async (signal?: AbortSignal) => { + try { + setLoading(true) + setError('') + + const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=1000`, { + credentials: 'include', + signal, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch feature flags: ${response.statusText}`) + } + + const result = await response.json() + + // Extract docs array from Payload API response and filter out null/invalid entries + const flagsArray = (result.docs || []).filter((flag: any) => flag && flag.id && flag.name) + + // Only update state if the component is still mounted (signal not aborted) + if (!signal?.aborted) { + setFlags(flagsArray as FeatureFlag[]) + } + } catch (err) { + // Don't show error if request was aborted (component unmounting) + if (err instanceof Error && err.name === 'AbortError') { + return + } + console.error('Error fetching feature flags:', err) + if (!signal?.aborted) { + setError(err instanceof Error ? err.message : 'Failed to fetch feature flags') + } + } finally { + if (!signal?.aborted) { + setLoading(false) + } + } + } + + const updateFlag = useCallback(async (flagId: string, updates: Partial) => { + // 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}/feature-flags/${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]) + + const handleSort = useCallback((field: typeof sortField) => { + if (sortField === field) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field) + setSortDirection('asc') + } + }, [sortField]) + + const filteredAndSortedFlags = useMemo(() => { + // Filter out null/undefined entries first + let filtered = flags.filter(flag => flag && flag.name) + + // Filter by search + if (search) { + filtered = filtered.filter(flag => + flag.name?.toLowerCase().includes(search.toLowerCase()) || + flag.description?.toLowerCase().includes(search.toLowerCase()) || + flag.tags?.some(t => t.tag?.toLowerCase().includes(search.toLowerCase())) + ) + } + + // Sort + filtered.sort((a, b) => { + let aVal: any = a[sortField] + let bVal: any = b[sortField] + + if (sortField === 'updatedAt') { + aVal = new Date(aVal || 0).getTime() + bVal = new Date(bVal || 0).getTime() + } + + if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1 + if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1 + return 0 + }) + + return filtered + }, [flags, search, sortField, sortDirection]) + + const SortIcon = ({ field }: { field: typeof sortField }) => { + if (sortField !== field) { + return + } + return {sortDirection === 'asc' ? '↑' : '↓'} + } + + // Theme-aware styles + const getThemeStyles = () => ({ + background: 'var(--theme-bg)', + surface: 'var(--theme-elevation-50)', + surfaceHover: 'var(--theme-elevation-100)', + border: 'var(--theme-elevation-150)', + text: 'var(--theme-text)', + textMuted: 'var(--theme-text-400)', + textSubdued: 'var(--theme-text-600)', + primary: 'var(--theme-success-500)', + warning: 'var(--theme-warning-500)', + error: 'var(--theme-error-500)', + info: 'var(--theme-info-500)', + inputBg: 'var(--theme-elevation-0)', + inputBorder: 'var(--theme-elevation-250)', + headerBg: 'var(--theme-elevation-100)', + }) + + const styles = getThemeStyles() + + return ( +
+ {/* Header */} +
+

+ Feature Flags Dashboard +

+

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

+ {!canUpdate && ( +
+ Read-Only Access: You can view feature flags but cannot edit them. Contact your administrator to request update permissions. +
+ )} +
+ + {loading ? ( +
+
Loading feature flags...
+
+ ) : ( + <> + {/* Success/Error Messages */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + {/* Controls */} +
+ setSearch(e.target.value)} + style={{ + padding: '0.5rem 1rem', + border: `1px solid ${styles.inputBorder}`, + borderRadius: '0.5rem', + fontSize: '0.875rem', + width: '300px', + backgroundColor: styles.inputBg, + color: styles.text + }} + /> + +
+
+ {filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags +
+ +
+
+ + {/* Spreadsheet Table */} +
+
+ + + + + + + + + + + + + + + {filteredAndSortedFlags.length === 0 ? ( + + + + ) : ( + filteredAndSortedFlags.map(flag => ( + e.currentTarget.style.backgroundColor = styles.surfaceHover} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''} + > + + + + + + + + + + )) + )} + +
+ Status + handleSort('name')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'left', + fontWeight: '600', + color: styles.text, + borderBottom: `1px solid ${styles.border}`, + cursor: 'pointer', + minWidth: '200px', + userSelect: 'none' + }} + > + Name + + Description + handleSort('enabled')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'center', + fontWeight: '600', + color: styles.text, + borderBottom: `1px solid ${styles.border}`, + cursor: 'pointer', + minWidth: '80px', + userSelect: 'none' + }} + > + Enabled + handleSort('rolloutPercentage')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'center', + fontWeight: '600', + color: styles.text, + borderBottom: `1px solid ${styles.border}`, + cursor: 'pointer', + minWidth: '120px', + userSelect: 'none' + }} + > + Rollout % + + Variants + + Tags + handleSort('updatedAt')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'left', + fontWeight: '600', + color: styles.text, + borderBottom: `1px solid ${styles.border}`, + cursor: 'pointer', + minWidth: '150px', + userSelect: 'none' + }} + > + Last Updated +
+ {search ? 'No flags match your search' : 'No feature flags yet'} +
+
+
+ e.currentTarget.style.textDecoration = 'underline'} + onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'} + > + {flag.name} + + + {flag.description || '-'} + + updateFlag(flag.id, { enabled: e.target.checked })} + disabled={!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 + }} + /> + +
+ { + const value = Math.min(100, Math.max(0, parseInt(e.target.value) || 0)) + updateFlag(flag.id, { rolloutPercentage: 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 + }} + /> + % +
+
+ {flag.variants && flag.variants.length > 0 ? ( + + {flag.variants.length} variants + + ) : '-'} + + {flag.tags && flag.tags.length > 0 ? ( +
+ {flag.tags.map((t, i) => ( + + {t.tag} + + ))} +
+ ) : '-'} +
+ {new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+
+ + {/* Summary Stats */} +
+
+ Total: {flags.filter(f => f && f.name).length} flags +
+
+ Enabled: {flags.filter(f => f && f.enabled).length} +
+
+ Disabled: {flags.filter(f => f && !f.enabled).length} +
+
+ Rolling out: {flags.filter(f => f && f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length} +
+
+ A/B Tests: {flags.filter(f => f && f.variants && f.variants.length > 0).length} +
+
+ + )} +
+ ) +} + +export const FeatureFlagsClient = memo(FeatureFlagsClientComponent) +export default FeatureFlagsClient \ No newline at end of file diff --git a/src/views/FeatureFlagsView.tsx b/src/views/FeatureFlagsView.tsx index 808d8e4..b844a46 100644 --- a/src/views/FeatureFlagsView.tsx +++ b/src/views/FeatureFlagsView.tsx @@ -1,11 +1,7 @@ -'use client' -import { useState, useEffect, useCallback, useMemo, memo } from 'react' -import { - useConfig, - useTheme -} from '@payloadcms/ui' +import type { AdminViewServerProps } from 'payload' import { DefaultTemplate } from '@payloadcms/next/templates' -import type { Locale } from 'payload' +import { Gutter } from '@payloadcms/ui' +import FeatureFlagsClient from './FeatureFlagsClient.js' interface FeatureFlag { id: string @@ -25,622 +21,143 @@ interface FeatureFlag { updatedAt: string } -interface FeatureFlagsViewProps { - i18n?: any - locale?: Locale - params?: Record - payload?: any - permissions?: any - searchParams?: Record - user?: any - visibleEntities?: any - [key: string]: any -} - -const FeatureFlagsViewComponent = (props: FeatureFlagsViewProps = {}) => { - const { config } = useConfig() - const { theme } = useTheme() - const [flags, setFlags] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [search, setSearch] = useState('') - const [sortField, setSortField] = useState<'name' | 'enabled' | 'rolloutPercentage' | 'updatedAt'>('name') - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') - const [saving, setSaving] = useState(null) - const [successMessage, setSuccessMessage] = useState('') - - useEffect(() => { - const abortController = new AbortController() - - const loadFlags = async () => { - await fetchFlags(abortController.signal) - } - - loadFlags() - - return () => { - abortController.abort() - } - }, [config.serverURL]) - - const fetchFlags = async (signal?: AbortSignal) => { - try { - setLoading(true) - setError('') - - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=1000`, { - credentials: 'include', - signal, - }) - - if (!response.ok) { - throw new Error(`Failed to fetch feature flags: ${response.statusText}`) - } - - const result = await response.json() - - // Extract docs array from Payload API response and filter out null/invalid entries - const flagsArray = (result.docs || []).filter((flag: any) => flag && flag.id && flag.name) - - // Only update state if the component is still mounted (signal not aborted) - if (!signal?.aborted) { - setFlags(flagsArray as FeatureFlag[]) - } - } catch (err) { - // Don't show error if request was aborted (component unmounting) - if (err instanceof Error && err.name === 'AbortError') { - return - } - console.error('Error fetching feature flags:', err) - if (!signal?.aborted) { - setError(err instanceof Error ? err.message : 'Failed to fetch feature flags') - } - } finally { - if (!signal?.aborted) { - setLoading(false) - } - } - } - - const updateFlag = useCallback(async (flagId: string, updates: Partial) => { - setSaving(flagId) - setError('') - setSuccessMessage('') - - try { - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagId}`, { - method: 'PATCH', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updates), - }) - - if (!response.ok) { - throw new Error(`Failed to update feature flag: ${response.statusText}`) - } - - const updatedFlag = await response.json() - - // Update local state - setFlags(prev => prev.map(flag => - flag.id === flagId ? { ...flag, ...updatedFlag } : flag - )) - - setSuccessMessage('✓ Saved') - setTimeout(() => setSuccessMessage(''), 2000) - } catch (err) { - console.error('Error updating feature flag:', err) - setError(err instanceof Error ? err.message : 'Failed to update feature flag') - setTimeout(() => setError(''), 5000) - } finally { - setSaving(null) - } - }, [config.serverURL, config.routes.api]) - - const handleSort = useCallback((field: typeof sortField) => { - if (sortField === field) { - setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc') - } else { - setSortField(field) - setSortDirection('asc') - } - }, [sortField]) - - const filteredAndSortedFlags = useMemo(() => { - // Filter out null/undefined entries first - let filtered = flags.filter(flag => flag && flag.name) - - // Filter by search - if (search) { - filtered = filtered.filter(flag => - flag.name?.toLowerCase().includes(search.toLowerCase()) || - flag.description?.toLowerCase().includes(search.toLowerCase()) || - flag.tags?.some(t => t.tag?.toLowerCase().includes(search.toLowerCase())) - ) - } - - // Sort - filtered.sort((a, b) => { - let aVal: any = a[sortField] - let bVal: any = b[sortField] - - if (sortField === 'updatedAt') { - aVal = new Date(aVal || 0).getTime() - bVal = new Date(bVal || 0).getTime() - } - - if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1 - if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1 - return 0 +async function fetchInitialFlags(payload: any): Promise { + try { + const result = await payload.find({ + collection: 'feature-flags', + limit: 1000, + sort: 'name', }) - return filtered - }, [flags, search, sortField, sortDirection]) - - const SortIcon = ({ field }: { field: typeof sortField }) => { - if (sortField !== field) { - return - } - return {sortDirection === 'asc' ? '↑' : '↓'} + return (result.docs || []).filter((flag: any) => flag && flag.id && flag.name) + } catch (error) { + console.error('Error fetching initial feature flags:', error) + return [] } - - // Theme-aware styles - const getThemeStyles = () => ({ - background: 'var(--theme-bg)', - surface: 'var(--theme-elevation-50)', - surfaceHover: 'var(--theme-elevation-100)', - border: 'var(--theme-elevation-150)', - text: 'var(--theme-text)', - textMuted: 'var(--theme-text-400)', - textSubdued: 'var(--theme-text-600)', - primary: 'var(--theme-success-500)', - warning: 'var(--theme-warning-500)', - error: 'var(--theme-error-500)', - info: 'var(--theme-info-500)', - inputBg: 'var(--theme-elevation-0)', - inputBorder: 'var(--theme-elevation-250)', - headerBg: 'var(--theme-elevation-100)', - }) - - const styles = getThemeStyles() - - const FeatureFlagsContent = () => ( -
- {/* Header */} -
-

- Feature Flags Dashboard -

-

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

-
- - {loading ? ( -
-
Loading feature flags...
-
- ) : ( - <> - {/* Success/Error Messages */} - {successMessage && ( -
- {successMessage} -
- )} - - {error && ( -
- Error: {error} -
- )} - - {/* Controls */} -
- setSearch(e.target.value)} - style={{ - padding: '0.5rem 1rem', - border: `1px solid ${styles.inputBorder}`, - borderRadius: '0.5rem', - fontSize: '0.875rem', - width: '300px', - backgroundColor: styles.inputBg, - color: styles.text - }} - /> - -
-
- {filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags -
- -
-
- - {/* Spreadsheet Table */} -
-
- - - - - - - - - - - - - - - {filteredAndSortedFlags.length === 0 ? ( - - - - ) : ( - filteredAndSortedFlags.map(flag => ( - e.currentTarget.style.backgroundColor = styles.surfaceHover} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''} - > - - - - - - - - - - )) - )} - -
- Status - handleSort('name')} - style={{ - padding: '0.75rem 1rem', - textAlign: 'left', - fontWeight: '600', - color: styles.text, - borderBottom: `1px solid ${styles.border}`, - cursor: 'pointer', - minWidth: '200px', - userSelect: 'none' - }} - > - Name - - Description - handleSort('enabled')} - style={{ - padding: '0.75rem 1rem', - textAlign: 'center', - fontWeight: '600', - color: styles.text, - borderBottom: `1px solid ${styles.border}`, - cursor: 'pointer', - minWidth: '80px', - userSelect: 'none' - }} - > - Enabled - handleSort('rolloutPercentage')} - style={{ - padding: '0.75rem 1rem', - textAlign: 'center', - fontWeight: '600', - color: styles.text, - borderBottom: `1px solid ${styles.border}`, - cursor: 'pointer', - minWidth: '120px', - userSelect: 'none' - }} - > - Rollout % - - Variants - - Tags - handleSort('updatedAt')} - style={{ - padding: '0.75rem 1rem', - textAlign: 'left', - fontWeight: '600', - color: styles.text, - borderBottom: `1px solid ${styles.border}`, - cursor: 'pointer', - minWidth: '150px', - userSelect: 'none' - }} - > - Last Updated -
- {search ? 'No flags match your search' : 'No feature flags yet'} -
-
-
- e.currentTarget.style.textDecoration = 'underline'} - onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'} - > - {flag.name} - - - {flag.description || '-'} - - updateFlag(flag.id, { enabled: e.target.checked })} - disabled={saving === flag.id} - style={{ - width: '18px', - height: '18px', - cursor: saving === flag.id ? 'wait' : 'pointer', - accentColor: styles.primary - }} - /> - -
- { - const value = Math.min(100, Math.max(0, parseInt(e.target.value) || 0)) - updateFlag(flag.id, { rolloutPercentage: value }) - }} - disabled={!flag.enabled || saving === flag.id} - min="0" - max="100" - style={{ - width: '60px', - padding: '0.25rem 0.5rem', - border: `1px solid ${styles.inputBorder}`, - borderRadius: '0.25rem', - fontSize: '0.875rem', - textAlign: 'center', - cursor: saving === flag.id ? 'wait' : 'text', - opacity: flag.enabled ? 1 : 0.5, - backgroundColor: styles.inputBg, - color: styles.text - }} - /> - % -
-
- {flag.variants && flag.variants.length > 0 ? ( - - {flag.variants.length} variants - - ) : '-'} - - {flag.tags && flag.tags.length > 0 ? ( -
- {flag.tags.map((t, i) => ( - - {t.tag} - - ))} -
- ) : '-'} -
- {new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
-
- - {/* Summary Stats */} -
-
- Total: {flags.filter(f => f && f.name).length} flags -
-
- Enabled: {flags.filter(f => f && f.enabled).length} -
-
- Disabled: {flags.filter(f => f && !f.enabled).length} -
-
- Rolling out: {flags.filter(f => f && f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length} -
-
- A/B Tests: {flags.filter(f => f && f.variants && f.variants.length > 0).length} -
-
- - )} -
- ) - - // Use DefaultTemplate with proper props structure - return ( - - - - ) } -export const FeatureFlagsView = memo(FeatureFlagsViewComponent) -export default FeatureFlagsView \ No newline at end of file +export default async function FeatureFlagsView({ + initPageResult, + params, + searchParams, +}: AdminViewServerProps) { + const { + req: { user }, + permissions, + } = initPageResult + + // Security check: User must be logged in + if (!user) { + return ( + + +
+

+ Authentication Required +

+

+ You must be logged in to view the Feature Flags Dashboard. +

+ + Go to Login + +
+
+
+ ) + } + + // Security check: User must have permissions to access feature-flags collection + const canReadFeatureFlags = permissions?.collections?.['feature-flags']?.read + if (!canReadFeatureFlags) { + return ( + + +
+

+ Access Denied +

+

+ You don't have permission to access the Feature Flags Dashboard. +

+

+ Contact your administrator to request access to the feature-flags collection. +

+
+
+
+ ) + } + + // Fetch initial data server-side (only if user has access) + const initialFlags = await fetchInitialFlags(initPageResult.req.payload) + + // Check if user can update feature flags + const canUpdateFeatureFlags = permissions?.collections?.['feature-flags']?.update || false + + // Use DefaultTemplate with proper props structure from initPageResult + return ( + + + + + + ) +} \ No newline at end of file