diff --git a/dev/mockHandlers.ts b/dev/mockHandlers.ts index db53d54..11c788c 100644 --- a/dev/mockHandlers.ts +++ b/dev/mockHandlers.ts @@ -32,23 +32,54 @@ const mockEnvironments = [ }, ]; +// Mock tags for the project +const mockTags = [ + { id: 1, label: 'ui', color: '#2196F3' }, + { id: 2, label: 'theme', color: '#9C27B0' }, + { id: 3, label: 'checkout', color: '#4CAF50' }, + { id: 4, label: 'experiment', color: '#FF9800' }, + { id: 5, label: 'api', color: '#F44336' }, + { id: 6, label: 'performance', color: '#00BCD4' }, + { id: 7, label: 'beta', color: '#E91E63' }, + { id: 8, label: 'ops', color: '#795548' }, + { id: 9, label: 'notifications', color: '#607D8B' }, + { id: 10, label: 'v2', color: '#3F51B5' }, + { id: 11, label: 'payments', color: '#8BC34A' }, + { id: 12, label: 'integration', color: '#FFEB3B' }, + { id: 13, label: 'cache', color: '#009688' }, + { id: 14, label: 'analytics', color: '#673AB7' }, + { id: 15, label: 'onboarding', color: '#CDDC39' }, + { id: 16, label: 'ux', color: '#FF5722' }, + { id: 17, label: 'search', color: '#03A9F4' }, + { id: 18, label: 'v3', color: '#FFC107' }, + { id: 19, label: 'ai', color: '#9E9E9E' }, + { id: 20, label: 'recommendations', color: '#00E676' }, + { id: 21, label: 'export', color: '#651FFF' }, + { id: 22, label: 'bulk', color: '#1DE9B6' }, + { id: 23, label: 'admin', color: '#D500F9' }, + { id: 24, label: 'security', color: '#C51162' }, + { id: 25, label: 'audit', color: '#304FFE' }, + { id: 26, label: 'unique', color: '#64DD17' }, + { id: 27, label: 'page2', color: '#FFAB00' }, +]; + // Feature name templates for generating mock data const featureTemplates = [ - { name: 'dark_mode', desc: 'Enable dark mode theme for the application', tags: ['ui', 'theme'], type: 'FLAG' }, - { name: 'new_checkout_flow', desc: 'A/B test for the new checkout experience', tags: ['checkout', 'experiment'], type: 'FLAG' }, - { name: 'api_rate_limit', desc: 'API rate limiting configuration', tags: ['api', 'performance'], type: 'CONFIG' }, - { name: 'beta_features', desc: 'Enable beta features for selected users', tags: ['beta'], type: 'FLAG' }, - { name: 'maintenance_mode', desc: 'Put the application in maintenance mode', tags: ['ops'], type: 'FLAG' }, - { name: 'notifications_v2', desc: 'New notification system', tags: ['notifications', 'v2'], type: 'FLAG' }, - { name: 'payment_gateway', desc: 'Enable new payment gateway integration', tags: ['payments', 'integration'], type: 'FLAG' }, - { name: 'cache_ttl', desc: 'Cache time-to-live configuration', tags: ['cache', 'performance'], type: 'CONFIG' }, - { name: 'feature_analytics', desc: 'Track feature usage analytics', tags: ['analytics'], type: 'FLAG' }, - { name: 'user_onboarding', desc: 'New user onboarding flow', tags: ['onboarding', 'ux'], type: 'FLAG' }, - { name: 'search_v3', desc: 'Enhanced search functionality', tags: ['search', 'v3'], type: 'FLAG' }, - { name: 'recommendation_engine', desc: 'AI-powered recommendations', tags: ['ai', 'recommendations'], type: 'FLAG' }, - { name: 'export_csv', desc: 'Enable CSV export functionality', tags: ['export'], type: 'FLAG' }, - { name: 'bulk_operations', desc: 'Enable bulk edit operations', tags: ['bulk', 'admin'], type: 'FLAG' }, - { name: 'audit_logging', desc: 'Enhanced audit logging', tags: ['security', 'audit'], type: 'FLAG' }, + { name: 'dark_mode', desc: 'Enable dark mode theme for the application', tags: [1, 2], type: 'FLAG' }, + { name: 'new_checkout_flow', desc: 'A/B test for the new checkout experience', tags: [3, 4], type: 'FLAG' }, + { name: 'api_rate_limit', desc: 'API rate limiting configuration', tags: [5, 6], type: 'CONFIG' }, + { name: 'beta_features', desc: 'Enable beta features for selected users', tags: [7], type: 'FLAG' }, + { name: 'maintenance_mode', desc: 'Put the application in maintenance mode', tags: [8], type: 'FLAG' }, + { name: 'notifications_v2', desc: 'New notification system', tags: [9, 10], type: 'FLAG' }, + { name: 'payment_gateway', desc: 'Enable new payment gateway integration', tags: [11, 12], type: 'FLAG' }, + { name: 'cache_ttl', desc: 'Cache time-to-live configuration', tags: [13, 6], type: 'CONFIG' }, + { name: 'feature_analytics', desc: 'Track feature usage analytics', tags: [14], type: 'FLAG' }, + { name: 'user_onboarding', desc: 'New user onboarding flow', tags: [15, 16], type: 'FLAG' }, + { name: 'search_v3', desc: 'Enhanced search functionality', tags: [17, 18], type: 'FLAG' }, + { name: 'recommendation_engine', desc: 'AI-powered recommendations', tags: [19, 20], type: 'FLAG' }, + { name: 'export_csv', desc: 'Enable CSV export functionality', tags: [21], type: 'FLAG' }, + { name: 'bulk_operations', desc: 'Enable bulk edit operations', tags: [22, 23], type: 'FLAG' }, + { name: 'audit_logging', desc: 'Enhanced audit logging', tags: [24, 25], type: 'FLAG' }, ]; // Generate 55 mock features @@ -108,7 +139,7 @@ const generateMockFeatures = () => { type: 'FLAG', is_archived: false, is_server_key_only: false, - tags: ['unique', 'page2'], + tags: [26, 27], owners: [{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }], num_segment_overrides: 1, num_identity_overrides: 3, @@ -302,6 +333,11 @@ export const handlers = [ return res(ctx.json({ results: mockEnvironments })); }), + // Get project tags + rest.get('*/proxy/flagsmith/projects/:projectId/tags/', (req, res, ctx) => { + return res(ctx.json({ results: mockTags })); + }), + // Get project features rest.get('*/proxy/flagsmith/projects/:projectId/features/', (req, res, ctx) => { return res(ctx.json({ results: mockFeatures })); diff --git a/src/api/FlagsmithClient.ts b/src/api/FlagsmithClient.ts index 9585cec..a07dbb7 100644 --- a/src/api/FlagsmithClient.ts +++ b/src/api/FlagsmithClient.ts @@ -13,6 +13,12 @@ export interface FlagsmithProject { created_date: string; } +export interface FlagsmithTag { + id: number; + label: string; + color?: string; +} + export interface FlagsmithEnvironment { id: number; name: string; @@ -56,7 +62,7 @@ export interface FlagsmithFeature { first_name?: string; last_name?: string; } | null; - tags?: Array; + tags?: Array; is_server_key_only?: boolean; type?: string; default_enabled?: boolean; @@ -200,6 +206,20 @@ export class FlagsmithClient { return await response.json(); } + async getProjectTags(projectId: string): Promise { + const baseUrl = await this.getBaseUrl(); + const response = await this.fetchApi.fetch( + `${baseUrl}/projects/${projectId}/tags/`, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch project tags: ${response.statusText}`); + } + + const data = await response.json(); + return data.results || data; + } + async getUsageData( orgId: number, projectId?: number, diff --git a/src/components/FlagsTab/ExpandableRow.tsx b/src/components/FlagsTab/ExpandableRow.tsx index 180a850..7fafff3 100644 --- a/src/components/FlagsTab/ExpandableRow.tsx +++ b/src/components/FlagsTab/ExpandableRow.tsx @@ -19,8 +19,9 @@ import { FlagsmithEnvironment, FlagsmithFeature, FlagsmithFeatureDetails, + FlagsmithTag, } from '../../api/FlagsmithClient'; -import { FlagsmithLink } from '../shared'; +import { FlagsmithLink, TagChip } from '../shared'; import { buildFlagUrl } from '../../theme/flagsmithTheme'; import { switchOnStyle } from '../../theme/sharedStyles'; import { @@ -78,13 +79,14 @@ const TRAILING_COLUMNS_COUNT = 1; interface ExpandableRowProps { feature: FlagsmithFeature; environments: FlagsmithEnvironment[]; + tagMap: Map; client: FlagsmithClient; projectId: string; orgId: number; } export const ExpandableRow = memo( - ({ feature, environments, client, projectId, orgId }: ExpandableRowProps) => { + ({ feature, environments, tagMap, client, projectId, orgId }: ExpandableRowProps) => { const classes = useStyles(); const [open, setOpen] = useState(false); const [details, setDetails] = useState(null); @@ -165,14 +167,8 @@ export const ExpandableRow = memo( - {displayTags.map((tag, index) => ( - + {displayTags.map((tagId, index) => ( + ))} {remainingTagsCount > 0 && ( ; liveVersion: LiveVersionInfo; segmentOverrides: number; scheduledVersion?: FlagsmithFeatureVersion | null; @@ -69,6 +71,7 @@ const getCreatorDisplayName = (feature: FlagsmithFeature): string => { export const FeatureDetailsGrid = ({ feature, + tagMap, liveVersion, segmentOverrides, scheduledVersion, @@ -207,8 +210,8 @@ export const FeatureDetailsGrid = ({ Tags - {feature.tags.map((tag, index) => ( - + {feature.tags.map((tagId, index) => ( + ))} diff --git a/src/components/FlagsTab/index.tsx b/src/components/FlagsTab/index.tsx index 097edba..c1a072b 100644 --- a/src/components/FlagsTab/index.tsx +++ b/src/components/FlagsTab/index.tsx @@ -50,7 +50,7 @@ export const FlagsTab = () => { const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE); const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; - const { project, environments, features, loading, error, client } = + const { project, environments, features, tagMap, loading, error, client } = useFlagsmithProject(projectId); const filteredFeatures = useMemo(() => { @@ -153,6 +153,7 @@ export const FlagsTab = () => { key={feature.id} feature={feature} environments={environments} + tagMap={tagMap} client={client} projectId={projectId!} orgId={project?.organisation || 0} diff --git a/src/components/shared/TagChip.tsx b/src/components/shared/TagChip.tsx new file mode 100644 index 0000000..5cdbcc0 --- /dev/null +++ b/src/components/shared/TagChip.tsx @@ -0,0 +1,37 @@ +import { Chip } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { FlagsmithTag } from '../../api/FlagsmithClient'; +import { getTagChipStyle } from '../../utils/colorUtils'; + +const useStyles = makeStyles(() => ({ + root: { + fontSize: '0.7rem', + height: 20, + }, +})); + +interface TagChipProps { + tagId: number; + tagMap: Map; +} + +export const TagChip = ({ tagId, tagMap }: TagChipProps) => { + const classes = useStyles(); + const tag = tagMap.get(tagId); + + if (!tag) { + return null; + } + + const tagStyle = getTagChipStyle(tag.color); + + return ( + + ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index dc0af19..e8d3a78 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -6,3 +6,4 @@ export { LoadingState } from './LoadingState'; export { EmptyState } from './EmptyState'; export { ErrorState } from './ErrorState'; export { ChartTooltip, ChartTooltipText } from './ChartTooltip'; +export { TagChip } from './TagChip'; diff --git a/src/hooks/useFlagsmithProject.test.tsx b/src/hooks/useFlagsmithProject.test.tsx index 2b47af8..14d8676 100644 --- a/src/hooks/useFlagsmithProject.test.tsx +++ b/src/hooks/useFlagsmithProject.test.tsx @@ -36,12 +36,14 @@ describe('useFlagsmithProject', () => { it('fetches project data successfully', async () => { const mockProject = { id: 123, name: 'Test', organisation: 1 }; const mockEnvs = [{ id: 1, name: 'Dev', api_key: 'key', project: 123 }]; - const mockFeatures = [{ id: 1, name: 'flag', created_date: '2024-01-01', project: 123 }]; + const mockFeatures = [{ id: 1, name: 'flag', created_date: '2024-01-01', project: 123, tags: [1] }]; + const mockTags = [{ id: 1, label: 'ui', color: '#2196F3' }]; mockFetch .mockResolvedValueOnce({ ok: true, json: async () => mockProject }) .mockResolvedValueOnce({ ok: true, json: async () => ({ results: mockEnvs }) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ results: mockFeatures }) }); + .mockResolvedValueOnce({ ok: true, json: async () => ({ results: mockFeatures }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ results: mockTags }) }); const { result } = renderHook(() => useFlagsmithProject('123'), { wrapper }); @@ -51,5 +53,6 @@ describe('useFlagsmithProject', () => { expect(result.current.project).toEqual(mockProject); expect(result.current.environments).toEqual(mockEnvs); expect(result.current.features).toEqual(mockFeatures); + expect(result.current.tagMap.get(1)).toEqual(mockTags[0]); }); }); diff --git a/src/hooks/useFlagsmithProject.ts b/src/hooks/useFlagsmithProject.ts index 0b0f80f..2df7877 100644 --- a/src/hooks/useFlagsmithProject.ts +++ b/src/hooks/useFlagsmithProject.ts @@ -5,12 +5,14 @@ import { FlagsmithProject, FlagsmithEnvironment, FlagsmithFeature, + FlagsmithTag, } from '../api/FlagsmithClient'; export interface UseFlagsmithProjectResult { project: FlagsmithProject | null; environments: FlagsmithEnvironment[]; features: FlagsmithFeature[]; + tagMap: Map; loading: boolean; error: string | null; client: FlagsmithClient; @@ -30,6 +32,7 @@ export function useFlagsmithProject( const [project, setProject] = useState(null); const [environments, setEnvironments] = useState([]); const [features, setFeatures] = useState([]); + const [tags, setTags] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -50,6 +53,9 @@ export function useFlagsmithProject( const projectFeatures = await client.getProjectFeatures(projectId); setFeatures(projectFeatures || []); + + const projectTags = await client.getProjectTags(projectId); + setTags(projectTags || []); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { @@ -69,5 +75,12 @@ export function useFlagsmithProject( [envIds], ); - return { project, environments: memoizedEnvironments, features, loading, error, client }; + // Create a map of tag ID to tag object for efficient lookup + const tagMap = useMemo(() => { + const map = new Map(); + tags.forEach(tag => map.set(tag.id, tag)); + return map; + }, [tags]); + + return { project, environments: memoizedEnvironments, features, tagMap, loading, error, client }; } diff --git a/src/utils/colorUtils.test.ts b/src/utils/colorUtils.test.ts new file mode 100644 index 0000000..c854d8a --- /dev/null +++ b/src/utils/colorUtils.test.ts @@ -0,0 +1,54 @@ +import { hexToRgb, getTagChipStyle } from './colorUtils'; + +describe('colorUtils', () => { + describe('hexToRgb', () => { + it('parses 6-digit hex', () => { + expect(hexToRgb('#2196F3')).toEqual({ r: 33, g: 150, b: 243 }); + }); + + it('parses 3-digit hex', () => { + expect(hexToRgb('#fff')).toEqual({ r: 255, g: 255, b: 255 }); + }); + + it('parses hex without # prefix', () => { + expect(hexToRgb('4CAF50')).toEqual({ r: 76, g: 175, b: 80 }); + }); + + it('returns null for invalid hex', () => { + expect(hexToRgb('invalid')).toBeNull(); + expect(hexToRgb('#GG0000')).toBeNull(); + }); + }); + + describe('getTagChipStyle', () => { + it('returns undefined when color is undefined', () => { + expect(getTagChipStyle(undefined)).toBeUndefined(); + }); + + it('returns undefined when color is empty string', () => { + expect(getTagChipStyle('')).toBeUndefined(); + }); + + it('returns undefined for invalid hex', () => { + expect(getTagChipStyle('notacolor')).toBeUndefined(); + }); + + it('returns tinted background with darkened text', () => { + const style = getTagChipStyle('#9C27B0'); + expect(style).toEqual({ + backgroundColor: 'rgba(156, 39, 176, 0.08)', + color: 'rgb(109, 27, 123)', + borderColor: 'rgba(156, 39, 176, 0.24)', + }); + }); + + it('applies consistent tint style for light colors', () => { + const style = getTagChipStyle('#FFEB3B'); + expect(style).toEqual({ + backgroundColor: 'rgba(255, 235, 59, 0.08)', + color: 'rgb(179, 165, 41)', + borderColor: 'rgba(255, 235, 59, 0.24)', + }); + }); + }); +}); diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts new file mode 100644 index 0000000..e556e55 --- /dev/null +++ b/src/utils/colorUtils.ts @@ -0,0 +1,62 @@ +/** + * Parse a hex color string to RGB values. + * Supports formats: #RGB, #RRGGBB + */ +export function hexToRgb( + hex: string, +): { r: number; g: number; b: number } | null { + const match = hex.match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (!match) return null; + const cleaned = match[1]; + if (cleaned.length === 3) { + return { + r: parseInt(cleaned[0] + cleaned[0], 16), + g: parseInt(cleaned[1] + cleaned[1], 16), + b: parseInt(cleaned[2] + cleaned[2], 16), + }; + } + return { + r: parseInt(cleaned.substring(0, 2), 16), + g: parseInt(cleaned.substring(2, 4), 16), + b: parseInt(cleaned.substring(4, 6), 16), + }; +} + +/** + * Darken an RGB color by a given factor (0–1). + */ +function darken( + rgb: { r: number; g: number; b: number }, + amount: number, +): { r: number; g: number; b: number } { + const factor = 1 - amount; + return { + r: Math.round(rgb.r * factor), + g: Math.round(rgb.g * factor), + b: Math.round(rgb.b * factor), + }; +} + +/** + * Build inline style object for a colored tag chip. + * Matches Flagsmith's dashboard style: light tinted background, + * subtle border, and darkened text color. + * Returns undefined if no color is provided. + */ +export function getTagChipStyle( + color: string | undefined, +): + | { backgroundColor: string; color: string; borderColor: string } + | undefined { + if (!color) return undefined; + const rgb = hexToRgb(color); + if (!rgb) return undefined; + + const textRgb = darken(rgb, 0.3); + + return { + backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.08)`, + color: `rgb(${textRgb.r}, ${textRgb.g}, ${textRgb.b})`, + borderColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.24)`, + }; +}