Files
resolutionflow/frontend/src/pages/admin/FeatureFlagsPage.tsx
chihlasm 5095b0d8df refactor: adopt shared Input/Textarea components (#101)
* refactor: adopt shared Input/Textarea components across 15 files

Replace 42 raw <input>/<textarea> elements with <Input>/<Textarea>
from components/ui/. Consistent focus states, error handling, and
styling across all form fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: replace hardcoded rgba/hex colors with Tailwind tokens

- rgba(255,255,255,0.xx) → bg-white/[0.xx], border-white/[0.xx]
- rgba(6,182,212,0.3) → border-primary/30 (focus states)
- #0a0a0a → bg-background
- Inline style hex colors → var(--color-primary), var(--color-brand-gradient-to)
- 28 files updated, zero hardcoded rgba() patterns remaining

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add PageMeta to 16 pages for SEO and proper browser tab titles

Public pages (Login, Register, Forgot/Reset Password, Verify Email,
Survey Thank You) get descriptions for SEO. Authenticated pages
(Dashboard, Flow Library, My Flows, Session History, AI Assistant,
Account Settings, Step Library, My Shares, Feedback, Guides) get
proper tab titles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add page transitions and staggered list animations

- ViewTransitionOutlet: wraps Outlet with fade-in-up animation keyed
  to route path. Sidebar/topbar stay still, only content area animates.
- StaggerList: reusable component that cascades children with
  incremental delay (50ms default). Pure CSS via @utility stagger-item.
- Applied stagger to TreeGridView, MyTreesPage cards, SessionHistoryPage.
- New stagger-fade-in keyframe in @theme block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: ViewTransitionOutlet needs h-full for React Flow canvas

The wrapper div broke the height chain needed by TreeEditorPage's
h-full layout, causing React Flow canvas to collapse to zero height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: main-content flex layout for tree editor + scrollable pages

Main content area is now flex-col so the ViewTransitionOutlet wrapper
gets an explicit computed height via flex-1 min-h-0. This makes h-full
resolve correctly in the tree editor (React Flow canvas) while still
allowing overflow-y-auto scrolling for normal pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve ESLint errors in Button and Skeleton components

- Button: suppress react-refresh/only-export-components for buttonVariants re-export
- Skeleton: replace empty interface with type alias, replace Math.random() with static widths array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add PageMeta, animation classes, and layout fixes to remaining pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:12:21 -04:00

250 lines
11 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, ToggleLeft } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { DataTable, PageHeader, StatusBadge, ActionMenu, EmptyState } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { FeatureFlagResponse, FeatureFlagCreate, AccountFeatureOverrideResponse, AccountFeatureOverrideCreate } from '@/types/admin'
const PLANS = ['free', 'pro', 'team']
export function FeatureFlagsPage() {
const [flags, setFlags] = useState<FeatureFlagResponse[]>([])
const [overrides, setOverrides] = useState<AccountFeatureOverrideResponse[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<FeatureFlagCreate>({ flag_key: '', display_name: '', description: '' })
const [overrideOpen, setOverrideOpen] = useState(false)
const [overrideForm, setOverrideForm] = useState<AccountFeatureOverrideCreate>({ account_display_code: '', flag_id: '', enabled: true, note: '' })
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [flagData, overrideData] = await Promise.all([
adminApi.listFeatureFlags(),
adminApi.listFeatureFlagOverrides(),
])
setFlags(flagData)
setOverrides(overrideData)
} catch {
toast.error('Failed to load feature flags')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
const handleCreate = async () => {
try {
await adminApi.createFeatureFlag(createForm)
toast.success('Feature flag created')
setCreateOpen(false)
setCreateForm({ flag_key: '', display_name: '', description: '' })
fetchData()
} catch {
toast.error('Failed to create feature flag')
}
}
const handleTogglePlan = async (flagId: string, plan: string, currentEnabled: boolean) => {
try {
await adminApi.updatePlanDefault({ plan, flag_id: flagId, enabled: !currentEnabled })
toast.success('Plan default updated')
fetchData()
} catch {
toast.error('Failed to update plan default')
}
}
const handleDeleteFlag = async (id: string) => {
try {
await adminApi.deleteFeatureFlag(id)
toast.success('Feature flag deleted')
fetchData()
} catch {
toast.error('Failed to delete feature flag')
}
}
const handleCreateOverride = async () => {
try {
await adminApi.createFeatureFlagOverride(overrideForm)
toast.success('Override created')
setOverrideOpen(false)
fetchData()
} catch {
toast.error('Failed to create override')
}
}
const handleDeleteOverride = async (id: string) => {
try {
await adminApi.deleteFeatureFlagOverride(id)
toast.success('Override deleted')
fetchData()
} catch {
toast.error('Failed to delete override')
}
}
const flagColumns: Column<FeatureFlagResponse>[] = [
{ key: 'name', header: 'Name', render: (f) => (
<div>
<div className="font-medium text-foreground">{f.display_name}</div>
<div className="text-xs text-muted-foreground">{f.flag_key}</div>
</div>
)},
{ key: 'description', header: 'Description', render: (f) => <span className="text-sm text-muted-foreground">{f.description || '-'}</span> },
...PLANS.map(plan => ({
key: plan,
header: plan.charAt(0).toUpperCase() + plan.slice(1),
render: (f: FeatureFlagResponse) => {
const entry = f.plan_defaults.find(d => d.plan === plan)
const enabled = entry?.enabled ?? false
return (
<button
onClick={() => handleTogglePlan(f.id, plan, enabled)}
className={cn(
'h-6 w-10 rounded-full transition-colors',
enabled ? 'bg-emerald-400' : 'bg-accent'
)}
>
<div className={cn(
'h-4 w-4 rounded-full bg-white transition-transform',
enabled ? 'translate-x-5' : 'translate-x-1'
)} />
</button>
)
},
})),
{
key: 'actions', header: '', className: 'w-12',
render: (f) => (
<ActionMenu items={[
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteFlag(f.id), destructive: true },
]} />
),
},
]
const overrideColumns: Column<AccountFeatureOverrideResponse>[] = [
{ key: 'account', header: 'Account', render: (o) => <span className="text-sm font-medium text-foreground">{o.account_display_code || o.account_id.slice(0, 8)}</span> },
{ key: 'flag', header: 'Flag', render: (o) => <span className="text-sm text-muted-foreground">{o.flag_display_name || o.flag_key || o.flag_id.slice(0, 8)}</span> },
{ key: 'enabled', header: 'Enabled', render: (o) => <StatusBadge variant={o.enabled ? 'success' : 'destructive'}>{o.enabled ? 'Yes' : 'No'}</StatusBadge> },
{ key: 'note', header: 'Note', render: (o) => <span className="text-sm text-muted-foreground">{o.note || '-'}</span> },
{
key: 'actions', header: '', className: 'w-12',
render: (o) => (
<ActionMenu items={[
{ label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => handleDeleteOverride(o.id), destructive: true },
]} />
),
},
]
const selectClass = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-hidden focus:border-primary focus:ring-2 focus:ring-primary/20')
return (
<div className="space-y-8">
<PageHeader
title="Feature Flags"
description="Manage feature availability per plan and account"
action={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Create Flag
</Button>
}
/>
<div>
<h2 className="text-lg font-semibold text-foreground">Feature Matrix</h2>
<div className="mt-3">
<DataTable columns={flagColumns} data={flags} keyExtractor={(f) => f.id} isLoading={loading}
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No feature flags" description="Create feature flags to control availability per plan." />}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Account Overrides</h2>
<Button onClick={() => setOverrideOpen(true)}>
<Plus className="h-4 w-4" />
Add Override
</Button>
</div>
<div className="mt-3">
<DataTable columns={overrideColumns} data={overrides} keyExtractor={(o) => o.id} isLoading={loading}
emptyState={<EmptyState icon={<ToggleLeft className="h-12 w-12" />} title="No overrides" description="Account-specific feature overrides will appear here." />}
/>
</div>
</div>
{/* Create Flag Modal */}
<Modal isOpen={createOpen} onClose={() => setCreateOpen(false)} title="Create Feature Flag" size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!createForm.flag_key || !createForm.display_name}>Create</Button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Flag Key</label>
<Input type="text" value={createForm.flag_key} onChange={(e) => setCreateForm({ ...createForm, flag_key: e.target.value })} placeholder="e.g. custom_branding" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Display Name</label>
<Input type="text" value={createForm.display_name} onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })} placeholder="e.g. Custom Branding" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Description</label>
<Input type="text" value={createForm.description ?? ''} onChange={(e) => setCreateForm({ ...createForm, description: e.target.value || null })} placeholder="Optional description" />
</div>
</div>
</Modal>
{/* Create Override Modal */}
<Modal isOpen={overrideOpen} onClose={() => setOverrideOpen(false)} title="Add Account Override" size="sm"
footer={
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setOverrideOpen(false)}>Cancel</Button>
<Button onClick={handleCreateOverride} disabled={!overrideForm.account_display_code || !overrideForm.flag_id}>Create</Button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Account Display Code</label>
<Input type="text" value={overrideForm.account_display_code} onChange={(e) => setOverrideForm({ ...overrideForm, account_display_code: e.target.value })} placeholder="e.g. ABC-1234" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Feature Flag</label>
<select value={overrideForm.flag_id} onChange={(e) => setOverrideForm({ ...overrideForm, flag_id: e.target.value })} className={selectClass}>
<option value="">Select a flag...</option>
{flags.map(f => <option key={f.id} value={f.id}>{f.display_name}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="override-enabled" checked={overrideForm.enabled} onChange={(e) => setOverrideForm({ ...overrideForm, enabled: e.target.checked })} className="h-4 w-4 rounded border-border" />
<label htmlFor="override-enabled" className="text-sm font-medium text-foreground">Enabled</label>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Note</label>
<Input type="text" value={overrideForm.note ?? ''} onChange={(e) => setOverrideForm({ ...overrideForm, note: e.target.value || null })} placeholder="Reason" />
</div>
</div>
</Modal>
</div>
)
}
export default FeatureFlagsPage