mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 02:43:25 +00:00
Replace feature flags overview with spreadsheet-style interface
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,8 +25,11 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
const [flags, setFlags] = useState<FeatureFlag[]>([])
|
const [flags, setFlags] = useState<FeatureFlag[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
|
|
||||||
const [search, setSearch] = 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<string | null>(null)
|
||||||
|
const [successMessage, setSuccessMessage] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
@@ -47,7 +50,7 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
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',
|
credentials: 'include',
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
@@ -81,36 +84,87 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFlag = useCallback(async (flagId: string, enabled: boolean) => {
|
const updateFlag = useCallback(async (flagId: string, updates: Partial<FeatureFlag>) => {
|
||||||
// For now, just show a message that editing isn't available in the custom view
|
setSaving(flagId)
|
||||||
setError('Toggle functionality coming soon. Please use the standard collection view to edit flags.')
|
setError('')
|
||||||
setTimeout(() => setError(''), 3000)
|
setSuccessMessage('')
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filteredFlags = useMemo(() => {
|
try {
|
||||||
return flags.filter(flag => {
|
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagId}`, {
|
||||||
const matchesFilter = filter === 'all' ||
|
method: 'PATCH',
|
||||||
(filter === 'enabled' && flag.enabled) ||
|
credentials: 'include',
|
||||||
(filter === 'disabled' && !flag.enabled)
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
const matchesSearch = !search ||
|
},
|
||||||
flag.name.toLowerCase().includes(search.toLowerCase()) ||
|
body: JSON.stringify(updates),
|
||||||
flag.description?.toLowerCase().includes(search.toLowerCase())
|
|
||||||
|
|
||||||
return matchesFilter && matchesSearch
|
|
||||||
})
|
})
|
||||||
}, [flags, filter, search])
|
|
||||||
|
|
||||||
const getStatusColor = (flag: FeatureFlag) => {
|
if (!response.ok) {
|
||||||
if (!flag.enabled) return '#ef4444'
|
throw new Error(`Failed to update feature flag: ${response.statusText}`)
|
||||||
if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return '#f59e0b'
|
|
||||||
return '#10b981'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (flag: FeatureFlag) => {
|
const updatedFlag = await response.json()
|
||||||
if (!flag.enabled) return 'Disabled'
|
|
||||||
if (flag.rolloutPercentage && flag.rolloutPercentage < 100) return `${flag.rolloutPercentage}% Rollout`
|
// Update local state
|
||||||
return 'Enabled'
|
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()) ||
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [flags, search, sortField, sortDirection])
|
||||||
|
|
||||||
|
const SortIcon = ({ field }: { field: typeof sortField }) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return <span style={{ opacity: 0.3 }}>⇅</span>
|
||||||
|
}
|
||||||
|
return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -121,24 +175,8 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div style={{ padding: '2rem', maxWidth: '100%' }}>
|
||||||
<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 */}
|
{/* Header */}
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
@@ -147,24 +185,54 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
color: '#111827',
|
color: '#111827',
|
||||||
marginBottom: '0.5rem'
|
marginBottom: '0.5rem'
|
||||||
}}>
|
}}>
|
||||||
🚩 Feature Flags
|
Feature Flags Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: '#6b7280', fontSize: '1rem' }}>
|
<p style={{ color: '#6b7280', fontSize: '1rem' }}>
|
||||||
Manage feature toggles, A/B tests, and gradual rollouts
|
Manage all feature flags in a spreadsheet view
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Success/Error Messages */}
|
||||||
|
{successMessage && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
right: '20px',
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
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 style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
backgroundColor: '#fef2f2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
color: '#dc2626'
|
||||||
|
}}>
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
marginBottom: '2rem',
|
marginBottom: '2rem',
|
||||||
flexWrap: 'wrap',
|
alignItems: 'center',
|
||||||
alignItems: 'center'
|
justifyContent: 'space-between'
|
||||||
}}>
|
}}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search flags..."
|
placeholder="Search flags by name, description, or tags..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
@@ -172,31 +240,14 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
border: '1px solid #d1d5db',
|
border: '1px solid #d1d5db',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
minWidth: '200px'
|
width: '300px'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
{(['all', 'enabled', 'disabled'] as const).map(filterType => (
|
<div style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
<button
|
{filteredAndSortedFlags.length} of {flags.length} flags
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchFlags()}
|
onClick={() => fetchFlags()}
|
||||||
style={{
|
style={{
|
||||||
@@ -212,201 +263,296 @@ const FeatureFlagsViewComponent = () => {
|
|||||||
🔄 Refresh
|
🔄 Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
|
||||||
gap: '1rem',
|
|
||||||
marginBottom: '2rem'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
padding: '1.5rem',
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#111827' }}>
|
|
||||||
{flags.length}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Total Flags</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Spreadsheet Table */}
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
padding: '1.5rem',
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
border: '1px solid #e5e7eb',
|
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',
|
borderRadius: '0.75rem',
|
||||||
border: '1px solid #e5e7eb',
|
overflow: 'hidden'
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#f59e0b' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
{flags.filter(f => f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
|
<table style={{
|
||||||
</div>
|
width: '100%',
|
||||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>Rolling Out</div>
|
borderCollapse: 'collapse',
|
||||||
</div>
|
fontSize: '0.875rem'
|
||||||
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
padding: '1.5rem',
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
border: '1px solid #e5e7eb',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: '700', color: '#8b5cf6' }}>
|
<thead>
|
||||||
{flags.filter(f => f.variants && f.variants.length > 0).length}
|
<tr style={{ backgroundColor: '#f9fafb' }}>
|
||||||
</div>
|
<th style={{
|
||||||
<div style={{ color: '#6b7280', fontSize: '0.875rem' }}>A/B Tests</div>
|
padding: '0.75rem 1rem',
|
||||||
</div>
|
textAlign: 'left',
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature Flags List */}
|
|
||||||
{filteredFlags.length === 0 ? (
|
|
||||||
<div style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '3rem',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '0.75rem',
|
|
||||||
border: '1px solid #e5e7eb'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: '1.125rem', color: '#6b7280', marginBottom: '0.5rem' }}>
|
|
||||||
{search || filter !== 'all' ? 'No flags match your criteria' : 'No feature flags yet'}
|
|
||||||
</div>
|
|
||||||
{(!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',
|
fontWeight: '600',
|
||||||
color: '#111827',
|
color: '#374151',
|
||||||
margin: 0
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
position: 'sticky',
|
||||||
|
left: 0,
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
minWidth: '50px'
|
||||||
|
}}>
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '200px',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name <SortIcon field="name" />
|
||||||
|
</th>
|
||||||
|
<th style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
minWidth: '300px'
|
||||||
|
}}>
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
onClick={() => handleSort('enabled')}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
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: '#374151',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '120px',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rollout % <SortIcon field="rolloutPercentage" />
|
||||||
|
</th>
|
||||||
|
<th style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
minWidth: '100px'
|
||||||
|
}}>
|
||||||
|
Variants
|
||||||
|
</th>
|
||||||
|
<th style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
minWidth: '150px'
|
||||||
|
}}>
|
||||||
|
Tags
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
onClick={() => 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 <SortIcon field="updatedAt" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAndSortedFlags.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} style={{
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}>
|
||||||
|
{search ? 'No flags match your search' : 'No feature flags yet'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredAndSortedFlags.map(flag => (
|
||||||
|
<tr key={flag.id} style={{
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f9fafb'}
|
||||||
|
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 ? '#f59e0b' : '#10b981')
|
||||||
|
: '#ef4444'
|
||||||
|
}} />
|
||||||
|
</td>
|
||||||
|
<td style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#111827'
|
||||||
}}>
|
}}>
|
||||||
{flag.name}
|
{flag.name}
|
||||||
</h3>
|
</td>
|
||||||
|
<td style={{
|
||||||
<div style={{
|
padding: '0.75rem 1rem',
|
||||||
padding: '0.25rem 0.75rem',
|
color: '#6b7280'
|
||||||
borderRadius: '9999px',
|
|
||||||
backgroundColor: getStatusColor(flag),
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}>
|
}}>
|
||||||
{getStatusText(flag)}
|
{flag.description || '-'}
|
||||||
</div>
|
</td>
|
||||||
|
<td style={{
|
||||||
{flag.environment && (
|
padding: '0.75rem 1rem',
|
||||||
<div style={{
|
textAlign: 'center'
|
||||||
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={flag.enabled}
|
checked={flag.enabled}
|
||||||
onChange={(e) => toggleFlag(flag.id, e.target.checked)}
|
onChange={(e) => updateFlag(flag.id, { enabled: e.target.checked })}
|
||||||
style={{ opacity: 0, width: 0, height: 0 }}
|
disabled={saving === flag.id}
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
width: '18px',
|
||||||
cursor: 'pointer',
|
height: '18px',
|
||||||
top: 0,
|
cursor: saving === flag.id ? 'wait' : 'pointer',
|
||||||
left: 0,
|
accentColor: '#10b981'
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: flag.enabled ? '#10b981' : '#ccc',
|
|
||||||
borderRadius: '34px',
|
|
||||||
transition: '0.4s',
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
|
</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, 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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#6b7280' }}>%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#6b7280'
|
||||||
|
}}>
|
||||||
|
{flag.variants && flag.variants.length > 0 ? (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute',
|
backgroundColor: '#f3f4f6',
|
||||||
display: 'block',
|
padding: '0.25rem 0.5rem',
|
||||||
height: '26px',
|
borderRadius: '0.25rem',
|
||||||
width: '26px',
|
fontSize: '0.75rem'
|
||||||
left: flag.enabled ? '30px' : '4px',
|
}}>
|
||||||
bottom: '4px',
|
{flag.variants.length} variants
|
||||||
backgroundColor: 'white',
|
</span>
|
||||||
borderRadius: '50%',
|
) : '-'}
|
||||||
transition: '0.4s'
|
</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: '#e0e7ff',
|
||||||
|
color: '#3730a3',
|
||||||
|
padding: '0.125rem 0.5rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
}}>
|
||||||
|
{t.tag}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
color: '#6b7280',
|
||||||
|
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: '#6b7280'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '600' }}>Total:</span> {flags.length} flags
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '600' }}>Enabled:</span> {flags.filter(f => f.enabled).length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '600' }}>Disabled:</span> {flags.filter(f => !f.enabled).length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '600' }}>Rolling out:</span> {flags.filter(f => f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: '600' }}>A/B Tests:</span> {flags.filter(f => f.variants && f.variants.length > 0).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user