From 9712ae8a32514b2582885a8f9c930b700d949a97 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 19 Dec 2025 13:26:06 -0300 Subject: [PATCH 01/13] 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 3b3265f1beb129cca2458db38dd7f0ad69405ef7 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 23 Dec 2025 12:14:46 -0300 Subject: [PATCH 02/13] 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 e94671b5ea9b6e682a537596544e38f072562cd1 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 23 Dec 2025 12:21:01 -0300 Subject: [PATCH 03/13] 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 701e041293cd5217979cb42e1ed598fa4c9e2292 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 09:18:21 -0300 Subject: [PATCH 04/13] 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 623420ad00af41570e23983aa44e97d70011c53b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 10:40:18 -0300 Subject: [PATCH 05/13] 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 464924d0870fdb9d1063ea64f8c4fb0a148cdfc1 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:43:14 -0300 Subject: [PATCH 06/13] refactor(demo): use makeStyles for LoadingScreen component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline styles with makeStyles in demo App LoadingScreen for consistency with the rest of the codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/App.tsx | 171 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 139 insertions(+), 32 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index 81e9e54..c964e9f 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -12,6 +12,7 @@ import { Container, CircularProgress, } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; import { TestApiProvider } from '@backstage/test-utils'; @@ -24,6 +25,43 @@ 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', @@ -49,12 +87,13 @@ const createDiscoveryApi = (config: DemoConfig) => ({ // which matches the MSW handlers pattern */proxy/flagsmith/... return `${window.location.origin}/api/proxy`; } - return config.baseUrl || 'https://api.flagsmith.com/api/v1'; + return 'https://api.flagsmith.com/api/v1'; }, }); const createFetchApi = (config: DemoConfig) => ({ - fetch: async (url: string, init?: RequestInit) => { + 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') { @@ -62,13 +101,15 @@ const createFetchApi = (config: DemoConfig) => ({ // 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) { - const headers = new Headers(init?.headers); - headers.set('Authorization', `Token ${config.apiKey}`); - return fetch(finalUrl, { ...init, headers }); + // 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(finalUrl, init); + return fetch(input, init); }, }); @@ -103,24 +144,31 @@ const TabPanel = ({ children, value, index }: TabPanelProps) => ( ); -const LoadingScreen = () => ( - - - - - - Loading demo... - - - -); +const useLoadingStyles = makeStyles(theme => ({ + container: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + flexDirection: 'column', + gap: theme.spacing(2), + }, +})); + +const LoadingScreen = () => { + const classes = useLoadingStyles(); + return ( + + + + + + Loading demo... + + + + ); +}; interface DemoContentProps { config: DemoConfig; @@ -191,8 +239,12 @@ 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); useEffect(() => { + let isMounted = true; + const initializeDemo = async () => { if (!config) { setLoading(false); @@ -200,31 +252,54 @@ export const App = () => { } if (config.mode === 'mock') { - await startMsw(); - setMswStarted(true); + 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); } - setLoading(false); }; initializeDemo(); return () => { - if (mswStarted) { - stopMsw(); - } + isMounted = false; }; - }, [config, mswStarted]); + }, [config]); const handleReconfigure = () => { if (mswStarted) { stopMsw(); setMswStarted(false); } + setResolvedConfig(null); + setError(null); clearConfig(); }; const handleConfigure = async (newConfig: DemoConfig) => { setLoading(true); + setError(null); setConfig(newConfig); }; @@ -241,10 +316,42 @@ export const App = () => { ); } + // 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 ( - + ); }; From 48bf958f5f061d74337757637f2eea54590e3f8e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:43:34 -0300 Subject: [PATCH 07/13] feat(demo): make project and org IDs optional in live mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove required validation for projectId and orgId fields - Auto-fetch first available org/project when not provided - Update helper text to clarify optional fields - Clarify API Key is Master API Key from Organisation Settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/components/ConfigScreen.tsx | 40 +++++++------------------------- demo/config/types.ts | 4 ++-- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/demo/components/ConfigScreen.tsx b/demo/components/ConfigScreen.tsx index 570a770..7f7d8ab 100644 --- a/demo/components/ConfigScreen.tsx +++ b/demo/components/ConfigScreen.tsx @@ -76,7 +76,6 @@ export const ConfigScreen: React.FC = ({ onConfigure }) => { 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 => { @@ -84,8 +83,7 @@ export const ConfigScreen: React.FC = ({ onConfigure }) => { 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'; + // Project ID and Org ID are optional - will use first available if not provided setErrors(newErrors); return Object.keys(newErrors).length === 0; }; @@ -99,9 +97,8 @@ export const ConfigScreen: React.FC = ({ onConfigure }) => { onConfigure({ mode: 'live', apiKey: apiKey.trim(), - projectId: projectId.trim(), - orgId: orgId.trim(), - baseUrl: baseUrl.trim() || 'https://api.flagsmith.com/api/v1', + projectId: projectId.trim() || undefined, + orgId: orgId.trim() || undefined, }); } }; @@ -157,7 +154,7 @@ export const ConfigScreen: React.FC = ({ onConfigure }) => { Your credentials will be stored in your browser's local - storage. Refresh the page to reconfigure. + storage. Click RECONFIGURE to change them. = ({ onConfigure }) => { error={!!errors.apiKey} helperText={ errors.apiKey || - 'Your Flagsmith API Key (found in Organisation Settings)' + 'Your Master API Key (found in Organisation Settings → API Keys)' } fullWidth required @@ -176,39 +173,20 @@ export const ConfigScreen: React.FC = ({ onConfigure }) => { /> setProjectId(e.target.value)} - error={!!errors.projectId} - helperText={ - errors.projectId || 'The numeric ID of your Flagsmith project' - } + helperText="Leave empty to use first available 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" + helperText="Leave empty to use first available organization" fullWidth variant="outlined" size="small" diff --git a/demo/config/types.ts b/demo/config/types.ts index 7a9b72f..e077fd4 100644 --- a/demo/config/types.ts +++ b/demo/config/types.ts @@ -2,10 +2,10 @@ export type DemoMode = 'mock' | 'live'; export interface DemoConfig { mode: DemoMode; - apiKey?: string; + // For live mode - API credentials + apiKey?: string; // Master API Key from Organisation Settings → API Keys projectId?: string; orgId?: string; - baseUrl?: string; } export interface ConfigContextValue { From aa36177429e8cb8e050494eb5460a06b728d1df4 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:44:00 -0300 Subject: [PATCH 08/13] chore(demo): add MSW debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add console logs for MSW service worker initialization to help debug mock mode issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/utils/mswLoader.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/demo/utils/mswLoader.ts b/demo/utils/mswLoader.ts index f7369b3..ea00e98 100644 --- a/demo/utils/mswLoader.ts +++ b/demo/utils/mswLoader.ts @@ -5,19 +5,24 @@ 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: `${basePath}mockServiceWorker.js`, + url: swUrl, }, }); + console.log('[MSW] Worker started successfully'); }; export const stopMsw = (): void => { From 6033d731d1f59163273d9460357b7cbfa3e50646 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:44:12 -0300 Subject: [PATCH 09/13] chore(demo): add MSW service worker for mock mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mockServiceWorker.js generated by MSW for intercepting API requests in mock mode demo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/mockServiceWorker.js | 303 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + yarn.lock | 5 + 3 files changed, 309 insertions(+) create mode 100644 demo/mockServiceWorker.js 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/package.json b/package.json index e1ffec4..9e10849 100644 --- a/package.json +++ b/package.json @@ -46,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": { diff --git a/yarn.lock b/yarn.lock index 6148697..cf273b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7782,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" From 02cec994035713d349cb3b9d7852a2d71d23f1f2 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:47:11 -0300 Subject: [PATCH 10/13] feat(demo): add dark/light theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a theme toggle button in the toolbar that allows users to switch between light and dark modes. Theme state is managed in the App component and passed to all child components. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/App.tsx | 75 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index c964e9f..fed6fbd 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -11,8 +11,12 @@ import { 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'; @@ -113,24 +117,28 @@ const createFetchApi = (config: DemoConfig) => ({ }, }); -const theme = createTheme({ - palette: { - type: 'light', - primary: { - main: '#0AC2A3', +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' }, }, - secondary: { - main: '#7B51FB', + typography: { + fontFamily: 'Roboto, sans-serif', }, - background: { - default: '#f5f5f5', - paper: '#ffffff', - }, - }, - typography: { - fontFamily: 'Roboto, sans-serif', - }, -}); + }); + +const lightTheme = createAppTheme('light'); +const darkTheme = createAppTheme('dark'); interface TabPanelProps { children?: React.ReactNode; @@ -155,7 +163,11 @@ const useLoadingStyles = makeStyles(theme => ({ }, })); -const LoadingScreen = () => { +interface LoadingScreenProps { + theme: typeof lightTheme; +} + +const LoadingScreen = ({ theme }: LoadingScreenProps) => { const classes = useLoadingStyles(); return ( @@ -173,9 +185,16 @@ const LoadingScreen = () => { interface DemoContentProps { config: DemoConfig; onReconfigure: () => void; + isDarkMode: boolean; + onToggleTheme: () => void; } -const DemoContent: React.FC = ({ config, onReconfigure }) => { +const DemoContent: React.FC = ({ + config, + onReconfigure, + isDarkMode, + onToggleTheme, +}) => { const [tabValue, setTabValue] = useState(0); const mockEntity = createMockEntity(config); @@ -198,6 +217,11 @@ const DemoContent: React.FC = ({ config, onReconfigure }) => { Flagsmith Backstage Plugin Demo + + + {isDarkMode ? : } + + { 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; @@ -304,7 +332,7 @@ export const App = () => { }; if (loading) { - return ; + return ; } if (!isConfigured || !config) { @@ -345,13 +373,18 @@ export const App = () => { // Wait for resolved config in live mode if (!resolvedConfig) { - return ; + return ; } return ( - + ); }; From f3d9e6e181899d3cc8d2645bd290a5879bd1dd31 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 11:53:27 -0300 Subject: [PATCH 11/13] fix(demo): make AppBar and Tabs theme-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded dark background with color="default" on AppBar - Add conditional text color for title based on theme mode - Make Tabs background adapt to light/dark mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index fed6fbd..d7822b1 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -212,9 +212,9 @@ const DemoContent: React.FC = ({ - + - + Flagsmith Backstage Plugin Demo @@ -228,7 +228,7 @@ const DemoContent: React.FC = ({ onChange={(_e, newValue) => setTabValue(newValue)} indicatorColor="primary" textColor="inherit" - style={{ backgroundColor: 'rgba(255,255,255,0.1)' }} + style={{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }} > From 22b55dda3641dfbcd3200489c3ef7475717d1b60 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 24 Dec 2025 12:09:36 -0300 Subject: [PATCH 12/13] fix(demo): improve theme toggle reliability and styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add key prop to ThemeProvider to force remount on theme change - Apply background.default to main content area for proper theming - Use explicit background/text colors on AppBar for consistent styling - Adjust Tabs background opacity for better visual balance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- demo/App.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/demo/App.tsx b/demo/App.tsx index d7822b1..2ab6d30 100644 --- a/demo/App.tsx +++ b/demo/App.tsx @@ -209,12 +209,18 @@ const DemoContent: React.FC = ({ ]} > - + - + - + Flagsmith Backstage Plugin Demo @@ -228,7 +234,7 @@ const DemoContent: React.FC = ({ onChange={(_e, newValue) => setTabValue(newValue)} indicatorColor="primary" textColor="inherit" - style={{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }} + style={{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)' }} > @@ -377,7 +383,7 @@ export const App = () => { } return ( - + Date: Wed, 24 Dec 2025 12:17:34 -0300 Subject: [PATCH 13/13] fix(ci): preserve build artifact before switching to gh-pages branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact was being lost when switching branches because git checkout replaces the working directory. Move artifact to /tmp before checkout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/deploy-demo.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index ef31d29..789f3f1 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -110,6 +110,9 @@ jobs: 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 @@ -127,7 +130,7 @@ jobs: PR_DIR="pr-${{ github.event.pull_request.number }}" rm -rf "$PR_DIR" mkdir -p "$PR_DIR" - cp -r dist-demo/* "$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"