From 2fa946372975b6330f62eb805e0294ecd5f87a2e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 13:26:06 -0300 Subject: [PATCH 01/15] feat(demo): add GitHub Pages demo with mock data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add static demo deployment for showcasing the plugin: - Vite config for standalone demo build - Demo app with MSW mock handlers - Demo banner indicating mock data usage - GitHub Actions workflow for auto-deploy to Pages - Build script: yarn build:demo Demo URL: https://flagsmith.github.io/flagsmith-backstage-plugin/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .eslintignore | 4 + .github/workflows/deploy-demo.yml | 53 +++++++++++ demo/App.tsx | 149 ++++++++++++++++++++++++++++++ demo/DemoBanner.tsx | 50 ++++++++++ demo/index.html | 27 ++++++ demo/main.tsx | 21 +++++ package.json | 6 +- vite.config.demo.ts | 21 +++++ yarn.lock | 59 ++++++++++-- 9 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 .eslintignore create mode 100644 .github/workflows/deploy-demo.yml create mode 100644 demo/App.tsx create mode 100644 demo/DemoBanner.tsx create mode 100644 demo/index.html create mode 100644 demo/main.tsx create mode 100644 vite.config.demo.ts 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..afa1733 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,53 @@ +name: Deploy Demo + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + 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: Build demo + run: yarn build:demo + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./dist-demo + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/demo/App.tsx b/demo/App.tsx new file mode 100644 index 0000000..3786f1c --- /dev/null +++ b/demo/App.tsx @@ -0,0 +1,149 @@ +import React, { useState } from 'react'; +import { + Box, + Tabs, + Tab, + ThemeProvider, + CssBaseline, + createTheme, + AppBar, + Toolbar, + Typography, + Container, +} from '@material-ui/core'; +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'; + +// Mock entity with Flagsmith annotations +const mockEntity: 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': '31465', + 'flagsmith.com/org-id': '24242', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'guests', + }, +}; + +// Mock Discovery API (returns the MSW-intercepted URL) +const mockDiscoveryApi = { + getBaseUrl: async (_pluginId: string) => { + // Return a URL that MSW will intercept + return `${window.location.origin}/api`; + }, +}; + +// Mock Fetch API (uses native fetch) +const mockFetchApi = { + fetch: async (url: string, init?: RequestInit) => { + return fetch(url, init); + }, +}; + +// Light theme similar to Backstage +const theme = createTheme({ + palette: { + type: 'light', + primary: { + main: '#0AC2A3', + }, + secondary: { + main: '#7B51FB', + }, + background: { + default: '#f5f5f5', + paper: '#ffffff', + }, + }, + typography: { + fontFamily: 'Roboto, sans-serif', + }, +}); + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel = ({ children, value, index }: TabPanelProps) => ( + +); + +export const App = () => { + const [tabValue, setTabValue] = useState(0); + + return ( + + + + + + {/* Demo Banner */} + + + {/* App Header */} + + + + Flagsmith Backstage Plugin Demo + + + setTabValue(newValue)} + indicatorColor="primary" + textColor="inherit" + style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} + > + + + + + + + {/* Tab Content */} + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/demo/DemoBanner.tsx b/demo/DemoBanner.tsx new file mode 100644 index 0000000..d56e45b --- /dev/null +++ b/demo/DemoBanner.tsx @@ -0,0 +1,50 @@ +import { Box, Typography, Link } 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'; + +const useStyles = makeStyles(() => ({ + banner: { + backgroundColor: '#7B51FB', + color: '#fff', + padding: '12px 24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + link: { + color: '#fff', + display: 'inline-flex', + alignItems: 'center', + gap: 4, + '&:hover': { + opacity: 0.9, + }, + }, + icon: { + fontSize: 20, + }, +})); + +export const DemoBanner = () => { + const classes = useStyles(); + return ( + + + + This is a demo using mock data. + + + + View on GitHub + + + ); +}; 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..082ebdd --- /dev/null +++ b/demo/main.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { setupWorker } from 'msw'; +import { handlers } from '../dev/mockHandlers'; +import { App } from './App'; + +// Start MSW worker for API mocking +const worker = setupWorker(...handlers); + +worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: '/flagsmith-backstage-plugin/mockServiceWorker.js', + }, +}).then(() => { + ReactDOM.createRoot(document.getElementById('root')!).render( + + + , + ); +}); diff --git a/package.json b/package.json index 5a5a587..e1ffec4 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": { @@ -58,12 +60,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/vite.config.demo.ts b/vite.config.demo.ts new file mode 100644 index 0000000..1267f4a --- /dev/null +++ b/vite.config.demo.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + root: 'demo', + base: '/flagsmith-backstage-plugin/', + 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..6148697 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== @@ -7880,7 +7911,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 +11893,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 +12863,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 +13877,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 +14483,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" From d9016714534bdb66d2cf7d8d5461554ee01914eb Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 23 Dec 2025 12:14:46 -0300 Subject: [PATCH 02/15] feat(demo): add configuration screen and PR preview deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add interactive demo configuration: - Initial setup screen with mock/live mode selection - Live mode: connect to real Flagsmith with API key - Mock mode: use MSW for sample data - Configuration persisted in localStorage - Reconfigure button in demo banner Add PR preview deployments: - Each PR deploys to /pr-{number}/ on GitHub Pages - Bot comments on PR with preview URL - Automatic cleanup when PR is closed/merged - Dynamic base path via VITE_BASE_PATH env variable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/deploy-demo.yml | 140 ++++++++++++++++- demo/App.tsx | 227 ++++++++++++++++++--------- demo/DemoBanner.tsx | 53 ++++++- demo/components/ConfigScreen.tsx | 244 ++++++++++++++++++++++++++++++ demo/components/index.ts | 1 + demo/config/ConfigContext.tsx | 60 ++++++++ demo/config/index.ts | 3 + demo/config/storage.ts | 20 +++ demo/config/types.ts | 16 ++ demo/main.tsx | 23 +-- demo/utils/mswLoader.ts | 28 ++++ vite.config.demo.ts | 5 +- 12 files changed, 722 insertions(+), 98 deletions(-) create mode 100644 demo/components/ConfigScreen.tsx create mode 100644 demo/components/index.ts create mode 100644 demo/config/ConfigContext.tsx create mode 100644 demo/config/index.ts create mode 100644 demo/config/storage.ts create mode 100644 demo/config/types.ts create mode 100644 demo/utils/mswLoader.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index afa1733..e8b91fe 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -3,20 +3,26 @@ name: Deploy Demo on: push: branches: [main] + pull_request: + types: [opened, synchronize, reopened, closed] workflow_dispatch: permissions: - contents: read + contents: write + pull-requests: write pages: write id-token: write concurrency: - group: "pages" - cancel-in-progress: false + 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 @@ -30,24 +36,144 @@ jobs: - 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: Setup Pages - uses: actions/configure-pages@v4 + - 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-pages-artifact@v3 + uses: actions/upload-artifact@v4 with: + name: demo-build path: ./dist-demo - deploy: + 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 gh-pages branch + uses: actions/checkout@v4 + with: + ref: gh-pages + path: gh-pages + + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: demo-build + path: ./dist-demo + + - name: Deploy PR preview + run: | + PR_DIR="gh-pages/pr-${{ github.event.pull_request.number }}" + rm -rf "$PR_DIR" + mkdir -p "$PR_DIR" + cp -r dist-demo/* "$PR_DIR/" + + cd gh-pages + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + git diff --staged --quiet || git commit -m "Deploy PR #${{ github.event.pull_request.number }} preview" + git push + + - 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 gh-pages branch + uses: actions/checkout@v4 + with: + ref: gh-pages + + - name: Remove PR preview + run: | + PR_DIR="pr-${{ github.event.pull_request.number }}" + if [ -d "$PR_DIR" ]; then + rm -rf "$PR_DIR" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + git diff --staged --quiet || git commit -m "Remove PR #${{ github.event.pull_request.number }} preview" + git push + fi diff --git a/demo/App.tsx b/demo/App.tsx index 3786f1c..527d08f 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Tabs, @@ -10,6 +10,7 @@ import { Toolbar, Typography, Container, + CircularProgress, } from '@material-ui/core'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; @@ -19,17 +20,19 @@ 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'; -// Mock entity with Flagsmith annotations -const mockEntity: Entity = { +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': '31465', - 'flagsmith.com/org-id': '24242', + 'flagsmith.com/project-id': config.projectId || '31465', + 'flagsmith.com/org-id': config.orgId || '24242', }, }, spec: { @@ -37,24 +40,28 @@ const mockEntity: Entity = { lifecycle: 'production', owner: 'guests', }, -}; +}); -// Mock Discovery API (returns the MSW-intercepted URL) -const mockDiscoveryApi = { +const createDiscoveryApi = (config: DemoConfig) => ({ getBaseUrl: async (_pluginId: string) => { - // Return a URL that MSW will intercept - return `${window.location.origin}/api`; + if (config.mode === 'mock') { + return `${window.location.origin}/api`; + } + return config.baseUrl || 'https://api.flagsmith.com/api/v1'; }, -}; +}); -// Mock Fetch API (uses native fetch) -const mockFetchApi = { +const createFetchApi = (config: DemoConfig) => ({ fetch: async (url: string, init?: RequestInit) => { + if (config.mode === 'live' && config.apiKey) { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Token ${config.apiKey}`); + return fetch(url, { ...init, headers }); + } return fetch(url, init); }, -}; +}); -// Light theme similar to Backstage const theme = createTheme({ palette: { type: 'light', @@ -86,64 +93,148 @@ const TabPanel = ({ children, value, index }: TabPanelProps) => ( ); -export const App = () => { +const LoadingScreen = () => ( + + + + + + Loading demo... + + + +); + +interface DemoContentProps { + config: DemoConfig; + onReconfigure: () => void; +} + +const DemoContent: React.FC = ({ config, onReconfigure }) => { const [tabValue, setTabValue] = useState(0); + const mockEntity = createMockEntity(config); + const mockDiscoveryApi = createDiscoveryApi(config); + const mockFetchApi = createFetchApi(config); + + return ( + + + + + + + + + Flagsmith Backstage Plugin Demo + + + setTabValue(newValue)} + indicatorColor="primary" + textColor="inherit" + style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const App = () => { + const { config, isConfigured, setConfig, clearConfig } = useConfig(); + const [mswStarted, setMswStarted] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const initializeDemo = async () => { + if (!config) { + setLoading(false); + return; + } + + if (config.mode === 'mock') { + await startMsw(); + setMswStarted(true); + } + setLoading(false); + }; + + initializeDemo(); + + return () => { + if (mswStarted) { + stopMsw(); + } + }; + }, [config, mswStarted]); + + const handleReconfigure = () => { + if (mswStarted) { + stopMsw(); + setMswStarted(false); + } + clearConfig(); + }; + + const handleConfigure = async (newConfig: DemoConfig) => { + setLoading(true); + setConfig(newConfig); + }; + + if (loading) { + return ; + } + + if (!isConfigured || !config) { + return ( + + + + + ); + } + return ( - - - - {/* Demo Banner */} - - - {/* App Header */} - - - - Flagsmith Backstage Plugin Demo - - - setTabValue(newValue)} - indicatorColor="primary" - textColor="inherit" - style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} - > - - - - - - - {/* Tab Content */} - - - - - - - - - - - - - - - - - - - - + ); }; diff --git a/demo/DemoBanner.tsx b/demo/DemoBanner.tsx index d56e45b..d0e634e 100644 --- a/demo/DemoBanner.tsx +++ b/demo/DemoBanner.tsx @@ -1,7 +1,9 @@ -import { Box, Typography, Link } from '@material-ui/core'; +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: { @@ -12,6 +14,7 @@ const useStyles = makeStyles(() => ({ alignItems: 'center', justifyContent: 'center', gap: 12, + flexWrap: 'wrap', }, link: { color: '#fff', @@ -25,16 +28,54 @@ const useStyles = makeStyles(() => ({ 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, + }, })); -export const DemoBanner = () => { +interface DemoBannerProps { + mode: DemoMode; + onReconfigure: () => void; +} + +export const DemoBanner = ({ mode, onReconfigure }: DemoBannerProps) => { const classes = useStyles(); + return ( - - - This is a demo using mock data. - + + {mode === 'mock' ? ( + + ) : ( + + )} + + {mode === 'mock' + ? 'Using mock data for demonstration' + : 'Connected to your Flagsmith instance'} + + + + + ({ + 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 [baseUrl, setBaseUrl] = useState('https://api.flagsmith.com/api/v1'); + const [errors, setErrors] = useState>({}); + + const validate = (): boolean => { + if (mode === 'mock') return true; + + const newErrors: Record = {}; + if (!apiKey.trim()) newErrors.apiKey = 'API Key is required'; + if (!projectId.trim()) newErrors.projectId = 'Project ID is required'; + if (!orgId.trim()) newErrors.orgId = 'Organization ID is required'; + 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(), + orgId: orgId.trim(), + baseUrl: baseUrl.trim() || 'https://api.flagsmith.com/api/v1', + }); + } + }; + + 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. Refresh the page to reconfigure. + + + setApiKey(e.target.value)} + error={!!errors.apiKey} + helperText={ + errors.apiKey || + 'Your Flagsmith API Key (found in Organisation Settings)' + } + fullWidth + required + variant="outlined" + size="small" + /> + + setProjectId(e.target.value)} + error={!!errors.projectId} + helperText={ + errors.projectId || 'The numeric ID of your Flagsmith project' + } + fullWidth + required + variant="outlined" + size="small" + /> + + setOrgId(e.target.value)} + error={!!errors.orgId} + helperText={ + errors.orgId || + 'The numeric ID of your Flagsmith organization' + } + fullWidth + required + variant="outlined" + size="small" + /> + + setBaseUrl(e.target.value)} + helperText="Only change for self-hosted Flagsmith instances" + 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..7a9b72f --- /dev/null +++ b/demo/config/types.ts @@ -0,0 +1,16 @@ +export type DemoMode = 'mock' | 'live'; + +export interface DemoConfig { + mode: DemoMode; + apiKey?: string; + projectId?: string; + orgId?: string; + baseUrl?: string; +} + +export interface ConfigContextValue { + config: DemoConfig | null; + setConfig: (config: DemoConfig) => void; + clearConfig: () => void; + isConfigured: boolean; +} diff --git a/demo/main.tsx b/demo/main.tsx index 082ebdd..113a761 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,21 +1,12 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { setupWorker } from 'msw'; -import { handlers } from '../dev/mockHandlers'; import { App } from './App'; +import { ConfigProvider } from './config'; -// Start MSW worker for API mocking -const worker = setupWorker(...handlers); - -worker.start({ - onUnhandledRequest: 'bypass', - serviceWorker: { - url: '/flagsmith-backstage-plugin/mockServiceWorker.js', - }, -}).then(() => { - ReactDOM.createRoot(document.getElementById('root')!).render( - +ReactDOM.createRoot(document.getElementById('root')!).render( + + - , - ); -}); + + , +); diff --git a/demo/utils/mswLoader.ts b/demo/utils/mswLoader.ts new file mode 100644 index 0000000..f7369b3 --- /dev/null +++ b/demo/utils/mswLoader.ts @@ -0,0 +1,28 @@ +import { setupWorker, SetupWorkerApi } from 'msw'; +import { handlers } from '../../dev/mockHandlers'; + +let worker: SetupWorkerApi | null = null; + +export const startMsw = async (): Promise => { + if (worker) { + return; + } + + worker = setupWorker(...handlers); + + const basePath = import.meta.env.BASE_URL || '/flagsmith-backstage-plugin/'; + + await worker.start({ + onUnhandledRequest: 'bypass', + serviceWorker: { + url: `${basePath}mockServiceWorker.js`, + }, + }); +}; + +export const stopMsw = (): void => { + if (worker) { + worker.stop(); + worker = null; + } +}; diff --git a/vite.config.demo.ts b/vite.config.demo.ts index 1267f4a..26a20fa 100644 --- a/vite.config.demo.ts +++ b/vite.config.demo.ts @@ -2,10 +2,13 @@ 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: '/flagsmith-backstage-plugin/', + base: basePath, build: { outDir: '../dist-demo', emptyOutDir: true, From 0f1a9e6cb0c9bd40753f2a9e9d5c25dde6a29c0c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 23 Dec 2025 12:21:01 -0300 Subject: [PATCH 03/15] fix(ci): handle missing gh-pages branch in PR preview workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check if gh-pages branch exists before checkout - Create orphan gh-pages branch if it doesn't exist - Update cleanup job to handle missing branch gracefully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/deploy-demo.yml | 48 ++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index e8b91fe..ef31d29 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -94,11 +94,10 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - name: Checkout gh-pages branch + - name: Checkout repository uses: actions/checkout@v4 with: - ref: gh-pages - path: gh-pages + fetch-depth: 0 - name: Download artifact uses: actions/download-artifact@v4 @@ -108,17 +107,31 @@ jobs: - name: Deploy PR preview run: | - PR_DIR="gh-pages/pr-${{ github.event.pull_request.number }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # 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 dist-demo/* "$PR_DIR/" - cd gh-pages - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" git add . git diff --staged --quiet || git commit -m "Deploy PR #${{ github.event.pull_request.number }} preview" - git push + git push origin gh-pages - name: Comment on PR uses: actions/github-script@v7 @@ -161,19 +174,28 @@ jobs: if: github.event_name == 'pull_request' && github.event.action == 'closed' runs-on: ubuntu-latest steps: - - name: Checkout gh-pages branch + - name: Checkout repository uses: actions/checkout@v4 with: - ref: gh-pages + 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 config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" git add . git diff --staged --quiet || git commit -m "Remove PR #${{ github.event.pull_request.number }} preview" - git push + git push origin gh-pages fi From c7cf7f91a359a18bbe3ae6a9ae7bfb7e2fcfbbee Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 23 Dec 2025 22:40:19 -0300 Subject: [PATCH 04/15] fix(test): fix FlagsTab test mock pattern matching and role selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort mock URL patterns by length to match more specific patterns first - Fix dashboard link test to use 'link' role instead of 'button' - Remove unused mockEntity import 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/__tests__/fixtures/testUtils.tsx | 6 +++++- src/components/FlagsTab/__tests__/FlagsTab.test.tsx | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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/__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(); }); }); From 4d693d38d1d48ced0676579182c52253eda10b76 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 09:18:21 -0300 Subject: [PATCH 05/15] fix(demo): correct discovery API URL for MSW interception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FlagsmithClient appends /flagsmith to the base URL from discovery API. MSW handlers expect patterns like */proxy/flagsmith/..., so the discovery API must return /api/proxy (not /api) for mock mode to work correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/demo/App.tsx b/demo/App.tsx index 527d08f..7fc9638 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -45,7 +45,9 @@ const createMockEntity = (config: DemoConfig): Entity => ({ const createDiscoveryApi = (config: DemoConfig) => ({ getBaseUrl: async (_pluginId: string) => { if (config.mode === 'mock') { - return `${window.location.origin}/api`; + // 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 config.baseUrl || 'https://api.flagsmith.com/api/v1'; }, From 0b804897d0f9667583f7749cbf12db866ad49478 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 10:40:18 -0300 Subject: [PATCH 06/15] fix(demo): strip /flagsmith from URLs in live mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlagsmithClient appends /flagsmith to all URLs (designed for Backstage proxy routing). In live mode, we hit the Flagsmith API directly, so strip /flagsmith from the path to get correct API URLs. Before: https://api.flagsmith.com/api/v1/flagsmith/projects/123/ After: https://api.flagsmith.com/api/v1/projects/123/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/App.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 7fc9638..81e9e54 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -55,12 +55,20 @@ const createDiscoveryApi = (config: DemoConfig) => ({ const createFetchApi = (config: DemoConfig) => ({ fetch: async (url: string, init?: RequestInit) => { - if (config.mode === 'live' && config.apiKey) { - const headers = new Headers(init?.headers); - headers.set('Authorization', `Token ${config.apiKey}`); - return fetch(url, { ...init, headers }); + 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/', '/'); + + if (config.apiKey) { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Token ${config.apiKey}`); + return fetch(finalUrl, { ...init, headers }); + } } - return fetch(url, init); + return fetch(finalUrl, init); }, }); From 69fb10eb9772497dd891f7e261946076bffd664c Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:41:58 -0300 Subject: [PATCH 07/15] refactor(components): replace inline styles with makeStyles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert all inline styles to Material-UI makeStyles for consistency: - FlagsTab: add errorHint, header classes - ExpandableRow: add collapseCell, loadingContainer classes - EnvironmentTable: add envName class - FeatureDetailsGrid: add tagsContainer, serverKeyChip classes - SegmentOverridesSection: add statusLabel class - FlagStatusIndicator: add size variant classes (dotSmall/dotMedium) - LoadingState: add container, message classes - FlagsmithUsageCard: add errorHint class - UsageTooltip: add container, title, content classes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/FlagsTab/EnvironmentTable.tsx | 5 ++- src/components/FlagsTab/ExpandableRow.tsx | 29 ++++++++++++----- .../FlagsTab/FeatureDetailsGrid.tsx | 18 +++++++---- .../FlagsTab/SegmentOverridesSection.tsx | 5 ++- src/components/FlagsTab/index.tsx | 5 ++- .../FlagsmithUsageCard/UsageTooltip.tsx | 31 +++++++++++++------ src/components/FlagsmithUsageCard/index.tsx | 5 ++- src/components/shared/FlagStatusIndicator.tsx | 27 +++++++++------- src/components/shared/LoadingState.tsx | 28 +++++++++++------ 9 files changed, 104 insertions(+), 49 deletions(-) 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/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 ( - +