Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 562a0a4285 | |||
| 8a483a7da0 | |||
| cc47ff2b46 | |||
| 33b4e2e446 | |||
| 4185ad2419 | |||
| b5a7af99f8 | |||
| f830fc6490 | |||
| 1ff0ddacec | |||
| 8e73c88b68 | |||
| d954986bce | |||
| bffb101d5b | |||
| b702d8dd22 | |||
| d29741b20e | |||
| 76c218a220 |
@@ -31,7 +31,7 @@ jobs:
|
||||
images: lasuite/impress-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
images: lasuite/impress-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
PUBLISH_AS_MIT=false
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
images: lasuite/impress-y-provider
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
|
||||
@@ -43,6 +43,10 @@ venv.bak/
|
||||
env.d/development/*.local
|
||||
env.d/terraform
|
||||
|
||||
# Docker
|
||||
compose.override.yml
|
||||
docker/auth/*.local
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
|
||||
+11
-13
@@ -14,9 +14,8 @@ and this project adheres to
|
||||
|
||||
- ♻️(frontend) replace Arial font-family with token font #1411
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) enable enter key to open documentss #1354
|
||||
- ♿(frontend) improve modal a11y: structure, labels, title #1349
|
||||
- ♿improve NVDA navigation in DocShareModal #1396
|
||||
- #1354
|
||||
- #1349
|
||||
- ♿ improve accessibility by adding landmark roles to layout #1394
|
||||
- ♿ add document visible in list and openable via enter key #1365
|
||||
- ♿ add pdf outline property to enable bookmarks display #1368
|
||||
@@ -26,10 +25,12 @@ and this project adheres to
|
||||
- ♿ remove redundant aria-label on hidden icons and update tests #1432
|
||||
- ♿ improve semantic structure and aria roles of leftpanel #1431
|
||||
- ♿ add default background to left panel for better accessibility #1423
|
||||
- ♿ restyle checked checkboxes: removing strikethrough #1439
|
||||
- ♿ restyle checked checkboxes: removing strikethrough #1439
|
||||
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
|
||||
- ♿ update labels and shared document icon accessibility #1442
|
||||
- 🍱(frontend) Fonts GDPR compliants #1453
|
||||
- ✨(backend) add async indexation of documents on save (or access save) #1276
|
||||
- ✨(backend) add debounce mechanism to limit indexation jobs #1276
|
||||
- ✨(api) add API route to search for indexed documents in Find #1276
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -37,15 +38,12 @@ and this project adheres to
|
||||
- ⚗️(service-worker) remove index from cache first strategy #1395
|
||||
- 🐛(frontend) fix 404 page when reload 403 page #1402
|
||||
- 🐛(frontend) fix legacy role computation #1376
|
||||
- 🛂(frontend) block editing title when not allowed #1412
|
||||
- 🐛(frontend) scroll back to top when navigate to a document #1406
|
||||
- 🐛(frontend) fix export pdf emoji problem #1453
|
||||
- 🐛(frontend) fix attachment download filename #1447
|
||||
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
|
||||
- 🔒(frontend) prevent readers from changing callout emoji #1449
|
||||
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
|
||||
- 🐛(backend) filter invitation with case insensitive email
|
||||
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿improve NVDA navigation in DocShareModal #1396
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
|
||||
@@ -94,14 +94,6 @@ RUN chmod g=u /etc/passwd
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
|
||||
# when python is upgraded and the path to the certificate changes.
|
||||
# The space between print and the ( is intended otherwise the git lint is failing
|
||||
RUN mkdir /cert && \
|
||||
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||
mv $path /cert/ && \
|
||||
ln -s /cert/cacert.pem $path
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
|
||||
@@ -247,6 +247,10 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
index: ## index all documents to remote search
|
||||
@$(MANAGE) index
|
||||
.PHONY: index
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
+18
@@ -72,6 +72,11 @@ services:
|
||||
- env.d/development/postgresql.local
|
||||
ports:
|
||||
- "8071:8000"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite-net:
|
||||
aliases:
|
||||
- impress
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -92,6 +97,9 @@ services:
|
||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "DEBUG"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
networks:
|
||||
- default
|
||||
- lasuite-net
|
||||
env_file:
|
||||
- env.d/development/common
|
||||
- env.d/development/common.local
|
||||
@@ -107,6 +115,11 @@ services:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "8083:8083"
|
||||
networks:
|
||||
default: {}
|
||||
lasuite-net:
|
||||
aliases:
|
||||
- nginx
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -217,3 +230,8 @@ services:
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
networks:
|
||||
lasuite-net:
|
||||
name: lasuite-net
|
||||
driver: bridge
|
||||
|
||||
@@ -99,8 +99,6 @@ 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 | |
|
||||
|
||||
@@ -82,7 +82,7 @@ backend:
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /cert/cacert.pem
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -1,589 +0,0 @@
|
||||
# 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.
|
||||
|
||||
<br>
|
||||
|
||||
### 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.
|
||||
|
||||
<br>
|
||||
|
||||
### 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.
|
||||
|
||||
<br>
|
||||
|
||||
**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. <br>
|
||||
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 <br>
|
||||
and matching versions for shared libraries.
|
||||
|
||||
<br>
|
||||
|
||||
## Getting Started: Building Your First Plugin
|
||||
|
||||
A plugin is a standalone React application bundled with Webpack <br>
|
||||
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.
|
||||
|
||||
<br>
|
||||
|
||||
### 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, <br>
|
||||
and discovering the exact versions of [shared dependencies](#choosing-shared-dependencies "Learn about shared dependencies").
|
||||
|
||||
<br>
|
||||
|
||||
1. **Clone the repository locally**:<br>
|
||||
If you haven't already, clone the Docs repository to your local machine and follow the initial setup instructions.
|
||||
2. **Set the development flag**:<br>
|
||||
In the host application's `.env.development` file, set `NEXT_PUBLIC_DEVELOP_PLUGINS=true`.
|
||||
3. **Stop conflicting services**:<br>
|
||||
If you are using the project's Docker setup, make sure <br>
|
||||
the frontend service is stopped (`docker compose stop frontend-development`), as we will run the Docs frontend locally.
|
||||
4. **Run the host**:<br>
|
||||
Navigate to `src/frontend/apps/impress`, run `yarn install`, and then `yarn dev`.
|
||||
5. **Check the logs**:<br>
|
||||
On startup, the Next.js dev server will print the versions of all shared singleton libraries (e.g., React, styled-components). <br>
|
||||
You will need these exact versions for your plugin's `package.json`.
|
||||
|
||||
<br>
|
||||
|
||||
### 2. Scaffolding a New Plugin Project
|
||||
|
||||
Create a new, simple React project. <br>
|
||||
Your project should have a [`webpack.config.js`](#4-federation-configuration "See the federation configuration") and include dependencies for React, Webpack, and TypeScript.
|
||||
|
||||
<br>
|
||||
|
||||
A minimal `package.json` would look like this:<br>
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode=development",
|
||||
"build": "webpack --mode=production"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "<same as host>",
|
||||
"react-dom": "<same as host>",
|
||||
"styled-components": "<same as host>",
|
||||
"@openfun/cunningham-react": "<same as host>",
|
||||
"@tanstack/react-query": "<same as host>"
|
||||
},
|
||||
"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 `<same as host>` with the versions found in the [host's dev startup log](#1-prepare-the-host-environment "See how to prepare the host").
|
||||
|
||||
<br>
|
||||
|
||||
### 3\. Creating a Plugin Component
|
||||
|
||||
This is a React component that your `webpack.config.js` file exposes. <br>
|
||||
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").
|
||||
|
||||
<br>
|
||||
|
||||
```typescript
|
||||
// src/MyCustomComponent.tsx
|
||||
import React from 'react';
|
||||
|
||||
// A simple component with inline prop types
|
||||
const MyCustomComponent = ({ message }: { message?: string }) => {
|
||||
return (
|
||||
<div>
|
||||
This is the plugin component.
|
||||
{message && <p>Message from props: {message}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyCustomComponent;
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
### 4\. Federation Configuration
|
||||
|
||||
The core of the plugin is its Webpack configuration. <br>
|
||||
All plugins should use this `webpack.config.js` as a base.
|
||||
|
||||
Disclaimer:
|
||||
We try to not change this file.<br>
|
||||
But in the future, it may evolve as the plugin system matures.
|
||||
|
||||
<br>
|
||||
|
||||
```javascript
|
||||
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
|
||||
const {
|
||||
NativeFederationTypeScriptHost,
|
||||
} = require('@module-federation/native-federation-typescript/webpack');
|
||||
const {
|
||||
NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
|
||||
} = require('@module-federation/native-federation-typescript');
|
||||
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
let mfTypesReady;
|
||||
const ensureFederatedTypesPlugin = {
|
||||
apply(compiler) {
|
||||
compiler.hooks.beforeCompile.tapPromise(
|
||||
'EnsureFederatedTypes',
|
||||
async () => {
|
||||
if (!mfTypesReady) {
|
||||
const downloader = NativeFederationTypeScriptHostCore.raw({
|
||||
moduleFederationConfig,
|
||||
});
|
||||
mfTypesReady = downloader.writeBundle();
|
||||
}
|
||||
await mfTypesReady;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
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
|
||||
? [
|
||||
ensureFederatedTypesPlugin, // ensures the zip is ready before the first compile
|
||||
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").
|
||||
|
||||
<br>
|
||||
|
||||
### 5\. Enabling Type-Sharing for Intellisense
|
||||
|
||||
To get autocompletion for components and hooks exposed by the host, <br>
|
||||
configure your plugin's `tsconfig.json` to find the host's types.
|
||||
|
||||
<br>
|
||||
|
||||
In your plugin's `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["./@mf-types/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
When you run the host application with `NEXT_PUBLIC_DEVELOP_PLUGINS=true`, it generates a `@mf-types.zip` file. <br>
|
||||
The `NativeFederationTypeScriptHost` plugin in your webpack config will automatically download and unpack it ahead of the first compile, <br>
|
||||
making the host's types available to your plugin and IDE. In development with this flag enabled, route changes may be slower because type generation and automatic exposure run during rebuilds; this does not affect production where navigations are instant.
|
||||
|
||||
<br>
|
||||
|
||||
### 6\. Running and Configuring Your Plugin
|
||||
|
||||
With the host application already running (from step 1), <br>
|
||||
you can now start your plugin's development server and configure the host to load it.
|
||||
|
||||
<br>
|
||||
|
||||
1. **Start the plugin**:<br>
|
||||
In your plugin's project directory, run `yarn dev`.
|
||||
2. **Configure the host**:<br>
|
||||
Tell the host to load your plugin by editing its configuration file. <br>
|
||||
When running Docs locally, this file is located at `src/backend/impress/configuration/plugins/default.json`. <br>
|
||||
Update it to point to your local plugin's `remoteEntry.js`.
|
||||
|
||||
<br>
|
||||
|
||||
```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!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
After changing the `target` to a valid CSS selector in the host's DOM, save the file. <br>
|
||||
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.
|
||||
|
||||
<br>
|
||||
|
||||
## Host-Plugin Interaction
|
||||
|
||||
### Host Exports
|
||||
|
||||
The host automatically exposes many of its components and hooks. <br>
|
||||
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`.
|
||||
|
||||
<br>
|
||||
|
||||
```typescript
|
||||
// In the plugin's code
|
||||
import { Icon } from 'impress/components';
|
||||
import { useAuthQuery } from 'impress/features/auth/api';
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
### Choosing Shared Dependencies
|
||||
|
||||
Sharing dependencies is critical for performance and stability.
|
||||
|
||||
<br>
|
||||
|
||||
- **Minimal Shared Libraries**:<br>
|
||||
Always share **`react`**, **`react-dom`**, **`styled-components`**, and **`@openfun/cunningham-react`** to use the same instances as the host.
|
||||
- **Sharing State**:<br>
|
||||
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`, <br>
|
||||
[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"). <br>
|
||||
You can use this to align versions and add more shared libraries to your plugin.
|
||||
|
||||
<br>
|
||||
|
||||
> **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. <br>
|
||||
> If a shared dependency is omitted from the plugin's config, Webpack will bundle a separate copy, breaking the singleton pattern.
|
||||
|
||||
<br>
|
||||
|
||||
## 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.
|
||||
|
||||
<br>
|
||||
|
||||
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. |
|
||||
|
||||
<br>
|
||||
|
||||
### 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 <br>
|
||||
and re-test after host upgrades.
|
||||
- Treat plugin bundles as untrusted: vet dependencies and avoid unsafe scripts.
|
||||
|
||||
<br>
|
||||
|
||||
## 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").
|
||||
|
||||
<br>
|
||||
|
||||
### 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 (`!`). |
|
||||
|
||||
<br>
|
||||
|
||||
### Injection Position Examples
|
||||
|
||||
The `injection.position` property controls how the plugin is inserted relative to the `target` element.
|
||||
|
||||
<details>
|
||||
<summary>View injection examples</summary>
|
||||
|
||||
<br>
|
||||
|
||||
**before**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#item2",
|
||||
"position": "before"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<li id="item1"></li>
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**after**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#item1",
|
||||
"position": "after"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<li id="item1"></li>
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**prepend**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#some-element-in-the-host",
|
||||
"position": "prepend"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item1"></li>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**append** (default)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#some-element-in-the-host",
|
||||
"position": "append"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<li id="item1"></li>
|
||||
<li id="item2"></li>
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
</ul>
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
**replace**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-component-0",
|
||||
"injection": {
|
||||
"target": "#item1",
|
||||
"position": "replace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<ul id="some-element-in-the-host">
|
||||
<div id="plugin-container-my-custom-component-0"></div>
|
||||
<li id="item1" data-pluginsystem-hidden="true"></li>
|
||||
<li id="item2"></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
## Releasing a Plugin
|
||||
|
||||
When you are ready to release your plugin, you need to create a production build.
|
||||
|
||||
<br>
|
||||
|
||||
Run the build command in your plugin's directory:<br>
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
This command bundles your code for production. <br>
|
||||
Webpack will generate a **`dist`** folder (or similar) containing the **`remoteEntry.js`** file and other JavaScript chunks. <br>
|
||||
The `remoteEntry.js` is the manifest that tells other applications what modules your plugin exposes. <br>
|
||||
These are the files you will need for deployment.
|
||||
|
||||
<br>
|
||||
|
||||
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, <br>
|
||||
so no code changes are needed before building.
|
||||
|
||||
<br>
|
||||
|
||||
## Deploying Docs with Plugins
|
||||
|
||||
To use plugins in a production environment, you need to deploy both the plugin assets and the configuration file. <br>
|
||||
The recommended approach is to serve the plugin's static files from the same webserver that serves the host (docs frontend).
|
||||
|
||||
<br>
|
||||
|
||||
1. **Deploy Plugin Assets**: <br>
|
||||
Copy the contents of your plugin's build output directory (e.g., `dist/`) <br>
|
||||
into the frontend container's `/usr/share/nginx/html/assets` directory at a chosen path.<br>
|
||||
E.g.: Placing assets in `/usr/share/nginx/html/assets/plugins/my-plugin/` <br>
|
||||
would make the plugin's **`remoteEntry.js`** available at `https://production.domain/assets/plugins/my-plugin/remoteEntry.js`.
|
||||
|
||||
<br>
|
||||
|
||||
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. <br>
|
||||
This file is typically managed via infrastructure methods <br>
|
||||
(e.g., a Kubernetes configmap replacing `/app/impress/configuration/plugins/default.json` in the backend container).
|
||||
|
||||
<br>
|
||||
|
||||
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!"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -49,6 +49,14 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session.
|
||||
|
||||
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# To create one, use the bin/fernetkey command.
|
||||
# OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key=="
|
||||
|
||||
# AI
|
||||
AI_FEATURE_ENABLED=true
|
||||
AI_BASE_URL=https://openaiendpoint.com
|
||||
@@ -67,6 +75,8 @@ 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
|
||||
# impress
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
@@ -740,8 +740,6 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
if self.instance is None:
|
||||
attrs["issuer"] = user
|
||||
|
||||
attrs["email"] = attrs["email"].lower()
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_role(self, role):
|
||||
@@ -879,3 +877,13 @@ class MoveDocumentSerializer(serializers.Serializer):
|
||||
choices=enums.MoveNodePositionChoices.choices,
|
||||
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
||||
)
|
||||
|
||||
|
||||
class FindDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for Find search requests"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
|
||||
@@ -21,6 +21,7 @@ from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Left, Length
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -31,6 +32,7 @@ from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
from lasuite.malware_detection import malware_detection
|
||||
from lasuite.oidc_login.decorators import refresh_oidc_access_token
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
@@ -47,6 +49,10 @@ from core.services.converter_services import (
|
||||
from core.services.converter_services import (
|
||||
YdocConverter,
|
||||
)
|
||||
from core.services.search_indexers import (
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
from core.tasks.mail import send_ask_for_access_mail
|
||||
from core.utils import extract_attachments, filter_descendants
|
||||
|
||||
@@ -373,6 +379,7 @@ class DocumentViewSet(
|
||||
list_serializer_class = serializers.ListDocumentSerializer
|
||||
trashbin_serializer_class = serializers.ListDocumentSerializer
|
||||
tree_serializer_class = serializers.ListDocumentSerializer
|
||||
search_serializer_class = serializers.ListDocumentSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset performing all annotation and filtering on the document tree structure."""
|
||||
@@ -1030,6 +1037,68 @@ class DocumentViewSet(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `FindDocumentSerializer`.
|
||||
Depending of the configuration it can be:
|
||||
- A fulltext search through the opensearch indexation app "find" if the backend is
|
||||
enabled (see SEARCH_BACKEND_CLASS)
|
||||
- A filtering by the model field 'title'.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
|
||||
serializer = serializers.FindDocumentSerializer(data=request.query_params)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
text = serializer.validated_data["q"]
|
||||
|
||||
# The indexer is not configured, so we fallback on a simple filter on the
|
||||
# model field 'title'.
|
||||
if not indexer:
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset).order_by("-updated_at")
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
page=serializer.validated_data.get("page", 1),
|
||||
page_size=serializer.validated_data.get("page_size", 20),
|
||||
)
|
||||
|
||||
queryset = queryset.filter(pk__in=results).order_by("-updated_at")
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -2164,7 +2233,6 @@ class ConfigView(drf.views.APIView):
|
||||
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)
|
||||
|
||||
@@ -2202,44 +2270,3 @@ class ConfigView(drf.views.APIView):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Impress Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the impress core app."""
|
||||
class CoreConfig(AppConfig):
|
||||
"""Configuration class for the impress core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("impress core application")
|
||||
name = "core"
|
||||
app_label = "core"
|
||||
verbose_name = _("Impress core application")
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals when the app is ready.
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel, unused-import
|
||||
from . import signals # noqa: PLC0415
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Handle search setup that needs to be done at bootstrap time.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
logger = logging.getLogger("docs.search.bootstrap_search")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Index all documents to remote search service"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Launch and log search index generation."""
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if not indexer:
|
||||
raise CommandError("The indexer is not enabled or properly configured.")
|
||||
|
||||
logger.info("Starting to regenerate Find index...")
|
||||
start = time.perf_counter()
|
||||
|
||||
try:
|
||||
count = indexer.index()
|
||||
except Exception as err:
|
||||
raise CommandError("Unable to regenerate index") from err
|
||||
|
||||
duration = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Search index regenerated from %d document(s) in %.2f seconds.",
|
||||
count,
|
||||
duration,
|
||||
)
|
||||
+26
-23
@@ -221,7 +221,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
Expired invitations are ignored.
|
||||
"""
|
||||
valid_invitations = Invitation.objects.filter(
|
||||
email__iexact=self.email,
|
||||
email=self.email,
|
||||
created_at__gte=(
|
||||
timezone.now()
|
||||
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
@@ -430,32 +430,35 @@ class Document(MP_Node, BaseModel):
|
||||
def save(self, *args, **kwargs):
|
||||
"""Write content to object storage only if _content has changed."""
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self._content:
|
||||
file_key = self.file_key
|
||||
bytes_content = self._content.encode("utf-8")
|
||||
self.save_content(self._content)
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
raise
|
||||
def save_content(self, content):
|
||||
"""Save content to object storage."""
|
||||
|
||||
file_key = self.file_key
|
||||
bytes_content = content.encode("utf-8")
|
||||
|
||||
# Attempt to directly check if the object exists using the storage client.
|
||||
try:
|
||||
response = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file_key
|
||||
)
|
||||
except ClientError as excpt:
|
||||
# If the error is a 404, the object doesn't exist, so we should create it.
|
||||
if excpt.response["Error"]["Code"] == "404":
|
||||
has_changed = True
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"')
|
||||
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Compare the existing ETag with the MD5 hash of the new content.
|
||||
has_changed = (
|
||||
response["ETag"].strip('"') != hashlib.md5(bytes_content).hexdigest() # noqa: S324
|
||||
)
|
||||
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
if has_changed:
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Document search index management utilities and indexers"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from functools import cache
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_document_indexer():
|
||||
"""Returns an instance of indexer service if enabled and properly configured."""
|
||||
classpath = settings.SEARCH_INDEXER_CLASS
|
||||
|
||||
# For this usecase an empty indexer class is not an issue but a feature.
|
||||
if not classpath:
|
||||
logger.info("Document indexer is not configured (see SEARCH_INDEXER_CLASS)")
|
||||
return None
|
||||
|
||||
try:
|
||||
indexer_class = import_string(settings.SEARCH_INDEXER_CLASS)
|
||||
return indexer_class()
|
||||
except ImportError as err:
|
||||
logger.error("SEARCH_INDEXER_CLASS setting is not valid : %s", err)
|
||||
except ImproperlyConfigured as err:
|
||||
logger.error("Document indexer is not properly configured : %s", err)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_batch_accesses_by_users_and_teams(paths):
|
||||
"""
|
||||
Get accesses related to a list of document paths,
|
||||
grouped by users and teams, including all ancestor paths.
|
||||
"""
|
||||
ancestor_map = utils.get_ancestor_to_descendants_map(
|
||||
paths, steplen=models.Document.steplen
|
||||
)
|
||||
ancestor_paths = list(ancestor_map.keys())
|
||||
|
||||
access_qs = models.DocumentAccess.objects.filter(
|
||||
document__path__in=ancestor_paths
|
||||
).values("document__path", "user__sub", "team")
|
||||
|
||||
access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()})
|
||||
|
||||
for access in access_qs:
|
||||
ancestor_path = access["document__path"]
|
||||
user_sub = access["user__sub"]
|
||||
team = access["team"]
|
||||
|
||||
for descendant_path in ancestor_map.get(ancestor_path, []):
|
||||
if user_sub:
|
||||
access_by_document_path[descendant_path]["users"].add(str(user_sub))
|
||||
if team:
|
||||
access_by_document_path[descendant_path]["teams"].add(team)
|
||||
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
"visited" by the user.
|
||||
"""
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
.filter(
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
Base class for document indexers.
|
||||
|
||||
Handles batching and access resolution. Subclasses must implement both
|
||||
`serialize_document()` and `push()` to define backend-specific behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, batch_size=None):
|
||||
"""
|
||||
Initialize the indexer.
|
||||
|
||||
Args:
|
||||
batch_size (int, optional): Number of documents per batch.
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
"""
|
||||
self.batch_size = batch_size or settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_SECRET must be set in Django settings."
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
|
||||
def index(self):
|
||||
"""
|
||||
Fetch documents in batches, serialize them, and push to the search backend.
|
||||
"""
|
||||
last_id = 0
|
||||
count = 0
|
||||
|
||||
while True:
|
||||
documents_batch = list(
|
||||
models.Document.objects.filter(
|
||||
id__gt=last_id,
|
||||
).order_by("id")[: self.batch_size]
|
||||
)
|
||||
|
||||
if not documents_batch:
|
||||
break
|
||||
|
||||
doc_paths = [doc.path for doc in documents_batch]
|
||||
last_id = documents_batch[-1].id
|
||||
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
|
||||
|
||||
serialized_batch = [
|
||||
self.serialize_document(document, accesses_by_document_path)
|
||||
for document in documents_batch
|
||||
if document.content or document.title
|
||||
]
|
||||
|
||||
self.push(serialized_batch)
|
||||
count += len(serialized_batch)
|
||||
|
||||
return count
|
||||
|
||||
@abstractmethod
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document instance to a JSON-serializable format for indexing.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of serialized documents to the backend.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), page=1, page_size=50):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
|
||||
page (int, optional):
|
||||
The page number to retrieve.
|
||||
Defaults to 1 if not specified.
|
||||
page_size (int, optional):
|
||||
The number of results to return per page.
|
||||
Defaults to 50 if not specified.
|
||||
"""
|
||||
response = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"page_number": page,
|
||||
"page_size": page_size,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
|
||||
class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
accesses (dict): Mapping of document ID to user/team access.
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
doc_path = document.path
|
||||
doc_content = document.content
|
||||
text_content = utils.base64_yjs_to_text(doc_content) if doc_content else ""
|
||||
|
||||
return {
|
||||
"id": str(document.id),
|
||||
"title": document.title or "",
|
||||
"content": text_content,
|
||||
"depth": document.depth,
|
||||
"path": document.path,
|
||||
"numchild": document.numchild,
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"users": list(accesses.get(doc_path, {}).get("users", set())),
|
||||
"groups": list(accesses.get(doc_path, {}).get("teams", set())),
|
||||
"reach": document.computed_link_reach,
|
||||
"size": len(text_content.encode("utf-8")),
|
||||
"is_active": not bool(document.ancestors_deleted_at),
|
||||
}
|
||||
|
||||
def search_query(self, data, token) -> requests.Response:
|
||||
"""
|
||||
Retrieve documents from the Find app API.
|
||||
|
||||
Args:
|
||||
data (dict): search data
|
||||
token (str): OICD token
|
||||
|
||||
Returns:
|
||||
dict: A JSON-serializable dictionary.
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
self.search_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error("HTTPError: %s", e)
|
||||
raise
|
||||
|
||||
def push(self, data):
|
||||
"""
|
||||
Push a batch of documents to the Find backend.
|
||||
|
||||
Args:
|
||||
data (list): List of document dictionaries.
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
self.indexer_url,
|
||||
json=data,
|
||||
headers={"Authorization": f"Bearer {self.indexer_secret}"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error("HTTPError: %s", e)
|
||||
raise
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Declare and configure the signals for the impress core application
|
||||
"""
|
||||
|
||||
from functools import partial
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from . import models
|
||||
from .tasks.find import trigger_document_indexer
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.Document)
|
||||
def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
Note : Within the transaction we can have an empty content and a serialization
|
||||
error.
|
||||
"""
|
||||
transaction.on_commit(partial(trigger_document_indexer, instance))
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.DocumentAccess)
|
||||
def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Asynchronous call to the document indexer at the end of the transaction.
|
||||
"""
|
||||
if not created:
|
||||
transaction.on_commit(partial(trigger_document_indexer, instance.document))
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Trigger document indexation using celery task."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from impress.celery_app import app
|
||||
|
||||
logger = getLogger(__file__)
|
||||
|
||||
|
||||
def indexer_debounce_lock(document_id):
|
||||
"""Increase or reset counter"""
|
||||
key = f"doc-indexer-debounce-{document_id}"
|
||||
|
||||
try:
|
||||
return cache.incr(key)
|
||||
except ValueError:
|
||||
cache.set(key, 1)
|
||||
return 1
|
||||
|
||||
|
||||
def indexer_debounce_release(document_id):
|
||||
"""Decrease or reset counter"""
|
||||
key = f"doc-indexer-debounce-{document_id}"
|
||||
|
||||
try:
|
||||
return cache.decr(key)
|
||||
except ValueError:
|
||||
cache.set(key, 0)
|
||||
return 0
|
||||
|
||||
|
||||
@app.task
|
||||
def document_indexer_task(document_id):
|
||||
"""Celery Task : Sends indexation query for a document."""
|
||||
# Prevents some circular imports
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from core import models # noqa : PLC0415
|
||||
from core.services.search_indexers import ( # noqa : PLC0415
|
||||
get_batch_accesses_by_users_and_teams,
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
# check if the counter : if still up, skip the task. only the last one
|
||||
# within the countdown delay will do the query.
|
||||
if indexer_debounce_release(document_id) > 0:
|
||||
logger.info("Skip document %s indexation", document_id)
|
||||
return
|
||||
|
||||
indexer = get_document_indexer()
|
||||
|
||||
if indexer is None:
|
||||
return
|
||||
|
||||
doc = models.Document.objects.get(pk=document_id)
|
||||
accesses = get_batch_accesses_by_users_and_teams((doc.path,))
|
||||
|
||||
data = indexer.serialize_document(document=doc, accesses=accesses)
|
||||
|
||||
logger.info("Start document %s indexation", document_id)
|
||||
indexer.push(data)
|
||||
|
||||
|
||||
def trigger_document_indexer(document):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
Args:
|
||||
document (Document): The document instance.
|
||||
"""
|
||||
countdown = settings.SEARCH_INDEXER_COUNTDOWN
|
||||
|
||||
# DO NOT create a task if indexation if disabled
|
||||
if not settings.SEARCH_INDEXER_CLASS:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Add task for document %s indexation in %.2f seconds",
|
||||
document.pk,
|
||||
countdown,
|
||||
)
|
||||
|
||||
# Each time this method is called during the countdown, we increment the
|
||||
# counter and each task decrease it, so the index be run only once.
|
||||
indexer_debounce_lock(document.pk)
|
||||
|
||||
document_indexer_task.apply_async(args=[document.pk], countdown=countdown)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Unit test for `index` command.
|
||||
"""
|
||||
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import transaction
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
empty_doc = factories.DocumentFactory(title=None, content="")
|
||||
no_title_doc = factories.DocumentFactory(title=None)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=empty_doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=no_title_doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(empty_doc.path): {"users": [user.sub]},
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
# called once but with a batch of docs
|
||||
mock_push.assert_called_once()
|
||||
|
||||
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(no_title_doc, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_index_improperly_configured(indexer_settings):
|
||||
"""The command should raise an exception if the indexer is not configured"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
with pytest.raises(CommandError) as err:
|
||||
call_command("index")
|
||||
|
||||
assert str(err.value) == "The indexer is not enabled or properly configured."
|
||||
@@ -24,3 +24,29 @@ def mock_user_teams():
|
||||
"core.models.User.teams", new_callable=mock.PropertyMock
|
||||
) as mock_teams:
|
||||
yield mock_teams
|
||||
|
||||
|
||||
@pytest.fixture(name="indexer_settings")
|
||||
def indexer_settings_fixture(settings):
|
||||
"""
|
||||
Setup valid settings for the document indexer. Clear the indexer cache.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from core.services.search_indexers import ( # noqa: PLC0415
|
||||
get_document_indexer,
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
yield settings
|
||||
|
||||
# clear cache to prevent issues with other tests
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
@@ -596,32 +596,6 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_invitations_create_lower_email():
|
||||
"""
|
||||
No matter the case, the email should be converted to lowercase.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
|
||||
# Build an invitation to the email of an existing identity in the db
|
||||
invitation_values = {
|
||||
"email": "GuEst@example.com",
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
|
||||
|
||||
# Update
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
"""
|
||||
|
||||
from json import loads as json_loads
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[],
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_pagination(indexer_settings):
|
||||
"""Documents should be ordered by descending "updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[{"_id": str(doc.pk)} for doc in docs],
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "alpha", "page": 2, "page_size": 5}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert len(results) == 5
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"page_number": 2,
|
||||
"page_size": 5,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
@@ -6,6 +6,7 @@ Unit tests for the Document model
|
||||
import random
|
||||
import smtplib
|
||||
from logging import Logger
|
||||
from operator import itemgetter
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
@@ -13,12 +14,14 @@ from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import transaction
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -1411,3 +1414,285 @@ def test_models_documents_compute_ancestors_links_paths_mapping_structure(
|
||||
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push, indexer_settings):
|
||||
"""Test indexation task on document creation"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The debounce counters should be reset
|
||||
assert cache.get(f"doc-indexer-debounce-{doc1.pk}") == 0
|
||||
assert cache.get(f"doc-indexer-debounce-{doc2.pk}") == 0
|
||||
assert cache.get(f"doc-indexer-debounce-{doc3.pk}") == 0
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
with transaction.atomic():
|
||||
factories.DocumentFactory()
|
||||
|
||||
assert mock_push.call_args_list == []
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push, indexer_settings):
|
||||
"""Test indexation task on document creation"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc2, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc3, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc1.path): {"users": [user.sub]},
|
||||
str(doc2.path): {"users": [user.sub]},
|
||||
str(doc3.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc1, accesses),
|
||||
indexer.serialize_document(doc2, accesses),
|
||||
indexer.serialize_document(doc3, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The debounce counters should be reset
|
||||
assert cache.get(f"doc-indexer-debounce-{doc1.pk}") == 0
|
||||
assert cache.get(f"doc-indexer-debounce-{doc2.pk}") == 0
|
||||
assert cache.get(f"doc-indexer-debounce-{doc3.pk}") == 0
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push, indexer_settings):
|
||||
"""Indexation task on deleted or ancestor_deleted documents"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_deleted = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_ancestor_deleted = factories.DocumentFactory(
|
||||
parent=doc_deleted,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
doc_deleted.soft_delete()
|
||||
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.refresh_from_db()
|
||||
doc_ancestor_deleted.refresh_from_db()
|
||||
|
||||
assert doc_deleted.deleted_at is not None
|
||||
assert doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert doc_ancestor_deleted.deleted_at is None
|
||||
assert doc_ancestor_deleted.ancestors_deleted_at is not None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(doc_deleted.path): {"users": [user.sub]},
|
||||
str(doc_ancestor_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# Even deleted document are re-indexed : only update their status in the future ?
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
# The debounce counters should be reset
|
||||
assert cache.get(f"doc-indexer-debounce-{doc.pk}") == 0
|
||||
assert cache.get(f"doc-indexer-debounce-{doc_deleted.pk}") == 0
|
||||
assert cache.get(f"doc-indexer-debounce-{doc_ancestor_deleted.pk}") == 0
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push, indexer_settings):
|
||||
"""Restart indexation task on restored documents"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_deleted = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
)
|
||||
doc_ancestor_deleted = factories.DocumentFactory(
|
||||
parent=doc_deleted,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
)
|
||||
doc_deleted.soft_delete()
|
||||
doc_ancestor_deleted.ancestors_deleted_at = doc_deleted.deleted_at
|
||||
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_deleted, user=user)
|
||||
factories.UserDocumentAccessFactory(document=doc_ancestor_deleted, user=user)
|
||||
|
||||
doc_deleted.refresh_from_db()
|
||||
doc_ancestor_deleted.refresh_from_db()
|
||||
|
||||
assert doc_deleted.deleted_at is not None
|
||||
assert doc_deleted.ancestors_deleted_at is not None
|
||||
|
||||
assert doc_ancestor_deleted.deleted_at is None
|
||||
assert doc_ancestor_deleted.ancestors_deleted_at is not None
|
||||
|
||||
doc_restored = models.Document.objects.get(pk=doc_deleted.pk)
|
||||
doc_restored.restore()
|
||||
|
||||
doc_ancestor_restored = models.Document.objects.get(pk=doc_ancestor_deleted.pk)
|
||||
|
||||
assert doc_restored.deleted_at is None
|
||||
assert doc_restored.ancestors_deleted_at is None
|
||||
|
||||
assert doc_ancestor_restored.deleted_at is None
|
||||
assert doc_ancestor_restored.ancestors_deleted_at is None
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
str(doc_deleted.path): {"users": [user.sub]},
|
||||
str(doc_ancestor_deleted.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert sorted(data, key=itemgetter("id")) == sorted(
|
||||
[
|
||||
indexer.serialize_document(doc, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses),
|
||||
indexer.serialize_document(doc_deleted, accesses), # soft_delete()
|
||||
indexer.serialize_document(doc_restored, accesses), # restore()
|
||||
indexer.serialize_document(doc_ancestor_deleted, accesses),
|
||||
],
|
||||
key=itemgetter("id"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_debounce(indexer_settings):
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# Simulate 1 waiting task
|
||||
cache.set(f"doc-indexer-debounce-{doc.pk}", 1)
|
||||
|
||||
# save doc to trigger the indexer, but nothing should be done since
|
||||
# the counter is over 0
|
||||
with transaction.atomic():
|
||||
doc.save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.set(f"doc-indexer-debounce-{doc.pk}", 0)
|
||||
|
||||
with transaction.atomic():
|
||||
doc = models.Document.objects.get(pk=doc.pk)
|
||||
doc.save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == [
|
||||
indexer.serialize_document(doc, accesses),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_access_post_save_indexer(indexer_settings):
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
doc_access = factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
accesses = {
|
||||
str(doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc_access.save()
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == [
|
||||
indexer.serialize_document(doc, accesses),
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -66,33 +66,3 @@ def test_models_users_sub_validator(sub, is_valid):
|
||||
match=("Enter a valid sub. This value should be ASCII only."),
|
||||
):
|
||||
user.full_clean()
|
||||
|
||||
|
||||
def test_modes_users_convert_valid_invitations():
|
||||
"""
|
||||
The "convert_valid_invitations" method should convert valid invitations to document accesses.
|
||||
"""
|
||||
email = "test@example.com"
|
||||
document = factories.DocumentFactory()
|
||||
other_document = factories.DocumentFactory()
|
||||
invitation_document = factories.InvitationFactory(email=email, document=document)
|
||||
invitation_other_document = factories.InvitationFactory(
|
||||
email="Test@example.coM", document=other_document
|
||||
)
|
||||
other_email_invitation = factories.InvitationFactory(
|
||||
email="pre_test@example.com", document=document
|
||||
)
|
||||
|
||||
assert document.accesses.count() == 0
|
||||
assert other_document.accesses.count() == 0
|
||||
|
||||
user = factories.UserFactory(email=email)
|
||||
|
||||
assert document.accesses.filter(user=user).count() == 1
|
||||
assert other_document.accesses.filter(user=user).count() == 1
|
||||
|
||||
assert not models.Invitation.objects.filter(id=invitation_document.id).exists()
|
||||
assert not models.Invitation.objects.filter(
|
||||
id=invitation_other_document.id
|
||||
).exists()
|
||||
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Tests for Documents search indexers"""
|
||||
|
||||
from functools import partial
|
||||
from json import dumps as json_dumps
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from requests import HTTPError
|
||||
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
FindDocumentIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class FakeDocumentIndexer(BaseDocumentIndexer):
|
||||
"""Fake indexer for test purpose"""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
return {}
|
||||
|
||||
def push(self, data):
|
||||
pass
|
||||
|
||||
def search_query(self, data, token):
|
||||
return {}
|
||||
|
||||
|
||||
def test_services_search_indexer_class_invalid(indexer_settings):
|
||||
"""
|
||||
Should raise RuntimeError if SEARCH_INDEXER_CLASS cannot be imported.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = "unknown.Unknown"
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
|
||||
def test_services_search_indexer_class(indexer_settings):
|
||||
"""
|
||||
Import indexer class defined in setting SEARCH_INDEXER_CLASS.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.tests.test_services_search_indexers.FakeDocumentIndexer"
|
||||
)
|
||||
|
||||
assert isinstance(
|
||||
get_document_indexer(),
|
||||
import_string("core.tests.test_services_search_indexers.FakeDocumentIndexer"),
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_is_configured(indexer_settings):
|
||||
"""
|
||||
Should return true only when the indexer class and other configuration settings
|
||||
are valid.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
# None
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Empty
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = ""
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.FindDocumentIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_SECRET is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
"""
|
||||
It should serialize documents with correct metadata and access control.
|
||||
"""
|
||||
user_a, user_b = factories.UserFactory.create_batch(2)
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(parent=document)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_a)
|
||||
factories.UserDocumentAccessFactory(document=document, user=user_b)
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team1")
|
||||
factories.TeamDocumentAccessFactory(document=document, team="team2")
|
||||
|
||||
accesses = {
|
||||
document.path: {
|
||||
"users": {str(user_a.sub), str(user_b.sub)},
|
||||
"teams": {"team1", "team2"},
|
||||
}
|
||||
}
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
assert set(result.pop("groups")) == {"team1", "team2"}
|
||||
assert result == {
|
||||
"id": str(document.id),
|
||||
"title": document.title,
|
||||
"depth": 1,
|
||||
"path": document.path,
|
||||
"numchild": 1,
|
||||
"content": utils.base64_yjs_to_text(document.content),
|
||||
"created_at": document.created_at.isoformat(),
|
||||
"updated_at": document.updated_at.isoformat(),
|
||||
"reach": document.link_reach,
|
||||
"size": 13,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_deleted():
|
||||
"""Deleted documents are marked as just in the serialized json."""
|
||||
parent = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(parent=parent)
|
||||
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
assert result["title"] == ""
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/index/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
FindDocumentIndexer().index()
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_BATCH_SIZE = 2
|
||||
documents = factories.DocumentFactory.create_batch(5)
|
||||
|
||||
# Attach a single user access to each document
|
||||
expected_user_subs = {}
|
||||
for document in documents:
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert FindDocumentIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
|
||||
seen_doc_ids = set()
|
||||
|
||||
for call in mock_push.call_args_list:
|
||||
batch = call.args[0]
|
||||
assert isinstance(batch, list)
|
||||
|
||||
for doc_json in batch:
|
||||
doc_id = doc_json["id"]
|
||||
seen_doc_ids.add(doc_id)
|
||||
|
||||
# Only one user expected per document
|
||||
assert doc_json["users"] == [expected_user_subs[doc_id]]
|
||||
assert doc_json["groups"] == []
|
||||
|
||||
# Make sure all 5 documents were indexed
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
Documents indexing should be processed in batches,
|
||||
and only the access data relevant to each batch should be used.
|
||||
"""
|
||||
document = factories.DocumentFactory()
|
||||
factories.DocumentFactory(content="", title="")
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
# Make sure only not eempty documents are indexed
|
||||
results = {doc["id"] for doc in mock_push.call_args[0][0]}
|
||||
assert results == {
|
||||
str(d.id)
|
||||
for d in (
|
||||
document,
|
||||
empty_content,
|
||||
empty_title,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
great_grand_parent = factories.DocumentFactory(link_reach="restricted")
|
||||
grand_parent = factories.DocumentFactory(
|
||||
parent=great_grand_parent, link_reach="authenticated"
|
||||
)
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert FindDocumentIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
assert results[str(great_grand_parent.id)]["reach"] == "restricted"
|
||||
assert results[str(grand_parent.id)]["reach"] == "authenticated"
|
||||
assert results[str(parent.id)]["reach"] == "public"
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
user_gp, user_p, user_d = factories.UserFactory.create_batch(3)
|
||||
|
||||
grand_parent = factories.DocumentFactory(users=[user_gp])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["users"] == [str(user_gp.sub)]
|
||||
assert set(results[str(parent.id)]["users"]) == {str(user_gp.sub), str(user_p.sub)}
|
||||
assert set(results[str(document.id)]["users"]) == {
|
||||
str(user_gp.sub),
|
||||
str(user_p.sub),
|
||||
str(user_d.sub),
|
||||
}
|
||||
|
||||
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
grand_parent = factories.DocumentFactory(teams=["team_gp"])
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
assert results[str(grand_parent.id)]["groups"] == ["team_gp"]
|
||||
assert set(results[str(parent.id)]["groups"]) == {"team_gp", "team_p"}
|
||||
assert set(results[str(document.id)]["groups"]) == {"team_gp", "team_p", "team_d"}
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
"""
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
|
||||
indexer = FindDocumentIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
indexer.push(sample_data)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
|
||||
def test_get_visited_document_ids_of():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user BUT without specific access configuration (like public ones)
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
other = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
# The third document is not visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc1.pk), str(doc2.pk)]
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(user=other, document=doc1)
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_get_visited_document_ids_of_deleted():
|
||||
"""
|
||||
get_visited_document_ids_of() returns the ids of the documents viewed
|
||||
by the user if they are not deleted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
anonymous = AnonymousUser()
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
assert not get_visited_document_ids_of(queryset, anonymous)
|
||||
assert not get_visited_document_ids_of(queryset, user)
|
||||
|
||||
doc = factories.DocumentFactory()
|
||||
doc_deleted = factories.DocumentFactory()
|
||||
doc_ancestor_deleted = factories.DocumentFactory(parent=doc_deleted)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc)
|
||||
create_link(document=doc_deleted)
|
||||
create_link(document=doc_ancestor_deleted)
|
||||
|
||||
# The all documents are visited
|
||||
assert sorted(get_visited_document_ids_of(queryset, user)) == sorted(
|
||||
[str(doc.pk), str(doc_deleted.pk), str(doc_ancestor_deleted.pk)]
|
||||
)
|
||||
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
Documents indexing response handling on Find API HTTP errors.
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://app-find/api/v1.0/documents/search/",
|
||||
status=401,
|
||||
body=json_dumps({"message": "Authentication failed."}),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
FindDocumentIndexer().search("alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
|
||||
doc1, doc2, _ = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
create_link = partial(models.LinkTrace.objects.create, user=user, is_masked=False)
|
||||
|
||||
create_link(document=doc1)
|
||||
create_link(document=doc2)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
assert sorted(query_data["visited"]) == sorted([str(doc1.pk), str(doc2.pk)])
|
||||
assert query_data["services"] == ["docs"]
|
||||
assert query_data["page_number"] == 1
|
||||
assert query_data["page_size"] == 50
|
||||
assert query_data["order_by"] == "updated_at"
|
||||
assert query_data["order_direction"] == "desc"
|
||||
|
||||
assert kwargs.get("headers") == {"Authorization": "Bearer mytoken"}
|
||||
assert kwargs.get("timeout") == 10
|
||||
@@ -75,3 +75,28 @@ def test_utils_extract_attachments():
|
||||
base64_string = base64.b64encode(update).decode("utf-8")
|
||||
# image_key2 is missing the "/media/" part and shouldn't get extracted
|
||||
assert utils.extract_attachments(base64_string) == [image_key1, image_key3]
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_single_path():
|
||||
"""Test ancestor mapping of a single path."""
|
||||
paths = ["000100020005"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_ancestor_to_descendants_map_multiple_paths():
|
||||
"""Test ancestor mapping of multiple paths with shared prefixes."""
|
||||
paths = ["000100020005", "00010003"]
|
||||
result = utils.get_ancestor_to_descendants_map(paths, steplen=4)
|
||||
|
||||
assert result == {
|
||||
"0001": {"000100020005", "00010003"},
|
||||
"00010002": {"000100020005"},
|
||||
"000100020005": {"000100020005"},
|
||||
"00010003": {"00010003"},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
import pycrdt
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -9,6 +10,27 @@ from bs4 import BeautifulSoup
|
||||
from core import enums
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
Each path is assumed to use materialized path format with fixed-length segments.
|
||||
|
||||
Args:
|
||||
paths (list of str): List of full document paths.
|
||||
steplen (int): Length of each path segment.
|
||||
|
||||
Returns:
|
||||
dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself).
|
||||
"""
|
||||
ancestor_map = defaultdict(set)
|
||||
for path in paths:
|
||||
for i in range(steplen, len(path) + 1, steplen):
|
||||
ancestor = path[:i]
|
||||
ancestor_map[ancestor].add(path)
|
||||
return ancestor_map
|
||||
|
||||
|
||||
def filter_descendants(paths, root_paths, skip_sorting=False):
|
||||
"""
|
||||
Filters paths to keep only those that are descendants of any path in root_paths.
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# ruff: noqa: S311, S106
|
||||
"""create_demo management command"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from uuid import uuid4
|
||||
|
||||
from django import db
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
import pycrdt
|
||||
from faker import Faker
|
||||
|
||||
from core import models
|
||||
@@ -27,6 +30,16 @@ def random_true_with_probability(probability):
|
||||
return random.random() < probability
|
||||
|
||||
|
||||
def get_ydoc_for_text(text):
|
||||
"""Return a ydoc from plain text for demo purposes."""
|
||||
ydoc = pycrdt.Doc()
|
||||
paragraph = pycrdt.XmlElement("p", {}, [pycrdt.XmlText(text)])
|
||||
fragment = pycrdt.XmlFragment([paragraph])
|
||||
ydoc["document-store"] = fragment
|
||||
update = ydoc.get_update()
|
||||
return base64.b64encode(update).decode("utf-8")
|
||||
|
||||
|
||||
class BulkQueue:
|
||||
"""A utility class to create Django model instances in bulk by just pushing to a queue."""
|
||||
|
||||
@@ -48,7 +61,7 @@ class BulkQueue:
|
||||
self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001
|
||||
|
||||
def push(self, obj):
|
||||
"""Add a model instance to queue to that it gets created in bulk."""
|
||||
"""Add a model instance to queue so that it gets created in bulk."""
|
||||
objects = self.queue[obj._meta.model.__name__] # noqa: SLF001
|
||||
objects.append(obj)
|
||||
if len(objects) > self.BATCH_SIZE:
|
||||
@@ -139,17 +152,19 @@ def create_demo(stdout):
|
||||
# pylint: disable=protected-access
|
||||
key = models.Document._int2str(i) # noqa: SLF001
|
||||
padding = models.Document.alphabet[0] * (models.Document.steplen - len(key))
|
||||
queue.push(
|
||||
models.Document(
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=fake.sentence(nb_words=4),
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
title = fake.sentence(nb_words=4)
|
||||
document = models.Document(
|
||||
id=uuid4(),
|
||||
depth=1,
|
||||
path=f"{padding}{key}",
|
||||
creator_id=random.choice(users_ids),
|
||||
title=title,
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED
|
||||
if random_true_with_probability(0.5)
|
||||
else random.choice(models.LinkReachChoices.values),
|
||||
)
|
||||
document.save_content(get_ydoc_for_text(f"Content for {title:s}"))
|
||||
queue.push(document)
|
||||
|
||||
queue.flush()
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from cryptography.fernet import Fernet
|
||||
from csp.constants import NONE
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
@@ -99,6 +100,28 @@ class Base(Configuration):
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Search
|
||||
SEARCH_INDEXER_CLASS = values.Value(
|
||||
default=None,
|
||||
environ_name="SEARCH_INDEXER_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
||||
@@ -496,18 +519,6 @@ 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
|
||||
@@ -934,6 +945,14 @@ class Development(Base):
|
||||
},
|
||||
}
|
||||
|
||||
# There is no key for token storage in default configuration.
|
||||
# In development environment we can create one if needed.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
|
||||
default=Fernet.generate_key().decode(),
|
||||
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
|
||||
|
||||
@@ -39,7 +39,7 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.7",
|
||||
"django==5.2.6",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { chromium, expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
|
||||
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -561,7 +560,20 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await updateShareLink(page, 'Public', 'Editing');
|
||||
await page.getByTestId('doc-visibility').click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
|
||||
// Close the modal
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
@@ -585,12 +597,17 @@ test.describe('Doc Editor', () => {
|
||||
* We open another browser that will connect to the collaborative server
|
||||
* and will block the current browser to edit the doc.
|
||||
*/
|
||||
const { otherPage } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl: urlChildDoc,
|
||||
docTitle: childTitle,
|
||||
withoutSignIn: true,
|
||||
const otherBrowser = await chromium.launch({ headless: true });
|
||||
const otherContext = await otherBrowser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'Europe/Paris',
|
||||
permissions: [],
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
const otherPage = await otherContext.newPage();
|
||||
|
||||
const webSocketPromise = otherPage.waitForEvent(
|
||||
'websocket',
|
||||
@@ -631,11 +648,6 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'false');
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toBeHidden();
|
||||
await expect(page.getByRole('heading', { name: childTitle })).toBeVisible();
|
||||
|
||||
await page.goto(urlParentDoc);
|
||||
|
||||
await verifyDocName(page, parentTitle);
|
||||
@@ -652,11 +664,6 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'true');
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText(childTitle);
|
||||
await expect(page.getByRole('heading', { name: childTitle })).toBeHidden();
|
||||
|
||||
await expect(
|
||||
card.getByText('Others are editing. Your network prevent changes.'),
|
||||
).toBeHidden();
|
||||
@@ -846,7 +853,6 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await page.getByText('Add PDF').click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByText('Upload file').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
@@ -867,12 +873,5 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
|
||||
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
|
||||
|
||||
// Check download with original filename
|
||||
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
|
||||
await page.locator('[data-test="downloadfile"]').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
verifyDocName,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -154,13 +153,11 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await writeInEditor({
|
||||
page,
|
||||
text: 'Hello World 😃🎉🚀🙋♀️🧑🏿❤️💋🧑🏾',
|
||||
});
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await openSuggestionMenu({ page });
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
|
||||
@@ -106,7 +106,7 @@ test.describe('Doc grid dnd', () => {
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(
|
||||
'You must be at least the administrator of the target document',
|
||||
'You must be at least the editor of the target document',
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -142,10 +142,6 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText('Mocked document');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export the document' }),
|
||||
).toBeVisible();
|
||||
@@ -222,10 +218,6 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText('Mocked document');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export the document' }),
|
||||
).toBeVisible();
|
||||
@@ -294,10 +286,6 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Mocked document' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export the document' }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -169,15 +169,8 @@ test.describe('Document create member', () => {
|
||||
|
||||
const inputSearch = page.getByTestId('quick-search-input');
|
||||
|
||||
let email = 'user.test21@example.COM';
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
|
||||
// Check email is found in search (case insensitive)
|
||||
await expect(page.getByRole('option').getByText(email)).toHaveCount(1);
|
||||
|
||||
email = email + 'M';
|
||||
await inputSearch.fill(email);
|
||||
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
@@ -198,7 +191,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
const listInvitation = page.getByTestId('doc-share-quick-search');
|
||||
const userInvitation = listInvitation.getByTestId(
|
||||
`doc-share-invitation-row-${email.toLowerCase()}`,
|
||||
`doc-share-invitation-row-${email}`,
|
||||
);
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
@@ -236,11 +229,11 @@ test.describe('Document create member', () => {
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
|
||||
const docUrl = page.url();
|
||||
const urlDoc = page.url();
|
||||
|
||||
// Other user will request access
|
||||
const { otherPage, otherBrowserName, cleanup } =
|
||||
await connectOtherUserToDoc({ browserName, docUrl });
|
||||
await connectOtherUserToDoc(browserName, urlDoc);
|
||||
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
|
||||
@@ -157,12 +157,12 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
|
||||
const docUrl = page.url();
|
||||
const urlDoc = page.url();
|
||||
|
||||
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
|
||||
const { otherBrowserName, otherPage } = await connectOtherUserToDoc(
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
urlDoc,
|
||||
);
|
||||
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
||||
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
||||
export type LinkRole = 'Reading' | 'Editing';
|
||||
export type LinkRole = 'Reading' | 'Edition';
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
@@ -88,17 +88,11 @@ export const updateRoleUser = async (
|
||||
* @param docTitle The title of the document (optional).
|
||||
* @returns An object containing the other browser, context, and page.
|
||||
*/
|
||||
export const connectOtherUserToDoc = async ({
|
||||
browserName,
|
||||
docUrl,
|
||||
docTitle,
|
||||
withoutSignIn,
|
||||
}: {
|
||||
browserName: BrowserName;
|
||||
docUrl: string;
|
||||
docTitle?: string;
|
||||
withoutSignIn?: boolean;
|
||||
}) => {
|
||||
export const connectOtherUserToDoc = async (
|
||||
browserName: BrowserName,
|
||||
docUrl: string,
|
||||
docTitle?: string,
|
||||
) => {
|
||||
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
|
||||
if (!otherBrowserName) {
|
||||
throw new Error('No alternative browser found');
|
||||
@@ -117,16 +111,15 @@ export const connectOtherUserToDoc = async ({
|
||||
const otherPage = await otherContext.newPage();
|
||||
await otherPage.goto(docUrl);
|
||||
|
||||
if (!withoutSignIn) {
|
||||
await otherPage
|
||||
.getByRole('main', { name: 'Main content' })
|
||||
.getByLabel('Login')
|
||||
.click({
|
||||
timeout: 15000,
|
||||
});
|
||||
await otherPage
|
||||
.getByRole('main', { name: 'Main content' })
|
||||
.getByLabel('Login')
|
||||
.click({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await keyCloakSignIn(otherPage, otherBrowserName, false);
|
||||
|
||||
await keyCloakSignIn(otherPage, otherBrowserName, false);
|
||||
}
|
||||
if (docTitle) {
|
||||
await verifyDocName(otherPage, docTitle);
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
@mf-types/
|
||||
node_modules/
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
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<ComponentProps> = ({
|
||||
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<string>('');
|
||||
const triggerRef = useRef<HTMLButtonElement>(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 = () => (
|
||||
<TabContent>
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
{t('User Information')}
|
||||
</Text>
|
||||
{authLoading ? (
|
||||
<Box $align="center" $justify="center" $padding="medium">
|
||||
<Loading />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{userFeatures.map((feature, idx) => (
|
||||
<InfoRow key={idx}>
|
||||
<Label>
|
||||
<Icon iconName={feature.icon} $size="s" style={{ marginRight: '8px' }} />
|
||||
{feature.label}
|
||||
</Label>
|
||||
<Value>{feature.value}</Value>
|
||||
</InfoRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</InfoCard>
|
||||
|
||||
{showDebugInfo && authData && (
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '8px', display: 'block' }}>
|
||||
Raw Auth Data
|
||||
</Text>
|
||||
<pre style={{
|
||||
fontSize: '0.75rem',
|
||||
overflow: 'auto',
|
||||
maxHeight: '200px',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
padding: '12px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{JSON.stringify(authData, null, 2)}
|
||||
</pre>
|
||||
</InfoCard>
|
||||
)}
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
const renderFeaturesTab = () => (
|
||||
<TabContent>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
|
||||
{t('Plugin Capabilities')}
|
||||
</Text>
|
||||
<FeatureList>
|
||||
{pluginCapabilities.map((capability, idx) => (
|
||||
<FeatureItem key={idx}>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">{capability}</Text>
|
||||
</FeatureItem>
|
||||
))}
|
||||
</FeatureList>
|
||||
|
||||
<InfoCard style={{ marginTop: '20px' }}>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Props from Config
|
||||
</Text>
|
||||
<InfoRow>
|
||||
<Label>Custom Message</Label>
|
||||
<Value>{customMessage}</Value>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<Label>Debug Mode</Label>
|
||||
<Value>{showDebugInfo ? 'Enabled' : 'Disabled'}</Value>
|
||||
</InfoRow>
|
||||
</InfoCard>
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
const renderSystemTab = () => (
|
||||
<TabContent>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
|
||||
{t('Integrated Host Features')}
|
||||
</Text>
|
||||
<FeatureList>
|
||||
{systemFeatures.map((feature, idx) => (
|
||||
<FeatureItem key={idx}>
|
||||
<Icon
|
||||
iconName={feature.enabled ? 'check_circle' : 'cancel'}
|
||||
variant="filled"
|
||||
$color={feature.enabled ? '#4caf50' : '#f44336'}
|
||||
/>
|
||||
<Text $size="s">{feature.label}</Text>
|
||||
</FeatureItem>
|
||||
))}
|
||||
</FeatureList>
|
||||
|
||||
<InfoCard style={{ marginTop: '20px' }}>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Available Host Components
|
||||
</Text>
|
||||
<Text $size="xs" style={{ lineHeight: '1.6' }}>
|
||||
Icon, Loading, Box, Text, Link, Card, Modal, DropButton, DropdownMenu,
|
||||
InfiniteScroll, QuickSearch, Separators, TextErrors, and more...
|
||||
</Text>
|
||||
</InfoCard>
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
const renderRoutingTab = () => (
|
||||
<TabContent>
|
||||
<Text $weight="bold" $size="l" style={{ marginBottom: '16px', display: 'block' }}>
|
||||
{t('Route Information (Plugin-Safe)')}
|
||||
</Text>
|
||||
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Current Route Information
|
||||
</Text>
|
||||
<InfoRow>
|
||||
<Label>
|
||||
<Icon iconName="route" $size="s" style={{ marginRight: '8px' }} />
|
||||
Current Path
|
||||
</Label>
|
||||
<Value>{currentPath || '/'}</Value>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<Label>
|
||||
<Icon iconName="link" $size="s" style={{ marginRight: '8px' }} />
|
||||
Full URL
|
||||
</Label>
|
||||
<Value style={{ fontSize: '0.75rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{typeof window !== 'undefined' ? window.location.href : 'N/A'}
|
||||
</Value>
|
||||
</InfoRow>
|
||||
<InfoRow>
|
||||
<Label>
|
||||
<Icon iconName="tag" $size="s" style={{ marginRight: '8px' }} />
|
||||
URL Hash
|
||||
</Label>
|
||||
<Value>{typeof window !== 'undefined' ? (window.location.hash || 'none') : 'N/A'}</Value>
|
||||
</InfoRow>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard>
|
||||
<Text $weight="bold" $size="m" style={{ marginBottom: '12px', display: 'block' }}>
|
||||
Plugin Routing Capabilities
|
||||
</Text>
|
||||
<FeatureList>
|
||||
<FeatureItem>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">Read pathname via window.location</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">Listen to popstate events for route changes</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="check_circle" variant="filled" $color="#4caf50" />
|
||||
<Text $size="s">Use visibility.routes in config for conditional rendering</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="info" variant="filled" $color="#2196f3" />
|
||||
<Text $size="s">Navigate using standard anchor tags or window APIs</Text>
|
||||
</FeatureItem>
|
||||
<FeatureItem>
|
||||
<Icon iconName="warning" variant="filled" $color="#ff9800" />
|
||||
<Text $size="s">Next.js router hooks not available (outside RouterContext)</Text>
|
||||
</FeatureItem>
|
||||
</FeatureList>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard style={{ background: 'linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%)', borderLeft: '4px solid #ff9800' }}>
|
||||
<Text $weight="bold" $size="s" style={{ marginBottom: '8px', display: 'block' }}>
|
||||
⚠️ Important Note
|
||||
</Text>
|
||||
<Text $size="xs" style={{ lineHeight: '1.6', marginBottom: '8px' }}>
|
||||
Plugins render outside the Next.js RouterContext, so useRouter() and usePathname()
|
||||
are not available. Instead, use:
|
||||
</Text>
|
||||
<ul style={{ fontSize: '0.75rem', lineHeight: '1.8', marginLeft: '20px' }}>
|
||||
<li><code>window.location.pathname</code> - Get current path</li>
|
||||
<li><code>window.addEventListener('popstate')</code> - Detect route changes</li>
|
||||
<li>Plugin config <code>visibility.routes</code> - Control when plugin appears</li>
|
||||
</ul>
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard style={{ background: 'linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%)', borderLeft: '4px solid #4caf50' }}>
|
||||
<Text $weight="bold" $size="s" style={{ marginBottom: '8px', display: 'block' }}>
|
||||
✅ Best Practice
|
||||
</Text>
|
||||
<Text $size="xs" style={{ lineHeight: '1.6' }}>
|
||||
For route-aware plugins, use the <code>visibility.routes</code> config option with
|
||||
glob patterns (e.g., <code>["/docs/*", "!/docs/secret"]</code>). The plugin system
|
||||
automatically shows/hides your plugin based on the current route!
|
||||
</Text>
|
||||
</InfoCard>
|
||||
</TabContent>
|
||||
);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<Box $padding="small">
|
||||
<Loading />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledButton
|
||||
ref={triggerRef}
|
||||
onPress={handleButtonPress}
|
||||
aria-label="Open Plugin Showcase"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Icon iconName="extension" variant="filled" />
|
||||
{customMessage}
|
||||
</StyledButton>
|
||||
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={10}
|
||||
>
|
||||
{loadingDemo ? (
|
||||
<Box
|
||||
$padding="large"
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$height="300px"
|
||||
>
|
||||
<Loading />
|
||||
<Text $size="s" style={{ marginTop: '16px', color: '#666' }}>
|
||||
Fake Loading...
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TabContainer>
|
||||
<Tab
|
||||
$active={activeTab === 'user'}
|
||||
onClick={() => setActiveTab('user')}
|
||||
>
|
||||
<Icon iconName="person" $size="s" /> User
|
||||
</Tab>
|
||||
<Tab
|
||||
$active={activeTab === 'features'}
|
||||
onClick={() => setActiveTab('features')}
|
||||
>
|
||||
<Icon iconName="settings" $size="s" /> Features
|
||||
</Tab>
|
||||
<Tab
|
||||
$active={activeTab === 'system'}
|
||||
onClick={() => setActiveTab('system')}
|
||||
>
|
||||
<Icon iconName="integration_instructions" $size="s" /> System
|
||||
</Tab>
|
||||
<Tab
|
||||
$active={activeTab === 'routing'}
|
||||
onClick={() => setActiveTab('routing')}
|
||||
>
|
||||
<Icon iconName="route" $size="s" /> Routing
|
||||
</Tab>
|
||||
</TabContainer>
|
||||
|
||||
{activeTab === 'user' && renderUserTab()}
|
||||
{activeTab === 'features' && renderFeaturesTab()}
|
||||
{activeTab === 'system' && renderSystemTab()}
|
||||
{activeTab === 'routing' && renderRoutingTab()}
|
||||
</>
|
||||
)}
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyCustomComponent;
|
||||
@@ -1,109 +0,0 @@
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
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<NavigationCategory[] | null> => {
|
||||
// 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<string, unknown>;
|
||||
|
||||
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<CentralMenuProps> = ({
|
||||
icsBaseUrl = '',
|
||||
portalBaseUrl = '',
|
||||
}) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
const { data: auth } = useAuthQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [navigation, setNavigation] = useState<NavigationCategory[] | null>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(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) => (
|
||||
<li
|
||||
key={category.identifier}
|
||||
data-testid="od-menu-app-category"
|
||||
>
|
||||
<span className="menu-category">{category.display_name}</span>
|
||||
<ul className="menu-entries" data-testid="od-menu-apps">
|
||||
{category.entries.map((entry) => (
|
||||
<li key={entry.identifier} data-testid="od-menu-app">
|
||||
<a
|
||||
href={entry.link}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
target={entry.target}
|
||||
className="menu-link"
|
||||
role="menuitem"
|
||||
>
|
||||
<img
|
||||
alt={entry.display_name}
|
||||
src={entry.icon_url}
|
||||
className="menu-icon"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span>{entry.display_name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{icsBaseUrl && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="opendesk login"
|
||||
src={`${icsBaseUrl}/silent`}
|
||||
data-testid="od-menu-iframe"
|
||||
hidden
|
||||
onLoad={handleIframeLoad}
|
||||
style={{ display: 'none', visibility: 'hidden' }}
|
||||
/>
|
||||
)}
|
||||
<nav id="central-menu" role="navigation" aria-label="Main Navigation">
|
||||
<StyledButton
|
||||
id="nav-button"
|
||||
className={isOpen ? 'active' : ''}
|
||||
ref={triggerRef}
|
||||
onPress={handleToggle}
|
||||
aria-label="Toggle Central Menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="nav-content"
|
||||
data-testid="od-menu-open-button"
|
||||
>
|
||||
<Icon iconName="apps" aria-hidden="true" />
|
||||
</StyledButton>
|
||||
<StyledPopover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={0}
|
||||
containerPadding={0}
|
||||
data-testid="od-menu-popover"
|
||||
>
|
||||
<div id="nav-content">
|
||||
<ul className="menu-list">
|
||||
{status === 'error' ? (
|
||||
<li className="menu-category" data-testid="od-menu-app-category">
|
||||
<small>
|
||||
{t('Navigation could not be accessed.')}
|
||||
{portalBaseUrl && (
|
||||
<>
|
||||
<br />
|
||||
<a
|
||||
href={portalBaseUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('Try logging out and logging in again.')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
</li>
|
||||
) : status === 'loading' ? (
|
||||
<li className="menu-category" data-testid="od-menu-app-category">
|
||||
<Loading />
|
||||
</li>
|
||||
) : (
|
||||
renderNavigation()
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</StyledPopover>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CentralMenu;
|
||||
@@ -1,220 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ThemeColors {
|
||||
// Primary colors
|
||||
'primary-100'?: string;
|
||||
'primary-200'?: string;
|
||||
'primary-300'?: string;
|
||||
'primary-400'?: string;
|
||||
'primary-500'?: string;
|
||||
'primary-600'?: string;
|
||||
'primary-700'?: string;
|
||||
'primary-800'?: string;
|
||||
'primary-900'?: string;
|
||||
'primary-text'?: string;
|
||||
'primary-bg'?: string;
|
||||
|
||||
// Secondary colors
|
||||
'secondary-100'?: string;
|
||||
'secondary-200'?: string;
|
||||
'secondary-300'?: string;
|
||||
'secondary-400'?: string;
|
||||
'secondary-500'?: string;
|
||||
'secondary-600'?: string;
|
||||
'secondary-700'?: string;
|
||||
'secondary-800'?: string;
|
||||
'secondary-900'?: string;
|
||||
'secondary-text'?: string;
|
||||
'secondary-bg'?: string;
|
||||
|
||||
// Info colors
|
||||
'info-100'?: string;
|
||||
'info-200'?: string;
|
||||
'info-300'?: string;
|
||||
'info-400'?: string;
|
||||
'info-500'?: string;
|
||||
'info-600'?: string;
|
||||
'info-700'?: string;
|
||||
'info-800'?: string;
|
||||
'info-900'?: string;
|
||||
'info-text'?: string;
|
||||
|
||||
// Success colors
|
||||
'success-100'?: string;
|
||||
'success-200'?: string;
|
||||
'success-300'?: string;
|
||||
'success-400'?: string;
|
||||
'success-500'?: string;
|
||||
'success-600'?: string;
|
||||
|
||||
// Warning colors
|
||||
'warning-100'?: string;
|
||||
'warning-200'?: string;
|
||||
'warning-300'?: string;
|
||||
'warning-400'?: string;
|
||||
'warning-500'?: string;
|
||||
'warning-600'?: string;
|
||||
|
||||
// Error colors
|
||||
'error-100'?: string;
|
||||
'error-200'?: string;
|
||||
'error-300'?: string;
|
||||
'error-400'?: string;
|
||||
'error-500'?: string;
|
||||
'error-600'?: string;
|
||||
|
||||
// Greyscale
|
||||
'greyscale-000'?: string;
|
||||
'greyscale-100'?: string;
|
||||
'greyscale-200'?: string;
|
||||
'greyscale-300'?: string;
|
||||
'greyscale-400'?: string;
|
||||
'greyscale-500'?: string;
|
||||
'greyscale-600'?: string;
|
||||
'greyscale-700'?: string;
|
||||
'greyscale-800'?: string;
|
||||
'greyscale-900'?: string;
|
||||
}
|
||||
|
||||
interface ThemeSpacings {
|
||||
'xxxxs'?: string;
|
||||
'xxxs'?: string;
|
||||
'xxs'?: string;
|
||||
'xs'?: string;
|
||||
's'?: string;
|
||||
'b'?: string;
|
||||
'st'?: string;
|
||||
't'?: string;
|
||||
'l'?: string;
|
||||
'xl'?: string;
|
||||
'xxl'?: string;
|
||||
'xxxl'?: string;
|
||||
}
|
||||
|
||||
interface ThemeFontSizes {
|
||||
'xxxxs'?: string;
|
||||
'xxxs'?: string;
|
||||
'xxs'?: string;
|
||||
'xs'?: string;
|
||||
's'?: string;
|
||||
'm'?: string;
|
||||
'l'?: string;
|
||||
'xl'?: string;
|
||||
'xxl'?: string;
|
||||
'xxxl'?: string;
|
||||
'xxxxl'?: string;
|
||||
}
|
||||
|
||||
interface ThemingComponentProps {
|
||||
colors?: ThemeColors;
|
||||
spacings?: ThemeSpacings;
|
||||
fontSizes?: ThemeFontSizes;
|
||||
customVars?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemingComponent - Override Cunningham design tokens from a plugin
|
||||
*
|
||||
* This component allows plugins to customize the host application's theme by
|
||||
* setting CSS custom properties (CSS variables).
|
||||
*
|
||||
* ⚠️ IMPORTANT: This component ONLY updates CSS variables, not the Zustand store.
|
||||
* This means:
|
||||
* - ✅ Components using CSS variables like `var(--c--theme--colors--primary-text)` WILL update
|
||||
* - ❌ Components using JS values like `colorsTokens['primary-text']` will NOT update
|
||||
*
|
||||
* For host components to be themeable by plugins, they must use CSS variables instead of
|
||||
* JS token values. Example:
|
||||
*
|
||||
* ❌ Don't use: `$color={colorsTokens['primary-text']}`
|
||||
* ✅ Use instead: `$theme="primary" $variation="text"` (which generates CSS vars)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThemingComponent
|
||||
* colors={{
|
||||
* 'primary-500': '#FF6B6B',
|
||||
* 'primary-600': '#EE5A6F',
|
||||
* 'primary-text': '#FF6B6B',
|
||||
* 'secondary-500': '#4ECDC4',
|
||||
* }}
|
||||
* spacings={{
|
||||
* 's': '12px',
|
||||
* 'm': '24px',
|
||||
* }}
|
||||
* customVars={{
|
||||
* '--custom-header-height': '80px',
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const ThemingComponent: React.FC<ThemingComponentProps> = ({
|
||||
colors = {},
|
||||
spacings = {},
|
||||
fontSizes = {},
|
||||
customVars = {},
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Store original values for cleanup
|
||||
const originalValues: Record<string, string> = {};
|
||||
|
||||
console.log('[ThemingComponent] Applying CSS variable overrides');
|
||||
|
||||
// Apply CSS custom properties (for CSS variable-based usage)
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
const cssVar = `--c--theme--colors--${key}`;
|
||||
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
// Apply spacing tokens
|
||||
Object.entries(spacings).forEach(([key, value]) => {
|
||||
const cssVar = `--c--theme--spacings--${key}`;
|
||||
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
// Apply font size tokens
|
||||
Object.entries(fontSizes).forEach(([key, value]) => {
|
||||
const cssVar = `--c--theme--font--sizes--${key}`;
|
||||
originalValues[cssVar] = root.style.getPropertyValue(cssVar);
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
// Apply custom CSS variables
|
||||
Object.entries(customVars).forEach(([key, value]) => {
|
||||
originalValues[key] = root.style.getPropertyValue(key);
|
||||
root.style.setProperty(key, value);
|
||||
});
|
||||
|
||||
console.log('[ThemingComponent] Applied CSS variable overrides:', {
|
||||
colors: Object.keys(colors).length,
|
||||
spacings: Object.keys(spacings).length,
|
||||
fontSizes: Object.keys(fontSizes).length,
|
||||
customVars: Object.keys(customVars).length,
|
||||
});
|
||||
|
||||
// Cleanup: restore original values on unmount
|
||||
return () => {
|
||||
console.log('[ThemingComponent] Restoring original CSS variables');
|
||||
|
||||
// Restore CSS variables
|
||||
Object.entries(originalValues).forEach(([cssVar, originalValue]) => {
|
||||
if (originalValue) {
|
||||
root.style.setProperty(cssVar, originalValue);
|
||||
} else {
|
||||
root.style.removeProperty(cssVar);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[ThemingComponent] Restored original CSS variables');
|
||||
};
|
||||
}, [colors, spacings, fontSizes, customVars]);
|
||||
|
||||
// This component doesn't render anything visible
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ThemingComponent;
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import ThemingComponent from './ThemingComponent';
|
||||
|
||||
/**
|
||||
* ThemingDemo - Interactive demo component to test theme overrides
|
||||
*
|
||||
* This component demonstrates the ThemingComponent by providing
|
||||
* a UI to dynamically change theme tokens and see the results.
|
||||
*/
|
||||
const ThemingDemo: React.FC = () => {
|
||||
const [primaryColor, setPrimaryColor] = useState('#667eea');
|
||||
const [secondaryColor, setSecondaryColor] = useState('#764ba2');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enabled && (
|
||||
<ThemingComponent
|
||||
colors={{
|
||||
// Primary color variants
|
||||
'primary-500': primaryColor,
|
||||
'primary-600': secondaryColor,
|
||||
'primary-text': primaryColor,
|
||||
'primary-bg': primaryColor,
|
||||
|
||||
// Secondary color variants
|
||||
'secondary-500': secondaryColor,
|
||||
'secondary-text': secondaryColor,
|
||||
'secondary-bg': secondaryColor,
|
||||
}}
|
||||
customVars={{
|
||||
'--demo-gradient': `linear-gradient(135deg, ${primaryColor} 0%, ${secondaryColor} 100%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid var(--c--theme--colors--greyscale-300)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
zIndex: 9999,
|
||||
minWidth: '300px',
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--c--theme--colors--greyscale-900)',
|
||||
}}>
|
||||
🎨 Theme Override Demo
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Enable Theme Override
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
color: 'var(--c--theme--colors--greyscale-700)',
|
||||
}}>
|
||||
Primary Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={primaryColor}
|
||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||||
disabled={!enabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
border: '1px solid var(--c--theme--colors--greyscale-300)',
|
||||
borderRadius: '6px',
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--c--theme--colors--greyscale-500)',
|
||||
}}>
|
||||
{primaryColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '4px',
|
||||
color: 'var(--c--theme--colors--greyscale-700)',
|
||||
}}>
|
||||
Secondary Color
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
value={secondaryColor}
|
||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||
disabled={!enabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
border: '1px solid var(--c--theme--colors--greyscale-300)',
|
||||
borderRadius: '6px',
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--c--theme--colors--greyscale-500)',
|
||||
}}>
|
||||
{secondaryColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '12px',
|
||||
background: 'var(--demo-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%))',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
Preview: Gradient with current colors
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--c--theme--colors--greyscale-600)',
|
||||
lineHeight: '1.4',
|
||||
}}>
|
||||
💡 This demo overrides primary-500, primary-600, and secondary-500 css variables. It only works with CSS.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemingDemo;
|
||||
@@ -1,2 +0,0 @@
|
||||
// This file is just to satisfy webpack's entry point requirement
|
||||
// The actual component is exposed via Module Federation
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["./@mf-types/*"],
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
const path = require('path');
|
||||
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
|
||||
const {
|
||||
NativeFederationTypeScriptHost,
|
||||
} = require('@module-federation/native-federation-typescript/webpack');
|
||||
const {
|
||||
NativeFederationTypeScriptHost: NativeFederationTypeScriptHostCore,
|
||||
} = require('@module-federation/native-federation-typescript');
|
||||
|
||||
const moduleFederationConfig = {
|
||||
name: 'plugin_frontend',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./MyCustomComponent': './src/MyCustomComponent.tsx',
|
||||
'./MyCustomHeaderMenu': './src/MyCustomHeaderMenu.tsx',
|
||||
'./ThemingComponent': './src/ThemingComponent.tsx',
|
||||
'./ThemingDemo': './src/ThemingDemo.tsx',
|
||||
},
|
||||
remotes: {
|
||||
impress: 'impress@http://localhost:3000/_next/static/chunks/remoteEntry.js',
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true },
|
||||
'react-dom': { singleton: true },
|
||||
'styled-components': { singleton: true },
|
||||
'react-aria-components': { singleton: true },
|
||||
'@openfun/cunningham-react': { singleton: true },
|
||||
'react-i18next': { singleton: true },
|
||||
'yjs': { singleton: true },
|
||||
'@gouvfr-lasuite/ui-kit': { singleton: true },
|
||||
'clsx': { singleton: true },
|
||||
'cmdk': { singleton: true },
|
||||
'react-intersection-observer': { singleton: true },
|
||||
'@tanstack/react-query': { singleton: true },
|
||||
'zustand': { singleton: true },
|
||||
},
|
||||
};
|
||||
|
||||
let mfTypesReady;
|
||||
const ensureFederatedTypesPlugin = {
|
||||
apply(compiler) {
|
||||
compiler.hooks.beforeCompile.tapPromise(
|
||||
'EnsureFederatedTypes',
|
||||
async () => {
|
||||
if (!mfTypesReady) {
|
||||
const downloader = NativeFederationTypeScriptHostCore.raw({
|
||||
moduleFederationConfig,
|
||||
});
|
||||
mfTypesReady = downloader.writeBundle();
|
||||
}
|
||||
await mfTypesReady;
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const dev = argv.mode !== 'production';
|
||||
|
||||
return {
|
||||
entry: './src/index.tsx',
|
||||
mode: dev ? 'development' : 'production',
|
||||
devServer: dev ? {
|
||||
port: 3002,
|
||||
hot: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
|
||||
},
|
||||
} : undefined,
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
publicPath: dev ? 'http://localhost:3002/' : undefined,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js', '.jsx'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin(moduleFederationConfig),
|
||||
...(dev
|
||||
? [
|
||||
ensureFederatedTypesPlugin,
|
||||
NativeFederationTypeScriptHost({
|
||||
moduleFederationConfig,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=false
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=true
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
NEXT_PUBLIC_DEVELOP_PLUGINS=false
|
||||
|
||||
|
||||
@@ -35,8 +35,3 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
service-worker.js
|
||||
|
||||
# Font embedding
|
||||
public/assets/fonts/emoji/*
|
||||
!public/assets/fonts/emoji/fallback.png
|
||||
public/assets/fonts/Marianne/*
|
||||
@@ -1,3 +1,2 @@
|
||||
next-env.d.ts
|
||||
service-worker.js
|
||||
public/assets/fonts/*
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
const ts = require('typescript');
|
||||
|
||||
/**
|
||||
* @file This file configures Module Federation for the 'impress' application.
|
||||
* It automatically exposes components and shares dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts the root name of a package from an import specifier.
|
||||
* e.g., '@scope/name/foo' becomes '@scope/name', and 'next/link' becomes 'next'.
|
||||
* @param {string} spec - The import specifier.
|
||||
* @returns {string} The root package name.
|
||||
*/
|
||||
function pkgRootName(spec) {
|
||||
// '@scope/name/foo' -> '@scope/name', 'next/link' -> 'next'
|
||||
if (spec.startsWith('@')) {
|
||||
const [scope, name] = spec.split('/');
|
||||
return `${scope}/${name || ''}`;
|
||||
}
|
||||
return spec.split('/')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an import specifier is a relative path or a path alias.
|
||||
* @param {string} spec - The import specifier.
|
||||
* @returns {boolean} True if the path is relative or an alias.
|
||||
*/
|
||||
function isRelativeOrAlias(spec) {
|
||||
return spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('@/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the key for the 'exposes' object from an absolute file path.
|
||||
* This creates a public path like './components/Button' from a file path.
|
||||
* @param {string} absPath - The absolute path to the file.
|
||||
* @param {string} root - The project root directory.
|
||||
* @returns {string} The key for the 'exposes' object.
|
||||
*/
|
||||
function buildExposeKey(absPath, root) {
|
||||
const relPath = path.relative(root, absPath).replace(/\\/g, '/'); // Convert backslashes to forward slashes
|
||||
|
||||
// Remove 'src/' prefix to create cleaner public paths
|
||||
const pathWithoutSrc = relPath.startsWith('src/')
|
||||
? relPath.substring(4)
|
||||
: relPath;
|
||||
|
||||
const relNoExt = pathWithoutSrc.replace(/\.(t|j)sx?$/, ''); // Remove file extension
|
||||
// Remove '/index' from the end of the path to allow importing the directory
|
||||
const withoutIndex = relNoExt.replace(/\/index$/, '');
|
||||
return './' + withoutIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans specified folders for TypeScript/JavaScript files to expose via Module Federation.
|
||||
* It uses the TypeScript compiler API to find all files with actual runtime exports.
|
||||
* @param {string[]} folders - An array of folder paths to scan.
|
||||
* @returns {{exposes: Record<string, string>, program: ts.Program}} An object containing the exposed modules and the TypeScript program instance.
|
||||
*/
|
||||
function makeExposes(folders) {
|
||||
const projectRoot = process.cwd();
|
||||
const tsconfigPath = path.resolve(projectRoot, 'tsconfig.json');
|
||||
|
||||
// Read and parse the tsconfig.json file
|
||||
const cfg = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
||||
if (cfg.error) {
|
||||
throw new Error(formatTsError('Failed to read tsconfig', cfg.error));
|
||||
}
|
||||
|
||||
const parsed = ts.parseJsonConfigFileContent(
|
||||
cfg.config,
|
||||
ts.sys,
|
||||
path.dirname(tsconfigPath),
|
||||
undefined,
|
||||
tsconfigPath,
|
||||
);
|
||||
|
||||
// Create a TypeScript program to analyze the source files
|
||||
const program = ts.createProgram({
|
||||
rootNames: parsed.fileNames,
|
||||
options: parsed.options,
|
||||
});
|
||||
const checker = program.getTypeChecker();
|
||||
|
||||
const dirSet = new Set(
|
||||
folders.map((f) => path.resolve(process.cwd(), f).replace(/\\/g, '/')),
|
||||
);
|
||||
const exposes = {};
|
||||
const used = new Set(); // Tracks used expose keys to detect duplicates
|
||||
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
// Skip declaration files (.d.ts)
|
||||
if (sf.isDeclarationFile) {
|
||||
continue;
|
||||
}
|
||||
const abs = sf.fileName.replace(/\\/g, '/');
|
||||
|
||||
// Only include files from the specified folders
|
||||
if (
|
||||
![...dirSet].some((dir) =>
|
||||
abs.startsWith(dir.endsWith('/') ? dir : dir + '/'),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include valid script files
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exclude node_modules, test files, and mocks
|
||||
if (
|
||||
abs.includes('/node_modules/') ||
|
||||
/[.-](test|spec|stories)\.(t|j)sx?$/.test(abs) ||
|
||||
abs.includes('/__tests__/') ||
|
||||
abs.includes('/__mocks__/')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleSymbol = sf.symbol;
|
||||
if (!moduleSymbol) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the module has any runtime exports (values, not just types)
|
||||
const hasRuntime = checker.getExportsOfModule(moduleSymbol).some((sym) => {
|
||||
const tgt =
|
||||
sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
|
||||
return (tgt.getFlags() & ts.SymbolFlags.Value) !== 0;
|
||||
});
|
||||
if (!hasRuntime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = buildExposeKey(abs, process.cwd());
|
||||
const rel = './' + path.relative(process.cwd(), abs).replace(/\\/g, '/');
|
||||
|
||||
// Handle duplicate keys, preferring non-index files
|
||||
if (used.has(key)) {
|
||||
const isNewFileIndex = path.basename(abs, path.extname(abs)) === 'index';
|
||||
const existingPath = exposes[key];
|
||||
|
||||
console.warn(
|
||||
`[makeExposes] Duplicate expose key "${key}". ` +
|
||||
`${isNewFileIndex ? 'Skipping index file' : 'Keeping first occurrence'}: "${rel}". ` +
|
||||
`Existing: "${existingPath}".`,
|
||||
);
|
||||
if (isNewFileIndex) {
|
||||
continue; // Index files lose in case of conflicts
|
||||
}
|
||||
// Otherwise: the first file encountered keeps the key
|
||||
}
|
||||
exposes[key] = rel;
|
||||
used.add(key);
|
||||
}
|
||||
|
||||
return { exposes, program };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the installed version of a package.
|
||||
* @param {string} pkgName - The name of the package.
|
||||
* @param {string} projectRoot - The root directory of the project.
|
||||
* @returns {string|null} The installed version, or null if not found.
|
||||
*/
|
||||
function resolveInstalledVersion(pkgName, projectRoot) {
|
||||
try {
|
||||
const pkgJsonPath = require.resolve(`${pkgName}/package.json`, {
|
||||
paths: [projectRoot],
|
||||
});
|
||||
const { version } = require(pkgJsonPath);
|
||||
return version || null; // e.g. "18.3.1"
|
||||
} catch {
|
||||
return null; // not installed or cannot resolve
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically determines which dependencies to share based on the imports
|
||||
* of the exposed files.
|
||||
* @param {object} options - The options for making shared dependencies.
|
||||
* @param {ts.Program} options.program - The TypeScript program instance.
|
||||
* @param {Record<string, string>} options.exposes - The exposed modules.
|
||||
* @param {string[]} [options.include=[]] - A list of packages to include in sharing (e.g., 'react', 'styled-components').
|
||||
* @param {string[]} [options.exclude=[]] - A list of packages to exclude from sharing.
|
||||
* @returns {Record<string, object>} The shared dependencies configuration.
|
||||
*/
|
||||
function makeSharedAuto({ program, exposes, include = [], exclude = [] }) {
|
||||
const projectRoot = process.cwd();
|
||||
const { dependencies = {}, peerDependencies = {} } = require(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
);
|
||||
const declared = { ...dependencies, ...peerDependencies };
|
||||
|
||||
const wanted = new Set(include);
|
||||
|
||||
const exposedFiles = new Set(
|
||||
Object.values(exposes).map((p) =>
|
||||
path.resolve(projectRoot, p.replace(/^\.\//, '')).replace(/\\/g, '/'),
|
||||
),
|
||||
);
|
||||
|
||||
// Analyze imports of exposed files to find dependencies to share
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
const abs = sf.fileName.replace(/\\/g, '/');
|
||||
if (!exposedFiles.has(abs)) {
|
||||
continue;
|
||||
}
|
||||
const specs = (sf.imports || []).map((n) => n.text).filter(Boolean);
|
||||
for (const spec of specs) {
|
||||
if (isRelativeOrAlias(spec)) {
|
||||
continue;
|
||||
}
|
||||
wanted.add(pkgRootName(spec));
|
||||
}
|
||||
}
|
||||
|
||||
// Prune excluded packages
|
||||
for (const name of [...wanted]) {
|
||||
if (exclude.includes(name)) {
|
||||
wanted.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the shared object for Webpack
|
||||
return Object.fromEntries(
|
||||
[...wanted].map((name) => {
|
||||
// Prefer the actually installed version for 'requiredVersion'
|
||||
const resolved = resolveInstalledVersion(name, projectRoot);
|
||||
// Fallback to the version range from package.json if resolution fails
|
||||
const range = declared[name] || undefined;
|
||||
const requiredVersion = resolved || range; // Use concrete version when possible
|
||||
|
||||
return [
|
||||
name,
|
||||
{
|
||||
singleton: true,
|
||||
eager: false,
|
||||
requiredVersion,
|
||||
strictVersion: !!resolved, // Enforce exact match if we know the concrete version
|
||||
allowNodeModulesSuffixMatch: true,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a TypeScript diagnostic error into a readable string.
|
||||
* @param {string} context - A string describing the context of the error.
|
||||
* @param {ts.Diagnostic} diag - The TypeScript diagnostic object.
|
||||
* @returns {string} The formatted error message.
|
||||
*/
|
||||
function formatTsError(context, diag) {
|
||||
const message = ts.flattenDiagnosticMessageText(diag.messageText, '\n');
|
||||
return `${context}: ${message}`;
|
||||
}
|
||||
|
||||
// ---------- Final Module Federation Config ----------
|
||||
|
||||
// Define which folders should be scanned for exposable modules.
|
||||
const foldersToExpose = [
|
||||
'./src/components',
|
||||
'./src/features/auth',
|
||||
'./src/cunningham',
|
||||
];
|
||||
const { exposes, program } = makeExposes(foldersToExpose);
|
||||
|
||||
// Automatically determine shared dependencies based on the imports of exposed files.
|
||||
const shared = makeSharedAuto({
|
||||
program,
|
||||
exposes,
|
||||
include: ['react', 'react-dom', 'styled-components', 'yjs'],
|
||||
exclude: ['next'], // Packages to never share
|
||||
});
|
||||
|
||||
const moduleFederationConfig = {
|
||||
name: 'impress',
|
||||
filename: 'static/chunks/remoteEntry.js',
|
||||
extraOptions: { skipSharingNextInternals: true },
|
||||
|
||||
// The modules exposed by this federated module.
|
||||
exposes,
|
||||
|
||||
// The shared dependencies for this federated module.
|
||||
shared,
|
||||
|
||||
// TypeScript type generation for plugin development.
|
||||
// This is enabled when NEXT_PUBLIC_DEVELOP_PLUGINS is 'true'.
|
||||
dts:
|
||||
process.env.NEXT_PUBLIC_DEVELOP_PLUGINS === 'true'
|
||||
? {
|
||||
generateTypes: {
|
||||
extractThirdParty: true,
|
||||
tsConfigPath: './tsconfig.json',
|
||||
},
|
||||
}
|
||||
: false,
|
||||
};
|
||||
|
||||
module.exports = { moduleFederationConfig };
|
||||
@@ -1,15 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
NativeFederationTypeScriptRemote,
|
||||
} = require('@module-federation/native-federation-typescript/webpack');
|
||||
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||
|
||||
const { moduleFederationConfig } = require('./mf.config.js');
|
||||
|
||||
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
@@ -27,18 +19,7 @@ const nextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_BUILD_ID: buildId,
|
||||
},
|
||||
webpack(config, { isServer, dev }) {
|
||||
// Prevent rebuild loops by ignoring node_modules and generated types/outputs
|
||||
config.watchOptions = {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/dist/**',
|
||||
'**/@mf-types/**',
|
||||
'**/@mf-types.zip',
|
||||
],
|
||||
};
|
||||
|
||||
webpack(config, { isServer }) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg'),
|
||||
@@ -60,83 +41,23 @@ const nextConfig = {
|
||||
},
|
||||
);
|
||||
|
||||
// Copy necessary fonts from node_modules to public directory during build or dev
|
||||
config.plugins.push(
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.resolve(
|
||||
__dirname,
|
||||
'../../node_modules/emoji-datasource-apple/img/apple/64',
|
||||
),
|
||||
to: path.resolve(__dirname, 'public/assets/fonts/emoji'),
|
||||
force: true,
|
||||
},
|
||||
{
|
||||
from: path.resolve(
|
||||
__dirname,
|
||||
'../../node_modules/@gouvfr-lasuite/ui-kit/dist/assets/fonts/Marianne',
|
||||
),
|
||||
to: path.resolve(__dirname, 'public/assets/fonts/Marianne'),
|
||||
force: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isServer) {
|
||||
// Host configuration for Module Federation (PluginSystem)
|
||||
config.plugins.push(new NextFederationPlugin(moduleFederationConfig));
|
||||
if (dev && process.env.NEXT_PUBLIC_DEVELOP_PLUGINS === 'true') {
|
||||
console.log('[DEBUG] moduleFederationConfig:');
|
||||
console.log(moduleFederationConfig);
|
||||
|
||||
config.plugins.push(
|
||||
// Allow the plugin to get types/intellisense from the host at development time
|
||||
NativeFederationTypeScriptRemote({
|
||||
moduleFederationConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
// Copy the generated @mf-types.zip to .next/static/chunks so it's served at /_next/static/chunks/
|
||||
const mfTypesSource = path.resolve(__dirname, '.next', '@mf-types.zip');
|
||||
const mfTypesDest = path.resolve(
|
||||
__dirname,
|
||||
'.next',
|
||||
'static',
|
||||
'chunks',
|
||||
'@mf-types.zip',
|
||||
);
|
||||
config.plugins.push(
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: mfTypesSource,
|
||||
to: mfTypesDest,
|
||||
force: true,
|
||||
noErrorOnMissing: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
swSrc: './src/features/service-worker/service-worker.ts',
|
||||
swDest: '../public/service-worker.js',
|
||||
include: [
|
||||
({ asset }) => {
|
||||
return !!asset.name.match(/.*(static).*/);
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
swSrc: './src/features/service-worker/service-worker.ts',
|
||||
swDest: '../public/service-worker.js',
|
||||
include: [
|
||||
({ asset }) => {
|
||||
return !!asset.name.match(/.*(static).*/);
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "NEXT_PRIVATE_LOCAL_WEBPACK=true next dev",
|
||||
"build": "prettier --check . && yarn stylelint && NEXT_PRIVATE_LOCAL_WEBPACK=true next build",
|
||||
"dev": "next dev",
|
||||
"build": "prettier --check . && yarn stylelint && next build",
|
||||
"build:ci": "cp .env.development .env.local && yarn build",
|
||||
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier && yarn stylelint --fix",
|
||||
"start": "npx -y serve@latest out",
|
||||
@@ -30,13 +30,10 @@
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@module-federation/runtime": "0.19.1",
|
||||
"@module-federation/runtime-core": "0.19.1",
|
||||
"@openfun/cunningham-react": "3.2.3",
|
||||
"@react-pdf/renderer": "4.3.0",
|
||||
"@sentry/nextjs": "10.11.0",
|
||||
@@ -46,7 +43,6 @@
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.5.0",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.5.0",
|
||||
"i18next": "25.5.2",
|
||||
@@ -69,8 +65,6 @@
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@module-federation/nextjs-mf": "8.8.42",
|
||||
"@module-federation/native-federation-typescript": "0.6.2",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.87.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
@@ -83,7 +77,6 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "17.2.2",
|
||||
"eslint-plugin-docs": "*",
|
||||
@@ -97,6 +90,7 @@
|
||||
"typescript": "*",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"webpack": "5.101.3",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,101 @@
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Thin.woff2') format('woff2'),
|
||||
url('Marianne-Thin.woff') format('woff');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Thin_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Thin_Italic.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Light.woff2') format('woff2'),
|
||||
url('Marianne-Light.woff') format('woff');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Light_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Light_Italic.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Regular.woff2') format('woff2'),
|
||||
url('Marianne-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Regular_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Regular_Italic.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Medium.woff2') format('woff2'),
|
||||
url('Marianne-Medium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Medium_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Medium_Italic.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Bold.woff2') format('woff2'),
|
||||
url('Marianne-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Bold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Bold_Italic.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-ExtraBold.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold.woff') format('woff');
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold_Italic.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -12,7 +12,6 @@ import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import { useResponsiveStore } from '@/stores/';
|
||||
|
||||
import { ConfigProvider } from './config/';
|
||||
import { PluginSystemProvider } from './plugin/PluginSystemProvider';
|
||||
|
||||
export const DEFAULT_QUERY_RETRY = 1;
|
||||
|
||||
@@ -74,11 +73,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CunninghamProvider theme={theme}>
|
||||
<PluginSystemProvider>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
</ConfigProvider>
|
||||
</PluginSystemProvider>
|
||||
<ConfigProvider>
|
||||
<Auth>{children}</Auth>
|
||||
</ConfigProvider>
|
||||
</CunninghamProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Resource } from 'i18next';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { PluginConfig } from '@/core/plugin';
|
||||
import { Theme } from '@/cunningham/';
|
||||
import { FooterType } from '@/features/footer';
|
||||
import { PostHogConf } from '@/services';
|
||||
@@ -27,7 +26,6 @@ export interface ConfigResponse {
|
||||
POSTHOG_KEY?: PostHogConf;
|
||||
SENTRY_DSN?: string;
|
||||
theme_customization?: ThemeCustomization;
|
||||
plugins?: PluginConfig[];
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'docs_config';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './AppProvider';
|
||||
export * from './plugin';
|
||||
export * from './config';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
export * from './PluginSystemProvider';
|
||||
@@ -1,5 +1,6 @@
|
||||
@import url('@gouvfr-lasuite/ui-kit/style');
|
||||
@import url('./cunningham-tokens.css');
|
||||
@import url('/assets/fonts/Marianne/Marianne-font.css');
|
||||
|
||||
:root {
|
||||
/**
|
||||
@@ -75,3 +76,29 @@
|
||||
.c__toast__container:has(.c__toast) {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('https://fonts.gstatic.com/s/inter/v18/UcCm3FwrK3iLTcvnUwQT9g.woff2')
|
||||
format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2')
|
||||
format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useConfig } from '@/core/config';
|
||||
import { useConfig } from '@/core';
|
||||
|
||||
import { HOME_URL } from '../conf';
|
||||
import { useAuth } from '../hooks';
|
||||
|
||||
+2
-15
@@ -76,23 +76,11 @@ export const FileDownloadButton = ({
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(
|
||||
blob,
|
||||
fileBlock.props.name || url.split('/').pop() || 'file',
|
||||
);
|
||||
downloadFile(blob, url.split('/').pop() || 'file');
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
|
||||
const baseName =
|
||||
fileBlock.props.name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
const unsafeName = baseName.includes('.')
|
||||
? baseName.replace(regFindLastDot, '-unsafe$1')
|
||||
: baseName + '-unsafe';
|
||||
|
||||
downloadFile(blob, unsafeName);
|
||||
downloadFile(blob, url.split('/').pop() || 'file (unsafe)');
|
||||
};
|
||||
|
||||
open(onConfirm);
|
||||
@@ -112,7 +100,6 @@ export const FileDownloadButton = ({
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button --docs--editor-file-download-button"
|
||||
data-test="downloadfile"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
|
||||
+3
-11
@@ -25,12 +25,8 @@ export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
@@ -69,13 +65,9 @@ export const CalloutBlock = createReactBlockSpec(
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
|
||||
@@ -29,12 +29,7 @@ export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
|
||||
headings: [],
|
||||
setHeadings: (editor) => {
|
||||
const headingBlocks = editor?.document
|
||||
.filter(
|
||||
(block) =>
|
||||
block.type === 'heading' &&
|
||||
block.props.level >= 1 &&
|
||||
block.props.level <= 3,
|
||||
)
|
||||
.filter((block) => block.type === 'heading')
|
||||
.map((block) => ({
|
||||
...block,
|
||||
contentText: recursiveTextContent(
|
||||
|
||||
@@ -16,19 +16,6 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: #767676 !important;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
||||
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
||||
import i18next from 'i18next';
|
||||
import { cloneElement, isValidElement, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -96,20 +95,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
if (format === DocDownloadFormat.PDF) {
|
||||
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
|
||||
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
|
||||
emojiSource: {
|
||||
format: 'png',
|
||||
builder(code) {
|
||||
const emoji = jsonemoji.find((e) =>
|
||||
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
|
||||
);
|
||||
|
||||
if (emoji) {
|
||||
return `/assets/fonts/emoji/${emoji.image}`;
|
||||
}
|
||||
|
||||
return '/assets/fonts/emoji/fallback.png';
|
||||
},
|
||||
},
|
||||
});
|
||||
const rawPdfDocument = (await exporter.toReactPDFDocument(
|
||||
exportDocument,
|
||||
@@ -165,6 +150,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
useDocStore,
|
||||
useIsCollaborativeEditable,
|
||||
useTrans,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
@@ -22,10 +21,7 @@ interface DocTitleProps {
|
||||
}
|
||||
|
||||
export const DocTitle = ({ doc }: DocTitleProps) => {
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
|
||||
|
||||
if (readOnly) {
|
||||
if (!doc.abilities.partial_update) {
|
||||
return <DocTitleText />;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,6 @@ export const DocPage403 = ({ id }: DocProps) => {
|
||||
className="c__image-system-filter"
|
||||
src={img403}
|
||||
alt={t('Image 403')}
|
||||
width={300}
|
||||
height={300}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useConfig } from '@/core/config';
|
||||
import { useConfig } from '@/core';
|
||||
import { useIsOffline } from '@/features/service-worker';
|
||||
|
||||
import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit';
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ export const DocShareAddMemberList = ({
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email.toLowerCase(),
|
||||
email: user.email,
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
|
||||
@@ -296,9 +296,7 @@ const QuickSearchInviteInputSection = ({
|
||||
language: '',
|
||||
};
|
||||
|
||||
const hasEmailInUsers = users.some(
|
||||
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
|
||||
);
|
||||
const hasEmailInUsers = users.some((user) => user.email === userQuery);
|
||||
|
||||
return {
|
||||
groupName: t('Search user result'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user