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..1e098efe94 --- /dev/null +++ b/docs/frontend-plugins.md @@ -0,0 +1,557 @@ +# Frontend Plugin System + +## Table of Contents +- [Overview](#overview "Go to Overview section") +- [Getting Started: Building Your First Plugin](#getting-started-building-your-first-plugin "Go to the Getting Started guide") + - [1. Prepare the Host Environment](#1-prepare-the-host-environment "Step 1: Prepare the host") + - [2. Scaffolding a New Plugin Project](#2-scaffolding-a-new-plugin-project "Step 2: Scaffold the plugin") + - [3. Creating a Plugin Component](#3-creating-a-plugin-component "Step 3: Create the React component") + - [4. Federation Configuration](#4-federation-configuration "Step 4: Configure module federation") + - [5. Enabling Type-Sharing for Intellisense](#5-enabling-type-sharing-for-intellisense "Step 5: Enable type-sharing") + - [6. Running and Configuring Your Plugin](#6-running-and-configuring-your-plugin "Step 6: Run and configure") +- [Host-Plugin Interaction](#host-plugin-interaction "How the host and plugin interact") + - [Host Exports](#host-exports "What the host exports") + - [Choosing Shared Dependencies](#choosing-shared-dependencies "Learn about shared dependencies") +- [Development Workflow](#development-workflow "Go to Development Workflow section") + - [Test and Debug](#test-and-debug "How to test and debug") + - [Best Practices](#best-practices "View best practices") +- [Plugin Configuration File Reference](#plugin-configuration-file-reference "Go to the Configuration File reference") + - [Configuration Structure](#configuration-structure "See the config file structure") + - [Injection Position Examples](#injection-position-examples "See examples of injection positions") +- [Releasing a Plugin](#releasing-a-plugin "How to release a plugin") +- [Deploying Docs with Plugins](#deploying-docs-with-plugins "How to deploy plugins in production") + +## 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**: The technology that enables runtime module sharing between separate applications. + +
+ +### Features and Limitations +**Features:** +- Add new UI components. +- Reuse host UI components. +- Dynamically inject components via CSS selectors and a [configuration file](#plugin-configuration-file-reference "See the configuration file reference"). +- Integrate without rebuilding or redeploying the host application. +- Build and version plugins independently. + +
+ +**Limitations:** +- Focused on DOM/UI customisations; you cannot add Next.js routes or other server-side features. +- Runs client-side without direct host state access.
+ Shared caches (e.g., React Query) only work if the dependency is also [shared as a singleton](#choosing-shared-dependencies "Learn about shared dependencies"). +- Host upgrades may require tweaking CSS selectors
+ and matching versions for shared libraries. + +
+ +## Getting Started: Building Your First Plugin + +A plugin is a standalone React application bundled with Webpack
+that exposes one or more components via [Module Federation](#4-federation-configuration "See the federation configuration"). +This guide walks you through creating your first plugin. + +
+ +### 1. Prepare the Host Environment + +Developing a plugin requires running the host application (Docs) in parallel. +This live integration is essential for rendering your plugin, enabling hot-reloading, sharing types for Intellisense,
+and discovering the exact versions of [shared dependencies](#choosing-shared-dependencies "Learn about shared dependencies"). + +
+ +1. **Clone the repository locally**:
+ If you haven't already, clone the Docs repository to your local machine and follow the initial setup instructions. +2. **Set the development flag**:
+ In the host application's `.env.development` file, set `NEXT_PUBLIC_DEVELOP_PLUGINS=true`. +3. **Stop conflicting services**:
+ If you are using the project's Docker setup, make sure
+ the frontend service is stopped (`docker compose stop frontend-development`), as we will run the Docs frontend locally. +4. **Run the host**:
+ Navigate to `src/frontend/apps/impress`, run `yarn install`, and then `yarn dev`. +5. **Check the logs**:
+ On startup, the Next.js dev server will print the versions of all shared singleton libraries (e.g., React, styled-components).
+ You will need these exact versions for your plugin's `package.json`. + +
+ +### 2. Scaffolding a New Plugin Project + +Create a new, simple React project.
+Your project should have a [`webpack.config.js`](#4-federation-configuration "See the federation configuration") and include dependencies for React, Webpack, and TypeScript. + +
+ +A minimal `package.json` would look like this:
+ +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "scripts": { + "dev": "webpack serve --mode=development", + "build": "webpack --mode=production" + }, + "dependencies": { + "react": "", + "react-dom": "", + "styled-components": "", + "@openfun/cunningham-react": "", + "@tanstack/react-query": "" + }, + "devDependencies": { + "webpack": "^5.0.0", + "webpack-cli": "^5.0.0", + "webpack-dev-server": "^4.0.0", + "ts-loader": "^9.0.0", + "typescript": "^5.0.0", + "@types/react": "^18.0.0", + "@module-federation/native-federation-typescript": "^0.2.1" + } +} +``` + +> Replace `` with the versions found in the [host's dev startup log](#1-prepare-the-host-environment "See how to prepare the host"). + +
+ +### 3\. Creating a Plugin Component + +This is a React component that your `webpack.config.js` file exposes.
+This minimal example shows how to accept `props`, which can be passed from the [plugin configuration file](#plugin-configuration-file-reference "See the configuration file reference"). + +
+ +```typescript +// src/MyCustomComponent.tsx +import React from 'react'; + +// A simple component with inline prop types +const MyCustomComponent = ({ message }: { message?: string }) => { + return ( +
+ This is the plugin component. + {message &&

Message from props: {message}

} +
+ ); +}; + +export default MyCustomComponent; +``` + +
+ +### 4\. Federation Configuration + +The core of the plugin is its Webpack configuration.
+All plugins should use this sample `webpack.config.js` as a base. + +
+ +```javascript +const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +const { NativeFederationTypeScriptHost } = require('@module-federation/native-federation-typescript/webpack'); + +module.exports = (env, argv) => { + const dev = argv.mode !== 'production'; + + const moduleFederationConfig = { + name: 'my_plugin', // A unique name for your plugin + filename: 'remoteEntry.js', + exposes: { + // Maps a public name to a component file + './MyCustomComponent': './src/MyCustomComponent.tsx', + }, + remotes: { + // Allows importing from the host application. The URL is switched automatically. + impress: dev + ? 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js' // Development + : 'impress@/_next/static/chunks/remoteEntry.js', // Production + }, + shared: { + // Defines shared libraries to avoid duplication + react: { singleton: true }, + 'react-dom': { singleton: true }, + 'styled-components': { singleton: true }, + '@openfun/cunningham-react': { singleton: true }, + '@tanstack/react-query': { singleton: true }, + }, + }; + + return { + devServer: { + // The port should match the one in your plugin's configuration file + port: 8080, + }, + entry: './src/index.tsx', // Your plugin's entry point; can be an empty file as modules are exposed directly. + plugins: [ + new ModuleFederationPlugin(moduleFederationConfig), + // This plugin enables type-sharing for intellisense + ...(dev ? [NativeFederationTypeScriptHost({ moduleFederationConfig })] : []), + ], + // ... other webpack config (output, module rules, etc.) + }; +}; +``` + +> Don't change `remotes.impress` if you want your [released plugin](#releasing-a-plugin "Learn how to release a plugin") to be [deployable by others](#deploying-docs-with-plugins "Learn about deployment"). + +
+ +### 5\. Enabling Type-Sharing for Intellisense + +To get autocompletion for components and hooks exposed by the host,
+configure your plugin's `tsconfig.json` to find the host's types. + +
+ +In your plugin's `tsconfig.json`: + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["./@mf-types/*"] + } + } +} +``` + +
+ +When you run the host application with `NEXT_PUBLIC_DEVELOP_PLUGINS=true`, it generates a `@mf-types.zip` file.
+The `NativeFederationTypeScriptHost` plugin in your webpack config will automatically download and unpack it,
+making the host's types available to your plugin and IDE. + +
+ +### 6\. Running and Configuring Your Plugin + +With the host application already running (from step 1),
+you can now start your plugin's development server and configure the host to load it. + +
+ +1. **Start the plugin**:
+ In your plugin's project directory, run `yarn dev`. +2. **Configure the host**:
+ Tell the host to load your plugin by editing its configuration file.
+ When running Docs locally, this file is located at `src/backend/impress/configuration/plugins/default.json`.
+ Update it to point to your local plugin's `remoteEntry.js`. + +
+ +```json +{ + "id": "my-custom-component", + "remote": { + "url": "http://localhost:8080/remoteEntry.js", + "name": "my_plugin", + "module": "./MyCustomComponent" + }, + "injection": { + "target": "#some-element-in-the-host" + }, + "props": { + "message": "Hello from the configuration!" + } +} +``` + +
+ +After changing the `target` to a valid CSS selector in the host's DOM, save the file.
+The host application will automatically detect the change and inject your component, passing the `props` object along. + +Your component should appear in the running host application after a reload. + +
+ +## Host-Plugin Interaction + +### Host Exports + +The host automatically exposes many of its components and hooks.
+You can import them in the plugin as if they were local modules, thanks to the [`remotes` configuration](#4-federation-configuration "See the remotes config in Webpack") in the `webpack.config.js`. + +
+ +```typescript +// In the plugin's code +import { Icon } from 'impress/components'; +import { useAuthQuery } from 'impress/features/auth/api'; +``` + +
+ +### Choosing Shared Dependencies + +Sharing dependencies is critical for performance and stability. + +
+ + - **Minimal Shared Libraries**:
+ Always share **`react`**, **`react-dom`**, **`styled-components`**, and **`@openfun/cunningham-react`** to use the same instances as the host. + - **Sharing State**:
+ Libraries that rely on a global context (like `@tanstack/react-query`) **must** be shared to access the host's state and cache. + - **Discovering More Shared Libraries**: With `NEXT_PUBLIC_DEVELOP_PLUGINS=true`,
+ [the host prints its shared dependency map to the Next.js dev server logs on startup](#1-prepare-the-host-environment "See how to prepare the host").
+ You can use this to align versions and add more shared libraries to your plugin. + +
+ +> **Important**: Both the host and the plugin must declare a dependency in [`moduleFederationConfig.shared`](#4-federation-configuration "See the federation configuration") for it to become a true singleton.
+> If a shared dependency is omitted from the plugin's config, Webpack will bundle a separate copy, breaking the singleton pattern. + +
+ +## Development Workflow + +### Test and Debug + + - Use the `[PluginSystem]` logs in the browser console to see if the plugin is loading correctly. + - Errors in the plugin are caught by an `ErrorBoundary` and will not crash the host. + +
+ +Common Errors: +| Issue | Cause/Fix | +| :--- | :--- | +| Unreachable `remoteEntry.js` | Check the `url` in the [plugin configuration](#6-running-and-configuring-your-plugin "See how to configure the plugin"). | +| Library version conflicts | Ensure `shared` library versions in `package.json` match the [host's versions](#1-prepare-the-host-environment "See how to check host versions"). | +| Invalid CSS selectors | Validate the `target` selector against the host's DOM. | + +
+ +### Best Practices + + - Build modular components with well-typed props. + - Prefer using the host's exposed types and components over implementing new ones. + - Keep shared dependency versions aligned with the host
+ and re-test after host upgrades. + - Treat plugin bundles as untrusted: vet dependencies and avoid unsafe scripts. + +
+ +## Plugin Configuration File Reference + +This section provides a detailed reference for all fields in the plugin configuration JSON. +For deployment details, see [Deploying Docs with Plugins](#deploying-docs-with-plugins "Learn about production deployment"). + +
+ +### Configuration 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`). See [examples](#injection-position-examples "See injection examples"). | +| - `observerRoots` | String/Boolean | No | DOM observation: CSS selector, `true` (observe whole document), or `false` (default; disable observers). | +| `props` | Object | No | Props passed to the [plugin component](#3-creating-a-plugin-component "See how to create a component with props"). | +| `visibility` | Object | No | Visibility controls. | +| - `routes` | Array | No | Path globs (e.g., `["/docs/*", "!/docs/secret*"]`); supports `*` and `?` wildcards plus negation (`!`). | + +
+ +### Injection Position Examples + +The `injection.position` property controls how the plugin is inserted relative to the `target` element. + +
+View injection examples + +
+ +**before** + +```json +{ + "id": "my-custom-component-0", + "injection": { + "target": "#item2", + "position": "before" + } +} +``` + +```html +
    +
  • +
    +
  • +
+``` + +
+ +**after** + +```json +{ + "id": "my-custom-component-0", + "injection": { + "target": "#item1", + "position": "after" + } +} +``` + +```html +
    +
  • +
    +
  • +
+``` + +
+ +**prepend** + +```json +{ + "id": "my-custom-component-0", + "injection": { + "target": "#some-element-in-the-host", + "position": "prepend" + } +} +``` + +```html +
    +
    +
  • +
  • +
+``` + +
+ +**append** (default) + +```json +{ + "id": "my-custom-component-0", + "injection": { + "target": "#some-element-in-the-host", + "position": "append" + } +} +``` + +```html +
    +
  • +
  • +
    +
+``` + +
+ +**replace** + +```json +{ + "id": "my-custom-component-0", + "injection": { + "target": "#item1", + "position": "replace" + } +} +``` + +```html +
    +
    +
  • +
  • +
+``` + +
+ +
+ +## Releasing a Plugin + +When you are ready to release your plugin, you need to create a production build. + +
+ +Run the build command in your plugin's directory:
+ +```bash +yarn build +``` + +This command bundles your code for production.
+Webpack will generate a **`dist`** folder (or similar) containing the **`remoteEntry.js`** file and other JavaScript chunks.
+The `remoteEntry.js` is the manifest that tells other applications what modules your plugin exposes.
+These are the files you will need for deployment. + +
+ +The [`webpack.config.js` provided](#4-federation-configuration "See the federation configuration") is already configured to switch the `remotes` URL to the correct production path automatically,
+so no code changes are needed before building. + +
+ +## Deploying Docs with Plugins + +To use plugins in a production environment, you need to deploy both the plugin assets and the configuration file.
+The recommended approach is to serve the plugin's static files from the same webserver that serves the host (docs frontend). + +
+ +1. **Deploy Plugin Assets**:
+ Copy the contents of your plugin's build output directory (e.g., `dist/`)
+ into the frontend container's `/usr/share/nginx/html/assets` directory at a chosen path.
+ E.g.: Placing assets in `/usr/share/nginx/html/assets/plugins/my-plugin/`
+ would make the plugin's **`remoteEntry.js`** available at `https://production.domain/assets/plugins/my-plugin/remoteEntry.js`. + +
+ +2. **Deploy Plugin Configuration**: The host's [plugin configuration file](#plugin-configuration-file-reference "See the configuration file reference") must be updated to point to the deployed assets.
+ This file is typically managed via infrastructure methods
+ (e.g., a Kubernetes configmap replacing `/app/impress/configuration/plugins/default.json` in the backend container). + +
+ +Update the **`remote.url`** to the public-facing path that matches where you deployed the assets: + +```json +{ + "id": "my-custom-component", + "remote": { + "url": "/assets/plugins/my-plugin/remoteEntry.js", + "name": "my_plugin", + "module": "./MyCustomComponent" + }, + "injection": { + "target": "#some-element-in-the-host" + }, + "props": { + "message": "Hello from production!" + } +} +``` \ 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 && ( +