From f0f2ca5edd12df8e990289c8784db92c96fd1e58 Mon Sep 17 00:00:00 2001 From: rvveber Date: Tue, 4 Nov 2025 10:22:58 +0100 Subject: [PATCH 1/8] Integrates plugin system POC to test ... more fine grained commits later --- docs/env.md | 2 + docs/frontend-plugins.md | 327 + env.d/development/common | 4 + src/backend/core/api/viewsets.py | 42 + .../configuration/plugins/default.json | 54 + src/backend/impress/settings.py | 12 + src/frontend/apps/impress-plugin/.gitignore | 2 + src/frontend/apps/impress-plugin/README.md | 14 + src/frontend/apps/impress-plugin/package.json | 37 + .../impress-plugin/src/MyCustomComponent.tsx | 489 ++ .../impress-plugin/src/MyCustomHeaderMenu.css | 109 + .../impress-plugin/src/MyCustomHeaderMenu.tsx | 283 + .../impress-plugin/src/ThemingComponent.tsx | 220 + .../apps/impress-plugin/src/ThemingDemo.tsx | 164 + .../apps/impress-plugin/src/index.tsx | 2 + .../apps/impress-plugin/tsconfig.json | 20 + .../apps/impress-plugin/webpack.config.js | 80 + src/frontend/apps/impress/.env | 6 +- src/frontend/apps/impress/.env.development | 2 + src/frontend/apps/impress/mf.config.js | 305 + src/frontend/apps/impress/next.config.js | 75 +- src/frontend/apps/impress/package.json | 9 +- .../apps/impress/src/core/AppProvider.tsx | 9 +- .../impress/src/core/config/api/useConfig.tsx | 2 + src/frontend/apps/impress/src/core/index.ts | 2 +- .../src/core/plugin/PluginSystemProvider.tsx | 1222 ++++ .../apps/impress/src/core/plugin/index.ts | 1 + .../src/features/auth/components/Auth.tsx | 2 +- .../hooks/useIsCollaborativeEditable.tsx | 2 +- .../language/components/LanguagePicker.tsx | 2 +- .../apps/impress/src/pages/globals.css | 5 + src/frontend/apps/impress/tsconfig.json | 2 +- src/frontend/bun.lock | 5620 +++++++++++++++++ src/frontend/yarn.lock | 2053 +++++- 34 files changed, 11054 insertions(+), 126 deletions(-) create mode 100644 docs/frontend-plugins.md create mode 100644 src/backend/impress/configuration/plugins/default.json create mode 100644 src/frontend/apps/impress-plugin/.gitignore create mode 100644 src/frontend/apps/impress-plugin/README.md create mode 100644 src/frontend/apps/impress-plugin/package.json create mode 100644 src/frontend/apps/impress-plugin/src/MyCustomComponent.tsx create mode 100644 src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.css create mode 100644 src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.tsx create mode 100644 src/frontend/apps/impress-plugin/src/ThemingComponent.tsx create mode 100644 src/frontend/apps/impress-plugin/src/ThemingDemo.tsx create mode 100644 src/frontend/apps/impress-plugin/src/index.tsx create mode 100644 src/frontend/apps/impress-plugin/tsconfig.json create mode 100644 src/frontend/apps/impress-plugin/webpack.config.js create mode 100644 src/frontend/apps/impress/mf.config.js create mode 100644 src/frontend/apps/impress/src/core/plugin/PluginSystemProvider.tsx create mode 100644 src/frontend/apps/impress/src/core/plugin/index.ts create mode 100644 src/frontend/bun.lock diff --git a/docs/env.md b/docs/env.md index 0b3f9b3bf6..781ad2f853 100644 --- a/docs/env.md +++ b/docs/env.md @@ -99,6 +99,8 @@ These are the environment variables you can set for the `impress-backend` contai | STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | | THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | +| PLUGINS_CONFIG_FILE_PATH | Full path to the JSON file containing the plugins configuration loaded by the backend. Example: src/backend/impress/configuration/plugins/default.json | BASE_DIR/impress/configuration/plugins/default.json | +| PLUGINS_CONFIG_CACHE_TIMEOUT | Time in seconds the plugins configuration file is cached by the backend before being reloaded. Default is 2 hours (7200 seconds). | 7200 | | TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 | | USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] | | Y_PROVIDER_API_BASE_URL | Y Provider url | | diff --git a/docs/frontend-plugins.md b/docs/frontend-plugins.md new file mode 100644 index 0000000000..cf7cb71a1d --- /dev/null +++ b/docs/frontend-plugins.md @@ -0,0 +1,327 @@ +## Overview + +The plugin system allows developers to extend the application's functionality and appearance without modifying the core. It's ideal for teams or third parties to add custom features. + +### Glossary +- **Remote**: An application exposing components via module federation. +- **Host**: The main entry point application. This is Docs itself ("impress") +- **Plugin**: A remote module integrated into the host to provide UI components. +- **Module Federation**: Technology for runtime module sharing between apps. +- **Container**: Environment executing a remote module. +- **Exposed Module**: A module or component made available by a remote. + +### Features and Limitations +**Features:** +- Add new UI components. +- Reuse host UI components. +- Dynamically inject via CSS selectors and config. +- Integrate without rebuilding or redeploying the host. +- Build and version plugins independently. + +**Limitations:** +- Primarily for UI additions/modifications. +- Libraries used, that the host already provides, should match host versions to allow singleton behavior. +- Cannot extend Next.js-specific features like pages. +- No direct access to host state (only few exceptions where a global intermediary cache is used e.g. react-queries). +- Host updates may require plugin checks/updates for DOM/component changes (usually minimal). + +### Overview +This diagram shows the plugin integration flow: fetching config and plugins, checking visibility, starting DOM observation, conditionally rendering components, and re-checking on DOM changes. + + +```mermaid +flowchart TD + subgraph Host + A["Fetches Config
(default.json)"] --> B["Fetches Plugins
(remoteEntry.js)"] + B --> C["Checks Visibility
(route matches)"] + C --> F["Checks if Targets exist
(CSS selector)"] + F --> G["Checks if Component already injected"] + G --> D["Render Component with Props into Targets"] + D --> H["Start DOM Observer
(if enabled)"] + H -.-> C + end +``` + + +## Configuration + +Plugins are configured via a JSON file (e.g., `impress/configuration/plugins/default.json`) loaded at runtime by the host. Place it in the backend, via a Docker volume for single-file drop-in or Kubernetes ConfigMap. + +### Structure +| Field | Type | Required | Description | +|-------------|---------|----------|-------------| +| `id` | String | Yes | Unique component identifier (e.g., "my-widget"). | +| `remote` | Object | Yes | Remote module details. | +| - `url` | String | Yes | Path to `remoteEntry.js` (absolute/relative). | +| - `name` | String | Yes | Federation remote name (e.g., "myPlugin"). | +| - `module` | String | Yes | Exposed module (e.g., "./Widget"). | +| `injection`| Object | Yes | Integration control. | +| - `target` | String | Yes | CSS selector for insertion point. | +| - `position` | String | No (default: "append") | Insertion position (`before`, `after`, `replace`, `prepend`, `append`). | +| - `observerRoots` | String/Boolean | No | DOM observation: CSS selector, `true` (full page), `false` (none). | +| `props` | Object | No | Props passed to the plugin component. | +| `visibility` | Object | No | Visibility controls. | +| - `routes` | Array | No | Path globs (e.g., ["/docs/*", "!/docs/secret*"]); supports negation (`!`). | + + +### Example +```json +{ + "id": "my-custom-component-1", + "remote": { + "url": "localhost:3001/remoteEntry.js", + "name": "my-plugin", + "module": "./MyCustomComponent" + }, + "injection": { + "target": "#list #item3", + "position": "append", + "observerRoots": "#list" + }, + "props": { + "title": "My Widget", + "color": "#ffcc00" + }, + "visibility": { + "routes": ["/docs/*", "!/docs/secret*"] + } +} +``` + +### Key Notes +- `remote` and `injection` are required. + - `remote.url` can be relative if the plugins compiled `remoteEntry.js` is placed in the hosts public folder (e.g. via k8s ConfigMap) + ```diff + - "url": "http://localhost:3001/remoteEntry.js", + + "url": "/plugins/my-plugin/remoteEntry.js", + ``` +- Use `target`/`position` for flexible placement (e.g., replace or append). +- `observerRoots` enables re-injection if target lost through DOM changes
(e.g., components re-rendering/un-mounting)
It should be a selector to the closest stable ancestor
(e.g. `"#list"` if list is a dynamically populated container). +- Restrict visibility with `routes` globs and negations. +- Pass custom data via `props`. + + +#### `injection.position` + +Below are simple examples for all possible values.
+Each shows the relevant JSON config and the resulting HTML structure after injection: + + +**before** +```json +{ + "injection": { + "target": "#list #item3", + "position": "before", + "observerRoots": "#list" + } +} +``` +```html + +``` + +**after** +```json +{ + "injection": { + "target": "#list #item1", + "position": "after", + "observerRoots": "#list" + } +} +``` +```html + +``` + +**prepend** +```json +{ + "injection": { + "target": "#list", + "position": "prepend", + "observerRoots": "#list" + } +} +``` +```html + +``` + +**append** +```json +{ + "injection": { + "target": "#list", + "position": "append", + "observerRoots": "#list" + } +} +``` +```html + +``` + +**replace** +```json +{ + "injection": { + "target": "#list #item2", + "position": "replace", + "observerRoots": "#list" + } +} +``` +```html + +``` + + +## Development Guide + +### Environment Variables +Set `NEXT_PUBLIC_DEVELOP_PLUGINS=true` in `.env.development` for debug logs, type-sharing, and hot-reload support. This also logs all exposed modules with exact versions on startup, helping match plugin dependencies. + +### Type-Sharing for Intellisense +In plugin `tsconfig.json`: +```json +{ + "baseUrl": ".", + "paths": { + "*": ["./@mf-types/*"] + } +} +``` +Types update on build for compatibility and autocompletion. + +### Exports Support +The host automatically exposes components and some features under the same structure that is used in docs code. + +```typescript +// Direct import +import { useAuthQuery } from 'impress/features/auth/api/useAuthQuery'; +import { Icon } from 'impress/components/Icon'; + +// Clean barrel export import +import { useAuthQuery } from 'impress/features/auth/api'; +import { Icon } from 'impress/components'; +``` + +**Important Notes:** +- Only barrel exports with runtime values are exposed (not type-only exports) +- In case of naming conflicts (e.g., `Button.tsx` and `Button/index.tsx`), explicit files take precedence over barrel exports +- The host logs warnings for any naming conflicts during build + +### Recommended Workflow +2. Enable NEXT_PUBLIC_DEVELOP_PLUGINS and start host(docs) & plugin dev servers in parallel. +3. Configure federation in plugin `webpack.config.js` and expose components (see Examples). +4. Develop with hot-reload; use host components via shared types. +5. Test in host via config; debug with logs. +6. Version and deploy independently. + +### Integration in Host +1. Build plugin and host `remoteEntry.js`. +2. Add to host config JSON. +3. Start host; plugin loads at runtime. +4. Verify via `[PluginSystem]` logs. + +### Debugging +Enable `NEXT_PUBLIC_DEVELOP_PLUGINS=true` for console logs like `[PluginSystem] ...`. + +Common Errors: +| Issue | Cause/Fix | +|------------------------|-----------| +| Unreachable `remoteEntry.js` | Check URL; ensure accessible. | +| Library version conflicts | Match React/etc. versions; use singletons in federation. | +| Invalid CSS selectors | Validate `target` against host DOM. | +| Type mismatches | Update shared types on build. | + +Errors are isolated via host ErrorBoundary. + +## Best Practices and Security + +### Best Practices +- Keep components modular and self-contained. +- Use props for flexibility/reusability. +- Document interfaces and expected props. +- Version plugins for host compatibility. +- Avoid internal host dependencies; use official types/components. +- Test regularly in host; monitor logs. +- Update federation config with host changes. + +### Security and Isolation +- Plugins run isolated with ErrorBoundaries; errors don't crash host. +- No direct host state access; use interfaces/props. +- Vet third-party libraries for security. +- Check plugin functionality after host updates. +- Avoid unsafe external scripts/resources. + +## Examples + +### Federation Configuration (webpack.config.js) +Define `moduleFederationConfig` first for reuse: + +```js +const { ModuleFederationPlugin } = require('webpack').container; +const NativeFederationTypeScriptHost = require('@module-federation/native-federation-typescript/host'); + +const moduleFederationConfig = { + name: 'my-plugin', + filename: 'remoteEntry.js', + exposes: { + './MyCustomComponent': './src/MyCustomComponent.tsx', + }, + remotes: { + impress: 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js', + }, + shared: { + react: { singleton: true }, + 'react-dom': { singleton: true }, + 'styled-components': { singleton: true }, + 'cunningham-react': { singleton: true }, + }, +}; + +module.exports = { + plugins: [ + new ModuleFederationPlugin(moduleFederationConfig), + ...(dev ? [NativeFederationTypeScriptHost({ moduleFederationConfig })] : []), + ] +}; +``` +Adjust names/paths; declare shared libs as singletons. Use relative remotes if the plugin `remoteEntry.js` lives in host's public folder (e.g., public/plugins/my-plugin/remoteEntry.js). +```diff +- impress: 'impress@localhost:3000/_next/static/chunks/remoteEntry.js' ++ impress: 'impress@/_next/static/chunks/remoteEntry.js', +``` + +For plugin config example, see Configuration section. + +## Summary +The plugin system enables runtime frontend extensions via module federation, with easy config, type-sharing, and independent deployment. Focus on UI mods, match versions, and test for compatibility. Use the diagram, tables, and examples for quick reference. \ No newline at end of file diff --git a/env.d/development/common b/env.d/development/common index a0cf0fe5c0..4839310989 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -66,3 +66,7 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/ Y_PROVIDER_API_KEY=yprovider-api-key + +# Cache +PLUGINS_CONFIG_CACHE_TIMEOUT=0 +THEME_CUSTOMIZATION_CACHE_TIMEOUT=0 \ No newline at end of file diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1fb95c4eb6..dbb2b5c0be 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2164,6 +2164,7 @@ def get(self, request): dict_settings[setting] = getattr(settings, setting) dict_settings["theme_customization"] = self._load_theme_customization() + dict_settings["plugins"] = self._load_plugins_config() return drf.response.Response(dict_settings) @@ -2201,3 +2202,44 @@ def _load_theme_customization(self): ) return theme_customization + + def _load_plugins_config(self): + if not settings.PLUGINS_CONFIG_FILE_PATH: + return [] + + cache_key = ( + f"plugins_config_{slugify(settings.PLUGINS_CONFIG_FILE_PATH)}" + ) + plugins_config = cache.get(cache_key) + if plugins_config is not None: + return plugins_config + + plugins_config = [] + try: + with open( + settings.PLUGINS_CONFIG_FILE_PATH, "r", encoding="utf-8" + ) as f: + data = json.load(f) + # Support both array format and object with "plugins" key + if isinstance(data, list): + plugins_config = data + elif isinstance(data, dict): + plugins_config = data.get("plugins", []) + except FileNotFoundError: + logger.error( + "Plugins configuration file not found: %s", + settings.PLUGINS_CONFIG_FILE_PATH, + ) + except json.JSONDecodeError: + logger.error( + "Plugins configuration file is not a valid JSON: %s", + settings.PLUGINS_CONFIG_FILE_PATH, + ) + else: + cache.set( + cache_key, + plugins_config, + settings.PLUGINS_CONFIG_CACHE_TIMEOUT, + ) + + return plugins_config diff --git a/src/backend/impress/configuration/plugins/default.json b/src/backend/impress/configuration/plugins/default.json new file mode 100644 index 0000000000..3627c8593a --- /dev/null +++ b/src/backend/impress/configuration/plugins/default.json @@ -0,0 +1,54 @@ +[ + { + "id": "my-custom-component-in-header", + "remote": { + "url": "http://localhost:3002/remoteEntry.js", + "name": "plugin_frontend", + "module": "MyCustomComponent" + }, + "injection": { + "target": "body header [data-testid=\"header-logo-link\"]", + "position": "after", + "observerRoots": "body header" + }, + "props": { + "customMessage": "Plugin Demo", + "showDebugInfo": true + }, + "visibility": { + "routes": [ + "/docs/*" + ] + } + }, + { + "id": "central-header-menu", + "remote": { + "url": "http://localhost:3002/remoteEntry.js", + "name": "plugin_frontend", + "module": "./MyCustomHeaderMenu" + }, + "injection": { + "target": "body header [data-testid=\"header-logo-link\"]", + "position": "after", + "observerRoots": "body header" + }, + "props": { + "icsBaseUrl": "http://localhost:8000", + "portalBaseUrl": "http://localhost:8001" + } + }, + { + "id": "theme-demo-panel", + "remote": { + "url": "http://localhost:3002/remoteEntry.js", + "name": "plugin_frontend", + "module": "./ThemingDemo" + }, + "injection": { + "target": "body", + "position": "append", + "observerRoots": false + } + } +] \ No newline at end of file diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 151f2bfdaa..b438575a2f 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -496,6 +496,18 @@ class Base(Configuration): environ_prefix=None, ) + PLUGINS_CONFIG_FILE_PATH = values.Value( + os.path.join(BASE_DIR, "impress/configuration/plugins/default.json"), + environ_name="PLUGINS_CONFIG_FILE_PATH", + environ_prefix=None, + ) + + PLUGINS_CONFIG_CACHE_TIMEOUT = values.Value( + 60 * 60 * 2, + environ_name="PLUGINS_CONFIG_CACHE_TIMEOUT", + environ_prefix=None, + ) + # Posthog POSTHOG_KEY = values.DictValue( None, environ_name="POSTHOG_KEY", environ_prefix=None diff --git a/src/frontend/apps/impress-plugin/.gitignore b/src/frontend/apps/impress-plugin/.gitignore new file mode 100644 index 0000000000..1207decc84 --- /dev/null +++ b/src/frontend/apps/impress-plugin/.gitignore @@ -0,0 +1,2 @@ +@mf-types/ +node_modules/ \ No newline at end of file diff --git a/src/frontend/apps/impress-plugin/README.md b/src/frontend/apps/impress-plugin/README.md new file mode 100644 index 0000000000..0f4360cf6e --- /dev/null +++ b/src/frontend/apps/impress-plugin/README.md @@ -0,0 +1,14 @@ +# Impress Plugin + +Minimal Module Federation Plugin for Impress + +## Development + +```bash +yarn install +yarn dev +``` + +Server runs on http://localhost:3002 + +Remote entry point: http://localhost:3002/remoteEntry.js diff --git a/src/frontend/apps/impress-plugin/package.json b/src/frontend/apps/impress-plugin/package.json new file mode 100644 index 0000000000..5a3de8cef4 --- /dev/null +++ b/src/frontend/apps/impress-plugin/package.json @@ -0,0 +1,37 @@ +{ + "name": "app-impress-plugin", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "webpack serve --mode development", + "build": "webpack --mode production" + }, + "devDependencies": { + "@module-federation/native-federation-typescript": "0.6.2", + "@openfun/cunningham-react": "3.2.3", + "@types/react": "19.1.1", + "@types/react-dom": "19.1.1", + "css-loader": "^7.1.2", + "html-webpack-plugin": "^5.6.3", + "style-loader": "^4.0.0", + "ts-loader": "^9.5.1", + "typescript": "*", + "webpack": "^5.101.3", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.0" + }, + "dependencies": { + "react": "19.1.1", + "react-dom": "19.1.1", + "styled-components": "6.1.19", + "@openfun/cunningham-react": "3.2.3", + "react-i18next": "15.7.3", + "react-aria-components": "1.12.1", + "@gouvfr-lasuite/ui-kit": "0.16.1", + "yjs": "13.6.27", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "react-intersection-observer": "9.16.0", + "@tanstack/react-query": "5.87.4" + } +} diff --git a/src/frontend/apps/impress-plugin/src/MyCustomComponent.tsx b/src/frontend/apps/impress-plugin/src/MyCustomComponent.tsx new file mode 100644 index 0000000000..cf8633046f --- /dev/null +++ b/src/frontend/apps/impress-plugin/src/MyCustomComponent.tsx @@ -0,0 +1,489 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Button, Popover } from 'react-aria-components'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { useAuthQuery } from 'impress/features/auth/api'; +import { Icon, Loading, Box, Text } from 'impress/components'; + +// Styled Components showcasing styled-components integration +const StyledButton = styled(Button)` + cursor: pointer; + border: 1px solid #0066cc; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 8px 16px; + border-radius: 8px; + font-family: Marianne, Arial, serif; + font-weight: 500; + font-size: 0.875rem; + transition: all 0.2s ease-in-out; + display: flex; + align-items: center; + gap: 8px; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + } + + &:active { + transform: translateY(0); + } + + &:focus-visible { + outline: 2px solid #667eea; + outline-offset: 2px; + } +`; + +const StyledPopover = styled(Popover)` + background-color: white; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); + border: 1px solid #e0e0e0; + min-width: 400px; + max-width: 500px; + max-height: 600px; + overflow: hidden; + display: flex; + flex-direction: column; +`; + +const TabContainer = styled.div` + display: flex; + border-bottom: 2px solid #f0f0f0; + background-color: #fafafa; +`; + +const Tab = styled.button<{ $active: boolean }>` + flex: 1; + padding: 12px 16px; + border: none; + background: ${props => props.$active ? 'white' : 'transparent'}; + color: ${props => props.$active ? '#667eea' : '#666'}; + font-weight: ${props => props.$active ? '600' : '400'}; + font-size: 0.875rem; + cursor: pointer; + border-bottom: 2px solid ${props => props.$active ? '#667eea' : 'transparent'}; + margin-bottom: -2px; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: ${props => props.$active ? 'white' : '#f5f5f5'}; + color: ${props => props.$active ? '#667eea' : '#333'}; + } +`; + +const TabContent = styled.div` + padding: 20px; + overflow-y: auto; + max-height: 500px; +`; + +const InfoCard = styled.div` + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; + border-left: 4px solid #667eea; +`; + +const InfoRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + + &:last-child { + border-bottom: none; + } +`; + +const Label = styled.span` + font-weight: 600; + color: #333; + font-size: 0.875rem; +`; + +const Value = styled.span` + color: #666; + font-size: 0.875rem; + font-family: 'Courier New', monospace; + background-color: rgba(0, 0, 0, 0.05); + padding: 2px 8px; + border-radius: 4px; +`; + +const FeatureList = styled.ul` + list-style: none; + padding: 0; + margin: 0; +`; + +const FeatureItem = styled.li` + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + margin-bottom: 8px; + background-color: #f9f9f9; + border-radius: 6px; + transition: background-color 0.2s ease-in-out; + + &:hover { + background-color: #f0f0f0; + } +`; + +interface ComponentProps { + customMessage?: string; + showDebugInfo?: boolean; +} + +const MyCustomComponent: React.FC = ({ + customMessage = 'Plugin Showcase', + showDebugInfo = true +}) => { + const { t, i18n } = useTranslation(); + const { data: authData, isLoading: authLoading } = useAuthQuery(); + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState<'user' | 'features' | 'system' | 'routing'>('user'); + const [loadingDemo, setLoadingDemo] = useState(false); + const [currentPath, setCurrentPath] = useState(''); + const triggerRef = useRef(null); + + // Get current pathname from window.location (works in plugins) + useEffect(() => { + setCurrentPath(window.location.pathname); + + // Listen for route changes via popstate + const handleRouteChange = () => { + setCurrentPath(window.location.pathname); + }; + + window.addEventListener('popstate', handleRouteChange); + return () => window.removeEventListener('popstate', handleRouteChange); + }, []); + + const handleButtonPress = () => { + if (!isOpen) { + // Simulate a loading state when opening + setLoadingDemo(true); + setTimeout(() => { + setLoadingDemo(false); + }, 800); + } + setIsOpen(!isOpen); + }; + + // Derive authenticated from authData + const authenticated = !!authData?.id; + + // Example of accessing host features + const userFeatures = [ + { icon: 'person', label: 'Authentication', value: authenticated ? 'Active' : 'Inactive' }, + { icon: 'language', label: 'Language', value: i18n.language || 'en' }, + { icon: 'email', label: 'User Email', value: authData?.email || 'N/A' }, + { icon: 'badge', label: 'User ID', value: authData?.id || 'N/A' }, + ]; + + const systemFeatures = [ + { icon: 'check_circle', label: 'React Hook (useAuthQuery)', enabled: true }, + { icon: 'check_circle', label: 'Window Location API', enabled: true }, + { icon: 'check_circle', label: 'PopState Events', enabled: true }, + { icon: 'check_circle', label: 'i18next Integration', enabled: true }, + { icon: 'check_circle', label: 'styled-components', enabled: true }, + { icon: 'check_circle', label: 'react-aria-components', enabled: true }, + { icon: 'check_circle', label: 'Host UI Components', enabled: true }, + { icon: 'check_circle', label: '@tanstack/react-query', enabled: true }, + ]; + + const pluginCapabilities = [ + 'Access authentication state from host', + 'Use host UI components (Icon, Box, Text, Loading)', + 'Leverage host hooks and utilities (useAuthQuery)', + 'Read current route via window.location', + 'Listen to route changes via popstate events', + 'Integrate with i18n for translations', + 'Use styled-components for styling', + 'Implement accessible UI with react-aria', + 'Receive props from plugin configuration', + 'React to route changes via visibility config', + ]; + + const renderUserTab = () => ( + + + + {t('User Information')} + + {authLoading ? ( + + + + ) : ( + <> + {userFeatures.map((feature, idx) => ( + + + {feature.value} + + ))} + + )} + + + {showDebugInfo && authData && ( + + + Raw Auth Data + +
+            {JSON.stringify(authData, null, 2)}
+          
+
+ )} +
+ ); + + const renderFeaturesTab = () => ( + + + {t('Plugin Capabilities')} + + + {pluginCapabilities.map((capability, idx) => ( + + + {capability} + + ))} + + + + + Props from Config + + + + {customMessage} + + + + {showDebugInfo ? 'Enabled' : 'Disabled'} + + + + ); + + const renderSystemTab = () => ( + + + {t('Integrated Host Features')} + + + {systemFeatures.map((feature, idx) => ( + + + {feature.label} + + ))} + + + + + Available Host Components + + + Icon, Loading, Box, Text, Link, Card, Modal, DropButton, DropdownMenu, + InfiniteScroll, QuickSearch, Separators, TextErrors, and more... + + + + ); + + const renderRoutingTab = () => ( + + + {t('Route Information (Plugin-Safe)')} + + + + + Current Route Information + + + + {currentPath || '/'} + + + + + {typeof window !== 'undefined' ? window.location.href : 'N/A'} + + + + + {typeof window !== 'undefined' ? (window.location.hash || 'none') : 'N/A'} + + + + + + Plugin Routing Capabilities + + + + + Read pathname via window.location + + + + Listen to popstate events for route changes + + + + Use visibility.routes in config for conditional rendering + + + + Navigate using standard anchor tags or window APIs + + + + Next.js router hooks not available (outside RouterContext) + + + + + + + ⚠️ Important Note + + + Plugins render outside the Next.js RouterContext, so useRouter() and usePathname() + are not available. Instead, use: + +
    +
  • window.location.pathname - Get current path
  • +
  • window.addEventListener('popstate') - Detect route changes
  • +
  • Plugin config visibility.routes - Control when plugin appears
  • +
+
+ + + + ✅ Best Practice + + + For route-aware plugins, use the visibility.routes config option with + glob patterns (e.g., ["/docs/*", "!/docs/secret"]). The plugin system + automatically shows/hides your plugin based on the current route! + + +
+ ); + + if (authLoading) { + return ( + + + + ); + } + + return ( + <> + + + {customMessage} + + + + {loadingDemo ? ( + + + + Loading plugin data... + + + ) : ( + <> + + setActiveTab('user')} + > + User + + setActiveTab('features')} + > + Features + + setActiveTab('system')} + > + System + + setActiveTab('routing')} + > + Routing + + + + {activeTab === 'user' && renderUserTab()} + {activeTab === 'features' && renderFeaturesTab()} + {activeTab === 'system' && renderSystemTab()} + {activeTab === 'routing' && renderRoutingTab()} + + )} + + + ); +}; + +export default MyCustomComponent; diff --git a/src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.css b/src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.css new file mode 100644 index 0000000000..0a00f347bf --- /dev/null +++ b/src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.css @@ -0,0 +1,109 @@ + + #central-menu-wrapper { + flex-direction: row; + align-self: stretch; + align-items: stretch; + gap: 25px; +} + +#central-menu-wrapper > * { + display: flex; + align-items: center; + height: auto; +} + +#central-menu-wrapper > a > div { + height: 100%; + margin: unset; +} + +#central-menu-wrapper > a > div > svg { + width: 82px; +} + +#central-menu-wrapper + div > button { + display: none; +} + +#central-menu-wrapper #central-menu { + position: relative; + color: var(--c--theme--colors--greyscale-text); + display: inline-block; +} + +#central-menu-wrapper #nav-button { + background: none; + border: none; + cursor: pointer; + height: 100%; + padding: 0 22px; + outline: none; +} + +#central-menu-wrapper #nav-button:hover { + background-color: var( + --c--components--button--primary-text--background--color-hover + ); +} + +#central-menu-wrapper #nav-button.active { + background-color: var(--c--components--button--primary--background--color); + color: var(--c--theme--colors--greyscale-000); +} + +[data-testid="od-menu-popover"] { + background: unset !important; + border: unset !important; +} + +#nav-content { + position: absolute; + width: max-content; + background: var(--c--theme--colors--greyscale-000); + border-radius: 8px; + border: 1px solid var(--c--theme--colors--card-border); + border-top: 4px solid var(--c--components--button--primary--background--color); + max-width: 280px; + left: 50%; + transform: translateX(-50%); + padding: 4px 0 20px; + z-index: 1000; +} + +#nav-content .menu-list { + list-style: none; + margin: 0; + padding: 0; +} + +#nav-content .menu-category { + font-weight: bold; + display: block; + margin: 20px 24px 8px; +} + +#nav-content .menu-entries { + list-style: none; + padding: 0; + margin: 0; +} + +#nav-content .menu-link { + display: flex; + padding: 4px 24px; + align-items: center; + text-decoration: none; + color: inherit; +} + +#nav-content .menu-link:hover { + background-color: var( + --c--components--button--primary-text--background--color-hover + ); +} + +#nav-content .menu-icon { + width: 24px; + height: 24px; + margin-right: 8px; +} diff --git a/src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.tsx b/src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.tsx new file mode 100644 index 0000000000..1aa79227b9 --- /dev/null +++ b/src/frontend/apps/impress-plugin/src/MyCustomHeaderMenu.tsx @@ -0,0 +1,283 @@ +import './MyCustomHeaderMenu.css'; + +import React, { useState, useRef, useEffect } from 'react'; +import { Button, Popover } from 'react-aria-components'; +import { useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import { useAuthQuery } from 'impress/features/auth/api'; +import { Icon, Loading } from 'impress/components'; + +interface NavigationCategory { + identifier: string; + display_name: string; + entries: NavigationEntry[]; +} + +interface NavigationEntry { + identifier: string; + link: string; + target: string; + display_name: string; + icon_url: string; +} + +const StyledPopover = styled(Popover)` + background-color: white; + border-radius: 4px; + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1); + border: 1px solid #dddddd; + transition: opacity 0.2s ease-in-out; +`; + +const StyledButton = styled(Button)` + cursor: pointer; + border: none; + background: none; + outline: none; + transition: all 0.2s ease-in-out; + font-family: Marianne, Arial, serif; + font-weight: 500; + font-size: 0.938rem; + padding: 0; + text-wrap: nowrap; +`; + +// Fake navigation response for development/debugging +const fakeNavigationData = { + categories: [ + { + identifier: 'fake-cat', + display_name: 'Dummy Category', + entries: [ + { + identifier: 'fake-entry-1', + link: 'https://www.google.com', + target: '_blank', + display_name: 'Google', + icon_url: 'https://placehold.co/24', + }, + { + identifier: 'fake-entry-2', + link: 'https://www.example.com', + target: '_blank', + display_name: 'Example', + icon_url: 'https://placehold.co/24', + }, + ], + }, + ], +}; + +const formatLanguage = (language: string): string => { + const [lang, region] = language.split('-'); + return region + ? `${lang}-${lang.toUpperCase()}` + : `${language}-${language.toUpperCase()}`; +}; + +const fetchNavigation = async ( + language: string, + baseUrl: string, +): Promise => { + // Uncomment below for development/debugging with fake data + return fakeNavigationData.categories; + + try { + if (!baseUrl) { + console.warn('[CentralMenu] ICS_BASE_URL not configured'); + return null; + } + + const response = await fetch( + `${baseUrl}/navigation.json?language=${language}`, + { + method: 'GET', + credentials: 'include', + redirect: 'follow', + }, + ); + + if (response.ok) { + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { + const jsonData = await response.json() as Record; + + if ( + jsonData && + typeof jsonData === 'object' && + 'categories' in jsonData && + Array.isArray(jsonData.categories) + ) { + return jsonData.categories as NavigationCategory[]; + } else { + console.warn('[CentralMenu] Invalid JSON format in navigation response.'); + return null; + } + } else { + console.warn('[CentralMenu] Unexpected content type:', contentType); + return null; + } + } else { + console.warn('[CentralMenu] Navigation fetch failed. Status:', response.status); + return null; + } + } catch (error) { + console.error('[CentralMenu] Error fetching navigation:', error); + return null; + } +}; + +interface CentralMenuProps { + icsBaseUrl?: string; + portalBaseUrl?: string; +} + +const CentralMenu: React.FC = ({ + icsBaseUrl = '', + portalBaseUrl = '', +}) => { + const { i18n, t } = useTranslation(); + const { data: auth } = useAuthQuery(); + const [isOpen, setIsOpen] = useState(false); + const [navigation, setNavigation] = useState(null); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const iframeRef = useRef(null); + const triggerRef = useRef(null); + + const handleToggle = () => { + setIsOpen(!isOpen); + }; + + const handleIframeLoad = async () => { + const language = i18n.language ? formatLanguage(i18n.language) : 'en-US'; + const navData = await fetchNavigation(language, icsBaseUrl); + + if (navData) { + setNavigation(navData); + setStatus('success'); + } else { + setStatus('error'); + } + }; + + // Handle language changes - refetch navigation when language changes + useEffect(() => { + // Only refetch if iframe has already loaded (navigation exists or error occurred) + if (status !== 'loading') { + handleIframeLoad(); + } + }, [i18n.language]); + + if (!auth?.id) { + return null; + } + + const renderNavigation = () => { + if (!navigation) { + return null; + } + + return navigation.map((category) => ( +
  • + {category.display_name} + +
  • + )); + }; + + return ( + <> + {icsBaseUrl && ( +