diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b5d2417 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +# Exclude demo build files from linting +demo/ +dist-demo/ +vite.config.demo.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 0000000..789f3f1 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,204 @@ +name: Deploy Demo + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, closed] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + pages: write + id-token: write + +concurrency: + group: 'pages-${{ github.event.pull_request.number || github.ref }}' + cancel-in-progress: true + +jobs: + build: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + outputs: + preview_url: ${{ steps.set-url.outputs.url }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Determine base path + id: base-path + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "path=/flagsmith-backstage-plugin/pr-${{ github.event.pull_request.number }}/" >> $GITHUB_OUTPUT + else + echo "path=/flagsmith-backstage-plugin/" >> $GITHUB_OUTPUT + fi + + - name: Build demo + run: yarn build:demo + env: + VITE_BASE_PATH: ${{ steps.base-path.outputs.path }} + + - name: Set preview URL + id: set-url + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "url=https://${{ github.repository_owner }}.github.io/flagsmith-backstage-plugin/pr-${{ github.event.pull_request.number }}/" >> $GITHUB_OUTPUT + fi + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + deploy-main: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload to Pages + uses: actions/upload-pages-artifact@v3 + with: + path: ./dist-demo + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + deploy-pr-preview: + if: github.event_name == 'pull_request' && github.event.action != 'closed' + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + - name: Deploy PR preview + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Move artifact to temp location before switching branches + mv dist-demo /tmp/dist-demo + + # Check if gh-pages branch exists + if git ls-remote --heads origin gh-pages | grep -q gh-pages; then + git fetch origin gh-pages + git checkout gh-pages + else + # Create orphan gh-pages branch if it doesn't exist + git checkout --orphan gh-pages + git rm -rf . + echo "# GitHub Pages" > README.md + git add README.md + git commit -m "Initialize gh-pages branch" + fi + + # Deploy PR preview + PR_DIR="pr-${{ github.event.pull_request.number }}" + rm -rf "$PR_DIR" + mkdir -p "$PR_DIR" + cp -r /tmp/dist-demo/* "$PR_DIR/" + + git add . + git diff --staged --quiet || git commit -m "Deploy PR #${{ github.event.pull_request.number }} preview" + git push origin gh-pages + + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const url = '${{ needs.build.outputs.preview_url }}'; + const body = `## Demo Preview + + Preview URL: ${url} + + This preview will be automatically cleaned up when the PR is closed.`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes('Demo Preview') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + cleanup-pr-preview: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Remove PR preview + run: | + # Check if gh-pages branch exists + if ! git ls-remote --heads origin gh-pages | grep -q gh-pages; then + echo "gh-pages branch does not exist, nothing to clean up" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin gh-pages + git checkout gh-pages + + PR_DIR="pr-${{ github.event.pull_request.number }}" + if [ -d "$PR_DIR" ]; then + rm -rf "$PR_DIR" + git add . + git diff --staged --quiet || git commit -m "Remove PR #${{ github.event.pull_request.number }} preview" + git push origin gh-pages + fi diff --git a/demo/App.tsx b/demo/App.tsx new file mode 100644 index 0000000..2ab6d30 --- /dev/null +++ b/demo/App.tsx @@ -0,0 +1,396 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Tabs, + Tab, + ThemeProvider, + CssBaseline, + createTheme, + AppBar, + Toolbar, + Typography, + Container, + CircularProgress, + IconButton, + Tooltip, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Brightness4Icon from '@material-ui/icons/Brightness4'; +import Brightness7Icon from '@material-ui/icons/Brightness7'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { TestApiProvider } from '@backstage/test-utils'; +import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { DemoBanner } from './DemoBanner'; +import { FlagsTab } from '../src/components/FlagsTab'; +import { FlagsmithOverviewCard } from '../src/components/FlagsmithOverviewCard'; +import { FlagsmithUsageCard } from '../src/components/FlagsmithUsageCard'; +import { useConfig, DemoConfig } from './config'; +import { ConfigScreen } from './components'; +import { startMsw, stopMsw } from './utils/mswLoader'; + +// Fetch first available org and project if not provided +const resolveOrgAndProject = async ( + config: DemoConfig, +): Promise<{ projectId: string; orgId: string }> => { + const headers = { Authorization: `Api-Key ${config.apiKey}` }; + + // Get first organisation + let orgId = config.orgId; + if (!orgId) { + const orgsResponse = await fetch( + 'https://api.flagsmith.com/api/v1/organisations/', + { headers }, + ); + if (!orgsResponse.ok) throw new Error('Failed to fetch organisations'); + const orgs = await orgsResponse.json(); + if (!orgs.results?.length) throw new Error('No organisations found'); + orgId = String(orgs.results[0].id); + console.log('[Demo] Using first organisation:', orgId, orgs.results[0].name); + } + + // Get first project + let projectId = config.projectId; + if (!projectId) { + const projectsResponse = await fetch( + `https://api.flagsmith.com/api/v1/organisations/${orgId}/projects/`, + { headers }, + ); + if (!projectsResponse.ok) throw new Error('Failed to fetch projects'); + const projects = await projectsResponse.json(); + if (!projects.length) throw new Error('No projects found'); + projectId = String(projects[0].id); + console.log('[Demo] Using first project:', projectId, projects[0].name); + } + + return { projectId, orgId }; +}; + +const createMockEntity = (config: DemoConfig): Entity => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'demo-service', + description: 'A demo service with Flagsmith feature flags integration', + annotations: { + 'flagsmith.com/project-id': config.projectId || '31465', + 'flagsmith.com/org-id': config.orgId || '24242', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'guests', + }, +}); + +const createDiscoveryApi = (config: DemoConfig) => ({ + getBaseUrl: async (_pluginId: string) => { + if (config.mode === 'mock') { + // Return /api/proxy so FlagsmithClient builds URLs like /api/proxy/flagsmith/... + // which matches the MSW handlers pattern */proxy/flagsmith/... + return `${window.location.origin}/api/proxy`; + } + return 'https://api.flagsmith.com/api/v1'; + }, +}); + +const createFetchApi = (config: DemoConfig) => ({ + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + let finalUrl = url; + + if (config.mode === 'live') { + // FlagsmithClient appends /flagsmith to all URLs (for Backstage proxy routing) + // but in live mode we're hitting the Flagsmith API directly, so strip it + finalUrl = url.replace('/flagsmith/', '/'); + + const headers = new Headers(init?.headers); + if (config.apiKey) { + // Flagsmith API expects Authorization header with Api-Key prefix + headers.set('Authorization', `Api-Key ${config.apiKey}`); + } + console.log('[Demo] Live mode fetch:', finalUrl, 'Headers:', Object.fromEntries(headers.entries())); + return fetch(finalUrl, { ...init, headers }); + } + return fetch(input, init); + }, +}); + +const createAppTheme = (mode: 'light' | 'dark') => + createTheme({ + palette: { + type: mode, + primary: { + main: '#0AC2A3', + }, + secondary: { + main: '#7B51FB', + }, + background: + mode === 'light' + ? { default: '#f5f5f5', paper: '#ffffff' } + : { default: '#121212', paper: '#1e1e1e' }, + }, + typography: { + fontFamily: 'Roboto, sans-serif', + }, + }); + +const lightTheme = createAppTheme('light'); +const darkTheme = createAppTheme('dark'); + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel = ({ children, value, index }: TabPanelProps) => ( + +); + +const useLoadingStyles = makeStyles(theme => ({ + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + flexDirection: 'column', + gap: theme.spacing(2), + }, +})); + +interface LoadingScreenProps { + theme: typeof lightTheme; +} + +const LoadingScreen = ({ theme }: LoadingScreenProps) => { + const classes = useLoadingStyles(); + return ( + + + + + + Loading demo... + + + + ); +}; + +interface DemoContentProps { + config: DemoConfig; + onReconfigure: () => void; + isDarkMode: boolean; + onToggleTheme: () => void; +} + +const DemoContent: React.FC = ({ + config, + onReconfigure, + isDarkMode, + onToggleTheme, +}) => { + const [tabValue, setTabValue] = useState(0); + + const mockEntity = createMockEntity(config); + const mockDiscoveryApi = createDiscoveryApi(config); + const mockFetchApi = createFetchApi(config); + + return ( + + + + + + + + + Flagsmith Backstage Plugin Demo + + + + {isDarkMode ? : } + + + + setTabValue(newValue)} + indicatorColor="primary" + textColor="inherit" + style={{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)' }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const App = () => { + const { config, isConfigured, setConfig, clearConfig } = useConfig(); + const [mswStarted, setMswStarted] = useState(false); + const [loading, setLoading] = useState(true); + const [resolvedConfig, setResolvedConfig] = useState(null); + const [error, setError] = useState(null); + const [isDarkMode, setIsDarkMode] = useState(false); + + const theme = isDarkMode ? darkTheme : lightTheme; + const toggleTheme = () => setIsDarkMode(prev => !prev); + + useEffect(() => { + let isMounted = true; + + const initializeDemo = async () => { + if (!config) { + setLoading(false); + return; + } + + if (config.mode === 'mock') { + try { + await startMsw(); + if (isMounted) { + setMswStarted(true); + setResolvedConfig(config); + } + } catch (err) { + console.error('Failed to start MSW:', err); + } + } else if (config.mode === 'live') { + // Resolve org and project if not provided + try { + const { projectId, orgId } = await resolveOrgAndProject(config); + if (isMounted) { + setResolvedConfig({ ...config, projectId, orgId }); + } + } catch (err) { + console.error('Failed to resolve org/project:', err); + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to connect to Flagsmith'); + } + } + } + if (isMounted) { + setLoading(false); + } + }; + + initializeDemo(); + + return () => { + isMounted = false; + }; + }, [config]); + + const handleReconfigure = () => { + if (mswStarted) { + stopMsw(); + setMswStarted(false); + } + setResolvedConfig(null); + setError(null); + clearConfig(); + }; + + const handleConfigure = async (newConfig: DemoConfig) => { + setLoading(true); + setError(null); + setConfig(newConfig); + }; + + if (loading) { + return ; + } + + if (!isConfigured || !config) { + return ( + + + + + ); + } + + // Show error if failed to resolve org/project + if (error) { + return ( + + + + + {error} + + + Please check your API Key and try again. + + + + + + + ); + } + + // Wait for resolved config in live mode + if (!resolvedConfig) { + return ; + } + + return ( + + + + + ); +}; diff --git a/demo/DemoBanner.tsx b/demo/DemoBanner.tsx new file mode 100644 index 0000000..d0e634e --- /dev/null +++ b/demo/DemoBanner.tsx @@ -0,0 +1,91 @@ +import { Box, Typography, Link, Button } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import InfoIcon from '@material-ui/icons/Info'; +import GitHubIcon from '@material-ui/icons/GitHub'; +import CloudIcon from '@material-ui/icons/Cloud'; +import { DemoMode } from './config'; + +const useStyles = makeStyles(() => ({ + banner: { + backgroundColor: '#7B51FB', + color: '#fff', + padding: '12px 24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + flexWrap: 'wrap', + }, + link: { + color: '#fff', + display: 'inline-flex', + alignItems: 'center', + gap: 4, + '&:hover': { + opacity: 0.9, + }, + }, + icon: { + fontSize: 20, + }, + reconfigureButton: { + color: '#fff', + borderColor: 'rgba(255, 255, 255, 0.5)', + marginLeft: 8, + '&:hover': { + borderColor: '#fff', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }, + modeIndicator: { + display: 'flex', + alignItems: 'center', + gap: 6, + }, +})); + +interface DemoBannerProps { + mode: DemoMode; + onReconfigure: () => void; +} + +export const DemoBanner = ({ mode, onReconfigure }: DemoBannerProps) => { + const classes = useStyles(); + + return ( + + + {mode === 'mock' ? ( + + ) : ( + + )} + + {mode === 'mock' + ? 'Using mock data for demonstration' + : 'Connected to your Flagsmith instance'} + + + + + + + + View on GitHub + + + ); +}; diff --git a/demo/components/ConfigScreen.tsx b/demo/components/ConfigScreen.tsx new file mode 100644 index 0000000..7f7d8ab --- /dev/null +++ b/demo/components/ConfigScreen.tsx @@ -0,0 +1,222 @@ +import React, { useState } from 'react'; +import { + Box, + Card, + CardContent, + Typography, + Button, + TextField, + RadioGroup, + Radio, + FormControlLabel, + FormControl, + FormLabel, + Collapse, + Link, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Alert } from '@material-ui/lab'; +import { DemoMode, DemoConfig } from '../config'; + +const useStyles = makeStyles(theme => ({ + root: { + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#f5f5f5', + padding: theme.spacing(2), + }, + card: { + maxWidth: 520, + width: '100%', + }, + header: { + textAlign: 'center', + marginBottom: theme.spacing(3), + }, + logo: { + width: 48, + height: 48, + marginBottom: theme.spacing(1), + }, + formControl: { + width: '100%', + marginTop: theme.spacing(2), + }, + liveFields: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + marginTop: theme.spacing(2), + paddingLeft: theme.spacing(3), + }, + actions: { + marginTop: theme.spacing(3), + display: 'flex', + justifyContent: 'flex-end', + }, + alert: { + marginTop: theme.spacing(2), + }, + helpText: { + marginTop: theme.spacing(2), + fontSize: '0.875rem', + color: theme.palette.text.secondary, + }, +})); + +interface ConfigScreenProps { + onConfigure: (config: DemoConfig) => void; +} + +export const ConfigScreen: React.FC = ({ onConfigure }) => { + const classes = useStyles(); + const [mode, setMode] = useState('mock'); + const [apiKey, setApiKey] = useState(''); + const [projectId, setProjectId] = useState(''); + const [orgId, setOrgId] = useState(''); + const [errors, setErrors] = useState>({}); + + const validate = (): boolean => { + if (mode === 'mock') return true; + + const newErrors: Record = {}; + if (!apiKey.trim()) newErrors.apiKey = 'API Key is required'; + // Project ID and Org ID are optional - will use first available if not provided + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = () => { + if (!validate()) return; + + if (mode === 'mock') { + onConfigure({ mode: 'mock' }); + } else { + onConfigure({ + mode: 'live', + apiKey: apiKey.trim(), + projectId: projectId.trim() || undefined, + orgId: orgId.trim() || undefined, + }); + } + }; + + return ( + + + + + + Flagsmith Plugin Demo + + + Configure how you want to explore the Backstage plugin + + + + + Data Source + setMode(e.target.value as DemoMode)} + > + } + label={ + + Use Mock Data + + Recommended for quick exploration with sample feature + flags + + + } + /> + } + label={ + + Connect to Flagsmith + + Use your real Flagsmith data + + + } + /> + + + + + + + Your credentials will be stored in your browser's local + storage. Click RECONFIGURE to change them. + + + setApiKey(e.target.value)} + error={!!errors.apiKey} + helperText={ + errors.apiKey || + 'Your Master API Key (found in Organisation Settings → API Keys)' + } + fullWidth + required + variant="outlined" + size="small" + /> + + setProjectId(e.target.value)} + helperText="Leave empty to use first available project" + fullWidth + variant="outlined" + size="small" + /> + + setOrgId(e.target.value)} + helperText="Leave empty to use first available organization" + fullWidth + variant="outlined" + size="small" + /> + + + + + + + + + Learn more about the{' '} + + Flagsmith Backstage Plugin + + + + + + ); +}; diff --git a/demo/components/index.ts b/demo/components/index.ts new file mode 100644 index 0000000..5769adf --- /dev/null +++ b/demo/components/index.ts @@ -0,0 +1 @@ +export * from './ConfigScreen'; diff --git a/demo/config/ConfigContext.tsx b/demo/config/ConfigContext.tsx new file mode 100644 index 0000000..a5377fd --- /dev/null +++ b/demo/config/ConfigContext.tsx @@ -0,0 +1,60 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + ReactNode, +} from 'react'; +import { ConfigContextValue, DemoConfig } from './types'; +import { + loadConfig, + saveConfig, + clearConfig as clearStoredConfig, +} from './storage'; + +const ConfigContext = createContext(null); + +interface ConfigProviderProps { + children: ReactNode; +} + +export const ConfigProvider: React.FC = ({ children }) => { + const [config, setConfigState] = useState(null); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + const stored = loadConfig(); + setConfigState(stored); + setInitialized(true); + }, []); + + const setConfig = (newConfig: DemoConfig) => { + saveConfig(newConfig); + setConfigState(newConfig); + }; + + const clearConfig = () => { + clearStoredConfig(); + setConfigState(null); + }; + + if (!initialized) { + return null; + } + + return ( + + {children} + + ); +}; + +export const useConfig = (): ConfigContextValue => { + const context = useContext(ConfigContext); + if (!context) { + throw new Error('useConfig must be used within ConfigProvider'); + } + return context; +}; diff --git a/demo/config/index.ts b/demo/config/index.ts new file mode 100644 index 0000000..bc1bfc3 --- /dev/null +++ b/demo/config/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './storage'; +export * from './ConfigContext'; diff --git a/demo/config/storage.ts b/demo/config/storage.ts new file mode 100644 index 0000000..fb33ca6 --- /dev/null +++ b/demo/config/storage.ts @@ -0,0 +1,20 @@ +import { DemoConfig } from './types'; + +const STORAGE_KEY = 'flagsmith-backstage-demo-config'; + +export const loadConfig = (): DemoConfig | null => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +}; + +export const saveConfig = (config: DemoConfig): void => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); +}; + +export const clearConfig = (): void => { + localStorage.removeItem(STORAGE_KEY); +}; diff --git a/demo/config/types.ts b/demo/config/types.ts new file mode 100644 index 0000000..e077fd4 --- /dev/null +++ b/demo/config/types.ts @@ -0,0 +1,16 @@ +export type DemoMode = 'mock' | 'live'; + +export interface DemoConfig { + mode: DemoMode; + // For live mode - API credentials + apiKey?: string; // Master API Key from Organisation Settings → API Keys + projectId?: string; + orgId?: string; +} + +export interface ConfigContextValue { + config: DemoConfig | null; + setConfig: (config: DemoConfig) => void; + clearConfig: () => void; + isConfigured: boolean; +} diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..99578c4 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,27 @@ + + + + + + Flagsmith Backstage Plugin Demo + + + + +
+ + + diff --git a/demo/main.tsx b/demo/main.tsx new file mode 100644 index 0000000..113a761 --- /dev/null +++ b/demo/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import { ConfigProvider } from './config'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/demo/mockServiceWorker.js b/demo/mockServiceWorker.js new file mode 100644 index 0000000..1f45c4c --- /dev/null +++ b/demo/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.3.5). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/demo/utils/mswLoader.ts b/demo/utils/mswLoader.ts new file mode 100644 index 0000000..ea00e98 --- /dev/null +++ b/demo/utils/mswLoader.ts @@ -0,0 +1,33 @@ +import { setupWorker, SetupWorkerApi } from 'msw'; +import { handlers } from '../../dev/mockHandlers'; + +let worker: SetupWorkerApi | null = null; + +export const startMsw = async (): Promise => { + if (worker) { + console.log('[MSW] Worker already started'); + return; + } + + console.log('[MSW] Setting up worker with handlers:', handlers.length); + worker = setupWorker(...handlers); + + const basePath = import.meta.env.BASE_URL || '/flagsmith-backstage-plugin/'; + const swUrl = `${basePath}mockServiceWorker.js`; + console.log('[MSW] Starting service worker from:', swUrl); + + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: swUrl, + }, + }); + console.log('[MSW] Worker started successfully'); +}; + +export const stopMsw = (): void => { + if (worker) { + worker.stop(); + worker = null; + } +}; diff --git a/package.json b/package.json index 5a5a587..9e10849 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "postpack": "backstage-cli package postpack", "tsc": "tsc || test -f dist-types/src/index.d.ts", "build:all": "yarn tsc && backstage-cli package build", + "build:demo": "vite build --config vite.config.demo.ts && cp public/mockServiceWorker.js dist-demo/", + "preview:demo": "vite preview --config vite.config.demo.ts", "prepare": "husky" }, "lint-staged": { @@ -44,6 +46,7 @@ "@backstage/plugin-catalog-react": "^1.13.3", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", + "flagsmith": "^10.0.0", "recharts": "^2.5.0" }, "peerDependencies": { @@ -58,12 +61,14 @@ "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", + "@vitejs/plugin-react": "^4.3.4", "husky": "^9.1.7", "lint-staged": "^16.2.7", "msw": "^1.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", - "react-router-dom": "^6.0.0" + "react-router-dom": "^6.0.0", + "vite": "^6.0.5" }, "files": [ "dist" diff --git a/src/__tests__/fixtures/testUtils.tsx b/src/__tests__/fixtures/testUtils.tsx index 116ef32..b3e5fb4 100644 --- a/src/__tests__/fixtures/testUtils.tsx +++ b/src/__tests__/fixtures/testUtils.tsx @@ -20,7 +20,11 @@ export const createMockDiscoveryApi = (baseUrl = 'http://localhost:7007'): Disco export const createMockFetchApi = (responses: Record = {}) => { const mockFetch = jest.fn().mockImplementation(async (url: string) => { // Find matching response based on URL pattern - for (const [pattern, data] of Object.entries(responses)) { + // Sort patterns by length (longest first) to match more specific patterns first + const sortedPatterns = Object.entries(responses).sort( + ([a], [b]) => b.length - a.length, + ); + for (const [pattern, data] of sortedPatterns) { if (url.includes(pattern)) { return { ok: true, diff --git a/src/components/FlagsTab/EnvironmentTable.tsx b/src/components/FlagsTab/EnvironmentTable.tsx index 9900cef..f30f302 100644 --- a/src/components/FlagsTab/EnvironmentTable.tsx +++ b/src/components/FlagsTab/EnvironmentTable.tsx @@ -45,6 +45,9 @@ const useStyles = makeStyles(theme => ({ fontSize: '0.85rem', color: theme.palette.text.primary, }, + envName: { + fontWeight: 500, + }, })); interface EnvironmentTableProps { @@ -79,7 +82,7 @@ export const EnvironmentTable = ({ - + {env.name} {segmentCount > 0 && ( diff --git a/src/components/FlagsTab/ExpandableRow.tsx b/src/components/FlagsTab/ExpandableRow.tsx index 4e27a78..89ff01d 100644 --- a/src/components/FlagsTab/ExpandableRow.tsx +++ b/src/components/FlagsTab/ExpandableRow.tsx @@ -25,6 +25,12 @@ import { FeatureDetailsGrid } from './FeatureDetailsGrid'; import { SegmentOverridesSection } from './SegmentOverridesSection'; const useStyles = makeStyles(theme => ({ + tableRow: { + '& > td': { + paddingTop: theme.spacing(1.5), + paddingBottom: theme.spacing(1.5), + }, + }, flagName: { display: 'flex', alignItems: 'center', @@ -34,6 +40,17 @@ const useStyles = makeStyles(theme => ({ backgroundColor: theme.palette.background.default, padding: theme.spacing(2), }, + collapseCell: { + paddingBottom: 0, + paddingTop: 0, + }, + loadingContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(1), + padding: theme.spacing(2), + }, })); interface ExpandableRowProps { @@ -91,7 +108,7 @@ export const ExpandableRow = ({ return ( <> - + - + {loadingDetails && ( - + - + Loading feature details... diff --git a/src/components/FlagsTab/FeatureDetailsGrid.tsx b/src/components/FlagsTab/FeatureDetailsGrid.tsx index 9cd71f4..3ab188b 100644 --- a/src/components/FlagsTab/FeatureDetailsGrid.tsx +++ b/src/components/FlagsTab/FeatureDetailsGrid.tsx @@ -10,6 +10,16 @@ const useStyles = makeStyles(theme => ({ border: `1px solid ${theme.palette.divider}`, borderRadius: theme.shape.borderRadius, }, + tagsContainer: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + }, + serverKeyChip: { + marginTop: theme.spacing(0.5), + backgroundColor: flagsmithColors.secondary, + color: 'white', + }, })); type LiveVersionInfo = FlagsmithFeature['live_version']; @@ -77,11 +87,7 @@ export const FeatureDetailsGrid = ({ )} @@ -89,7 +95,7 @@ export const FeatureDetailsGrid = ({ {feature.tags && feature.tags.length > 0 && ( - + {feature.tags.map((tag, index) => ( ({ height: 20, marginLeft: theme.spacing(1), }, + statusLabel: { + marginLeft: theme.spacing(1), + }, })); type LiveVersionInfo = FlagsmithFeature['live_version']; @@ -107,7 +110,7 @@ export const SegmentOverridesSection = ({ > - + {state.enabled ? 'Enabled' : 'Disabled'} {state.feature_segment && ( diff --git a/src/components/FlagsTab/__tests__/FlagsTab.test.tsx b/src/components/FlagsTab/__tests__/FlagsTab.test.tsx index c579a51..af334bc 100644 --- a/src/components/FlagsTab/__tests__/FlagsTab.test.tsx +++ b/src/components/FlagsTab/__tests__/FlagsTab.test.tsx @@ -6,7 +6,6 @@ import { mockProject, mockEnvironments, mockFeatures, - mockEntity, mockEntityNoAnnotations, } from '../../../__tests__/fixtures'; @@ -173,8 +172,8 @@ describe('FlagsTab', () => { expect(screen.getByText('feature-one')).toBeInTheDocument(); }); - // Should have Open Dashboard button - const dashboardButton = screen.getByRole('button', { name: /open dashboard/i }); - expect(dashboardButton).toBeInTheDocument(); + // Should have Open Dashboard link (renders as an anchor with aria-label) + const dashboardLink = screen.getByRole('link', { name: /open dashboard/i }); + expect(dashboardLink).toBeInTheDocument(); }); }); diff --git a/src/components/FlagsTab/index.tsx b/src/components/FlagsTab/index.tsx index 8aa3fb7..c8ab14d 100644 --- a/src/components/FlagsTab/index.tsx +++ b/src/components/FlagsTab/index.tsx @@ -28,6 +28,9 @@ const useStyles = makeStyles(theme => ({ gap: theme.spacing(2), justifyContent: 'flex-end', }, + errorHint: { + marginTop: theme.spacing(2), + }, })); export const FlagsTab = () => { @@ -63,7 +66,7 @@ export const FlagsTab = () => { Error: {error} {!projectId && ( - + Add a flagsmith.com/project-id annotation to this entity to view feature flags. diff --git a/src/components/FlagsmithUsageCard/UsageTooltip.tsx b/src/components/FlagsmithUsageCard/UsageTooltip.tsx index 0cd156a..8dd4acb 100644 --- a/src/components/FlagsmithUsageCard/UsageTooltip.tsx +++ b/src/components/FlagsmithUsageCard/UsageTooltip.tsx @@ -1,6 +1,22 @@ import { Box, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; import { FlagsmithUsageData } from '../../api/FlagsmithClient'; +const useStyles = makeStyles(theme => ({ + container: { + padding: theme.spacing(1.5), + backgroundColor: 'rgba(12, 0, 0, 0.95)', + border: '1px solid #ccc', + borderRadius: theme.shape.borderRadius, + }, + title: { + fontWeight: 600, + }, + content: { + marginTop: theme.spacing(1), + }, +})); + interface UsageTooltipProps { active?: boolean; payload?: Array<{ @@ -9,6 +25,8 @@ interface UsageTooltipProps { } export const UsageTooltip = ({ active, payload }: UsageTooltipProps) => { + const classes = useStyles(); + if (!active || !payload || !payload.length) { return null; } @@ -16,22 +34,15 @@ export const UsageTooltip = ({ active, payload }: UsageTooltipProps) => { const data = payload[0].payload; return ( - - + + {new Date(data.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} - + Flags: {data.flags ?? 0} diff --git a/src/components/FlagsmithUsageCard/index.tsx b/src/components/FlagsmithUsageCard/index.tsx index cd045ce..1024c79 100644 --- a/src/components/FlagsmithUsageCard/index.tsx +++ b/src/components/FlagsmithUsageCard/index.tsx @@ -13,6 +13,9 @@ const useStyles = makeStyles(theme => ({ alignItems: 'center', gap: theme.spacing(1), }, + errorHint: { + marginTop: theme.spacing(1), + }, })); export const FlagsmithUsageCard = () => { @@ -43,7 +46,7 @@ export const FlagsmithUsageCard = () => { Error: {error} {!orgId && ( - + Add a flagsmith.com/organization-id annotation to this entity. diff --git a/src/components/shared/FlagStatusIndicator.tsx b/src/components/shared/FlagStatusIndicator.tsx index 84a84eb..9835f90 100644 --- a/src/components/shared/FlagStatusIndicator.tsx +++ b/src/components/shared/FlagStatusIndicator.tsx @@ -9,18 +9,27 @@ const useStyles = makeStyles(() => ({ gap: 6, }, dot: { - width: 10, - height: 10, borderRadius: '50%', flexShrink: 0, }, + dotSmall: { + width: 8, + height: 8, + }, + dotMedium: { + width: 10, + height: 10, + }, enabled: { backgroundColor: flagsmithColors.enabled, }, disabled: { backgroundColor: flagsmithColors.disabled, }, - label: { + labelSmall: { + fontSize: '0.75rem', + }, + labelMedium: { fontSize: '0.875rem', }, })); @@ -44,20 +53,16 @@ export const FlagStatusIndicator = ({ }: FlagStatusIndicatorProps) => { const classes = useStyles(); - const dotSize = size === 'small' ? 8 : 10; + const dotSizeClass = size === 'small' ? classes.dotSmall : classes.dotMedium; + const labelClass = size === 'small' ? classes.labelSmall : classes.labelMedium; const indicator = ( {showLabel && ( - + {enabled ? 'On' : 'Off'} )} diff --git a/src/components/shared/LoadingState.tsx b/src/components/shared/LoadingState.tsx index 1633e5c..6c39355 100644 --- a/src/components/shared/LoadingState.tsx +++ b/src/components/shared/LoadingState.tsx @@ -1,4 +1,18 @@ import { Box, CircularProgress, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + container: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: theme.spacing(3), + }, + message: { + marginTop: theme.spacing(2), + }, +})); interface LoadingStateProps { message?: string; @@ -9,22 +23,16 @@ export const LoadingState = ({ message = 'Loading...', size = 40, }: LoadingStateProps) => { + const classes = useStyles(); + return ( - +