diff --git a/dev/mockHandlers.ts b/dev/mockHandlers.ts index 2b38676..db53d54 100644 --- a/dev/mockHandlers.ts +++ b/dev/mockHandlers.ts @@ -14,119 +14,118 @@ const mockEnvironments = [ name: 'Development', api_key: 'dev_api_key_123', project: 31465, + use_v2_feature_versioning: true, }, { id: 102, name: 'Staging', api_key: 'staging_api_key_456', project: 31465, + use_v2_feature_versioning: true, }, { id: 103, name: 'Production', api_key: 'prod_api_key_789', project: 31465, + use_v2_feature_versioning: false, // Production still on v1 }, ]; -const mockFeatures = [ - { - id: 1001, - name: 'dark_mode', - description: 'Enable dark mode theme for the application', - created_date: '2024-02-01T09:00:00Z', +// 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' }, +]; + +// Generate 55 mock features +const generateMockFeatures = () => { + const features = []; + for (let i = 0; i < 55; i++) { + const template = featureTemplates[i % featureTemplates.length]; + const suffix = i < featureTemplates.length ? '' : `_${Math.floor(i / featureTemplates.length) + 1}`; + const id = 1001 + i; + const daysAgo = Math.floor(Math.random() * 365); + const date = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + + // Determine initial_value for CONFIG type features + const configValues = ['100', 'enabled', '{"key": "value"}']; + const initialValue = template.type === 'CONFIG' ? configValues[i % 3] : null; + + // Add multivariate options for a few features + const multivariateOptions = i === 5 ? [ + { id: 1, type: 'string', string_value: 'variant_a', integer_value: null, boolean_value: null, default_percentage_allocation: 50 }, + { id: 2, type: 'string', string_value: 'variant_b', integer_value: null, boolean_value: null, default_percentage_allocation: 50 }, + ] : undefined; + + features.push({ + id, + name: `${template.name}${suffix}`, + description: template.desc, + created_date: date.toISOString(), + project: 31465, + default_enabled: Math.random() > 0.5, + type: template.type, + is_archived: i === 10, // One archived flag for demo + is_server_key_only: i === 15, // One server-side only flag for demo + tags: template.tags, + owners: i % 3 === 0 ? [{ id: 1, name: 'John Doe', email: 'john@example.com' }] : [], + group_owners: i % 5 === 0 ? [{ id: 1, name: 'Engineering Team' }] : [], + created_by: { id: 1, email: 'creator@example.com', first_name: 'Alice', last_name: 'Smith' }, + num_segment_overrides: Math.floor(Math.random() * 5), + num_identity_overrides: Math.floor(Math.random() * 20), + initial_value: initialValue, + multivariate_options: multivariateOptions, + environment_state: [ + { id: 101, enabled: Math.random() > 0.3 }, + { id: 102, enabled: Math.random() > 0.4 }, + { id: 103, enabled: Math.random() > 0.6 }, + ], + }); + } + + // Add a special feature on "page 2" (index 51) that's easy to search for + features[51] = { + id: 9999, + name: 'zebra_stripe_mode', + description: 'A unique feature on page 2 - search for "zebra" to find it', + created_date: '2024-06-15T10:00:00Z', project: 31465, default_enabled: true, type: 'FLAG', 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, - name: 'new_checkout_flow', - description: 'A/B test for the new checkout experience', - created_date: '2024-03-10T14:30:00Z', - project: 31465, - default_enabled: false, - type: 'FLAG', - is_archived: false, - tags: ['checkout', 'experiment'], + is_server_key_only: false, + tags: ['unique', 'page2'], 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, - name: 'api_rate_limit', - description: 'API rate limiting configuration', - created_date: '2024-01-20T11:15:00Z', - project: 31465, - default_enabled: true, - type: 'CONFIG', - 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, - name: 'beta_features', - description: 'Enable beta features for selected users', - created_date: '2024-04-05T16:45:00Z', - project: 31465, - default_enabled: false, - type: 'FLAG', - 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, - name: 'maintenance_mode', - description: 'Put the application in maintenance mode', - created_date: '2024-02-28T08:00:00Z', - project: 31465, - default_enabled: false, - type: 'FLAG', - is_archived: false, - tags: ['ops'], - owners: [], - num_segment_overrides: 0, - num_identity_overrides: 0, + num_segment_overrides: 1, + num_identity_overrides: 3, environment_state: [ - { id: 101, enabled: false }, // Dev - disabled - { id: 102, enabled: false }, // Staging - disabled - { id: 103, enabled: false }, // Prod - disabled + { id: 101, enabled: true }, + { id: 102, enabled: true }, + { id: 103, enabled: false }, ], - }, -]; + }; + + return features; +}; + +const mockFeatures = generateMockFeatures(); + +// Helper to create a future date (7 days from now) +const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); const mockFeatureVersions: Record = { 1001: [ @@ -137,6 +136,14 @@ const mockFeatureVersions: Record = { published: true, published_by: 'John Doe', }, + // Scheduled change for dark_mode - goes live in 7 days + { + uuid: 'v2-dark-mode-uuid', + is_live: false, + live_from: futureDate, + published: true, + published_by: 'Jane Smith', + }, ], 1002: [ { @@ -261,64 +268,28 @@ const mockFeatureStates: Record = { ], }; -const mockUsageData = [ - { - flags: 15420, - identities: 3250, - traits: 8900, - environment_document: 450, - day: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, - { - flags: 16800, - identities: 3400, - traits: 9200, - environment_document: 480, - day: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, - { - flags: 14200, - identities: 3100, - traits: 8500, - environment_document: 420, - day: new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, - { - flags: 17500, - identities: 3600, - traits: 9800, - environment_document: 510, - day: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, - { - flags: 18200, - identities: 3750, - traits: 10100, - environment_document: 530, - day: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, - { - flags: 16900, - identities: 3500, - traits: 9400, - environment_document: 490, - day: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, - { - flags: 15800, - identities: 3300, - traits: 9000, - environment_document: 460, - day: new Date().toISOString().split('T')[0], +// Generate usage data for each environment with distinct values (0-500 range) +const generateUsageData = (envId: number) => { + // Different data patterns per environment to make lines visually distinct + const envData: Record = { + 101: [50, 80, 65, 95, 120, 90, 75], // Development - lowest, volatile (50-120) + 102: [150, 170, 180, 200, 195, 210, 185], // Staging - medium range (150-210) + 103: [350, 380, 360, 420, 400, 450, 410], // Production - highest, stable (350-450) + }; + const flags = envData[envId] || [200, 220, 210, 240, 230, 250, 220]; + + return Array.from({ length: 7 }, (_, i) => ({ + flags: flags[i], + identities: Math.round(flags[i] * 0.2), + traits: Math.round(flags[i] * 0.5), + environment_document: Math.round(flags[i] * 0.03), + day: new Date(Date.now() - (6 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], labels: { client_application_name: 'web-app', client_application_version: '1.0.0', user_agent: null }, - }, -]; + })); +}; + +// Default usage data (for requests without environment_id) +const mockUsageData = generateUsageData(103); export const handlers = [ // Get project @@ -350,8 +321,12 @@ export const handlers = [ return res(ctx.json(states)); }), - // Get usage data + // Get usage data - returns different data per environment rest.get('*/proxy/flagsmith/organisations/:orgId/usage-data/', (req, res, ctx) => { + const environmentId = req.url.searchParams.get('environment_id'); + if (environmentId) { + return res(ctx.json(generateUsageData(parseInt(environmentId, 10)))); + } return res(ctx.json(mockUsageData)); }), ]; diff --git a/src/api/FlagsmithClient.test.ts b/src/api/FlagsmithClient.test.ts index ac3ad3e..e0bd571 100644 --- a/src/api/FlagsmithClient.test.ts +++ b/src/api/FlagsmithClient.test.ts @@ -140,6 +140,7 @@ describe('FlagsmithClient', () => { expect(result.liveVersion).toEqual(versions[0]); expect(result.featureState).toEqual(states); expect(result.segmentOverrides).toBe(1); + expect(result.scheduledVersion).toBeNull(); }); it('returns nulls when no live version', async () => { @@ -150,6 +151,42 @@ describe('FlagsmithClient', () => { expect(result.liveVersion).toBeNull(); expect(result.featureState).toBeNull(); expect(result.segmentOverrides).toBe(0); + expect(result.scheduledVersion).toBeNull(); + }); + + it('detects scheduled version with future live_from date', async () => { + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const versions = [ + { uuid: 'v1', is_live: true, published: true, live_from: '2024-01-01T00:00:00Z' }, + { uuid: 'v2', is_live: false, published: true, live_from: futureDate }, + ]; + const states = [{ id: 1, enabled: true, feature_segment: null }]; + + mockFetch + .mockResolvedValueOnce(mockOk({ results: versions })) + .mockResolvedValueOnce(mockOk(states)); + + const result = await client.getFeatureDetails(1, 100); + + expect(result.liveVersion).toEqual(versions[0]); + expect(result.scheduledVersion).toEqual(versions[1]); + }); + + it('ignores past live_from dates for scheduled version', async () => { + const pastDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const versions = [ + { uuid: 'v1', is_live: true, published: true }, + { uuid: 'v2', is_live: false, published: true, live_from: pastDate }, + ]; + const states = [{ id: 1, enabled: true, feature_segment: null }]; + + mockFetch + .mockResolvedValueOnce(mockOk({ results: versions })) + .mockResolvedValueOnce(mockOk(states)); + + const result = await client.getFeatureDetails(1, 100); + + expect(result.scheduledVersion).toBeNull(); }); }); }); diff --git a/src/api/FlagsmithClient.ts b/src/api/FlagsmithClient.ts index b2164d0..9585cec 100644 --- a/src/api/FlagsmithClient.ts +++ b/src/api/FlagsmithClient.ts @@ -18,6 +18,7 @@ export interface FlagsmithEnvironment { name: string; api_key: string; project: number; + use_v2_feature_versioning?: boolean; } export interface FlagsmithFeature { @@ -45,12 +46,30 @@ export interface FlagsmithFeature { name: string; email: string; }>; + group_owners?: Array<{ + id: number; + name: string; + }>; + created_by?: { + id: number; + email: string; + first_name?: string; + last_name?: string; + } | null; tags?: Array; is_server_key_only?: boolean; type?: string; default_enabled?: boolean; is_archived?: boolean; initial_value?: string | null; + multivariate_options?: Array<{ + id: number; + type: string; + integer_value?: number | null; + string_value?: string | null; + boolean_value?: boolean | null; + default_percentage_allocation: number; + }>; } export interface FlagsmithFeatureVersion { @@ -85,6 +104,7 @@ export interface FlagsmithFeatureDetails { liveVersion: FlagsmithFeatureVersion | null; featureState: FlagsmithFeatureState[] | null; segmentOverrides: number; + scheduledVersion: FlagsmithFeatureVersion | null; } export interface FlagsmithUsageData { @@ -183,12 +203,16 @@ export class FlagsmithClient { async getUsageData( orgId: number, projectId?: number, + environmentId?: number, ): Promise { const baseUrl = await this.getBaseUrl(); const url = new URL(`${baseUrl}/organisations/${orgId}/usage-data/`); if (projectId) { url.searchParams.set('project_id', projectId.toString()); } + if (environmentId) { + url.searchParams.set('environment_id', environmentId.toString()); + } const response = await this.fetchApi.fetch(url.toString()); @@ -199,6 +223,35 @@ export class FlagsmithClient { return await response.json(); } + /** + * Fetch usage data for multiple environments in parallel + */ + async getUsageDataByEnvironments( + orgId: number, + projectId: number, + environments: Pick[], + ): Promise> { + const results = new Map(); + + // Fetch usage data for each environment in parallel + const promises = environments.map(async env => { + try { + const data = await this.getUsageData(orgId, projectId, env.id); + return { envName: env.name, data }; + } catch { + // If environment-level filtering isn't supported, return empty + return { envName: env.name, data: [] }; + } + }); + + const responses = await Promise.all(promises); + responses.forEach(({ envName, data }) => { + results.set(envName, data); + }); + + return results; + } + // Lazy loading methods for feature details async getFeatureVersions( environmentId: number, @@ -244,6 +297,14 @@ export class FlagsmithClient { const versions = await this.getFeatureVersions(environmentId, featureId); const liveVersion = versions.find(v => v.is_live) || null; + // Find next scheduled version (future live_from date, not yet live) + // If multiple versions are scheduled, pick the earliest one + const now = new Date(); + const scheduledVersions = versions + .filter(v => !v.is_live && v.live_from && new Date(v.live_from) > now) + .sort((a, b) => new Date(a.live_from!).getTime() - new Date(b.live_from!).getTime()); + const scheduledVersion = scheduledVersions[0] || null; + let featureState: FlagsmithFeatureState[] | null = null; let segmentOverrides = 0; @@ -262,6 +323,7 @@ export class FlagsmithClient { liveVersion, featureState, segmentOverrides, + scheduledVersion, }; } } diff --git a/src/components/FlagsTab/EnvironmentTable.tsx b/src/components/FlagsTab/EnvironmentTable.tsx index 2c51e53..be8018c 100644 --- a/src/components/FlagsTab/EnvironmentTable.tsx +++ b/src/components/FlagsTab/EnvironmentTable.tsx @@ -1,6 +1,7 @@ import { Box, Chip, + Switch, Table, TableBody, TableCell, @@ -10,7 +11,10 @@ import { } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { FlagsmithEnvironment, FlagsmithFeature } from '../../api/FlagsmithClient'; -import { flagsmithColors } from '../../theme/flagsmithTheme'; +import { switchOnStyle } from '../../theme/sharedStyles'; +import { MAX_DETAIL_ENVIRONMENTS } from '../../constants'; +import { isDefined } from '../../utils/flagTypeHelpers'; +import { formatDate } from '../../utils/dateFormatters'; const useStyles = makeStyles(theme => ({ envTable: { @@ -26,14 +30,7 @@ const useStyles = makeStyles(theme => ({ textTransform: 'uppercase', }, }, - statusOn: { - color: flagsmithColors.primary, - fontWeight: 600, - }, - statusOff: { - color: theme.palette.text.secondary, - fontWeight: 600, - }, + switchOn: switchOnStyle, envBadge: { fontSize: '0.7rem', height: 18, @@ -45,6 +42,17 @@ const useStyles = makeStyles(theme => ({ fontSize: '0.85rem', color: theme.palette.text.primary, }, + envName: { + fontWeight: 500, + }, + overridesContainer: { + display: 'flex', + flexWrap: 'wrap', + gap: 4, + }, + hiddenEnvsMessage: { + marginTop: theme.spacing(1), + }, })); interface EnvironmentTableProps { @@ -52,68 +60,115 @@ interface EnvironmentTableProps { environments: FlagsmithEnvironment[]; } +/** + * Format override count with proper pluralization + */ +const formatOverrideLabel = (count: number, singular: string, plural: string): string => { + return `${count} ${count > 1 ? plural : singular}`; +}; + export const EnvironmentTable = ({ feature, environments, }: EnvironmentTableProps) => { const classes = useStyles(); + const displayedEnvironments = environments.slice(0, MAX_DETAIL_ENVIRONMENTS); + const hiddenCount = environments.length - MAX_DETAIL_ENVIRONMENTS; + + // Check if any environment uses v2 versioning + const hasVersioning = displayedEnvironments.some(env => env.use_v2_feature_versioning); 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.initial_value : null; + <> +
+ + + Environment + Status + Value + Overrides + {hasVersioning && Version} + Last updated + + + + {displayedEnvironments.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 identityCount = feature.num_identity_overrides ?? 0; + const value = feature.type === 'CONFIG' ? feature.initial_value : null; + const hasOverrides = segmentCount > 0 || identityCount > 0; - return ( - - - - + 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()} - - - - ); - })} - -
+ + + + + + + {isDefined(value) ? `"${value}"` : '-'} + + + + + {segmentCount > 0 && ( + + )} + {identityCount > 0 && ( + + )} + {!hasOverrides && ( + + - + + )} + + + {hasVersioning && ( + + + {env.use_v2_feature_versioning ? 'v2' : 'v1'} + + + )} + + + {formatDate(feature.created_date)} + + + + ); + })} + + + {hiddenCount > 0 && ( + + + +{hiddenCount} more environment{hiddenCount > 1 ? 's' : ''} not shown + + + )} + ); }; diff --git a/src/components/FlagsTab/ExpandableRow.tsx b/src/components/FlagsTab/ExpandableRow.tsx index 4e27a78..180a850 100644 --- a/src/components/FlagsTab/ExpandableRow.tsx +++ b/src/components/FlagsTab/ExpandableRow.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, memo } from 'react'; import { Typography, Box, @@ -8,6 +8,8 @@ import { TableRow, IconButton, Collapse, + Chip, + Switch, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import KeyboardArrowDown from '@material-ui/icons/KeyboardArrowDown'; @@ -20,7 +22,16 @@ import { } from '../../api/FlagsmithClient'; import { FlagsmithLink } from '../shared'; import { buildFlagUrl } from '../../theme/flagsmithTheme'; +import { switchOnStyle } from '../../theme/sharedStyles'; +import { + MAX_DISPLAY_TAGS, + MAX_TABLE_ENVIRONMENTS, + DESCRIPTION_TRUNCATE_LENGTH, +} from '../../constants'; +import { truncateText, getErrorMessage } from '../../utils/flagTypeHelpers'; +import { formatDate } from '../../utils/dateFormatters'; import { EnvironmentTable } from './EnvironmentTable'; +import { FeatureAnalyticsSection } from './FeatureAnalyticsSection'; import { FeatureDetailsGrid } from './FeatureDetailsGrid'; import { SegmentOverridesSection } from './SegmentOverridesSection'; @@ -31,150 +42,231 @@ const useStyles = makeStyles(theme => ({ gap: theme.spacing(0.5), }, expandedContent: { - backgroundColor: theme.palette.background.default, - padding: theme.spacing(2), + // No backgroundColor - inherit from parent table + }, + expandedCell: { + paddingBottom: 0, + paddingTop: 0, + }, + clickableRow: { + cursor: 'pointer', + }, + tagChip: { + fontSize: '0.7rem', + height: 20, + marginRight: theme.spacing(0.5), + }, + tagsCell: { + maxWidth: 200, + }, + switchOn: switchOnStyle, + loadingText: { + marginLeft: theme.spacing(1), + }, + tagsContainer: { + display: 'flex', + flexWrap: 'wrap', + gap: 2, }, })); +/** Number of fixed columns before environment columns (checkbox, name, tags) */ +const FIXED_COLUMNS_COUNT = 3; +/** Number of fixed columns after environment columns (created date) */ +const TRAILING_COLUMNS_COUNT = 1; + interface ExpandableRowProps { feature: FlagsmithFeature; environments: FlagsmithEnvironment[]; client: FlagsmithClient; projectId: string; + orgId: number; } -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); +export const ExpandableRow = memo( + ({ feature, environments, client, projectId, orgId }: 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 primaryEnvId = environments[0]?.id; - const handleToggle = async () => { - const newOpen = !open; - setOpen(newOpen); + 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); + if (newOpen && !details && !loadingDetails && primaryEnvId) { + setLoadingDetails(true); + setDetailsError(null); + try { + const featureDetails = await client.getFeatureDetails( + primaryEnvId, + feature.id, + ); + setDetails(featureDetails); + } catch (err) { + setDetailsError(getErrorMessage(err, '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, - ); + const liveVersion = details?.liveVersion || feature.live_version; + const scheduledVersion = details?.scheduledVersion || null; + 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()} - - - + const displayedEnvs = environments.slice(0, MAX_TABLE_ENVIRONMENTS); + const tags = feature.tags || []; + const displayTags = tags.slice(0, MAX_DISPLAY_TAGS); + const remainingTagsCount = tags.length - MAX_DISPLAY_TAGS; + const totalColumns = + FIXED_COLUMNS_COUNT + displayedEnvs.length + TRAILING_COLUMNS_COUNT; - - - - - {loadingDetails && ( - - - - Loading feature details... - - - )} - {!loadingDetails && detailsError && ( - - {detailsError} - + return ( + <> + + + { + e.stopPropagation(); + handleToggle(); + }} + aria-label={open ? `Collapse ${feature.name}` : `Expand ${feature.name}`} + aria-expanded={open} + > + {open ? : } + + + + + e.stopPropagation()} + > + {feature.name} + + + {feature.description && ( + + {truncateText(feature.description, DESCRIPTION_TRUNCATE_LENGTH)} + + )} + + + + {displayTags.map((tag, index) => ( + + ))} + {remainingTagsCount > 0 && ( + )} - {!loadingDetails && !detailsError && ( - - + + + {displayedEnvs.map(env => { + const envState = feature.environment_state?.find(s => s.id === env.id); + const enabled = envState?.enabled ?? feature.default_enabled ?? false; + return ( + + + + ); + })} + + + {formatDate(feature.created_date)} + + + - - - + + + + + {loadingDetails && ( + + + + Loading feature details... + + + )} + {!loadingDetails && detailsError && ( + + {detailsError} + + )} + {!loadingDetails && !detailsError && ( + + + + - - + + + + + + + + - - )} - - - - - - ); -}; + )} + + + + + + ); + }, +); + +ExpandableRow.displayName = 'ExpandableRow'; diff --git a/src/components/FlagsTab/FeatureAnalyticsSection.tsx b/src/components/FlagsTab/FeatureAnalyticsSection.tsx new file mode 100644 index 0000000..9a33cbb --- /dev/null +++ b/src/components/FlagsTab/FeatureAnalyticsSection.tsx @@ -0,0 +1,247 @@ +import { useState, useEffect } from 'react'; +import { Box, CircularProgress, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; +import { + FlagsmithClient, + FlagsmithEnvironment, + FlagsmithUsageData, +} from '../../api/FlagsmithClient'; +import { + CHART_CONFIG, + MAX_TABLE_ENVIRONMENTS, + getEnvColor, +} from '../../constants'; +import { getErrorMessage } from '../../utils/flagTypeHelpers'; +import { formatShortDate } from '../../utils/dateFormatters'; +import { ChartTooltip, ChartTooltipText } from '../shared'; + +const useStyles = makeStyles(theme => ({ + container: { + padding: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + }, + chartContainer: { + width: '100%', + height: CHART_CONFIG.HEIGHT, + marginTop: theme.spacing(1), + }, + noData: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: 150, + color: theme.palette.text.secondary, + }, + loading: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(1), + height: 150, + }, +})); + +interface ChartDataPoint { + date: string; + [envName: string]: number | string; +} + +/** + * Custom tooltip component for analytics chart + */ +const AnalyticsTooltip = ({ active, payload, label }: any) => { + return ( + + {(data, tooltipLabel) => ( + <> + + {tooltipLabel} + + + {data.map((entry: any, index: number) => ( + + + + {entry.name}: {entry.value} + + + ))} + + + )} + + ); +}; + +interface FeatureAnalyticsSectionProps { + client: FlagsmithClient; + orgId: number; + projectId: number; + environments: FlagsmithEnvironment[]; +} + +/** + * Transform usage data by environment into chart-friendly format + */ +const transformUsageData = ( + usageByEnv: Map, +): { chartData: ChartDataPoint[]; envNames: string[] } => { + const dataByDate = new Map(); + const envNames: string[] = []; + + usageByEnv.forEach((data: FlagsmithUsageData[], envName: string) => { + envNames.push(envName); + data.forEach(item => { + const date = formatShortDate(item.day); + if (!dataByDate.has(date)) { + dataByDate.set(date, { date }); + } + const point = dataByDate.get(date)!; + point[envName] = item.flags ?? 0; + }); + }); + + const sortedData = Array.from(dataByDate.values()).sort((a, b) => { + const dateA = new Date(a.date); + const dateB = new Date(b.date); + return dateA.getTime() - dateB.getTime(); + }); + + return { chartData: sortedData, envNames }; +}; + +export const FeatureAnalyticsSection = ({ + client, + orgId, + projectId, + environments, +}: FeatureAnalyticsSectionProps) => { + const classes = useStyles(); + const [loading, setLoading] = useState(true); + const [chartData, setChartData] = useState([]); + const [envNames, setEnvNames] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUsageData = async () => { + if (!orgId || !projectId || environments.length === 0) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const displayedEnvs = environments.slice(0, MAX_TABLE_ENVIRONMENTS); + + const usageByEnv = await client.getUsageDataByEnvironments( + orgId, + projectId, + displayedEnvs, + ); + + const { chartData: data, envNames: names } = transformUsageData(usageByEnv); + setChartData(data); + setEnvNames(names); + } catch (err) { + setError(getErrorMessage(err, 'Failed to load analytics')); + } finally { + setLoading(false); + } + }; + + fetchUsageData(); + }, [client, orgId, projectId, environments]); + + if (loading) { + return ( + + + Usage Analytics + + + + + Loading analytics... + + + + ); + } + + if (error) { + return ( + + + Usage Analytics + + + + {error} + + + + ); + } + + if (chartData.length === 0) { + return null; + } + + return ( + + + Usage Analytics (Last 30 Days) + + + + + + + + } /> + + {envNames.map((envName, index) => ( + + ))} + + + + + ); +}; diff --git a/src/components/FlagsTab/FeatureDetailsGrid.tsx b/src/components/FlagsTab/FeatureDetailsGrid.tsx index 9cd71f4..1a17fc8 100644 --- a/src/components/FlagsTab/FeatureDetailsGrid.tsx +++ b/src/components/FlagsTab/FeatureDetailsGrid.tsx @@ -1,14 +1,49 @@ import { Box, Chip, Grid, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import { FlagsmithFeature } from '../../api/FlagsmithClient'; +import ArchiveIcon from '@material-ui/icons/Archive'; +import ScheduleIcon from '@material-ui/icons/Schedule'; +import VpnKeyIcon from '@material-ui/icons/VpnKey'; +import { FlagsmithFeature, FlagsmithFeatureVersion } from '../../api/FlagsmithClient'; import { flagsmithColors } from '../../theme/flagsmithTheme'; +import { detailCardStyle } from '../../theme/sharedStyles'; +import { getFlagType, getValueType, isDefined } from '../../utils/flagTypeHelpers'; +import { formatDate, formatDateTime } from '../../utils/dateFormatters'; const useStyles = makeStyles(theme => ({ - detailCard: { + detailCard: detailCardStyle(theme), + badgeChip: { + marginRight: theme.spacing(0.5), + marginTop: theme.spacing(0.5), + }, + archivedChip: { + backgroundColor: theme.palette.warning.light, + color: theme.palette.warning.contrastText, + }, + serverKeyChip: { + backgroundColor: flagsmithColors.secondary, + color: 'white', + }, + scheduledCard: { padding: theme.spacing(1.5), marginBottom: theme.spacing(1), - border: `1px solid ${theme.palette.divider}`, + border: `1px solid ${theme.palette.warning.main}`, borderRadius: theme.shape.borderRadius, + backgroundColor: `${theme.palette.warning.light}20`, + }, + scheduledHeader: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + color: theme.palette.warning.dark, + }, + scheduleIcon: { + fontSize: '1.2rem', + color: theme.palette.warning.main, + }, + tagsContainer: { + display: 'flex', + flexWrap: 'wrap', + gap: 4, }, })); @@ -18,17 +53,78 @@ interface FeatureDetailsGridProps { feature: FlagsmithFeature; liveVersion: LiveVersionInfo; segmentOverrides: number; + scheduledVersion?: FlagsmithFeatureVersion | null; } +/** + * Get display name for the feature creator + */ +const getCreatorDisplayName = (feature: FlagsmithFeature): string => { + if (!feature.created_by) return 'Unknown'; + const { email, first_name, last_name } = feature.created_by; + if (email) return email; + const fullName = `${first_name || ''} ${last_name || ''}`.trim(); + return fullName || 'Unknown'; +}; + export const FeatureDetailsGrid = ({ feature, liveVersion, segmentOverrides, + scheduledVersion, }: FeatureDetailsGridProps) => { const classes = useStyles(); + const flagType = getFlagType(feature); + const valueType = getValueType(feature); + return ( <> + {/* Status badges row */} + {(feature.is_server_key_only || feature.is_archived) && ( + + + {feature.is_server_key_only && ( + } + label="Server-side Only" + size="small" + className={`${classes.badgeChip} ${classes.serverKeyChip}`} + /> + )} + {feature.is_archived && ( + } + label="Archived" + size="small" + className={`${classes.badgeChip} ${classes.archivedChip}`} + /> + )} + + + )} + + {/* Scheduled changes card */} + {scheduledVersion && scheduledVersion.live_from && ( + + + + + Scheduled Change + + + Scheduled for: {formatDateTime(scheduledVersion.live_from)} + + {scheduledVersion.published_by && ( + + By: {scheduledVersion.published_by} + + )} + + + )} + + {/* Version card */} {liveVersion && ( @@ -40,13 +136,14 @@ export const FeatureDetailsGrid = ({ {liveVersion.live_from && ( - Live since: {new Date(liveVersion.live_from).toLocaleDateString()} + Live since: {formatDate(liveVersion.live_from)} )} )} + {/* Targeting card */} @@ -55,61 +152,67 @@ export const FeatureDetailsGrid = ({ Segment overrides: {segmentOverrides} - {feature.num_identity_overrides !== null && - feature.num_identity_overrides !== undefined && ( - - Identity overrides: {feature.num_identity_overrides} - - )} + {isDefined(feature.num_identity_overrides) && ( + + Identity overrides: {feature.num_identity_overrides} + + )} + {/* Details card */} Details ID: {feature.id} - - Type: {feature.type || 'Standard'} + Flag Type: {flagType} + Value Type: {valueType} + + + + {/* Ownership card */} + + + + Ownership - {feature.is_server_key_only && ( - + {feature.created_by && ( + + Creator: {getCreatorDisplayName(feature)} + + )} + {feature.owners && feature.owners.length > 0 ? ( + + Assigned Users: {feature.owners.map(o => o.email || o.name).join(', ')} + + ) : ( + + No assigned users + + )} + {feature.group_owners && feature.group_owners.length > 0 && ( + + Groups: {feature.group_owners.map(g => g.name).join(', ')} + )} + {/* Tags */} {feature.tags && feature.tags.length > 0 && ( - + + Tags + + {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/index.tsx b/src/components/FlagsTab/index.tsx index 8aa3fb7..097edba 100644 --- a/src/components/FlagsTab/index.tsx +++ b/src/components/FlagsTab/index.tsx @@ -9,13 +9,19 @@ import { TableContainer, TableHead, TableRow, + TablePagination, Paper, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { SearchInput, FlagsmithLink, LoadingState } from '../shared'; +import { SearchInput, FlagsmithLink, LoadingState, ErrorState } from '../shared'; import { buildProjectUrl } from '../../theme/flagsmithTheme'; import { useFlagsmithProject } from '../../hooks'; +import { + MAX_TABLE_ENVIRONMENTS, + DEFAULT_ROWS_PER_PAGE, + PAGINATION_OPTIONS, +} from '../../constants'; import { ExpandableRow } from './ExpandableRow'; const useStyles = makeStyles(theme => ({ @@ -28,12 +34,20 @@ const useStyles = makeStyles(theme => ({ gap: theme.spacing(2), justifyContent: 'flex-end', }, + errorHint: { + marginTop: theme.spacing(2), + }, })); +/** Number of fixed columns (checkbox, name, tags, created) */ +const FIXED_COLUMNS_COUNT = 4; + export const FlagsTab = () => { const classes = useStyles(); const { entity } = useEntity(); const [searchQuery, setSearchQuery] = useState(''); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE); const projectId = entity.metadata.annotations?.['flagsmith.com/project-id']; const { project, environments, features, loading, error, client } = @@ -49,11 +63,28 @@ export const FlagsTab = () => { ); }, [features, searchQuery]); + const paginatedFeatures = useMemo(() => { + const startIndex = page * rowsPerPage; + return filteredFeatures.slice(startIndex, startIndex + rowsPerPage); + }, [filteredFeatures, page, rowsPerPage]); + + const handleChangePage = (_event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + const dashboardUrl = buildProjectUrl( projectId || '', environments[0]?.id?.toString(), ); + const displayedEnvs = environments.slice(0, MAX_TABLE_ENVIRONMENTS); + const totalColumns = FIXED_COLUMNS_COUNT + displayedEnvs.length; + if (loading) { return ; } @@ -61,13 +92,10 @@ export const FlagsTab = () => { if (error) { return ( - Error: {error} - {!projectId && ( - - Add a flagsmith.com/project-id annotation to this - entity to view feature flags. - - )} + ); } @@ -99,14 +127,19 @@ export const FlagsTab = () => { Flag Name - Type + Tags + {displayedEnvs.map(env => ( + + {env.name} + + ))} Created {filteredFeatures.length === 0 ? ( - + {searchQuery ? 'No flags match your search' @@ -115,19 +148,29 @@ export const FlagsTab = () => { ) : ( - filteredFeatures.map(feature => ( + paginatedFeatures.map(feature => ( )) )} + ); }; diff --git a/src/components/FlagsmithOverviewCard/index.tsx b/src/components/FlagsmithOverviewCard/index.tsx index d9e6ca8..fc0acd1 100644 --- a/src/components/FlagsmithOverviewCard/index.tsx +++ b/src/components/FlagsmithOverviewCard/index.tsx @@ -14,7 +14,7 @@ import { import { makeStyles } from '@material-ui/core/styles'; import { InfoCard } from '@backstage/core-components'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { FlagsmithLink, MiniPagination, LoadingState } from '../shared'; +import { FlagsmithLink, MiniPagination, LoadingState, ErrorState } from '../shared'; import { buildProjectUrl } from '../../theme/flagsmithTheme'; import { useFlagsmithProject } from '../../hooks'; import { calculateFeatureStats, paginate } from '../../utils'; @@ -50,9 +50,7 @@ export const FlagsmithOverviewCard = () => { if (error) { return ( - - Error: {error} - + ); } diff --git a/src/components/FlagsmithUsageCard/UsageChart.tsx b/src/components/FlagsmithUsageCard/UsageChart.tsx index b7ca169..6d545d3 100644 --- a/src/components/FlagsmithUsageCard/UsageChart.tsx +++ b/src/components/FlagsmithUsageCard/UsageChart.tsx @@ -10,12 +10,48 @@ import { } from 'recharts'; import { FlagsmithUsageData } from '../../api/FlagsmithClient'; import { flagsmithColors } from '../../theme/flagsmithTheme'; -import { UsageTooltip } from './UsageTooltip'; +import { ChartTooltip, ChartTooltipText } from '../shared'; interface UsageChartProps { data: FlagsmithUsageData[]; } +/** + * Custom tooltip for usage chart displaying flags, identities, traits, and environment document + */ +const UsageChartTooltip = ({ active, payload }: any) => ( + + {(data) => { + const usageData = data[0].payload as FlagsmithUsageData; + return ( + <> + + {new Date(usageData.day).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + + Flags: {usageData.flags ?? 0} + + + Identities: {usageData.identities} + + + Traits: {usageData.traits} + + + Environment Document: {usageData.environment_document} + + + + ); + }} + +); + export const UsageChart = ({ data }: UsageChartProps) => { if (data.length === 0) { return ( @@ -45,7 +81,7 @@ export const UsageChart = ({ data }: UsageChartProps) => { height={80} /> - } /> + } /> ; -} - -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 index cd045ce..7cef679 100644 --- a/src/components/FlagsmithUsageCard/index.tsx +++ b/src/components/FlagsmithUsageCard/index.tsx @@ -1,8 +1,8 @@ -import { Typography, Box } from '@material-ui/core'; +import { Box } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { InfoCard } from '@backstage/core-components'; import { useEntity } from '@backstage/plugin-catalog-react'; -import { FlagsmithLink, LoadingState } from '../shared'; +import { FlagsmithLink, LoadingState, ErrorState } from '../shared'; import { FLAGSMITH_DASHBOARD_URL } from '../../theme/flagsmithTheme'; import { useFlagsmithUsage } from '../../hooks'; import { UsageChart } from './UsageChart'; @@ -40,15 +40,10 @@ export const FlagsmithUsageCard = () => { if (error) { return ( - - Error: {error} - {!orgId && ( - - Add a flagsmith.com/organization-id annotation to this - entity. - - )} - + ); } diff --git a/src/components/shared/ChartTooltip.tsx b/src/components/shared/ChartTooltip.tsx new file mode 100644 index 0000000..e53e8ec --- /dev/null +++ b/src/components/shared/ChartTooltip.tsx @@ -0,0 +1,76 @@ +import { Box, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { ReactNode } from 'react'; + +const useStyles = makeStyles((theme) => ({ + tooltipBox: { + backgroundColor: theme.palette.grey[800], + border: 'none', + borderRadius: theme.shape.borderRadius, + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + }, + tooltipText: { + color: theme.palette.common.white, + }, +})); + +interface ChartTooltipProps { + active?: boolean; + payload?: any[]; + label?: string; + children: (payload: any[], label?: string) => ReactNode; +} + +/** + * Generic Recharts custom tooltip component + * Uses theme colors for consistent theming across light and dark modes + * Accepts a render function to customize content per use case + * + * @example + * + * {(payload, label) => ( + * <> + * {label} + * Value: {payload[0].value} + * + * )} + * + * } /> + */ +export const ChartTooltip = ({ active, payload, label, children }: ChartTooltipProps) => { + const classes = useStyles(); + + if (!active || !payload?.length) { + return null; + } + + return ( + + {children(payload, label)} + + ); +}; + +interface ChartTooltipTextProps { + variant?: 'subtitle2' | 'body2'; + fontWeight?: number; + children: ReactNode; +} + +/** + * Text component for chart tooltips with theme-aware white color + */ +export const ChartTooltipText = ({ + variant = 'body2', + fontWeight, + children, +}: ChartTooltipTextProps) => { + const classes = useStyles(); + + return ( + + {children} + + ); +}; diff --git a/src/components/shared/EmptyState.tsx b/src/components/shared/EmptyState.tsx new file mode 100644 index 0000000..bfc951e --- /dev/null +++ b/src/components/shared/EmptyState.tsx @@ -0,0 +1,11 @@ +import { Box, Typography } from '@material-ui/core'; + +interface EmptyStateProps { + message?: string; +} + +export const EmptyState = ({ message = 'No data found' }: EmptyStateProps) => ( + + {message} + +); diff --git a/src/components/shared/ErrorState.tsx b/src/components/shared/ErrorState.tsx new file mode 100644 index 0000000..be94ce4 --- /dev/null +++ b/src/components/shared/ErrorState.tsx @@ -0,0 +1,17 @@ +import { Box, Typography } from '@material-ui/core'; + +interface ErrorStateProps { + message: string; + hint?: string; +} + +export const ErrorState = ({ message, hint }: ErrorStateProps) => ( + + Error: {message} + {hint && ( + + {hint} + + )} + +); diff --git a/src/components/shared/FlagsmithLink.tsx b/src/components/shared/FlagsmithLink.tsx index 0050650..11257b1 100644 --- a/src/components/shared/FlagsmithLink.tsx +++ b/src/components/shared/FlagsmithLink.tsx @@ -3,7 +3,7 @@ import { makeStyles } from '@material-ui/core/styles'; import LaunchIcon from '@material-ui/icons/Launch'; import { flagsmithColors } from '../../theme/flagsmithTheme'; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ link: { display: 'inline-flex', alignItems: 'center', @@ -23,6 +23,11 @@ const useStyles = makeStyles(() => ({ padding: 4, color: flagsmithColors.primary, }, + tooltip: { + backgroundColor: theme.palette.grey[700], + color: theme.palette.common.white, + fontSize: '0.75rem', + }, })); interface FlagsmithLinkProps { @@ -30,6 +35,7 @@ interface FlagsmithLinkProps { children?: React.ReactNode; tooltip?: string; iconOnly?: boolean; + onClick?: (event: React.MouseEvent) => void; } /** @@ -41,12 +47,13 @@ export const FlagsmithLink = ({ children, tooltip = 'Open in Flagsmith', iconOnly = false, + onClick, }: FlagsmithLinkProps) => { const classes = useStyles(); if (iconOnly) { return ( - + @@ -62,13 +70,14 @@ export const FlagsmithLink = ({ } return ( - + {children}