diff --git a/dev/mockHandlers.ts b/dev/mockHandlers.ts index 238e39b..2b38676 100644 --- a/dev/mockHandlers.ts +++ b/dev/mockHandlers.ts @@ -41,6 +41,14 @@ const mockFeatures = [ is_archived: false, tags: ['ui', 'theme'], owners: [{ id: 1, name: 'John Doe', email: 'john@example.com' }], + num_segment_overrides: 1, + num_identity_overrides: 5, + // Multi-environment status + environment_state: [ + { id: 101, enabled: true }, // Dev - enabled + { id: 102, enabled: true }, // Staging - enabled + { id: 103, enabled: false }, // Prod - disabled (not yet rolled out) + ], }, { id: 1002, @@ -53,6 +61,13 @@ const mockFeatures = [ is_archived: false, tags: ['checkout', 'experiment'], owners: [{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }], + num_segment_overrides: 2, + num_identity_overrides: 0, + environment_state: [ + { id: 101, enabled: true }, // Dev - enabled + { id: 102, enabled: false }, // Staging - disabled + { id: 103, enabled: false }, // Prod - disabled + ], }, { id: 1003, @@ -65,6 +80,13 @@ const mockFeatures = [ is_archived: false, tags: ['api', 'performance'], owners: [], + num_segment_overrides: 0, + num_identity_overrides: 0, + environment_state: [ + { id: 101, enabled: true }, // Dev - enabled + { id: 102, enabled: true }, // Staging - enabled + { id: 103, enabled: true }, // Prod - enabled + ], }, { id: 1004, @@ -77,6 +99,13 @@ const mockFeatures = [ is_archived: false, tags: ['beta'], owners: [{ id: 1, name: 'John Doe', email: 'john@example.com' }], + num_segment_overrides: 3, + num_identity_overrides: 12, + environment_state: [ + { id: 101, enabled: true }, // Dev - enabled + { id: 102, enabled: true }, // Staging - enabled + { id: 103, enabled: true }, // Prod - enabled (for beta users only via segment) + ], }, { id: 1005, @@ -89,6 +118,13 @@ const mockFeatures = [ is_archived: false, tags: ['ops'], owners: [], + num_segment_overrides: 0, + num_identity_overrides: 0, + environment_state: [ + { id: 101, enabled: false }, // Dev - disabled + { id: 102, enabled: false }, // Staging - disabled + { id: 103, enabled: false }, // Prod - disabled + ], }, ]; @@ -142,23 +178,86 @@ const mockFeatureVersions: Record = { const mockFeatureStates: Record = { 'v1-dark-mode-uuid': [ - { id: 2001, enabled: true, feature_segment: null, feature_state_value: null }, - { id: 2002, enabled: true, feature_segment: 501, feature_state_value: null }, // Segment override + { + id: 2001, + enabled: true, + environment: 101, + feature_segment: null, + feature_state_value: { string_value: 'dark', integer_value: null, boolean_value: null }, + updated_at: '2024-12-01T10:00:00Z', + }, + { + id: 2002, + enabled: true, + environment: 101, + feature_segment: { segment: 501, priority: 1 }, + feature_state_value: { string_value: 'auto', integer_value: null, boolean_value: null }, + updated_at: '2024-12-05T14:30:00Z', + }, ], 'v1-checkout-uuid': [ - { id: 2003, enabled: false, feature_segment: null, feature_state_value: null }, - { id: 2004, enabled: true, feature_segment: 502, feature_state_value: null }, // Beta users segment + { + id: 2003, + enabled: false, + environment: 101, + feature_segment: null, + feature_state_value: null, + updated_at: '2024-03-15T09:00:00Z', + }, + { + id: 2004, + enabled: true, + environment: 101, + feature_segment: { segment: 502, priority: 1 }, + feature_state_value: { string_value: null, integer_value: null, boolean_value: true }, + updated_at: '2024-03-20T11:00:00Z', + }, ], 'v1-rate-limit-uuid': [ - { id: 2005, enabled: true, feature_segment: null, feature_state_value: '1000' }, + { + id: 2005, + enabled: true, + environment: 101, + feature_segment: null, + feature_state_value: { string_value: null, integer_value: 1000, boolean_value: null }, + updated_at: '2024-01-21T00:00:00Z', + }, ], 'v1-beta-uuid': [ - { id: 2006, enabled: false, feature_segment: null, feature_state_value: null }, - { id: 2007, enabled: true, feature_segment: 503, feature_state_value: null }, // Beta testers - { id: 2008, enabled: true, feature_segment: 504, feature_state_value: null }, // Internal users + { + id: 2006, + enabled: false, + environment: 101, + feature_segment: null, + feature_state_value: null, + updated_at: '2024-04-05T16:45:00Z', + }, + { + id: 2007, + enabled: true, + environment: 101, + feature_segment: { segment: 503, priority: 1 }, + feature_state_value: null, + updated_at: '2024-04-10T12:00:00Z', + }, + { + id: 2008, + enabled: true, + environment: 101, + feature_segment: { segment: 504, priority: 2 }, + feature_state_value: null, + updated_at: '2024-04-12T09:00:00Z', + }, ], 'v1-maintenance-uuid': [ - { id: 2009, enabled: false, feature_segment: null, feature_state_value: null }, + { + id: 2009, + enabled: false, + environment: 101, + feature_segment: null, + feature_state_value: { string_value: 'Scheduled maintenance', integer_value: null, boolean_value: null }, + updated_at: '2024-02-28T08:00:00Z', + }, ], }; diff --git a/src/api/FlagsmithClient.ts b/src/api/FlagsmithClient.ts index 59f9b7e..ddc18ac 100644 --- a/src/api/FlagsmithClient.ts +++ b/src/api/FlagsmithClient.ts @@ -60,11 +60,24 @@ export interface FlagsmithFeatureVersion { published_by?: string | null; } +export interface FlagsmithFeatureStateValue { + string_value?: string | null; + integer_value?: number | null; + boolean_value?: boolean | null; +} + +export interface FlagsmithFeatureSegment { + segment: number; + priority: number; +} + export interface FlagsmithFeatureState { id: number; enabled: boolean; - feature_segment?: number | null; - feature_state_value?: string | null; + environment?: number; + feature_segment?: FlagsmithFeatureSegment | null; + feature_state_value?: FlagsmithFeatureStateValue | null; + updated_at?: string | null; } export interface FlagsmithFeatureDetails { diff --git a/src/components/FlagsTab.tsx b/src/components/FlagsTab.tsx deleted file mode 100644 index 1810251..0000000 --- a/src/components/FlagsTab.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Typography, - Box, - CircularProgress, - FormControl, - InputLabel, - Select, - MenuItem, - Grid, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - IconButton, - Collapse, - Chip, -} from '@material-ui/core'; -import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown'; -import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import { - FlagsmithClient, - FlagsmithEnvironment, - FlagsmithFeature, - FlagsmithFeatureDetails, -} from '../api/FlagsmithClient'; - -interface ExpandableRowProps { - feature: FlagsmithFeature; - client: FlagsmithClient; - environmentId: number; -} - -const ExpandableRow = ({ - feature, - client, - environmentId, -}: ExpandableRowProps) => { - const [open, setOpen] = useState(false); - const [envStatesOpen, setEnvStatesOpen] = useState(false); - const [details, setDetails] = useState(null); - const [loadingDetails, setLoadingDetails] = useState(false); - const [detailsError, setDetailsError] = useState(null); - - const handleToggle = async () => { - const newOpen = !open; - setOpen(newOpen); - - // Load details on first expand - if (newOpen && !details && !loadingDetails) { - setLoadingDetails(true); - setDetailsError(null); - try { - const featureDetails = await client.getFeatureDetails( - environmentId, - feature.id, - ); - setDetails(featureDetails); - } catch (err) { - setDetailsError( - err instanceof Error ? err.message : 'Failed to load details', - ); - } finally { - setLoadingDetails(false); - } - } - }; - - // Use details if loaded, otherwise fall back to feature data - const liveVersion = details?.liveVersion || feature.live_version; - const environmentState = details?.featureState || feature.environment_state; - const segmentOverrides = - details?.segmentOverrides ?? feature.num_segment_overrides ?? 0; - - return ( - <> - - - - {open ? : } - - - - - {feature.name} - {feature.description && ( - - {feature.description} - - )} - - - - - - - - - - - - {new Date(feature.created_date).toLocaleDateString()} - - - - - - - - {loadingDetails && ( - - - - Loading feature details... - - - )} - {!loadingDetails && detailsError && ( - - {detailsError} - - )} - {!loadingDetails && !detailsError && ( - <> - {/* Main Info Row - 4 Columns */} - - {/* Column 1: Active Version */} - {liveVersion && ( - - - Active Version - - - - Status:{' '} - {liveVersion.is_live ? 'Active' : 'Inactive'} - - - Published:{' '} - {liveVersion.published ? 'Yes' : 'No'} - - {liveVersion.live_from && ( - - Active From:{' '} - {new Date(liveVersion.live_from).toLocaleString()} - - )} - - Published By: User ID{' '} - {liveVersion.published_by} - - - - )} - - {/* Column 2: Overview */} - - - Overview - - - - ID: {feature.id} - - - Type: {feature.type} - - - Default Enabled:{' '} - {feature.default_enabled ? 'Yes' : 'No'} - - - Archived:{' '} - {feature.is_archived ? 'Yes' : 'No'} - - {feature.is_server_key_only && ( - - - - )} - - - - {/* Column 3: Owners */} - {feature.owners && feature.owners.length > 0 && ( - - - Owners - - - {feature.owners.map((owner: any) => ( - - - - {owner.first_name} {owner.last_name} - - - - {owner.email} - - {owner.last_login && ( - - Last login:{' '} - {new Date(owner.last_login).toLocaleString()} - - )} - - ))} - - - )} - - {/* Column 4: Overrides */} - - - Overrides - - - - Segment Overrides: {segmentOverrides} - - {feature.num_identity_overrides !== null && - feature.num_identity_overrides !== undefined && ( - - Identity Overrides:{' '} - {feature.num_identity_overrides} - - )} - - - - {/* Tags Row (if exists) */} - {feature.tags && feature.tags.length > 0 && ( - - - Tags - - - {feature.tags.map((tag: any, index: number) => ( - - ))} - - - )} - - - {/* Environment States - Collapsible Section */} - {environmentState && environmentState.length > 0 && ( - - setEnvStatesOpen(!envStatesOpen)} - style={{ cursor: 'pointer' }} - > - - {envStatesOpen ? ( - - ) : ( - - )} - - - Environment States ({environmentState.length}) - - - - - {environmentState.map((state: any) => ( - - - - - {state.feature_segment && ( - - )} - {state.environment && ( - - Env ID: {state.environment} - - )} - - {state.updated_at && ( - - Updated:{' '} - {new Date(state.updated_at).toLocaleString()} - - )} - - - {/* Feature State Value */} - {state.feature_state_value && ( - - {state.feature_state_value.string_value !== null && - state.feature_state_value.string_value !== undefined && ( - - Value:{' '} - {state.feature_state_value.string_value} - - )} - {state.feature_state_value.integer_value !== null && - state.feature_state_value.integer_value !== undefined && ( - - Value:{' '} - {state.feature_state_value.integer_value} - - )} - {state.feature_state_value.boolean_value !== null && - state.feature_state_value.boolean_value !== undefined && ( - - Value:{' '} - {String(state.feature_state_value.boolean_value)} - - )} - - )} - - {/* Segment Information */} - {state.feature_segment && ( - - - Segment ID:{' '} - {state.feature_segment.segment} |{' '} - Priority:{' '} - {state.feature_segment.priority} - - - )} - - ))} - - - - )} - - )} - - - - - - ); -}; - -export const FlagsTab = () => { - const { entity } = useEntity(); - const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [projectInfo, setProjectInfo] = useState(null); - const [environments, setEnvironments] = useState([]); - const [selectedEnvironment, setSelectedEnvironment] = useState( - null, - ); - const [features, setFeatures] = useState([]); - const [featuresLoading, setFeaturesLoading] = useState(false); - const [client] = useState(() => new FlagsmithClient(discoveryApi, fetchApi)); - - // Get project ID from entity annotations - const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; - - useEffect(() => { - if (!projectId) { - setError('No Flagsmith project ID found in entity annotations'); - setLoading(false); - return; - } - - const fetchData = async () => { - try { - // Fetch project info - const project = await client.getProject(parseInt(projectId, 10)); - setProjectInfo(project); - - // Fetch environments - const envs = await client.getProjectEnvironments(parseInt(projectId, 10)); - setEnvironments(envs); - - // Select first environment by default - if (envs.length > 0) { - setSelectedEnvironment(envs[0].id); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [projectId, client]); - - // Fetch features when environment changes - useEffect(() => { - if (!selectedEnvironment || !projectId) return; - - const fetchFeaturesForEnvironment = async () => { - setFeaturesLoading(true); - try { - // Just get project features - details loaded lazily on expand - const projectFeatures = await client.getProjectFeatures(projectId); - setFeatures(projectFeatures); - } catch (err) { - setError('Failed to fetch features'); - } finally { - setFeaturesLoading(false); - } - }; - - fetchFeaturesForEnvironment(); - }, [selectedEnvironment, projectId, client]); - - // Handle environment selection change - const handleEnvironmentChange = (envId: number) => { - setSelectedEnvironment(envId); - }; - - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - Error: {error} - {!projectId && ( - - Add a flagsmith.com/project-id annotation to this - entity to view feature flags. - - )} - - ); - } - - return ( - - - - Feature Flags - - {projectInfo?.name} ({features.length} flags) - - - - - - Environment - - - - - - - {featuresLoading ? ( - - - - ) : ( - - - - - - Flag Name - Status - Value - Created - - - - {features.length === 0 ? ( - - - - No feature flags found for this project - - - - ) : ( - features.map(feature => ( - - )) - )} - -
-
- )} -
-
- ); -}; diff --git a/src/components/FlagsTab/EnvironmentTable.tsx b/src/components/FlagsTab/EnvironmentTable.tsx new file mode 100644 index 0000000..9900cef --- /dev/null +++ b/src/components/FlagsTab/EnvironmentTable.tsx @@ -0,0 +1,119 @@ +import { + Box, + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FlagsmithEnvironment, FlagsmithFeature } from '../../api/FlagsmithClient'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; + +const useStyles = makeStyles(theme => ({ + envTable: { + marginTop: theme.spacing(1), + '& th, & td': { + padding: theme.spacing(1, 1.5), + borderBottom: `1px solid ${theme.palette.divider}`, + }, + '& th': { + fontWeight: 600, + fontSize: '0.75rem', + color: theme.palette.text.secondary, + textTransform: 'uppercase', + }, + }, + statusOn: { + color: flagsmithColors.primary, + fontWeight: 600, + }, + statusOff: { + color: theme.palette.text.secondary, + fontWeight: 600, + }, + envBadge: { + fontSize: '0.7rem', + height: 18, + marginRight: theme.spacing(0.5), + marginTop: theme.spacing(0.5), + }, + valueCell: { + fontFamily: 'monospace', + fontSize: '0.85rem', + color: theme.palette.text.primary, + }, +})); + +interface EnvironmentTableProps { + feature: FlagsmithFeature; + environments: FlagsmithEnvironment[]; +} + +export const EnvironmentTable = ({ + feature, + environments, +}: EnvironmentTableProps) => { + const classes = useStyles(); + + return ( + + + + Environment + Status + Value + Last updated + + + + {environments.map(env => { + const envState = feature.environment_state?.find(s => s.id === env.id); + const enabled = envState?.enabled ?? feature.default_enabled ?? false; + const segmentCount = feature.num_segment_overrides ?? 0; + const value = feature.type === 'CONFIG' ? (feature as FlagsmithFeature & { initial_value?: string }).initial_value : null; + + return ( + + + + + {env.name} + + {segmentCount > 0 && ( + 1 ? 's' : ''}`} + size="small" + variant="outlined" + className={classes.envBadge} + /> + )} + + + + + {enabled ? 'ON' : 'OFF'} + + + + + {value !== null && value !== undefined ? `"${value}"` : '-'} + + + + + {new Date(feature.created_date).toLocaleDateString()} + + + + ); + })} + +
+ ); +}; diff --git a/src/components/FlagsTab/ExpandableRow.tsx b/src/components/FlagsTab/ExpandableRow.tsx new file mode 100644 index 0000000..4e27a78 --- /dev/null +++ b/src/components/FlagsTab/ExpandableRow.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react'; +import { + Typography, + Box, + CircularProgress, + Grid, + TableCell, + TableRow, + IconButton, + Collapse, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown'; +import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; +import { + FlagsmithClient, + FlagsmithEnvironment, + FlagsmithFeature, + FlagsmithFeatureDetails, +} from '../../api/FlagsmithClient'; +import { FlagsmithLink } from '../shared'; +import { buildFlagUrl } from '../../theme/flagsmithTheme'; +import { EnvironmentTable } from './EnvironmentTable'; +import { FeatureDetailsGrid } from './FeatureDetailsGrid'; +import { SegmentOverridesSection } from './SegmentOverridesSection'; + +const useStyles = makeStyles(theme => ({ + flagName: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, + expandedContent: { + backgroundColor: theme.palette.background.default, + padding: theme.spacing(2), + }, +})); + +interface ExpandableRowProps { + feature: FlagsmithFeature; + environments: FlagsmithEnvironment[]; + client: FlagsmithClient; + projectId: string; +} + +export const ExpandableRow = ({ + feature, + environments, + client, + projectId, +}: ExpandableRowProps) => { + const classes = useStyles(); + const [open, setOpen] = useState(false); + const [details, setDetails] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(false); + const [detailsError, setDetailsError] = useState(null); + + const primaryEnvId = environments[0]?.id; + + const handleToggle = async () => { + const newOpen = !open; + setOpen(newOpen); + + if (newOpen && !details && !loadingDetails && primaryEnvId) { + setLoadingDetails(true); + setDetailsError(null); + try { + const featureDetails = await client.getFeatureDetails( + primaryEnvId, + feature.id, + ); + setDetails(featureDetails); + } catch (err) { + setDetailsError( + err instanceof Error ? err.message : 'Failed to load details', + ); + } finally { + setLoadingDetails(false); + } + } + }; + + const liveVersion = details?.liveVersion || feature.live_version; + const segmentOverrides = + details?.segmentOverrides ?? feature.num_segment_overrides ?? 0; + const flagUrl = buildFlagUrl( + projectId, + primaryEnvId?.toString() || '', + feature.id, + ); + + return ( + <> + + + + {open ? : } + + + + + + {feature.name} + + + {feature.description && ( + + {feature.description.length > 60 + ? `${feature.description.substring(0, 60)}...` + : feature.description} + + )} + + + {feature.type || 'FLAG'} + + + + {new Date(feature.created_date).toLocaleDateString()} + + + + + + + + + {loadingDetails && ( + + + + Loading feature details... + + + )} + {!loadingDetails && detailsError && ( + + {detailsError} + + )} + {!loadingDetails && !detailsError && ( + + + + + + + + + + + + )} + + + + + + ); +}; diff --git a/src/components/FlagsTab/FeatureDetailsGrid.tsx b/src/components/FlagsTab/FeatureDetailsGrid.tsx new file mode 100644 index 0000000..9cd71f4 --- /dev/null +++ b/src/components/FlagsTab/FeatureDetailsGrid.tsx @@ -0,0 +1,115 @@ +import { Box, Chip, Grid, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FlagsmithFeature } from '../../api/FlagsmithClient'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; + +const useStyles = makeStyles(theme => ({ + detailCard: { + padding: theme.spacing(1.5), + marginBottom: theme.spacing(1), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + }, +})); + +type LiveVersionInfo = FlagsmithFeature['live_version']; + +interface FeatureDetailsGridProps { + feature: FlagsmithFeature; + liveVersion: LiveVersionInfo; + segmentOverrides: number; +} + +export const FeatureDetailsGrid = ({ + feature, + liveVersion, + segmentOverrides, +}: FeatureDetailsGridProps) => { + const classes = useStyles(); + + return ( + <> + {liveVersion && ( + + + + Version + + + Status: {liveVersion.is_live ? 'Active' : 'Inactive'} + + {liveVersion.live_from && ( + + Live since: {new Date(liveVersion.live_from).toLocaleDateString()} + + )} + + + )} + + + + + Targeting + + + Segment overrides: {segmentOverrides} + + {feature.num_identity_overrides !== null && + feature.num_identity_overrides !== undefined && ( + + Identity overrides: {feature.num_identity_overrides} + + )} + + + + + + + Details + + ID: {feature.id} + + Type: {feature.type || 'Standard'} + + {feature.is_server_key_only && ( + + )} + + + + {feature.tags && feature.tags.length > 0 && ( + + + {feature.tags.map((tag, index) => ( + + ))} + + + )} + + {feature.owners && feature.owners.length > 0 && ( + + + Owners:{' '} + {feature.owners.map(o => o.email || `${o.name}`).join(', ')} + + + )} + + ); +}; diff --git a/src/components/FlagsTab/SegmentOverridesSection.tsx b/src/components/FlagsTab/SegmentOverridesSection.tsx new file mode 100644 index 0000000..cd95262 --- /dev/null +++ b/src/components/FlagsTab/SegmentOverridesSection.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { Box, Chip, Collapse, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown'; +import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight'; +import { + FlagsmithFeature, + FlagsmithFeatureDetails, +} from '../../api/FlagsmithClient'; +import { FlagStatusIndicator } from '../shared'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; + +const useStyles = makeStyles(theme => ({ + showMoreButton: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + cursor: 'pointer', + color: theme.palette.primary.main, + fontSize: '0.875rem', + marginTop: theme.spacing(1), + '&:hover': { + textDecoration: 'underline', + }, + }, + showMoreContent: { + marginTop: theme.spacing(1.5), + padding: theme.spacing(1.5), + backgroundColor: + theme.palette.type === 'dark' + ? 'rgba(255, 255, 255, 0.05)' + : 'rgba(0, 0, 0, 0.02)', + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + }, + featureStateItem: { + padding: theme.spacing(1), + marginBottom: theme.spacing(0.5), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + }, + segmentBadge: { + backgroundColor: flagsmithColors.warning, + color: 'white', + fontSize: '0.7rem', + height: 20, + marginLeft: theme.spacing(1), + }, +})); + +type LiveVersionInfo = FlagsmithFeature['live_version']; + +interface SegmentOverridesSectionProps { + feature: FlagsmithFeature; + details: FlagsmithFeatureDetails | null; + liveVersion: LiveVersionInfo; +} + +export const SegmentOverridesSection = ({ + feature, + details, + liveVersion, +}: SegmentOverridesSectionProps) => { + const classes = useStyles(); + const [showMoreOpen, setShowMoreOpen] = useState(false); + + return ( + <> + setShowMoreOpen(!showMoreOpen)} + > + {showMoreOpen ? ( + + ) : ( + + )} + + {showMoreOpen ? 'Hide additional details' : 'Show additional details'} + + + + + + + + Published: {liveVersion?.published ? 'Yes' : 'No'} + {liveVersion?.published_by && ` (by ${liveVersion.published_by})`} + + + Archived: {feature.is_archived ? 'Yes' : 'No'} + + + + {details?.featureState && details.featureState.length > 0 && ( + + + Segment Overrides + + {details.featureState + .filter(state => state.feature_segment !== null) + .map((state, index) => ( + + + + + {state.enabled ? 'Enabled' : 'Disabled'} + + {state.feature_segment && ( + + )} + + {state.feature_state_value && ( + + {state.feature_state_value.string_value !== null && + state.feature_state_value.string_value !== + undefined && ( + + Value: "{state.feature_state_value.string_value}" + + )} + {state.feature_state_value.integer_value !== null && + state.feature_state_value.integer_value !== + undefined && ( + + Value: {state.feature_state_value.integer_value} + + )} + {state.feature_state_value.boolean_value !== null && + state.feature_state_value.boolean_value !== + undefined && ( + + Value:{' '} + {String(state.feature_state_value.boolean_value)} + + )} + + )} + + ))} + {details.featureState.filter(s => s.feature_segment !== null) + .length === 0 && ( + + No segment overrides configured. + + )} + + )} + + + + ); +}; diff --git a/src/components/FlagsTab/index.tsx b/src/components/FlagsTab/index.tsx new file mode 100644 index 0000000..8aa3fb7 --- /dev/null +++ b/src/components/FlagsTab/index.tsx @@ -0,0 +1,133 @@ +import { useState, useMemo } from 'react'; +import { + Typography, + Box, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { SearchInput, FlagsmithLink, LoadingState } from '../shared'; +import { buildProjectUrl } from '../../theme/flagsmithTheme'; +import { useFlagsmithProject } from '../../hooks'; +import { ExpandableRow } from './ExpandableRow'; + +const useStyles = makeStyles(theme => ({ + header: { + marginBottom: theme.spacing(2), + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + justifyContent: 'flex-end', + }, +})); + +export const FlagsTab = () => { + const classes = useStyles(); + const { entity } = useEntity(); + const [searchQuery, setSearchQuery] = useState(''); + + const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; + const { project, environments, features, loading, error, client } = + useFlagsmithProject(projectId); + + const filteredFeatures = useMemo(() => { + if (!searchQuery.trim()) return features; + const query = searchQuery.toLowerCase(); + return features.filter( + f => + f.name.toLowerCase().includes(query) || + f.description?.toLowerCase().includes(query), + ); + }, [features, searchQuery]); + + const dashboardUrl = buildProjectUrl( + projectId || '', + environments[0]?.id?.toString(), + ); + + if (loading) { + return ; + } + + if (error) { + return ( + + Error: {error} + {!projectId && ( + + Add a flagsmith.com/project-id annotation to this + entity to view feature flags. + + )} + + ); + } + + return ( + + + + Feature Flags + + {project?.name} ({features.length} flags) + + + + + + + + + + + + + + + + Flag Name + Type + Created + + + + {filteredFeatures.length === 0 ? ( + + + + {searchQuery + ? 'No flags match your search' + : 'No feature flags found for this project'} + + + + ) : ( + filteredFeatures.map(feature => ( + + )) + )} + +
+
+
+ ); +}; diff --git a/src/components/FlagsmithOverviewCard.tsx b/src/components/FlagsmithOverviewCard.tsx deleted file mode 100644 index 1b8e397..0000000 --- a/src/components/FlagsmithOverviewCard.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Typography, - Box, - CircularProgress, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Chip, - IconButton, -} from '@material-ui/core'; -import ChevronLeft from '@material-ui/icons/ChevronLeft'; -import ChevronRight from '@material-ui/icons/ChevronRight'; -import { InfoCard } from '@backstage/core-components'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import { - FlagsmithClient, - FlagsmithFeature, - FlagsmithEnvironment, -} from '../api/FlagsmithClient'; - -export const FlagsmithOverviewCard = () => { - const { entity } = useEntity(); - const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [projectInfo, setProjectInfo] = useState(null); - const [features, setFeatures] = useState([]); - const [environments, setEnvironments] = useState([]); - const [selectedEnvironment, setSelectedEnvironment] = useState( - null, - ); - const [page, setPage] = useState(0); - const pageSize = 5; - - // Get project ID from entity annotations - const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; - - useEffect(() => { - if (!projectId) { - setError('No Flagsmith project ID found in entity annotations'); - setLoading(false); - return; - } - - const fetchData = async () => { - try { - const client = new FlagsmithClient(discoveryApi, fetchApi); - - // Fetch project info - const project = await client.getProject(parseInt(projectId, 10)); - setProjectInfo(project); - - // Fetch environments - const envs = await client.getProjectEnvironments(parseInt(projectId, 10)); - setEnvironments(envs); - - // Select first environment by default - if (envs.length > 0) { - setSelectedEnvironment(envs[0].id); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [projectId, discoveryApi, fetchApi]); - - // Fetch features when environment changes - useEffect(() => { - if (!selectedEnvironment || !projectId) return; - - const fetchFeaturesForEnvironment = async () => { - try { - const client = new FlagsmithClient(discoveryApi, fetchApi); - // Just get project features - overview card shows basic info - const projectFeatures = await client.getProjectFeatures(projectId); - setFeatures(projectFeatures); - } catch { - setFeatures([]); - } - }; - - fetchFeaturesForEnvironment(); - }, [selectedEnvironment, projectId, discoveryApi, fetchApi]); - - if (loading) { - return ( - - - - - - ); - } - - if (error) { - return ( - - - Error: {error} - - - ); - } - - const paginatedFeatures = features.slice( - page * pageSize, - (page + 1) * pageSize, - ); - const totalPages = Math.ceil(features.length / pageSize); - - return ( - - - - - - Flag Name - Default - Environment - - - - {paginatedFeatures.length === 0 ? ( - - - - No feature flags found - - - - ) : ( - paginatedFeatures.map(feature => ( - - - {feature.name} - {feature.description && ( - - {feature.description.substring(0, 50)} - {feature.description.length > 50 ? '...' : ''} - - )} - - - - - - - {environments.find(env => env.id === selectedEnvironment) - ?.name || 'Unknown'} - - - - )) - )} - -
-
- - {/* Mini Pager */} - {totalPages > 1 && ( - - - Page {page + 1} of {totalPages} ({features.length} flags) - - - setPage(p => Math.max(0, p - 1))} - disabled={page === 0} - > - - - setPage(p => Math.min(totalPages - 1, p + 1))} - disabled={page >= totalPages - 1} - > - - - - - )} -
- ); -}; diff --git a/src/components/FlagsmithOverviewCard/FeatureFlagRow.tsx b/src/components/FlagsmithOverviewCard/FeatureFlagRow.tsx new file mode 100644 index 0000000..27088b2 --- /dev/null +++ b/src/components/FlagsmithOverviewCard/FeatureFlagRow.tsx @@ -0,0 +1,49 @@ +import { Box, TableCell, TableRow, Tooltip, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FlagsmithFeature, FlagsmithEnvironment } from '../../api/FlagsmithClient'; +import { FlagStatusIndicator } from '../shared'; +import { getFeatureEnvStatus, buildEnvStatusTooltip } from '../../utils'; + +const useStyles = makeStyles(() => ({ + envDots: { + display: 'flex', + gap: 2, + justifyContent: 'flex-end', + }, +})); + +interface FeatureFlagRowProps { + feature: FlagsmithFeature; + environments: FlagsmithEnvironment[]; +} + +export const FeatureFlagRow = ({ feature, environments }: FeatureFlagRowProps) => { + const classes = useStyles(); + + return ( + + + {feature.name} + {feature.description && ( + + {feature.description.substring(0, 40)} + {feature.description.length > 40 ? '...' : ''} + + )} + + + + + {environments.map(env => ( + + ))} + + + + + ); +}; diff --git a/src/components/FlagsmithOverviewCard/FlagStatsRow.tsx b/src/components/FlagsmithOverviewCard/FlagStatsRow.tsx new file mode 100644 index 0000000..d147d6b --- /dev/null +++ b/src/components/FlagsmithOverviewCard/FlagStatsRow.tsx @@ -0,0 +1,42 @@ +import { Box, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FlagStatusIndicator } from '../shared'; + +const useStyles = makeStyles(theme => ({ + statsRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + marginBottom: theme.spacing(1), + fontSize: '0.75rem', + }, + statItem: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, +})); + +interface FlagStatsRowProps { + enabledCount: number; + disabledCount: number; +} + +export const FlagStatsRow = ({ enabledCount, disabledCount }: FlagStatsRowProps) => { + const classes = useStyles(); + + return ( + + + + + {enabledCount} Enabled + + + + {disabledCount} Disabled + + + + ); +}; diff --git a/src/components/FlagsmithOverviewCard/index.tsx b/src/components/FlagsmithOverviewCard/index.tsx new file mode 100644 index 0000000..d9e6ca8 --- /dev/null +++ b/src/components/FlagsmithOverviewCard/index.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { + Typography, + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Tooltip, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { InfoCard } from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { FlagsmithLink, MiniPagination, LoadingState } from '../shared'; +import { buildProjectUrl } from '../../theme/flagsmithTheme'; +import { useFlagsmithProject } from '../../hooks'; +import { calculateFeatureStats, paginate } from '../../utils'; +import { FlagStatsRow } from './FlagStatsRow'; +import { FeatureFlagRow } from './FeatureFlagRow'; + +const useStyles = makeStyles(theme => ({ + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, +})); + +const PAGE_SIZE = 5; + +export const FlagsmithOverviewCard = () => { + const classes = useStyles(); + const { entity } = useEntity(); + const [page, setPage] = useState(0); + + const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; + const { project, environments, features, loading, error } = useFlagsmithProject(projectId); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + Error: {error} + + + ); + } + + const { paginatedItems: paginatedFeatures, totalPages } = paginate( + features, + page, + PAGE_SIZE, + ); + const { enabledCount, disabledCount } = calculateFeatureStats(features); + const dashboardUrl = buildProjectUrl( + projectId || '', + environments[0]?.id?.toString(), + ); + + return ( + + + + } + > + + + + + + + Flag Name + + e.name).join(' • ')}> + Environments + + + + + + {paginatedFeatures.length === 0 ? ( + + + + No feature flags found + + + + ) : ( + paginatedFeatures.map(feature => ( + + )) + )} + +
+
+ + setPage(p => Math.max(0, p - 1))} + onNext={() => setPage(p => Math.min(totalPages - 1, p + 1))} + itemLabel="flags" + /> +
+ ); +}; diff --git a/src/components/FlagsmithUsageCard.tsx b/src/components/FlagsmithUsageCard.tsx deleted file mode 100644 index 82934cd..0000000 --- a/src/components/FlagsmithUsageCard.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Typography, - Box, - CircularProgress, -} from '@material-ui/core'; -import { InfoCard } from '@backstage/core-components'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; -import { FlagsmithClient, FlagsmithUsageData } from '../api/FlagsmithClient'; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from 'recharts'; - -interface CustomTooltipProps { - active?: boolean; - payload?: Array<{ - payload: FlagsmithUsageData; - }>; -} - -const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( - - - {new Date(data.day).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - - - Flags: {data.flags ?? 0} - - - Identities: {data.identities} - - - Traits: {data.traits} - - - Environment Document: {data.environment_document} - - - - ); - } - - return null; -}; - -export const FlagsmithUsageCard = () => { - const { entity } = useEntity(); - const discoveryApi = useApi(discoveryApiRef); - const fetchApi = useApi(fetchApiRef); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [usageData, setUsageData] = useState([]); - const [projectInfo, setProjectInfo] = useState(null); - - // Get project ID and org ID from entity annotations - const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; - const orgId = entity.metadata.annotations?.['flagsmith.com/org-id']; - - useEffect(() => { - if (!projectId || !orgId) { - setError('Missing Flagsmith project ID or organization ID in entity annotations'); - setLoading(false); - return; - } - - const fetchData = async () => { - try { - const client = new FlagsmithClient(discoveryApi, fetchApi); - - // Fetch project info - const project = await client.getProject(parseInt(projectId, 10)); - setProjectInfo(project); - - // Fetch usage data - const usage = await client.getUsageData(parseInt(orgId, 10), parseInt(projectId, 10)); - setUsageData(usage); - - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [projectId, orgId, discoveryApi, fetchApi]); - - if (loading) { - return ( - - - - - - ); - } - - if (error) { - return ( - - - - Error: {error} - - {!orgId && ( - - Add a flagsmith.com/organization-id annotation to this entity. - - )} - - - ); - } - - // Calculate total flags - const totalFlags = usageData.reduce((sum, day) => sum + (day.flags ?? 0), 0); - - return ( - - - - - - { - const date = new Date(value); - return `${date.getMonth() + 1}/${date.getDate()}`; - }} - angle={-45} - textAnchor="end" - height={80} - /> - - } /> - - - - - {usageData.length === 0 && ( - - - No usage data available - - - )} - - - ); -}; diff --git a/src/components/FlagsmithUsageCard/UsageChart.tsx b/src/components/FlagsmithUsageCard/UsageChart.tsx new file mode 100644 index 0000000..b7ca169 --- /dev/null +++ b/src/components/FlagsmithUsageCard/UsageChart.tsx @@ -0,0 +1,57 @@ +import { Box, Typography } from '@material-ui/core'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { FlagsmithUsageData } from '../../api/FlagsmithClient'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; +import { UsageTooltip } from './UsageTooltip'; + +interface UsageChartProps { + data: FlagsmithUsageData[]; +} + +export const UsageChart = ({ data }: UsageChartProps) => { + if (data.length === 0) { + return ( + + + No usage data available + + + ); + } + + return ( + + + + { + const date = new Date(value); + return `${date.getMonth() + 1}/${date.getDate()}`; + }} + angle={-45} + textAnchor="end" + height={80} + /> + + } /> + + + + ); +}; diff --git a/src/components/FlagsmithUsageCard/UsageTooltip.tsx b/src/components/FlagsmithUsageCard/UsageTooltip.tsx new file mode 100644 index 0000000..0cd156a --- /dev/null +++ b/src/components/FlagsmithUsageCard/UsageTooltip.tsx @@ -0,0 +1,50 @@ +import { Box, Typography } from '@material-ui/core'; +import { FlagsmithUsageData } from '../../api/FlagsmithClient'; + +interface UsageTooltipProps { + active?: boolean; + payload?: Array<{ + payload: FlagsmithUsageData; + }>; +} + +export const UsageTooltip = ({ active, payload }: UsageTooltipProps) => { + if (!active || !payload || !payload.length) { + return null; + } + + const data = payload[0].payload; + + return ( + + + {new Date(data.day).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + + Flags: {data.flags ?? 0} + + + Identities: {data.identities} + + + Traits: {data.traits} + + + Environment Document: {data.environment_document} + + + + ); +}; diff --git a/src/components/FlagsmithUsageCard/index.tsx b/src/components/FlagsmithUsageCard/index.tsx new file mode 100644 index 0000000..cd045ce --- /dev/null +++ b/src/components/FlagsmithUsageCard/index.tsx @@ -0,0 +1,77 @@ +import { Typography, Box } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { InfoCard } from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { FlagsmithLink, LoadingState } from '../shared'; +import { FLAGSMITH_DASHBOARD_URL } from '../../theme/flagsmithTheme'; +import { useFlagsmithUsage } from '../../hooks'; +import { UsageChart } from './UsageChart'; + +const useStyles = makeStyles(theme => ({ + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, +})); + +export const FlagsmithUsageCard = () => { + const classes = useStyles(); + const { entity } = useEntity(); + + const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; + const orgId = entity.metadata.annotations?.['flagsmith.com/org-id']; + + const { project, usageData, totalFlags, loading, error } = useFlagsmithUsage( + projectId, + orgId, + ); + + const usageUrl = `${FLAGSMITH_DASHBOARD_URL}/organisation/${orgId}/usage`; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + Error: {error} + {!orgId && ( + + Add a flagsmith.com/organization-id annotation to this + entity. + + )} + + + ); + } + + const subheader = project?.name + ? `${project.name} - ${totalFlags.toLocaleString()} total flag calls` + : undefined; + + return ( + + + + ) + } + > + + + + + ); +}; diff --git a/src/components/shared/FlagStatusIndicator.tsx b/src/components/shared/FlagStatusIndicator.tsx new file mode 100644 index 0000000..84a84eb --- /dev/null +++ b/src/components/shared/FlagStatusIndicator.tsx @@ -0,0 +1,72 @@ +import { Box, Typography, Tooltip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + alignItems: 'center', + gap: 6, + }, + dot: { + width: 10, + height: 10, + borderRadius: '50%', + flexShrink: 0, + }, + enabled: { + backgroundColor: flagsmithColors.enabled, + }, + disabled: { + backgroundColor: flagsmithColors.disabled, + }, + label: { + fontSize: '0.875rem', + }, +})); + +interface FlagStatusIndicatorProps { + enabled: boolean; + showLabel?: boolean; + size?: 'small' | 'medium'; + tooltip?: string; +} + +/** + * Visual indicator for flag enabled/disabled status + * Shows a colored dot (green for enabled, gray for disabled) + */ +export const FlagStatusIndicator = ({ + enabled, + showLabel = false, + size = 'medium', + tooltip, +}: FlagStatusIndicatorProps) => { + const classes = useStyles(); + + const dotSize = size === 'small' ? 8 : 10; + + const indicator = ( + + + {showLabel && ( + + {enabled ? 'On' : 'Off'} + + )} + + ); + + if (tooltip) { + return {indicator}; + } + + return indicator; +}; diff --git a/src/components/shared/FlagsmithLink.tsx b/src/components/shared/FlagsmithLink.tsx new file mode 100644 index 0000000..0050650 --- /dev/null +++ b/src/components/shared/FlagsmithLink.tsx @@ -0,0 +1,78 @@ +import { Link, Tooltip, IconButton } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import LaunchIcon from '@material-ui/icons/Launch'; +import { flagsmithColors } from '../../theme/flagsmithTheme'; + +const useStyles = makeStyles(() => ({ + link: { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + color: 'inherit', + textDecoration: 'none', + '&:hover': { + color: flagsmithColors.primary, + textDecoration: 'underline', + }, + }, + icon: { + fontSize: '0.875rem', + opacity: 0.7, + }, + iconButton: { + padding: 4, + color: flagsmithColors.primary, + }, +})); + +interface FlagsmithLinkProps { + href: string; + children?: React.ReactNode; + tooltip?: string; + iconOnly?: boolean; +} + +/** + * External link to Flagsmith dashboard + * Opens in a new tab with appropriate security attributes + */ +export const FlagsmithLink = ({ + href, + children, + tooltip = 'Open in Flagsmith', + iconOnly = false, +}: FlagsmithLinkProps) => { + const classes = useStyles(); + + if (iconOnly) { + return ( + + + + + ); + } + + return ( + + + {children} + + ); +}; diff --git a/src/components/shared/LoadingState.tsx b/src/components/shared/LoadingState.tsx new file mode 100644 index 0000000..1633e5c --- /dev/null +++ b/src/components/shared/LoadingState.tsx @@ -0,0 +1,34 @@ +import { Box, CircularProgress, Typography } from '@material-ui/core'; + +interface LoadingStateProps { + message?: string; + size?: number; +} + +export const LoadingState = ({ + message = 'Loading...', + size = 40, +}: LoadingStateProps) => { + return ( + + + ); +}; diff --git a/src/components/shared/MiniPagination.tsx b/src/components/shared/MiniPagination.tsx new file mode 100644 index 0000000..20e4c78 --- /dev/null +++ b/src/components/shared/MiniPagination.tsx @@ -0,0 +1,58 @@ +import { Box, IconButton, Typography } from '@material-ui/core'; +import ChevronLeft from '@material-ui/icons/ChevronLeft'; +import ChevronRight from '@material-ui/icons/ChevronRight'; + +interface MiniPaginationProps { + page: number; + totalPages: number; + totalItems: number; + onPrevious: () => void; + onNext: () => void; + itemLabel?: string; +} + +export const MiniPagination = ({ + page, + totalPages, + totalItems, + onPrevious, + onNext, + itemLabel = 'items', +}: MiniPaginationProps) => { + if (totalPages <= 1) return null; + + return ( + + + Page {page + 1} of {totalPages} ({totalItems} {itemLabel}) + + + + + + = totalPages - 1} + aria-label="Next page" + > + + + + + ); +}; diff --git a/src/components/shared/SearchInput.tsx b/src/components/shared/SearchInput.tsx new file mode 100644 index 0000000..c8dbc8c --- /dev/null +++ b/src/components/shared/SearchInput.tsx @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; +import { TextField, InputAdornment } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import SearchIcon from '@material-ui/icons/Search'; +import ClearIcon from '@material-ui/icons/Clear'; +import IconButton from '@material-ui/core/IconButton'; + +const useStyles = makeStyles(() => ({ + root: { + minWidth: 200, + }, + clearButton: { + padding: 4, + }, +})); + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + debounceMs?: number; +} + +/** + * Reusable search input with debounce support + */ +export const SearchInput = ({ + value, + onChange, + placeholder = 'Search flags...', + debounceMs = 300, +}: SearchInputProps) => { + const classes = useStyles(); + const [localValue, setLocalValue] = useState(value); + const [debounceTimeout, setDebounceTimeout] = useState( + null, + ); + + const handleChange = useCallback( + (newValue: string) => { + setLocalValue(newValue); + + // Clear existing timeout + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + + // Set new timeout for debounced onChange + const timeout = setTimeout(() => { + onChange(newValue); + }, debounceMs); + + setDebounceTimeout(timeout); + }, + [onChange, debounceMs, debounceTimeout], + ); + + const handleClear = () => { + setLocalValue(''); + onChange(''); + }; + + return ( + handleChange(e.target.value)} + inputProps={{ + 'aria-label': placeholder, + role: 'searchbox', + }} + InputProps={{ + startAdornment: ( + + + ), + endAdornment: localValue ? ( + + + + + + ) : null, + }} + /> + ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts new file mode 100644 index 0000000..47e9473 --- /dev/null +++ b/src/components/shared/index.ts @@ -0,0 +1,5 @@ +export { FlagStatusIndicator } from './FlagStatusIndicator'; +export { SearchInput } from './SearchInput'; +export { FlagsmithLink } from './FlagsmithLink'; +export { MiniPagination } from './MiniPagination'; +export { LoadingState } from './LoadingState'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..01fda8f --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,5 @@ +export { useFlagsmithProject } from './useFlagsmithProject'; +export type { UseFlagsmithProjectResult } from './useFlagsmithProject'; + +export { useFlagsmithUsage } from './useFlagsmithUsage'; +export type { UseFlagsmithUsageResult } from './useFlagsmithUsage'; diff --git a/src/hooks/useFlagsmithProject.ts b/src/hooks/useFlagsmithProject.ts new file mode 100644 index 0000000..bd76d31 --- /dev/null +++ b/src/hooks/useFlagsmithProject.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { + FlagsmithClient, + FlagsmithProject, + FlagsmithEnvironment, + FlagsmithFeature, +} from '../api/FlagsmithClient'; + +export interface UseFlagsmithProjectResult { + project: FlagsmithProject | null; + environments: FlagsmithEnvironment[]; + features: FlagsmithFeature[]; + loading: boolean; + error: string | null; + client: FlagsmithClient; +} + +export function useFlagsmithProject( + projectId: string | undefined, +): UseFlagsmithProjectResult { + const discoveryApi = useApi(discoveryApiRef); + const fetchApi = useApi(fetchApiRef); + + const client = useMemo( + () => new FlagsmithClient(discoveryApi, fetchApi), + [discoveryApi, fetchApi], + ); + + const [project, setProject] = useState(null); + const [environments, setEnvironments] = useState([]); + const [features, setFeatures] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!projectId) { + setError('No Flagsmith project ID found in entity annotations'); + setLoading(false); + return; + } + + const fetchData = async () => { + try { + const projectData = await client.getProject(parseInt(projectId, 10)); + setProject(projectData); + + const envs = await client.getProjectEnvironments(parseInt(projectId, 10)); + setEnvironments(envs); + + const projectFeatures = await client.getProjectFeatures(projectId); + setFeatures(projectFeatures); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [projectId, client]); + + return { project, environments, features, loading, error, client }; +} diff --git a/src/hooks/useFlagsmithUsage.ts b/src/hooks/useFlagsmithUsage.ts new file mode 100644 index 0000000..f80e33f --- /dev/null +++ b/src/hooks/useFlagsmithUsage.ts @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react'; +import { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { + FlagsmithClient, + FlagsmithProject, + FlagsmithUsageData, +} from '../api/FlagsmithClient'; + +export interface UseFlagsmithUsageResult { + project: FlagsmithProject | null; + usageData: FlagsmithUsageData[]; + totalFlags: number; + loading: boolean; + error: string | null; +} + +export function useFlagsmithUsage( + projectId: string | undefined, + orgId: string | undefined, +): UseFlagsmithUsageResult { + const discoveryApi = useApi(discoveryApiRef); + const fetchApi = useApi(fetchApiRef); + + const [project, setProject] = useState(null); + const [usageData, setUsageData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!projectId || !orgId) { + setError('Missing Flagsmith project ID or organization ID in entity annotations'); + setLoading(false); + return; + } + + const fetchData = async () => { + try { + const client = new FlagsmithClient(discoveryApi, fetchApi); + + const projectData = await client.getProject(parseInt(projectId, 10)); + setProject(projectData); + + const usage = await client.getUsageData( + parseInt(orgId, 10), + parseInt(projectId, 10), + ); + setUsageData(usage); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [projectId, orgId, discoveryApi, fetchApi]); + + const totalFlags = usageData.reduce((sum, day) => sum + (day.flags ?? 0), 0); + + return { project, usageData, totalFlags, loading, error }; +} diff --git a/src/theme/flagsmithTheme.ts b/src/theme/flagsmithTheme.ts new file mode 100644 index 0000000..8a5a05d --- /dev/null +++ b/src/theme/flagsmithTheme.ts @@ -0,0 +1,52 @@ +/** + * Flagsmith brand colors and theme constants + */ +export const flagsmithColors = { + /** Teal - primary brand color, used for enabled states */ + primary: '#0AC2A3', + /** Purple - secondary brand color, used for focus/branding */ + secondary: '#7B51FB', + /** Green - flag enabled indicator */ + enabled: '#4CAF50', + /** Gray - flag disabled indicator */ + disabled: '#9E9E9E', + /** Orange - segment overrides warning */ + warning: '#FF9800', + /** Background colors for status states */ + background: { + enabled: 'rgba(76, 175, 80, 0.08)', + disabled: 'rgba(158, 158, 158, 0.08)', + warning: 'rgba(255, 152, 0, 0.1)', + }, +}; + +/** Default Flagsmith dashboard URL */ +export const FLAGSMITH_DASHBOARD_URL = 'https://app.flagsmith.com'; + +/** + * Build URL to a specific feature flag in the Flagsmith dashboard + */ +export function buildFlagUrl( + projectId: string | number, + environmentId: string | number, + featureId?: string | number, +): string { + const base = `${FLAGSMITH_DASHBOARD_URL}/project/${projectId}/environment/${environmentId}/features`; + if (featureId) { + return `${base}?feature=${featureId}`; + } + return base; +} + +/** + * Build URL to the project features page in the Flagsmith dashboard + */ +export function buildProjectUrl( + projectId: string | number, + environmentId?: string | number, +): string { + if (environmentId) { + return `${FLAGSMITH_DASHBOARD_URL}/project/${projectId}/environment/${environmentId}/features`; + } + return `${FLAGSMITH_DASHBOARD_URL}/project/${projectId}`; +} diff --git a/src/utils/flagHelpers.ts b/src/utils/flagHelpers.ts new file mode 100644 index 0000000..6a30d0d --- /dev/null +++ b/src/utils/flagHelpers.ts @@ -0,0 +1,58 @@ +import { FlagsmithFeature, FlagsmithEnvironment } from '../api/FlagsmithClient'; + +/** + * Get the enabled status for a feature in a specific environment. + * Falls back to default_enabled if no environment-specific state exists. + */ +export function getFeatureEnvStatus( + feature: FlagsmithFeature, + envId: number, +): boolean { + if (!feature.environment_state) { + return feature.default_enabled ?? false; + } + const state = feature.environment_state.find(s => s.id === envId); + return state?.enabled ?? feature.default_enabled ?? false; +} + +/** + * Build a tooltip string showing feature status across all environments. + */ +export function buildEnvStatusTooltip( + feature: FlagsmithFeature, + environments: FlagsmithEnvironment[], +): string { + return environments + .map(env => `${env.name}: ${getFeatureEnvStatus(feature, env.id) ? 'On' : 'Off'}`) + .join(' • '); +} + +/** + * Calculate enabled/disabled feature counts. + */ +export function calculateFeatureStats(features: FlagsmithFeature[]): { + enabledCount: number; + disabledCount: number; +} { + const enabledCount = features.filter(f => f.default_enabled).length; + return { + enabledCount, + disabledCount: features.length - enabledCount, + }; +} + +/** + * Paginate an array of items. + */ +export function paginate( + items: T[], + page: number, + pageSize: number, +): { + paginatedItems: T[]; + totalPages: number; +} { + const totalPages = Math.ceil(items.length / pageSize); + const paginatedItems = items.slice(page * pageSize, (page + 1) * pageSize); + return { paginatedItems, totalPages }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..54cc31d --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,6 @@ +export { + getFeatureEnvStatus, + buildEnvStatusTooltip, + calculateFeatureStats, + paginate, +} from './flagHelpers';