From 6d151d9e82b081d30aa995cc0c1246a87e30e22f Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 15:25:51 +0200 Subject: [PATCH 01/12] Replace feature flags overview with spreadsheet-style interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete rewrite of FeatureFlagsView component with table layout - Added inline editing for enabled/disabled checkboxes - Added inline editing for rollout percentages with validation (0-100) - Implemented sortable columns (name, enabled, rollout %, last updated) - Added real-time search functionality across name, description, and tags - Added visual status indicators with color coding - Implemented proper API integration with PATCH requests for updates - Added loading states and success/error notifications - Improved responsive design with sticky status column - Added summary statistics at the bottom The new interface provides a much more efficient way to manage multiple feature flags at once, similar to a spreadsheet application. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/views/FeatureFlagsView.tsx | 738 ++++++++++++++++++++------------- 1 file changed, 442 insertions(+), 296 deletions(-) diff --git a/src/views/FeatureFlagsView.tsx b/src/views/FeatureFlagsView.tsx index f52aa24..e9713b7 100644 --- a/src/views/FeatureFlagsView.tsx +++ b/src/views/FeatureFlagsView.tsx @@ -25,18 +25,21 @@ const FeatureFlagsViewComponent = () => { const [flags, setFlags] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all') 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() } @@ -46,12 +49,12 @@ const FeatureFlagsViewComponent = () => { try { setLoading(true) setError('') - - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags`, { + + 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}`) } @@ -60,7 +63,7 @@ const FeatureFlagsViewComponent = () => { // 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[]) @@ -81,36 +84,87 @@ const FeatureFlagsViewComponent = () => { } } - 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 updateFlag = useCallback(async (flagId: string, updates: Partial) => { + setSaving(flagId) + setError('') + setSuccessMessage('') - const filteredFlags = useMemo(() => { - return flags.filter(flag => { - const matchesFilter = filter === 'all' || - (filter === 'enabled' && flag.enabled) || - (filter === 'disabled' && !flag.enabled) - - const matchesSearch = !search || + 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(() => { + let filtered = flags + + // Filter by search + if (search) { + filtered = flags.filter(flag => flag.name.toLowerCase().includes(search.toLowerCase()) || - flag.description?.toLowerCase().includes(search.toLowerCase()) - - return matchesFilter && matchesSearch + 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).getTime() + bVal = new Date(bVal).getTime() + } + + if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1 + if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1 + return 0 }) - }, [flags, filter, search]) - const getStatusColor = (flag: FeatureFlag) => { - if (!flag.enabled) return '#ef4444' - if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return '#f59e0b' - return '#10b981' - } + return filtered + }, [flags, search, sortField, sortDirection]) - const getStatusText = (flag: FeatureFlag) => { - if (!flag.enabled) return 'Disabled' - if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return `${flag.rolloutPercentage}% Rollout` - return 'Enabled' + const SortIcon = ({ field }: { field: typeof sortField }) => { + if (sortField !== field) { + return โ‡… + } + return {sortDirection === 'asc' ? 'โ†‘' : 'โ†“'} } if (loading) { @@ -121,50 +175,64 @@ const FeatureFlagsViewComponent = () => { ) } - if (error) { - return ( -
-
+ {/* Header */} +
+

+ Feature Flags Dashboard +

+

+ Manage all feature flags in a spreadsheet view +

+
+ + {/* Success/Error Messages */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {error && ( +
Error: {error}
-
- ) - } - - return ( -
- {/* Header */} -
-

- ๐Ÿšฉ Feature Flags -

-

- Manage feature toggles, A/B tests, and gradual rollouts -

-
+ )} {/* Controls */} -
setSearch(e.target.value)} style={{ @@ -172,241 +240,319 @@ const FeatureFlagsViewComponent = () => { border: '1px solid #d1d5db', borderRadius: '0.5rem', fontSize: '0.875rem', - minWidth: '200px' + width: '300px' }} /> - -
- {(['all', 'enabled', 'disabled'] as const).map(filterType => ( - - ))} -
- -
- - {/* Stats */} -
-
-
- {flags.length} +
+
+ {filteredAndSortedFlags.length} of {flags.length} flags
-
Total Flags
-
- -
-
- {flags.filter(f => f.enabled).length} -
-
Enabled
-
- -
-
- {flags.filter(f => f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length} -
-
Rolling Out
-
- -
-
- {flags.filter(f => f.variants && f.variants.length > 0).length} -
-
A/B Tests
-
-
- - {/* Feature Flags List */} - {filteredFlags.length === 0 ? ( -
-
- {search || filter !== 'all' ? 'No flags match your criteria' : 'No feature flags yet'} -
- {(!search && filter === 'all') && ( -
- Create your first feature flag to get started -
- )} -
- ) : ( -
- {filteredFlags.map(flag => ( -
fetchFlags()} + style={{ + padding: '0.5rem 1rem', + border: '1px solid #d1d5db', + borderRadius: '0.5rem', backgroundColor: 'white', - borderRadius: '0.75rem', - border: '1px solid #e5e7eb', - padding: '1.5rem', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: '1rem' - }}> - {/* Flag Info */} -
-
-

- {flag.name} -

- -
- {getStatusText(flag)} -
- - {flag.environment && ( -
- {flag.environment} -
- )} -
- - {flag.description && ( -

- {flag.description} -

- )} - -
- {flag.variants && flag.variants.length > 0 && ( - ๐Ÿงช {flag.variants.length} variants - )} - {flag.tags && flag.tags.length > 0 && ( - ๐Ÿท๏ธ {flag.tags.map(t => t.tag).join(', ')} - )} - ๐Ÿ“… {new Date(flag.updatedAt).toLocaleDateString()} -
-
- - {/* Toggle Switch */} -
- -
-
- ))} + color: '#374151', + fontSize: '0.875rem', + cursor: 'pointer' + }} + > + ๐Ÿ”„ Refresh +
- )} +
+ + {/* Spreadsheet Table */} +
+
+ + + + + + + + + + + + + + + {filteredAndSortedFlags.length === 0 ? ( + + + + ) : ( + filteredAndSortedFlags.map(flag => ( + e.currentTarget.style.backgroundColor = '#f9fafb'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''} + > + + + + + + + + + + )) + )} + +
+ Status + handleSort('name')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'left', + fontWeight: '600', + color: '#374151', + borderBottom: '1px solid #e5e7eb', + cursor: 'pointer', + minWidth: '200px', + userSelect: 'none' + }} + > + Name + + Description + handleSort('enabled')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'center', + fontWeight: '600', + color: '#374151', + borderBottom: '1px solid #e5e7eb', + cursor: 'pointer', + minWidth: '80px', + userSelect: 'none' + }} + > + Enabled + handleSort('rolloutPercentage')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'center', + fontWeight: '600', + color: '#374151', + borderBottom: '1px solid #e5e7eb', + cursor: 'pointer', + minWidth: '120px', + userSelect: 'none' + }} + > + Rollout % + + Variants + + Tags + handleSort('updatedAt')} + style={{ + padding: '0.75rem 1rem', + textAlign: 'left', + fontWeight: '600', + color: '#374151', + borderBottom: '1px solid #e5e7eb', + cursor: 'pointer', + minWidth: '150px', + userSelect: 'none' + }} + > + Last Updated +
+ {search ? 'No flags match your search' : 'No feature flags yet'} +
+
+
+ {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: '#10b981' + }} + /> + +
+ { + 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 #d1d5db', + borderRadius: '0.25rem', + fontSize: '0.875rem', + textAlign: 'center', + cursor: saving === flag.id ? 'wait' : 'text', + opacity: flag.enabled ? 1 : 0.5 + }} + /> + % +
+
+ {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.length} flags +
+
+ Enabled: {flags.filter(f => f.enabled).length} +
+
+ Disabled: {flags.filter(f => !f.enabled).length} +
+
+ Rolling out: {flags.filter(f => f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length} +
+
+ A/B Tests: {flags.filter(f => f.variants && f.variants.length > 0).length} +
+
) } From b364fb9e8f660f4bfbab790ae29cf32ced042c67 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 15:28:02 +0200 Subject: [PATCH 02/12] Fix null reference errors in feature flags view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added null checks throughout filteredAndSortedFlags computation - Filter out null/undefined entries before processing - Added null checks in summary statistics calculations - Enhanced API response filtering to remove invalid entries - Added optional chaining for safer property access - Improved error handling for malformed API responses This resolves the "can't access property 'enabled', f is null" runtime error. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/views/FeatureFlagsView.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/views/FeatureFlagsView.tsx b/src/views/FeatureFlagsView.tsx index e9713b7..f0e6f53 100644 --- a/src/views/FeatureFlagsView.tsx +++ b/src/views/FeatureFlagsView.tsx @@ -61,8 +61,8 @@ const FeatureFlagsViewComponent = () => { const result = await response.json() - // Extract docs array from Payload API response - const flagsArray = result.docs || [] + // 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) { @@ -131,14 +131,15 @@ const FeatureFlagsViewComponent = () => { }, [sortField]) const filteredAndSortedFlags = useMemo(() => { - let filtered = flags + // Filter out null/undefined entries first + let filtered = flags.filter(flag => flag && flag.name) // Filter by search if (search) { - filtered = flags.filter(flag => - flag.name.toLowerCase().includes(search.toLowerCase()) || + 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())) + flag.tags?.some(t => t.tag?.toLowerCase().includes(search.toLowerCase())) ) } @@ -148,8 +149,8 @@ const FeatureFlagsViewComponent = () => { let bVal: any = b[sortField] if (sortField === 'updatedAt') { - aVal = new Date(aVal).getTime() - bVal = new Date(bVal).getTime() + aVal = new Date(aVal || 0).getTime() + bVal = new Date(bVal || 0).getTime() } if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1 @@ -246,7 +247,7 @@ const FeatureFlagsViewComponent = () => {
- {filteredAndSortedFlags.length} of {flags.length} flags + {filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
From 477f7f96ebb7b2aed18f7989b652678a69f6c9c5 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 15:39:10 +0200 Subject: [PATCH 03/12] Integrate Payload CMS theme system and add clickable flag names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theme Integration: - Added useTheme hook from @payloadcms/ui - Replaced all hardcoded colors with CSS custom properties - Created getThemeStyles() function for consistent theming - Updated all UI elements to respect dark/light theme settings - Added proper contrast for text, backgrounds, and borders Navigation Enhancement: - Made feature flag names clickable links - Links navigate to /admin/collections/feature-flags/{id} for editing - Added hover effects with underline on flag name links - Used theme-aware link color (info blue) The interface now properly adapts to Payload's admin panel theme, supporting both dark and light modes seamlessly. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/views/FeatureFlagsView.tsx | 144 ++++++++++++++++++++------------- 1 file changed, 90 insertions(+), 54 deletions(-) diff --git a/src/views/FeatureFlagsView.tsx b/src/views/FeatureFlagsView.tsx index f0e6f53..4b6001a 100644 --- a/src/views/FeatureFlagsView.tsx +++ b/src/views/FeatureFlagsView.tsx @@ -1,6 +1,6 @@ 'use client' import { useState, useEffect, useCallback, useMemo, memo } from 'react' -import { useConfig } from '@payloadcms/ui' +import { useConfig, useTheme } from '@payloadcms/ui' interface FeatureFlag { id: string @@ -22,6 +22,7 @@ interface FeatureFlag { const FeatureFlagsViewComponent = () => { const { config } = useConfig() + const { theme } = useTheme() const [flags, setFlags] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') @@ -168,27 +169,47 @@ const FeatureFlagsViewComponent = () => { 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() + if (loading) { return ( -
-
Loading feature flags...
+
+
Loading feature flags...
) } return ( -
+
{/* Header */}

Feature Flags Dashboard

-

+

Manage all feature flags in a spreadsheet view

@@ -199,7 +220,7 @@ const FeatureFlagsViewComponent = () => { position: 'fixed', top: '20px', right: '20px', - backgroundColor: '#10b981', + backgroundColor: styles.primary, color: 'white', padding: '0.75rem 1.5rem', borderRadius: '0.5rem', @@ -213,11 +234,11 @@ const FeatureFlagsViewComponent = () => { {error && (
Error: {error}
@@ -238,25 +259,27 @@ const FeatureFlagsViewComponent = () => { onChange={(e) => setSearch(e.target.value)} style={{ padding: '0.5rem 1rem', - border: '1px solid #d1d5db', + border: `1px solid ${styles.inputBorder}`, borderRadius: '0.5rem', fontSize: '0.875rem', - width: '300px' + 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 From 0a39d0631c04105ec11a6b00c40e1797bda8391d Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 17:21:17 +0200 Subject: [PATCH 10/12] v0.0.11: Accessibility and performance improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility Enhancements: - Added role="alert" to error messages and read-only notices for screen readers - Improved semantic HTML for better assistive technology support Performance Optimizations: - Implemented debounced search (300ms) to reduce re-renders during typing - Added pagination support for large datasets (configurable limit up to 1000) - Enhanced server-side data fetching with query parameter support Input Improvements: - Changed rollout percentage validation from parseInt to parseFloat for better decimal handling - Made admin URL construction configurable using config.routes.admin - Improved input validation with proper rounding for percentage values Developer Experience: - Added reusable useDebounce hook for performance optimization - Better error handling for edge cases in numeric inputs - Cleaner code organization with separated concerns ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package.json | 2 +- src/views/FeatureFlagsClient.tsx | 85 +++++++++++++++++++++----------- src/views/FeatureFlagsView.tsx | 10 ++-- 3 files changed, 65 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index cea4473..76cf062 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-feature-flags", - "version": "0.0.10", + "version": "0.0.11", "description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts", "license": "MIT", "type": "module", diff --git a/src/views/FeatureFlagsClient.tsx b/src/views/FeatureFlagsClient.tsx index df5a5db..37d53a5 100644 --- a/src/views/FeatureFlagsClient.tsx +++ b/src/views/FeatureFlagsClient.tsx @@ -5,6 +5,23 @@ import { useTheme } from '@payloadcms/ui' +// Simple debounce hook +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} + interface FeatureFlag { id: string name: string @@ -40,12 +57,17 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe const [saving, setSaving] = useState(null) const [successMessage, setSuccessMessage] = useState('') + // Debounce search to reduce re-renders + const debouncedSearch = useDebounce(search, 300) + const fetchFlags = async (signal?: AbortSignal) => { try { setLoading(true) setError('') - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=1000`, { + // Use a reasonable limit to prevent performance issues + const limit = Math.min(1000, 100) + const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=${limit}`, { credentials: 'include', signal, }) @@ -142,12 +164,13 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe // Filter out null/undefined entries first let filtered = flags.filter(flag => flag && flag.name) - // Filter by search - if (search) { + // Filter by debounced search + if (debouncedSearch) { + const searchLower = debouncedSearch.toLowerCase() 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())) + flag.name?.toLowerCase().includes(searchLower) || + flag.description?.toLowerCase().includes(searchLower) || + flag.tags?.some(t => t.tag?.toLowerCase().includes(searchLower)) ) } @@ -167,7 +190,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe }) return filtered - }, [flags, search, sortField, sortDirection]) + }, [flags, debouncedSearch, sortField, sortDirection]) const SortIcon = ({ field }: { field: typeof sortField }) => { if (sortField !== field) { @@ -219,15 +242,18 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe 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.
)} @@ -264,14 +290,17 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe )} {error && ( -
+
Error: {error}
)} @@ -450,7 +479,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe textAlign: 'center', color: styles.textMuted }}> - {search ? 'No flags match your search' : 'No feature flags yet'} + {debouncedSearch ? 'No flags match your search' : 'No feature flags yet'} ) : ( @@ -483,7 +512,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe color: styles.text }}> { - const value = Math.min(100, Math.max(0, parseInt(e.target.value) || 0)) - updateFlag(flag.id, { rolloutPercentage: value }) + 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" diff --git a/src/views/FeatureFlagsView.tsx b/src/views/FeatureFlagsView.tsx index b844a46..cbf182e 100644 --- a/src/views/FeatureFlagsView.tsx +++ b/src/views/FeatureFlagsView.tsx @@ -21,11 +21,15 @@ interface FeatureFlag { updatedAt: string } -async function fetchInitialFlags(payload: any): Promise { +async function fetchInitialFlags(payload: any, searchParams?: Record): Promise { try { + const limit = Math.min(1000, parseInt(searchParams?.limit as string) || 100) + const page = Math.max(1, parseInt(searchParams?.page as string) || 1) + const result = await payload.find({ collection: 'feature-flags', - limit: 1000, + limit, + page, sort: 'name', }) @@ -135,7 +139,7 @@ export default async function FeatureFlagsView({ } // Fetch initial data server-side (only if user has access) - const initialFlags = await fetchInitialFlags(initPageResult.req.payload) + const initialFlags = await fetchInitialFlags(initPageResult.req.payload, searchParams) // Check if user can update feature flags const canUpdateFeatureFlags = permissions?.collections?.['feature-flags']?.update || false From 0c7c8642487e97ed51e4fe13892977ce7904fcd2 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 17:36:53 +0200 Subject: [PATCH 11/12] v0.0.12: Type consistency and configuration improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- src/index.ts | 3 +++ src/types/index.ts | 24 +++++++++++++++++ src/views/FeatureFlagsClient.tsx | 44 ++++++++++++-------------------- src/views/FeatureFlagsView.tsx | 31 +++++++--------------- 5 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 src/types/index.ts diff --git a/package.json b/package.json index 76cf062..0fdad0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "license": "MIT", "type": "module", diff --git a/src/index.ts b/src/index.ts index 3d83577..3b03e47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,9 @@ export type CollectionOverrides = Partial< fields?: (args: { defaultFields: Field[] }) => Field[] } +// Export shared types for users of the plugin +export type { PayloadID, FeatureFlag } from './types/index.js' + export type PayloadFeatureFlagsConfig = { /** * Enable/disable the plugin diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..85c5f11 --- /dev/null +++ b/src/types/index.ts @@ -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 +} \ No newline at end of file diff --git a/src/views/FeatureFlagsClient.tsx b/src/views/FeatureFlagsClient.tsx index 37d53a5..ed0919c 100644 --- a/src/views/FeatureFlagsClient.tsx +++ b/src/views/FeatureFlagsClient.tsx @@ -4,6 +4,7 @@ import { useConfig, useTheme } from '@payloadcms/ui' +import type { PayloadID, FeatureFlag } from '../types/index.js' // Simple debounce hook function useDebounce(value: T, delay: number): T { @@ -22,30 +23,19 @@ function useDebounce(value: T, delay: number): T { 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 { initialFlags?: FeatureFlag[] 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 { theme } = useTheme() const [flags, setFlags] = useState(initialFlags) @@ -54,7 +44,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe 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 [saving, setSaving] = useState(null) const [successMessage, setSuccessMessage] = useState('') // Debounce search to reduce re-renders @@ -65,9 +55,9 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe setLoading(true) setError('') - // Use a reasonable limit to prevent performance issues - const limit = Math.min(1000, 100) - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=${limit}`, { + // 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, }) @@ -101,7 +91,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe } } - const updateFlag = useCallback(async (flagId: string, updates: Partial) => { + const updateFlag = useCallback(async (flagId: PayloadID, 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') @@ -114,7 +104,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe setSuccessMessage('') 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', credentials: 'include', headers: { @@ -149,7 +139,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe } finally { setSaving(null) } - }, [config.serverURL, config.routes.api, canUpdate]) + }, [config.serverURL, config.routes.api, canUpdate, collectionSlug]) const handleSort = useCallback((field: typeof sortField) => { if (sortField === field) { @@ -512,7 +502,7 @@ const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: Fe color: styles.text }}> - environment?: 'development' | 'staging' | 'production' - tags?: Array<{ tag: string }> - metadata?: any - createdAt: string - updatedAt: string -} +import type { FeatureFlag } from '../types/index.js' async function fetchInitialFlags(payload: any, searchParams?: Record): Promise { try { const limit = Math.min(1000, parseInt(searchParams?.limit as string) || 100) const page = Math.max(1, parseInt(searchParams?.page as string) || 1) + const collectionSlug = searchParams?.collectionSlug as string || 'feature-flags' const result = await payload.find({ - collection: 'feature-flags', + collection: collectionSlug, limit, page, sort: 'name', @@ -100,7 +84,8 @@ export default async function FeatureFlagsView({ } // 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) { return ( From e26d895864ce144fd1876d356c207297051c6c35 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 17:54:50 +0200 Subject: [PATCH 12/12] Fix race condition in fetchFlags useEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses race condition where fetchFlags could cause memory leaks and state updates after component unmount: - Convert fetchFlags to useCallback with AbortSignal support - Add useEffect with AbortController for proper request cancellation - Prevent state updates when requests are aborted - Handle AbortError gracefully without showing error messages ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/views/FeatureFlagsClient.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/views/FeatureFlagsClient.tsx b/src/views/FeatureFlagsClient.tsx index ed0919c..36c4590 100644 --- a/src/views/FeatureFlagsClient.tsx +++ b/src/views/FeatureFlagsClient.tsx @@ -50,7 +50,7 @@ const FeatureFlagsClientComponent = ({ // Debounce search to reduce re-renders const debouncedSearch = useDebounce(search, 300) - const fetchFlags = async (signal?: AbortSignal) => { + const fetchFlags = useCallback(async (signal?: AbortSignal) => { try { setLoading(true) setError('') @@ -89,7 +89,21 @@ const FeatureFlagsClientComponent = ({ 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) => { // Security check: Don't allow updates if user doesn't have permission