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) => (
+
+ {value === index && {children}}
+
+);
+
+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 (
-
+
{message && (
{message}
diff --git a/vite.config.demo.ts b/vite.config.demo.ts
new file mode 100644
index 0000000..26a20fa
--- /dev/null
+++ b/vite.config.demo.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { resolve } from 'path';
+
+const basePath =
+ process.env.VITE_BASE_PATH || '/flagsmith-backstage-plugin/';
+
+export default defineConfig({
+ plugins: [react()],
+ root: 'demo',
+ base: basePath,
+ build: {
+ outDir: '../dist-demo',
+ emptyOutDir: true,
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'src'),
+ },
+ },
+ optimizeDeps: {
+ include: ['react', 'react-dom', 'msw'],
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index a2be43e..cf273b0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -201,7 +201,7 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.5.tgz#a8a4962e1567121ac0b3b487f52107443b455c7f"
integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==
-"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9":
+"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9", "@babel/core@^7.28.0":
version "7.28.5"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.5.tgz#4c81b35e51e1b734f510c99b07dfbc7bbbb48f7e"
integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==
@@ -430,6 +430,20 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
+"@babel/plugin-transform-react-jsx-self@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92"
+ integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-react-jsx-source@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0"
+ integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.6", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
@@ -3709,6 +3723,11 @@
resolved "https://registry.yarnpkg.com/@remixicon/react/-/react-4.7.0.tgz#1e79467e3c47d5d1f4a304717936adb6211272ac"
integrity sha512-ODBQjdbOjnFguCqctYkpDjERXOInNaBnRPDKfZOBvbzExBAwr2BaH/6AHFTg/UAFzBDkwtylfMT8iKPAkLwPLQ==
+"@rolldown/pluginutils@1.0.0-beta.27":
+ version "1.0.0-beta.27"
+ resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f"
+ integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==
+
"@rollup/plugin-commonjs@^26.0.0":
version "26.0.3"
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-26.0.3.tgz#085ffb49818e43e4a2a96816a37affcc8a8cbaca"
@@ -4219,7 +4238,7 @@
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708"
integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==
-"@types/babel__core@^7.1.14":
+"@types/babel__core@^7.1.14", "@types/babel__core@^7.20.5":
version "7.20.5"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==
@@ -4870,6 +4889,18 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
+"@vitejs/plugin-react@^4.3.4":
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9"
+ integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==
+ dependencies:
+ "@babel/core" "^7.28.0"
+ "@babel/plugin-transform-react-jsx-self" "^7.27.1"
+ "@babel/plugin-transform-react-jsx-source" "^7.27.1"
+ "@rolldown/pluginutils" "1.0.0-beta.27"
+ "@types/babel__core" "^7.20.5"
+ react-refresh "^0.17.0"
+
"@xmldom/xmldom@^0.8.3":
version "0.8.11"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608"
@@ -7665,7 +7696,7 @@ fb-watchman@^2.0.0:
dependencies:
bser "2.1.1"
-fdir@^6.5.0:
+fdir@^6.4.4, fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
@@ -7751,6 +7782,11 @@ find-up@^5.0.0:
locate-path "^6.0.0"
path-exists "^4.0.0"
+flagsmith@^10.0.0:
+ version "10.0.0"
+ resolved "https://registry.yarnpkg.com/flagsmith/-/flagsmith-10.0.0.tgz#9dc4ea1c791afc340f7089afba534aad0d592dce"
+ integrity sha512-JEO4V6nO6ic4ahi5uZRVBYKFil9IuRkLWemKZuFv3QVUCxluQfgymwiuPSs4/LqwFqQ+89SuubaawHxUxUPTzg==
+
flat-cache@^3.0.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee"
@@ -7880,7 +7916,7 @@ fscreen@^1.0.2:
resolved "https://registry.yarnpkg.com/fscreen/-/fscreen-1.2.0.tgz#1a8c88e06bc16a07b473ad96196fb06d6657f59e"
integrity sha512-hlq4+BU0hlPmwsFjwGGzZ+OZ9N/wq9Ljg/sq3pX+2CD7hrJsX9tJgWWK/wiNTFM212CLHWhicOoqwXyZGGetJg==
-fsevents@^2.3.2, fsevents@~2.3.2:
+fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
@@ -11862,7 +11898,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
-postcss@^8.1.0, postcss@^8.4.33:
+postcss@^8.1.0, postcss@^8.4.33, postcss@^8.5.3:
version "8.5.6"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==
@@ -12832,7 +12868,7 @@ rollup-pluginutils@^2.8.2:
dependencies:
estree-walker "^0.6.1"
-rollup@^4.27.3:
+rollup@^4.27.3, rollup@^4.34.9:
version "4.53.5"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.5.tgz#820f46d435c207fd640256f34a0deadf8e95b118"
integrity sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==
@@ -13846,7 +13882,7 @@ tiny-warning@^1.0.2:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
-tinyglobby@^0.2.11, tinyglobby@^0.2.15, tinyglobby@^0.2.9:
+tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.15, tinyglobby@^0.2.9:
version "0.2.15"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
@@ -14452,6 +14488,20 @@ victory-vendor@^36.6.8:
d3-time "^3.0.0"
d3-timer "^3.0.1"
+vite@^6.0.5:
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96"
+ integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==
+ dependencies:
+ esbuild "^0.25.0"
+ fdir "^6.4.4"
+ picomatch "^4.0.2"
+ postcss "^8.5.3"
+ rollup "^4.34.9"
+ tinyglobby "^0.2.13"
+ optionalDependencies:
+ fsevents "~2.3.3"
+
vm-browserify@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"