Last active
May 29, 2025 22:10
-
-
Save brandonbryant12/ad3898e2f68c050eea8e3ace28c63a84 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useEffect, useCallback } from 'react'; | |
| import { | |
| Alert, | |
| Box, | |
| Switch, | |
| FormControlLabel, | |
| IconButton, | |
| Typography, | |
| } from '@mui/material'; | |
| import { Flag as FlagIcon, Close as CloseIcon } from '@mui/icons-material'; | |
| import { | |
| useApi, | |
| featureFlagsApiRef, | |
| FeatureFlagState, | |
| } from '@backstage/core-plugin-api'; | |
| export interface FeatureFlagBannerProps { | |
| flagName: string; | |
| displayName: string; | |
| description?: string; | |
| onToggle?: (enabled: boolean) => void; | |
| } | |
| export const FeatureFlagBanner: React.FC<FeatureFlagBannerProps> = ({ | |
| flagName, | |
| displayName, | |
| description, | |
| onToggle, | |
| }) => { | |
| const featureFlagsApi = useApi(featureFlagsApiRef); | |
| const [isEnabled, setIsEnabled] = useState(false); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [showBanner, setShowBanner] = useState(true); | |
| const loadState = useCallback(async () => { | |
| const registered = await featureFlagsApi.getRegisteredFlags(); | |
| if (!registered.some(f => f.name === flagName)) { | |
| console.error(`Feature flag "${flagName}" is not registered`); | |
| setIsLoading(false); | |
| return; | |
| } | |
| setIsEnabled(featureFlagsApi.isActive(flagName)); | |
| setIsLoading(false); | |
| }, [featureFlagsApi, flagName]); | |
| useEffect(() => { | |
| loadState(); | |
| }, [loadState]); | |
| const handleToggle = async (_: any, checked: boolean) => { | |
| await featureFlagsApi.save({ | |
| states: { [flagName]: checked ? FeatureFlagState.Active : FeatureFlagState.None }, | |
| }); | |
| setIsEnabled(checked); | |
| onToggle?.(checked); | |
| }; | |
| if (isLoading || !showBanner) return null; | |
| return ( | |
| <Alert | |
| severity="info" | |
| icon={<FlagIcon />} | |
| sx={{ | |
| mb: 2, | |
| alignItems: 'center', | |
| backgroundColor: '#e3f2fd', | |
| color: '#1976d2', | |
| border: '1px solid #1976d2', | |
| '& .MuiAlert-icon': { color: '#1976d2' }, | |
| }} | |
| action={ | |
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> | |
| <FormControlLabel | |
| sx={{ m: 0 }} | |
| label={ | |
| <Typography variant="body2" fontWeight="medium"> | |
| {isEnabled ? 'ON' : 'OFF'} | |
| </Typography> | |
| } | |
| labelPlacement="start" | |
| control={ | |
| <Switch | |
| checked={isEnabled} | |
| onChange={handleToggle} | |
| size="small" | |
| sx={{ | |
| '& .MuiSwitch-switchBase': { | |
| color: '#1976d2', | |
| '&.Mui-checked': { | |
| color: '#1976d2', | |
| '& + .MuiSwitch-track': { | |
| backgroundColor: '#1976d2', | |
| opacity: 0.5, | |
| }, | |
| }, | |
| }, | |
| '& .MuiSwitch-track': { | |
| backgroundColor: '#1976d2', | |
| opacity: 0.3, | |
| }, | |
| }} | |
| inputProps={{ 'aria-label': `Toggle ${displayName} feature` }} | |
| /> | |
| } | |
| /> | |
| <IconButton | |
| size="small" | |
| aria-label="close" | |
| onClick={() => setShowBanner(false)} | |
| sx={{ color: '#1976d2' }} | |
| > | |
| <CloseIcon fontSize="small" /> | |
| </IconButton> | |
| </Box> | |
| } | |
| > | |
| {/* LEFT SIDE: icon is rendered by Alert; this box holds name + description inline */} | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 1, | |
| flexGrow: 1, | |
| minWidth: 0, // allows ellipsis | |
| }} | |
| > | |
| <Typography variant="body1" fontWeight="medium" noWrap> | |
| {displayName} | |
| </Typography> | |
| {description && ( | |
| <Typography | |
| variant="body2" | |
| color="text.secondary" | |
| noWrap | |
| sx={{ flexShrink: 1 }} | |
| > | |
| {description} | |
| </Typography> | |
| )} | |
| </Box> | |
| </Alert> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from 'react'; | |
| import { render, screen, fireEvent, waitFor } from '@testing-library/react'; | |
| import { FeatureFlagBanner } from './FeatureFlagBanner'; | |
| import { featureFlagsApiRef, FeatureFlagState } from '@backstage/core-plugin-api'; | |
| // Mock the feature flags API | |
| const mockFeatureFlagsApi = { | |
| getRegisteredFlags: jest.fn(), | |
| isActive: jest.fn(), | |
| save: jest.fn(), | |
| }; | |
| // Mock the API hook | |
| jest.mock('@backstage/core-plugin-api', () => ({ | |
| ...jest.requireActual('@backstage/core-plugin-api'), | |
| useApi: (apiRef: any) => { | |
| if (apiRef === featureFlagsApiRef) { | |
| return mockFeatureFlagsApi; | |
| } | |
| return undefined; | |
| }, | |
| })); | |
| describe('FeatureFlagBanner', () => { | |
| beforeEach(() => { | |
| jest.clearAllMocks(); | |
| // Set up default mock implementations | |
| mockFeatureFlagsApi.getRegisteredFlags.mockResolvedValue([ | |
| { name: 'test-feature', pluginId: 'test-plugin' }, | |
| ]); | |
| mockFeatureFlagsApi.isActive.mockReturnValue(false); | |
| mockFeatureFlagsApi.save.mockResolvedValue(undefined); | |
| }); | |
| it('should not render while loading', async () => { | |
| // Make getRegisteredFlags hang to keep loading state | |
| mockFeatureFlagsApi.getRegisteredFlags.mockImplementation( | |
| () => new Promise(() => {}) | |
| ); | |
| const { container } = render( | |
| <FeatureFlagBanner flagName="test-feature" /> | |
| ); | |
| expect(container.firstChild).toBeNull(); | |
| }); | |
| it('should display feature flag information when loaded', async () => { | |
| render( | |
| <FeatureFlagBanner | |
| flagName="test-feature" | |
| displayName="Test Feature" | |
| description="This is a test feature flag" | |
| /> | |
| ); | |
| await waitFor(() => { | |
| expect(screen.getByText('Test Feature')).toBeInTheDocument(); | |
| expect(screen.getByText('This is a test feature flag')).toBeInTheDocument(); | |
| expect(screen.getByText('OFF')).toBeInTheDocument(); | |
| }); | |
| }); | |
| it('should show enabled state when feature is enabled', async () => { | |
| mockFeatureFlagsApi.isActive.mockReturnValue(true); | |
| render( | |
| <FeatureFlagBanner | |
| flagName="test-feature" | |
| displayName="Test Feature" | |
| /> | |
| ); | |
| await waitFor(() => { | |
| expect(screen.getByText('ON')).toBeInTheDocument(); | |
| }); | |
| }); | |
| it('should truncate long descriptions', async () => { | |
| const longDescription = 'This is a very long description that should be truncated after fifty characters to keep the banner clean'; | |
| render( | |
| <FeatureFlagBanner | |
| flagName="test-feature" | |
| displayName="Test Feature" | |
| description={longDescription} | |
| /> | |
| ); | |
| await waitFor(() => { | |
| expect(screen.getByText('This is a very long description that should be tru...')).toBeInTheDocument(); | |
| }); | |
| }); | |
| it('should handle toggle using save API', async () => { | |
| const onToggle = jest.fn(); | |
| render( | |
| <FeatureFlagBanner | |
| flagName="test-feature" | |
| onToggle={onToggle} | |
| /> | |
| ); | |
| await waitFor(() => { | |
| expect(screen.getByRole('checkbox')).toBeInTheDocument(); | |
| }); | |
| const toggle = screen.getByRole('checkbox'); | |
| fireEvent.click(toggle); | |
| await waitFor(() => { | |
| expect(mockFeatureFlagsApi.save).toHaveBeenCalledWith({ | |
| states: { | |
| 'test-feature': FeatureFlagState.Active | |
| } | |
| }); | |
| expect(onToggle).toHaveBeenCalledWith(true); | |
| }); | |
| }); | |
| it('should not render if feature flag is not registered', async () => { | |
| mockFeatureFlagsApi.getRegisteredFlags.mockResolvedValue([]); | |
| const { container } = render( | |
| <FeatureFlagBanner | |
| flagName="non-existent-feature" | |
| displayName="Non Existent" | |
| /> | |
| ); | |
| await waitFor(() => { | |
| expect(mockFeatureFlagsApi.getRegisteredFlags).toHaveBeenCalled(); | |
| }); | |
| expect(container.firstChild).toBeNull(); | |
| }); | |
| it('should dismiss banner when close button clicked', async () => { | |
| render( | |
| <FeatureFlagBanner | |
| flagName="test-feature" | |
| displayName="Test Feature" | |
| /> | |
| ); | |
| await waitFor(() => { | |
| expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); | |
| }); | |
| const closeButton = screen.getByRole('button', { name: /close/i }); | |
| fireEvent.click(closeButton); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Test Feature')).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment