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} +
+
) }