From 747085bfd1b1d46acb26f2491e08fca858bc77a4 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 10:59:04 -0300 Subject: [PATCH 01/14] feat(theme): add Flagsmith theme constants and dashboard URL helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add centralized theme configuration with: - Brand colors (primary teal, secondary purple) - Status colors (enabled green, disabled gray, warning orange) - Dashboard URL builder functions for deep linking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/theme/flagsmithTheme.ts | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/theme/flagsmithTheme.ts 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}`; +} From 92ee2bd80c6fecbe463104d089d7008878b4dc14 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 10:59:47 -0300 Subject: [PATCH 02/14] feat(components): add shared UI components for flag status display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add reusable components: - FlagStatusIndicator: colored dot for enabled/disabled states - SearchInput: debounced search input with clear button - FlagsmithLink: external link to Flagsmith dashboard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/shared/FlagStatusIndicator.tsx | 72 +++++++++++++++ src/components/shared/FlagsmithLink.tsx | 76 ++++++++++++++++ src/components/shared/SearchInput.tsx | 91 +++++++++++++++++++ src/components/shared/index.ts | 3 + 4 files changed, 242 insertions(+) create mode 100644 src/components/shared/FlagStatusIndicator.tsx create mode 100644 src/components/shared/FlagsmithLink.tsx create mode 100644 src/components/shared/SearchInput.tsx create mode 100644 src/components/shared/index.ts 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..24904ca --- /dev/null +++ b/src/components/shared/FlagsmithLink.tsx @@ -0,0 +1,76 @@ +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/SearchInput.tsx b/src/components/shared/SearchInput.tsx new file mode 100644 index 0000000..f99a418 --- /dev/null +++ b/src/components/shared/SearchInput.tsx @@ -0,0 +1,91 @@ +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={{ + startAdornment: ( + + + + ), + endAdornment: localValue ? ( + + + + + + ) : null, + }} + /> + ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts new file mode 100644 index 0000000..78b7c83 --- /dev/null +++ b/src/components/shared/index.ts @@ -0,0 +1,3 @@ +export { FlagStatusIndicator } from './FlagStatusIndicator'; +export { SearchInput } from './SearchInput'; +export { FlagsmithLink } from './FlagsmithLink'; From c847928b0da6ef0bf9389d206b3894107d9a5530 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 11:00:35 -0300 Subject: [PATCH 03/14] feat(api): add feature state interfaces for lazy loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TypeScript interfaces to support lazy loading of feature details: - FlagsmithFeatureStateValue: string/integer/boolean values - FlagsmithFeatureSegment: segment ID and priority - FlagsmithFeatureState: environment state with values and segments 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/api/FlagsmithClient.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 { From ebcdedc72c7a8fc6819ee35dc443ee014fe373ac Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 11:00:51 -0300 Subject: [PATCH 04/14] feat(FlagsTab): add Jira-style per-environment table with lazy loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor FlagsTab with improved UX: - Simplified main table with Flag Name, Type, and Created columns - Jira-style per-environment table in expanded row (inspired by #5641) - Lazy loading for detailed feature states on accordion expand - "Show additional details" collapsible for segment overrides - Search functionality with debounced filtering - Deep links to Flagsmith dashboard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsTab.tsx | 841 +++++++++++++++++++----------------- 1 file changed, 449 insertions(+), 392 deletions(-) diff --git a/src/components/FlagsTab.tsx b/src/components/FlagsTab.tsx index 1810251..8209fb2 100644 --- a/src/components/FlagsTab.tsx +++ b/src/components/FlagsTab.tsx @@ -1,12 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Typography, Box, CircularProgress, - FormControl, - InputLabel, - Select, - MenuItem, Grid, Table, TableBody, @@ -19,6 +15,7 @@ import { Collapse, Chip, } 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 { useEntity } from '@backstage/plugin-catalog-react'; @@ -33,35 +30,150 @@ import { FlagsmithFeature, FlagsmithFeatureDetails, } from '../api/FlagsmithClient'; +import { FlagStatusIndicator, SearchInput, FlagsmithLink } from './shared'; +import { flagsmithColors, buildFlagUrl, buildProjectUrl } from '../theme/flagsmithTheme'; + +const useStyles = makeStyles(theme => ({ + header: { + marginBottom: theme.spacing(2), + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + justifyContent: 'flex-end', + }, + flagName: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, + expandedContent: { + backgroundColor: theme.palette.background.default, + padding: theme.spacing(2), + }, + detailCard: { + padding: theme.spacing(1.5), + marginBottom: theme.spacing(1), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + }, + legend: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + marginTop: theme.spacing(1), + fontSize: '0.75rem', + color: theme.palette.text.secondary, + }, + legendItem: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, + 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), + }, + 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 ExpandableRowProps { feature: FlagsmithFeature; + environments: FlagsmithEnvironment[]; client: FlagsmithClient; - environmentId: number; + projectId: string; } const ExpandableRow = ({ feature, + environments, client, - environmentId, + projectId, }: ExpandableRowProps) => { + const classes = useStyles(); const [open, setOpen] = useState(false); - const [envStatesOpen, setEnvStatesOpen] = useState(false); + const [showMoreOpen, setShowMoreOpen] = useState(false); const [details, setDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); const [detailsError, setDetailsError] = useState(null); + // Use first environment for loading details + const primaryEnvId = environments[0]?.id; + const handleToggle = async () => { const newOpen = !open; setOpen(newOpen); // Load details on first expand - if (newOpen && !details && !loadingDetails) { + if (newOpen && !details && !loadingDetails && primaryEnvId) { setLoadingDetails(true); setDetailsError(null); try { const featureDetails = await client.getFeatureDetails( - environmentId, + primaryEnvId, feature.id, ); setDetails(featureDetails); @@ -75,39 +187,38 @@ const ExpandableRow = ({ } }; - // 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; + const segmentOverrides = details?.segmentOverrides ?? feature.num_segment_overrides ?? 0; + + // Build flag URL for first environment + const flagUrl = buildFlagUrl(projectId, primaryEnvId?.toString() || '', feature.id); return ( <> - - + + {open ? : } - - {feature.name} - {feature.description && ( - - {feature.description} - - )} + + + {feature.name} + + {feature.description && ( + + {feature.description.length > 60 + ? `${feature.description.substring(0, 60)}...` + : feature.description} + + )} - - - - - + + {feature.type || 'FLAG'} + @@ -115,10 +226,15 @@ const ExpandableRow = ({ + + {/* Expanded row content */} - + - + {loadingDetails && ( @@ -137,295 +253,243 @@ const ExpandableRow = ({ )} {!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} + + {/* Version Info */} + {liveVersion && ( + + + + Version - - Type: {feature.type} + + Status: {liveVersion.is_live ? 'Active' : 'Inactive'} - - Default Enabled:{' '} - {feature.default_enabled ? 'Yes' : 'No'} - - - Archived:{' '} - {feature.is_archived ? 'Yes' : 'No'} - - {feature.is_server_key_only && ( - - - + {liveVersion.live_from && ( + + Live since: {new Date(liveVersion.live_from).toLocaleDateString()} + )} + )} - {/* 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 + {/* Targeting Info */} + + + + Targeting - - - Segment Overrides: {segmentOverrides} - - {feature.num_identity_overrides !== null && - feature.num_identity_overrides !== undefined && ( - - Identity Overrides:{' '} - {feature.num_identity_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) => ( - - ))} - - - )} + {/* Metadata */} + + + + Details + + ID: {feature.id} + + Type: {feature.type || 'Standard'} + + {feature.is_server_key_only && ( + + )} + - {/* Environment States - Collapsible Section */} - {environmentState && environmentState.length > 0 && ( - - setEnvStatesOpen(!envStatesOpen)} - style={{ cursor: 'pointer' }} - > - - {envStatesOpen ? ( - - ) : ( - - )} - - - Environment States ({environmentState.length}) - + {/* Tags */} + {feature.tags && feature.tags.length > 0 && ( + + + {feature.tags.map((tag, index) => ( + + ))} - - - {environmentState.map((state: any) => ( - - - - - {state.feature_segment && ( + + )} + + {/* Owners */} + {feature.owners && feature.owners.length > 0 && ( + + + Owners:{' '} + {feature.owners + .map(o => o.email || `${o.name}`) + .join(', ')} + + + )} + + {/* Jira-style Per-Environment Table */} + + + + + 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; + // Get segments/variations count for this environment + const segmentCount = feature.num_segment_overrides ?? 0; + // For value, we use feature default or from environment_state if available + const value = feature.type === 'CONFIG' ? (feature as any).initial_value : null; + + return ( + + + + + {env.name} + + {segmentCount > 0 && ( 1 ? 's' : ''}`} size="small" - style={{ - backgroundColor: '#ff9800', - color: 'white', - marginRight: 8, - }} + variant="outlined" + className={classes.envBadge} /> )} - {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)} + + + + {enabled ? 'ON' : 'OFF'} + + + + + {value !== null && value !== undefined ? `"${value}"` : '-'} + + + + + {new Date(feature.created_date).toLocaleDateString()} + + + + ); + })} + +
+
+ + {/* Show More Section - Additional Details */} + + setShowMoreOpen(!showMoreOpen)} + > + {showMoreOpen ? : } + + {showMoreOpen ? 'Hide additional details' : 'Show additional details'} + + + + + + {/* Published & Archived Status */} + + + Published:{' '} + {liveVersion?.published ? 'Yes' : 'No'} + {liveVersion?.published_by && ` (by ${liveVersion.published_by})`} + + + Archived:{' '} + {feature.is_archived ? 'Yes' : 'No'} + + + + {/* Feature States with Segment Overrides */} + {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)} + + )} + )} - )} - - {/* Segment Information */} - {state.feature_segment && ( - - - Segment ID:{' '} - {state.feature_segment.segment} |{' '} - Priority:{' '} - {state.feature_segment.priority} - - - )} - - ))} - - -
- )} - + ))} + {details.featureState.filter(s => s.feature_segment !== null).length === 0 && ( + + No segment overrides configured. + + )} +
+ )} +
+
+ + )}
@@ -436,6 +500,7 @@ const ExpandableRow = ({ }; export const FlagsTab = () => { + const classes = useStyles(); const { entity } = useEntity(); const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); @@ -444,11 +509,8 @@ export const FlagsTab = () => { 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 [searchQuery, setSearchQuery] = useState(''); const [client] = useState(() => new FlagsmithClient(discoveryApi, fetchApi)); // Get project ID from entity annotations @@ -471,10 +533,9 @@ export const FlagsTab = () => { const envs = await client.getProjectEnvironments(parseInt(projectId, 10)); setEnvironments(envs); - // Select first environment by default - if (envs.length > 0) { - setSelectedEnvironment(envs[0].id); - } + // Fetch features + const projectFeatures = await client.getProjectFeatures(projectId); + setFeatures(projectFeatures); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -485,30 +546,26 @@ export const FlagsTab = () => { 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); - } - }; + // Filter features based on search query + 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]); - fetchFeaturesForEnvironment(); - }, [selectedEnvironment, projectId, client]); + // Count enabled/disabled + const enabledCount = features.filter(f => f.default_enabled).length; + const disabledCount = features.length - enabledCount; - // Handle environment selection change - const handleEnvironmentChange = (envId: number) => { - setSelectedEnvironment(envId); - }; + // Build project dashboard URL + const dashboardUrl = buildProjectUrl( + projectId || '', + environments[0]?.id?.toString(), + ); if (loading) { return ( @@ -534,73 +591,73 @@ export const FlagsTab = () => { return ( - - + {/* Header */} + + Feature Flags {projectInfo?.name} ({features.length} flags) + {/* Summary stats */} + + + + {enabledCount} Enabled + + + + {disabledCount} Disabled + + - - - - Environment - - + + + + + - - {featuresLoading ? ( - - - - ) : ( - - - - - - Flag Name - Status - Value - Created - - - - {features.length === 0 ? ( - - - - No feature flags found for this project - - - - ) : ( - features.map(feature => ( - - )) - )} - -
-
- )} -
+ {/* Table */} + + + + + + Flag Name + Type + Created + + + + {filteredFeatures.length === 0 ? ( + + + + {searchQuery + ? 'No flags match your search' + : 'No feature flags found for this project'} + + + + ) : ( + filteredFeatures.map(feature => ( + + )) + )} + +
+
); }; From 3d1e341fd903bac6c9b6e626a638a3feb8166518 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 11:03:47 -0300 Subject: [PATCH 05/14] feat(OverviewCard): add summary stats and dashboard link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve OverviewCard with: - Summary stats header (enabled/disabled counts) - Deep link to Flagsmith dashboard - Better visual hierarchy with Flagsmith theme colors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsmithOverviewCard.tsx | 142 ++++++++++++++++------- 1 file changed, 99 insertions(+), 43 deletions(-) diff --git a/src/components/FlagsmithOverviewCard.tsx b/src/components/FlagsmithOverviewCard.tsx index 1b8e397..e115386 100644 --- a/src/components/FlagsmithOverviewCard.tsx +++ b/src/components/FlagsmithOverviewCard.tsx @@ -10,9 +10,10 @@ import { TableHead, TableRow, Paper, - Chip, IconButton, + Tooltip, } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; import ChevronLeft from '@material-ui/icons/ChevronLeft'; import ChevronRight from '@material-ui/icons/ChevronRight'; import { InfoCard } from '@backstage/core-components'; @@ -27,8 +28,36 @@ import { FlagsmithFeature, FlagsmithEnvironment, } from '../api/FlagsmithClient'; +import { FlagStatusIndicator, FlagsmithLink } from './shared'; +import { buildProjectUrl } from '../theme/flagsmithTheme'; + +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), + }, + envDots: { + display: 'flex', + gap: 2, + justifyContent: 'flex-end', + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, +})); export const FlagsmithOverviewCard = () => { + const classes = useStyles(); const { entity } = useEntity(); const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); @@ -38,9 +67,6 @@ export const FlagsmithOverviewCard = () => { 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; @@ -66,10 +92,9 @@ export const FlagsmithOverviewCard = () => { const envs = await client.getProjectEnvironments(parseInt(projectId, 10)); setEnvironments(envs); - // Select first environment by default - if (envs.length > 0) { - setSelectedEnvironment(envs[0].id); - } + // Fetch features + const projectFeatures = await client.getProjectFeatures(projectId); + setFeatures(projectFeatures); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -80,24 +105,6 @@ export const FlagsmithOverviewCard = () => { 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 ( @@ -124,21 +131,70 @@ export const FlagsmithOverviewCard = () => { ); const totalPages = Math.ceil(features.length / pageSize); + // Calculate enabled/disabled counts + const enabledCount = features.filter(f => f.default_enabled).length; + const disabledCount = features.length - enabledCount; + + // Build dashboard URL + const dashboardUrl = buildProjectUrl( + projectId || '', + environments[0]?.id?.toString(), + ); + + // Get environment status for a feature + const getEnvStatus = (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 environment status tooltip + const buildEnvTooltip = (feature: FlagsmithFeature): string => { + return environments + .map(env => `${env.name}: ${getEnvStatus(feature, env.id) ? 'On' : 'Off'}`) + .join(' • '); + }; + return ( - + + + + } + > + {/* Summary Stats */} + + + + + {enabledCount} Enabled + + + + {disabledCount} Disabled + + + + Flag Name - Default - Environment + + e.name).join(' • ')}> + Environments + + {paginatedFeatures.length === 0 ? ( - + No feature flags found @@ -151,23 +207,23 @@ export const FlagsmithOverviewCard = () => { {feature.name} {feature.description && ( - {feature.description.substring(0, 50)} - {feature.description.length > 50 ? '...' : ''} + {feature.description.substring(0, 40)} + {feature.description.length > 40 ? '...' : ''} )} - - - - - {environments.find(env => env.id === selectedEnvironment) - ?.name || 'Unknown'} - + + + {environments.map(env => ( + + ))} + + )) From 355b327d7facffc7968761c7c0ae93091f4c3933 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 11:03:57 -0300 Subject: [PATCH 06/14] feat(UsageCard): apply Flagsmith brand colors to chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update usage chart with Flagsmith teal color for better brand consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsmithUsageCard.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/FlagsmithUsageCard.tsx b/src/components/FlagsmithUsageCard.tsx index 82934cd..543de55 100644 --- a/src/components/FlagsmithUsageCard.tsx +++ b/src/components/FlagsmithUsageCard.tsx @@ -4,6 +4,7 @@ import { Box, CircularProgress, } 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 { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; @@ -17,6 +18,16 @@ import { Tooltip, ResponsiveContainer, } from 'recharts'; +import { FlagsmithLink } from './shared'; +import { flagsmithColors, FLAGSMITH_DASHBOARD_URL } from '../theme/flagsmithTheme'; + +const useStyles = makeStyles(theme => ({ + headerActions: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, +})); interface CustomTooltipProps { active?: boolean; @@ -66,6 +77,7 @@ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { }; export const FlagsmithUsageCard = () => { + const classes = useStyles(); const { entity } = useEntity(); const discoveryApi = useApi(discoveryApiRef); const fetchApi = useApi(fetchApiRef); @@ -79,6 +91,9 @@ export const FlagsmithUsageCard = () => { const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; const orgId = entity.metadata.annotations?.['flagsmith.com/org-id']; + // Build usage analytics URL + const usageUrl = `${FLAGSMITH_DASHBOARD_URL}/organisation/${orgId}/usage`; + useEffect(() => { if (!projectId || !orgId) { setError('Missing Flagsmith project ID or organization ID in entity annotations'); @@ -141,7 +156,14 @@ export const FlagsmithUsageCard = () => { return ( + + + ) + } > @@ -162,7 +184,7 @@ export const FlagsmithUsageCard = () => { /> } /> - + From e75d793ff1af964abc418cfe83863b984f1d57cb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 11:04:31 -0300 Subject: [PATCH 07/14] test(mocks): update mock data for lazy loading feature states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive mock data to support the new UI: - Per-environment feature states with values - Segment override data with priorities - Feature versions with publish info - Multi-environment status for each feature 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- dev/mockHandlers.ts | 117 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 9 deletions(-) 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', + }, ], }; From 14787ef5a9c69b115b3a8090b770f179d45083ad Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 11:09:08 -0300 Subject: [PATCH 08/14] refactor(FlagsTab): remove misleading enabled/disabled counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the summary stats (X Enabled, Y Disabled) from the header as they were based on default_enabled which doesn't reflect actual per-environment status. A flag could be enabled in Production but disabled in Development, making this count confusing. Following LaunchDarkly plugin pattern of showing only project name and total flag count. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsTab.tsx | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/components/FlagsTab.tsx b/src/components/FlagsTab.tsx index 8209fb2..ea76751 100644 --- a/src/components/FlagsTab.tsx +++ b/src/components/FlagsTab.tsx @@ -58,19 +58,6 @@ const useStyles = makeStyles(theme => ({ border: `1px solid ${theme.palette.divider}`, borderRadius: theme.shape.borderRadius, }, - legend: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(2), - marginTop: theme.spacing(1), - fontSize: '0.75rem', - color: theme.palette.text.secondary, - }, - legendItem: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - }, showMoreButton: { display: 'flex', alignItems: 'center', @@ -557,10 +544,6 @@ export const FlagsTab = () => { ); }, [features, searchQuery]); - // Count enabled/disabled - const enabledCount = features.filter(f => f.default_enabled).length; - const disabledCount = features.length - enabledCount; - // Build project dashboard URL const dashboardUrl = buildProjectUrl( projectId || '', @@ -598,17 +581,6 @@ export const FlagsTab = () => { {projectInfo?.name} ({features.length} flags) - {/* Summary stats */} - - - - {enabledCount} Enabled - - - - {disabledCount} Disabled - - From 0a90fe168d1c3e32527492b576385ce6f8ae662e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 22 Dec 2025 23:21:44 -0300 Subject: [PATCH 09/14] refactor(FlagsTab): split into smaller focused components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract FlagsTab.tsx (636 lines) into modular components: - index.tsx: Main component with data fetching and table layout - ExpandableRow.tsx: Row expansion with lazy loading - EnvironmentTable.tsx: Per-environment status display - FeatureDetailsGrid.tsx: Version, targeting, and details cards - SegmentOverridesSection.tsx: Collapsible segment overrides This improves maintainability and prepares for test coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsTab.tsx | 635 ------------------ src/components/FlagsTab/EnvironmentTable.tsx | 119 ++++ src/components/FlagsTab/ExpandableRow.tsx | 175 +++++ .../FlagsTab/FeatureDetailsGrid.tsx | 115 ++++ .../FlagsTab/SegmentOverridesSection.tsx | 161 +++++ src/components/FlagsTab/index.tsx | 182 +++++ 6 files changed, 752 insertions(+), 635 deletions(-) delete mode 100644 src/components/FlagsTab.tsx create mode 100644 src/components/FlagsTab/EnvironmentTable.tsx create mode 100644 src/components/FlagsTab/ExpandableRow.tsx create mode 100644 src/components/FlagsTab/FeatureDetailsGrid.tsx create mode 100644 src/components/FlagsTab/SegmentOverridesSection.tsx create mode 100644 src/components/FlagsTab/index.tsx diff --git a/src/components/FlagsTab.tsx b/src/components/FlagsTab.tsx deleted file mode 100644 index ea76751..0000000 --- a/src/components/FlagsTab.tsx +++ /dev/null @@ -1,635 +0,0 @@ -import { useState, useEffect, useMemo } from 'react'; -import { - Typography, - Box, - CircularProgress, - Grid, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - IconButton, - Collapse, - Chip, -} 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 { useEntity } from '@backstage/plugin-catalog-react'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import { - FlagsmithClient, - FlagsmithEnvironment, - FlagsmithFeature, - FlagsmithFeatureDetails, -} from '../api/FlagsmithClient'; -import { FlagStatusIndicator, SearchInput, FlagsmithLink } from './shared'; -import { flagsmithColors, buildFlagUrl, buildProjectUrl } from '../theme/flagsmithTheme'; - -const useStyles = makeStyles(theme => ({ - header: { - marginBottom: theme.spacing(2), - }, - headerActions: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(2), - justifyContent: 'flex-end', - }, - flagName: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - }, - expandedContent: { - backgroundColor: theme.palette.background.default, - padding: theme.spacing(2), - }, - detailCard: { - padding: theme.spacing(1.5), - marginBottom: theme.spacing(1), - border: `1px solid ${theme.palette.divider}`, - borderRadius: theme.shape.borderRadius, - }, - 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), - }, - 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 ExpandableRowProps { - feature: FlagsmithFeature; - environments: FlagsmithEnvironment[]; - client: FlagsmithClient; - projectId: string; -} - -const ExpandableRow = ({ - feature, - environments, - client, - projectId, -}: ExpandableRowProps) => { - const classes = useStyles(); - const [open, setOpen] = useState(false); - const [showMoreOpen, setShowMoreOpen] = useState(false); - const [details, setDetails] = useState(null); - const [loadingDetails, setLoadingDetails] = useState(false); - const [detailsError, setDetailsError] = useState(null); - - // Use first environment for loading details - const primaryEnvId = environments[0]?.id; - - const handleToggle = async () => { - const newOpen = !open; - setOpen(newOpen); - - // Load details on first expand - 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; - - // Build flag URL for first environment - 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()} - - - - - {/* Expanded row content */} - - - - - {loadingDetails && ( - - - - Loading feature details... - - - )} - {!loadingDetails && detailsError && ( - - {detailsError} - - )} - {!loadingDetails && !detailsError && ( - - {/* Version Info */} - {liveVersion && ( - - - - Version - - - Status: {liveVersion.is_live ? 'Active' : 'Inactive'} - - {liveVersion.live_from && ( - - Live since: {new Date(liveVersion.live_from).toLocaleDateString()} - - )} - - - )} - - {/* Targeting Info */} - - - - Targeting - - - Segment overrides: {segmentOverrides} - - {feature.num_identity_overrides !== null && - feature.num_identity_overrides !== undefined && ( - - Identity overrides: {feature.num_identity_overrides} - - )} - - - - {/* Metadata */} - - - - Details - - ID: {feature.id} - - Type: {feature.type || 'Standard'} - - {feature.is_server_key_only && ( - - )} - - - - {/* Tags */} - {feature.tags && feature.tags.length > 0 && ( - - - {feature.tags.map((tag, index) => ( - - ))} - - - )} - - {/* Owners */} - {feature.owners && feature.owners.length > 0 && ( - - - Owners:{' '} - {feature.owners - .map(o => o.email || `${o.name}`) - .join(', ')} - - - )} - - {/* Jira-style Per-Environment Table */} - -
- - - 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; - // Get segments/variations count for this environment - const segmentCount = feature.num_segment_overrides ?? 0; - // For value, we use feature default or from environment_state if available - const value = feature.type === 'CONFIG' ? (feature as any).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()} - - - - ); - })} - -
- - - {/* Show More Section - Additional Details */} - - setShowMoreOpen(!showMoreOpen)} - > - {showMoreOpen ? : } - - {showMoreOpen ? 'Hide additional details' : 'Show additional details'} - - - - - - {/* Published & Archived Status */} - - - Published:{' '} - {liveVersion?.published ? 'Yes' : 'No'} - {liveVersion?.published_by && ` (by ${liveVersion.published_by})`} - - - Archived:{' '} - {feature.is_archived ? 'Yes' : 'No'} - - - - {/* Feature States with Segment Overrides */} - {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. - - )} - - )} - - - - - )} - - - - - - ); -}; - -export const FlagsTab = () => { - const classes = useStyles(); - 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 [features, setFeatures] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - 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); - - // Fetch features - const projectFeatures = await client.getProjectFeatures(projectId); - setFeatures(projectFeatures); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [projectId, client]); - - // Filter features based on search query - 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]); - - // Build project dashboard URL - 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 ( - - {/* Header */} - - - Feature Flags - - {projectInfo?.name} ({features.length} flags) - - - - - - - - - - - {/* Table */} - - - - - - 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/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..ba23df0 --- /dev/null +++ b/src/components/FlagsTab/ExpandableRow.tsx @@ -0,0 +1,175 @@ +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..a099acb --- /dev/null +++ b/src/components/FlagsTab/index.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Typography, + Box, + CircularProgress, + 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 { + useApi, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; +import { + FlagsmithClient, + FlagsmithEnvironment, + FlagsmithFeature, + FlagsmithProject, +} from '../../api/FlagsmithClient'; +import { SearchInput, FlagsmithLink } from '../shared'; +import { buildProjectUrl } from '../../theme/flagsmithTheme'; +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 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 [features, setFeatures] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [client] = useState(() => new FlagsmithClient(discoveryApi, fetchApi)); + + 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 project = await client.getProject(parseInt(projectId, 10)); + setProjectInfo(project); + + 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]); + + 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 + + {projectInfo?.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 => ( + + )) + )} + +
+
+
+ ); +}; From 25c64e44a68764748c6b5633a09886b4f215c767 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 22 Dec 2025 23:44:05 -0300 Subject: [PATCH 10/14] feat: add custom hooks and utility functions for testability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add reusable hooks for data fetching: - useFlagsmithProject: Fetches project, environments, and features - useFlagsmithUsage: Fetches usage data with total calculation Add utility functions in src/utils/flagHelpers.ts: - getFeatureEnvStatus: Get feature status per environment - buildEnvStatusTooltip: Build tooltip for environment statuses - calculateFeatureStats: Calculate enabled/disabled counts - paginate: Generic pagination helper These abstractions prepare for comprehensive test coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/hooks/index.ts | 5 +++ src/hooks/useFlagsmithProject.ts | 60 +++++++++++++++++++++++++++++++ src/hooks/useFlagsmithUsage.ts | 61 ++++++++++++++++++++++++++++++++ src/utils/flagHelpers.ts | 58 ++++++++++++++++++++++++++++++ src/utils/index.ts | 6 ++++ 5 files changed, 190 insertions(+) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useFlagsmithProject.ts create mode 100644 src/hooks/useFlagsmithUsage.ts create mode 100644 src/utils/flagHelpers.ts create mode 100644 src/utils/index.ts 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..055b0e9 --- /dev/null +++ b/src/hooks/useFlagsmithProject.ts @@ -0,0 +1,60 @@ +import { useState, useEffect } 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; +} + +export function useFlagsmithProject( + projectId: string | undefined, +): UseFlagsmithProjectResult { + const discoveryApi = useApi(discoveryApiRef); + const fetchApi = useApi(fetchApiRef); + + 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 client = new FlagsmithClient(discoveryApi, fetchApi); + + 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, discoveryApi, fetchApi]); + + return { project, environments, features, loading, error }; +} 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/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'; From 4fd70199d675e3767f703838e7ca8d916d1cb172 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 22 Dec 2025 23:46:05 -0300 Subject: [PATCH 11/14] refactor(FlagsmithOverviewCard): split into smaller components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract FlagsmithOverviewCard.tsx (269 lines) into modular components: - index.tsx: Main component using custom hook - FlagStatsRow.tsx: Enabled/disabled stats display - FeatureFlagRow.tsx: Individual feature row with environment dots - MiniPagination.tsx: Reusable pagination component Uses useFlagsmithProject hook and utility functions for cleaner code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsmithOverviewCard.tsx | 268 ------------------ .../FlagsmithOverviewCard/FeatureFlagRow.tsx | 49 ++++ .../FlagsmithOverviewCard/FlagStatsRow.tsx | 42 +++ .../FlagsmithOverviewCard/MiniPagination.tsx | 54 ++++ .../FlagsmithOverviewCard/index.tsx | 131 +++++++++ 5 files changed, 276 insertions(+), 268 deletions(-) delete mode 100644 src/components/FlagsmithOverviewCard.tsx create mode 100644 src/components/FlagsmithOverviewCard/FeatureFlagRow.tsx create mode 100644 src/components/FlagsmithOverviewCard/FlagStatsRow.tsx create mode 100644 src/components/FlagsmithOverviewCard/MiniPagination.tsx create mode 100644 src/components/FlagsmithOverviewCard/index.tsx diff --git a/src/components/FlagsmithOverviewCard.tsx b/src/components/FlagsmithOverviewCard.tsx deleted file mode 100644 index e115386..0000000 --- a/src/components/FlagsmithOverviewCard.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Typography, - Box, - CircularProgress, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - IconButton, - Tooltip, -} from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; -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'; -import { FlagStatusIndicator, FlagsmithLink } from './shared'; -import { buildProjectUrl } from '../theme/flagsmithTheme'; - -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), - }, - envDots: { - display: 'flex', - gap: 2, - justifyContent: 'flex-end', - }, - headerActions: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - }, -})); - -export const FlagsmithOverviewCard = () => { - const classes = useStyles(); - 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 [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); - - // Fetch features - const projectFeatures = await client.getProjectFeatures(projectId); - setFeatures(projectFeatures); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [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); - - // Calculate enabled/disabled counts - const enabledCount = features.filter(f => f.default_enabled).length; - const disabledCount = features.length - enabledCount; - - // Build dashboard URL - const dashboardUrl = buildProjectUrl( - projectId || '', - environments[0]?.id?.toString(), - ); - - // Get environment status for a feature - const getEnvStatus = (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 environment status tooltip - const buildEnvTooltip = (feature: FlagsmithFeature): string => { - return environments - .map(env => `${env.name}: ${getEnvStatus(feature, env.id) ? 'On' : 'Off'}`) - .join(' • '); - }; - - return ( - - - - } - > - {/* Summary Stats */} - - - - - {enabledCount} Enabled - - - - {disabledCount} Disabled - - - - - - - - - Flag Name - - e.name).join(' • ')}> - Environments - - - - - - {paginatedFeatures.length === 0 ? ( - - - - No feature flags found - - - - ) : ( - paginatedFeatures.map(feature => ( - - - {feature.name} - {feature.description && ( - - {feature.description.substring(0, 40)} - {feature.description.length > 40 ? '...' : ''} - - )} - - - - - {environments.map(env => ( - - ))} - - - - - )) - )} - -
-
- - {/* 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/MiniPagination.tsx b/src/components/FlagsmithOverviewCard/MiniPagination.tsx new file mode 100644 index 0000000..af658be --- /dev/null +++ b/src/components/FlagsmithOverviewCard/MiniPagination.tsx @@ -0,0 +1,54 @@ +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} + > + + + + + ); +}; diff --git a/src/components/FlagsmithOverviewCard/index.tsx b/src/components/FlagsmithOverviewCard/index.tsx new file mode 100644 index 0000000..9b3de31 --- /dev/null +++ b/src/components/FlagsmithOverviewCard/index.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { + Typography, + Box, + CircularProgress, + 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 } from '../shared'; +import { buildProjectUrl } from '../../theme/flagsmithTheme'; +import { useFlagsmithProject } from '../../hooks'; +import { calculateFeatureStats, paginate } from '../../utils'; +import { FlagStatsRow } from './FlagStatsRow'; +import { FeatureFlagRow } from './FeatureFlagRow'; +import { MiniPagination } from './MiniPagination'; + +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" + /> +
+ ); +}; From 5f65b4c5c6a31f5de147d87f4268825ce71ed339 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 22 Dec 2025 23:46:40 -0300 Subject: [PATCH 12/14] refactor(FlagsmithUsageCard): split into smaller components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract FlagsmithUsageCard.tsx (202 lines) into modular components: - index.tsx: Main component using custom hook - UsageChart.tsx: Bar chart with recharts - UsageTooltip.tsx: Custom tooltip for chart data points Uses useFlagsmithUsage hook for cleaner separation of concerns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsmithUsageCard.tsx | 201 ------------------ .../FlagsmithUsageCard/UsageChart.tsx | 57 +++++ .../FlagsmithUsageCard/UsageTooltip.tsx | 50 +++++ src/components/FlagsmithUsageCard/index.tsx | 79 +++++++ 4 files changed, 186 insertions(+), 201 deletions(-) delete mode 100644 src/components/FlagsmithUsageCard.tsx create mode 100644 src/components/FlagsmithUsageCard/UsageChart.tsx create mode 100644 src/components/FlagsmithUsageCard/UsageTooltip.tsx create mode 100644 src/components/FlagsmithUsageCard/index.tsx diff --git a/src/components/FlagsmithUsageCard.tsx b/src/components/FlagsmithUsageCard.tsx deleted file mode 100644 index 543de55..0000000 --- a/src/components/FlagsmithUsageCard.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Typography, - Box, - CircularProgress, -} 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 { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; -import { FlagsmithClient, FlagsmithUsageData } from '../api/FlagsmithClient'; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from 'recharts'; -import { FlagsmithLink } from './shared'; -import { flagsmithColors, FLAGSMITH_DASHBOARD_URL } from '../theme/flagsmithTheme'; - -const useStyles = makeStyles(theme => ({ - headerActions: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - }, -})); - -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 classes = useStyles(); - 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']; - - // Build usage analytics URL - const usageUrl = `${FLAGSMITH_DASHBOARD_URL}/organisation/${orgId}/usage`; - - 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..92ba7af --- /dev/null +++ b/src/components/FlagsmithUsageCard/index.tsx @@ -0,0 +1,79 @@ +import { Typography, Box, CircularProgress } 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 } 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 ( + + + + ) + } + > + + + + + ); +}; From 78a2a9885af326c0b256aca49a8c690b549e9075 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 22 Dec 2025 23:52:56 -0300 Subject: [PATCH 13/14] refactor(FlagsTab): use useFlagsmithProject hook for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace inline data fetching with useFlagsmithProject hook - Add client to hook return for lazy loading in ExpandableRow - Simplifies FlagsTab from 175 to 138 lines - All three main components now use their respective hooks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsTab/index.tsx | 54 +++---------------------------- src/hooks/useFlagsmithProject.ts | 14 +++++--- 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/src/components/FlagsTab/index.tsx b/src/components/FlagsTab/index.tsx index a099acb..e9ea9ee 100644 --- a/src/components/FlagsTab/index.tsx +++ b/src/components/FlagsTab/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { Typography, Box, @@ -14,19 +14,9 @@ import { } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { - useApi, - discoveryApiRef, - fetchApiRef, -} from '@backstage/core-plugin-api'; -import { - FlagsmithClient, - FlagsmithEnvironment, - FlagsmithFeature, - FlagsmithProject, -} from '../../api/FlagsmithClient'; import { SearchInput, FlagsmithLink } from '../shared'; import { buildProjectUrl } from '../../theme/flagsmithTheme'; +import { useFlagsmithProject } from '../../hooks'; import { ExpandableRow } from './ExpandableRow'; const useStyles = makeStyles(theme => ({ @@ -44,45 +34,11 @@ const useStyles = makeStyles(theme => ({ export const FlagsTab = () => { const classes = useStyles(); 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 [features, setFeatures] = useState([]); const [searchQuery, setSearchQuery] = useState(''); - const [client] = useState(() => new FlagsmithClient(discoveryApi, fetchApi)); 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 project = await client.getProject(parseInt(projectId, 10)); - setProjectInfo(project); - - 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]); + const { project, environments, features, loading, error, client } = + useFlagsmithProject(projectId); const filteredFeatures = useMemo(() => { if (!searchQuery.trim()) return features; @@ -127,7 +83,7 @@ export const FlagsTab = () => { Feature Flags - {projectInfo?.name} ({features.length} flags) + {project?.name} ({features.length} flags) diff --git a/src/hooks/useFlagsmithProject.ts b/src/hooks/useFlagsmithProject.ts index 055b0e9..bd76d31 100644 --- a/src/hooks/useFlagsmithProject.ts +++ b/src/hooks/useFlagsmithProject.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useApi, discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; import { FlagsmithClient, @@ -13,6 +13,7 @@ export interface UseFlagsmithProjectResult { features: FlagsmithFeature[]; loading: boolean; error: string | null; + client: FlagsmithClient; } export function useFlagsmithProject( @@ -21,6 +22,11 @@ export function useFlagsmithProject( 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([]); @@ -36,8 +42,6 @@ export function useFlagsmithProject( const fetchData = async () => { try { - const client = new FlagsmithClient(discoveryApi, fetchApi); - const projectData = await client.getProject(parseInt(projectId, 10)); setProject(projectData); @@ -54,7 +58,7 @@ export function useFlagsmithProject( }; fetchData(); - }, [projectId, discoveryApi, fetchApi]); + }, [projectId, client]); - return { project, environments, features, loading, error }; + return { project, environments, features, loading, error, client }; } From 8c1638a9a7cc9e4858c7d02cf66d2a91cd1ccb85 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Mon, 22 Dec 2025 23:56:39 -0300 Subject: [PATCH 14/14] feat(shared): add reusable LoadingState and MiniPagination components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shared components with accessibility improvements: **New shared components:** - LoadingState: Consistent loading spinner with message and ARIA support - MiniPagination: Moved from OverviewCard to shared (reusable) **Accessibility improvements:** - Add aria-label and aria-expanded to ExpandableRow toggle button - Add role="searchbox" and aria-label to SearchInput - Add aria-label to clear search button - Add aria-label to FlagsmithLink (both icon and text variants) - Add aria-hidden to decorative icons - Add role="navigation" to MiniPagination - Add role="status" to LoadingState **Refactored to use shared components:** - FlagsTab, FlagsmithOverviewCard, FlagsmithUsageCard now use LoadingState - FlagsmithOverviewCard now imports MiniPagination from shared 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsTab/ExpandableRow.tsx | 7 +++- src/components/FlagsTab/index.tsx | 9 ++--- .../FlagsmithOverviewCard/index.tsx | 8 ++--- src/components/FlagsmithUsageCard/index.tsx | 8 ++--- src/components/shared/FlagsmithLink.tsx | 6 ++-- src/components/shared/LoadingState.tsx | 34 +++++++++++++++++++ .../MiniPagination.tsx | 4 +++ src/components/shared/SearchInput.tsx | 7 +++- src/components/shared/index.ts | 2 ++ 9 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 src/components/shared/LoadingState.tsx rename src/components/{FlagsmithOverviewCard => shared}/MiniPagination.tsx (89%) diff --git a/src/components/FlagsTab/ExpandableRow.tsx b/src/components/FlagsTab/ExpandableRow.tsx index ba23df0..4e27a78 100644 --- a/src/components/FlagsTab/ExpandableRow.tsx +++ b/src/components/FlagsTab/ExpandableRow.tsx @@ -93,7 +93,12 @@ export const ExpandableRow = ({ <> - + {open ? : } diff --git a/src/components/FlagsTab/index.tsx b/src/components/FlagsTab/index.tsx index e9ea9ee..8aa3fb7 100644 --- a/src/components/FlagsTab/index.tsx +++ b/src/components/FlagsTab/index.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { Typography, Box, - CircularProgress, Grid, Table, TableBody, @@ -14,7 +13,7 @@ import { } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { SearchInput, FlagsmithLink } from '../shared'; +import { SearchInput, FlagsmithLink, LoadingState } from '../shared'; import { buildProjectUrl } from '../../theme/flagsmithTheme'; import { useFlagsmithProject } from '../../hooks'; import { ExpandableRow } from './ExpandableRow'; @@ -56,11 +55,7 @@ export const FlagsTab = () => { ); if (loading) { - return ( - - - - ); + return ; } if (error) { diff --git a/src/components/FlagsmithOverviewCard/index.tsx b/src/components/FlagsmithOverviewCard/index.tsx index 9b3de31..d9e6ca8 100644 --- a/src/components/FlagsmithOverviewCard/index.tsx +++ b/src/components/FlagsmithOverviewCard/index.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { Typography, Box, - CircularProgress, Table, TableBody, TableCell, @@ -15,13 +14,12 @@ import { import { makeStyles } from '@material-ui/core/styles'; import { InfoCard } from '@backstage/core-components'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { FlagsmithLink } from '../shared'; +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'; -import { MiniPagination } from './MiniPagination'; const useStyles = makeStyles(theme => ({ headerActions: { @@ -44,9 +42,7 @@ export const FlagsmithOverviewCard = () => { if (loading) { return ( - - - + ); } diff --git a/src/components/FlagsmithUsageCard/index.tsx b/src/components/FlagsmithUsageCard/index.tsx index 92ba7af..cd045ce 100644 --- a/src/components/FlagsmithUsageCard/index.tsx +++ b/src/components/FlagsmithUsageCard/index.tsx @@ -1,8 +1,8 @@ -import { Typography, Box, CircularProgress } from '@material-ui/core'; +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 } from '../shared'; +import { FlagsmithLink, LoadingState } from '../shared'; import { FLAGSMITH_DASHBOARD_URL } from '../../theme/flagsmithTheme'; import { useFlagsmithUsage } from '../../hooks'; import { UsageChart } from './UsageChart'; @@ -32,9 +32,7 @@ export const FlagsmithUsageCard = () => { if (loading) { return ( - - - + ); } diff --git a/src/components/shared/FlagsmithLink.tsx b/src/components/shared/FlagsmithLink.tsx index 24904ca..0050650 100644 --- a/src/components/shared/FlagsmithLink.tsx +++ b/src/components/shared/FlagsmithLink.tsx @@ -53,8 +53,9 @@ export const FlagsmithLink = ({ target="_blank" rel="noopener noreferrer" size="small" + aria-label={tooltip} > - +