From cc705643031870e92fe9a70fd2bf2dafb52a7059 Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Wed, 22 Jul 2026 00:35:02 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F(front)=20replace=20Next.j?= =?UTF-8?q?s=20with=20Vite=20and=20React=20Router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Laurent Paoletti --- CHANGELOG.md | 6 + README.md | 2 +- docs/env.md | 4 +- docs/examples/conversations.values.yaml | 2 +- docs/system-requirements.md | 12 +- src/backend/core/authentication/views.py | 7 +- .../core/tests/authentication/test_views.py | 6 +- src/frontend/Dockerfile | 11 +- src/frontend/apps/conversations/.env | 4 +- .../apps/conversations/.env.development | 4 +- src/frontend/apps/conversations/.env.test | 2 +- src/frontend/apps/conversations/.gitignore | 5 +- .../apps/conversations/.prettierignore | 2 +- src/frontend/apps/conversations/README.md | 39 +- .../apps/conversations/conf/default.conf | 14 +- .../apps/conversations/eslint.config.mjs | 5 +- src/frontend/apps/conversations/index.html | 12 + .../apps/conversations/jest.config.ts | 33 - src/frontend/apps/conversations/jest.setup.ts | 4 - .../conversations/jest/mocks/ComponentMock.js | 5 - .../apps/conversations/jest/mocks/fileMock.js | 16 - .../apps/conversations/jest/mocks/svg.js | 3 - src/frontend/apps/conversations/next-env.d.ts | 6 - .../apps/conversations/next.config.js | 52 - src/frontend/apps/conversations/package.json | 28 +- src/frontend/apps/conversations/src/App.tsx | 97 + .../src/api/__tests__/config.test.ts | 3 +- .../src/api/__tests__/helpers.test.tsx | 4 +- .../apps/conversations/src/api/config.ts | 4 +- .../conversations/src/components/Link.tsx | 18 +- .../conversations/src/components/Loader.tsx | 19 +- .../src/components/__tests__/Link.test.tsx | 25 +- .../__tests__/QuickSearchResultItem.test.tsx | 4 +- .../conversations/src/core/AppProvider.tsx | 25 +- .../src/core/config/ConfigProvider.tsx | 9 +- .../config/__tests__/ConfigProvider.test.tsx | 17 +- .../apps/conversations/src/core/settings.ts | 3 +- .../apps/conversations/src/custom-next.d.ts | 29 - .../auth/__tests__/silentLogin.test.ts | 26 +- .../auth/components/ActivationPage.tsx | 12 +- .../src/features/auth/components/Auth.tsx | 30 +- .../auth/hooks/__tests__/useAuth.test.tsx | 13 +- .../src/features/auth/hooks/useAuth.tsx | 4 +- .../banner/__tests__/StatusBanner.test.tsx | 4 +- .../api/__tests__/useAssistantHealth.test.tsx | 6 +- .../src/features/chat/components/Chat.tsx | 6 +- .../features/chat/components/ChatError.tsx | 6 +- .../chat/components/ModelSelector.tsx | 8 +- .../features/chat/components/SourceItem.tsx | 3 +- .../components/__tests__/ChatError.test.tsx | 18 +- .../ImageProcessingUnavailableBanner.test.tsx | 4 +- .../components/__tests__/InputChat.test.tsx | 56 +- .../__tests__/InputChatAction.test.tsx | 22 +- .../__tests__/MessageBlock.test.tsx | 16 +- .../__tests__/MessageEnergyIndicator.test.tsx | 12 +- .../components/__tests__/MessageItem.test.tsx | 40 +- .../__tests__/ProjectWelcomeMessage.test.tsx | 4 +- .../components/__tests__/SourceItem.test.tsx | 24 +- .../__tests__/SuggestionCarousel.test.tsx | 10 +- .../__tests__/ToolInvocationItem.test.tsx | 4 +- .../hooks/__tests__/useFileDragDrop.test.tsx | 24 +- .../chat/hooks/__tests__/useFileUrls.test.tsx | 6 +- .../src/features/footer/Footer.tsx | 6 +- .../components/ButtonToggleLeftPanel.tsx | 7 +- .../src/features/header/components/Header.tsx | 18 +- .../features/header/components/LaGaufre.tsx | 27 +- .../features/home/components/HomeHeader.tsx | 6 +- .../components/ModalRemoveConversation.tsx | 7 +- .../ConversationItemActions.test.tsx | 10 +- .../LeftPanelConversationItem.test.tsx | 37 +- .../ModalRenameConversation.test.tsx | 33 +- .../components/left-panel/LeftPanel.tsx | 8 +- .../left-panel/LeftPanelConversations.tsx | 5 +- .../components/left-panel/LeftPanelHeader.tsx | 6 +- .../left-panel/LeftPanelProjectItem.tsx | 8 +- .../left-panel/LeftPanelProjects.tsx | 5 +- .../components/left-panel/LeftPanelSearch.tsx | 6 +- .../left-panel/LeftPanelSearchModal.tsx | 8 +- .../__tests__/LeftPanelProjectItem.test.tsx | 30 +- .../components/projects/ModalProjectForm.tsx | 6 +- .../projects/ModalRemoveProject.tsx | 7 +- .../__tests__/ModalProjectForm.test.tsx | 29 +- .../__tests__/ModalRemoveProject.test.tsx | 31 +- .../components/MaintenancePage.tsx | 9 +- .../onboarding/components/OnboardingModal.tsx | 5 +- .../__tests__/OnboardingButton.test.tsx | 25 +- .../conversations/src/{pages => }/globals.css | 2 +- .../src/layouts/__tests__/MainLayout.test.tsx | 18 +- src/frontend/apps/conversations/src/main.tsx | 14 + .../apps/conversations/src/pages/401.tsx | 21 +- .../apps/conversations/src/pages/403.tsx | 25 +- .../apps/conversations/src/pages/404.tsx | 25 +- .../apps/conversations/src/pages/_app.tsx | 62 - .../conversations/src/pages/_document.tsx | 13 - .../src/pages/activation/index.tsx | 11 +- .../src/pages/chat/[id]/index.tsx | 24 - .../src/pages/chat/conversation.tsx | 17 + .../conversations/src/pages/chat/index.tsx | 10 +- .../conversations/src/pages/home/index.tsx | 3 +- .../apps/conversations/src/pages/index.tsx | 3 - .../conversations/src/pages/login/index.tsx | 5 +- .../conversations/src/pages/unauthorized.tsx | 34 +- .../src/services/PosthogAnalytic.tsx | 21 +- .../src/stores/useSentryStore.tsx | 2 +- .../apps/conversations/src/tests/utils.tsx | 9 +- .../apps/conversations/src/types/next.ts | 5 - .../apps/conversations/src/vite-env.d.ts | 39 + .../apps/conversations/stylelint.config.js | 2 +- src/frontend/apps/conversations/tsconfig.json | 15 +- .../apps/conversations/vite.config.ts | 43 + .../apps/conversations/vitest.setup.ts | 7 + .../eslint-config-conversations/package.json | 1 - .../{next.mjs => vite.mjs} | 11 +- src/frontend/yarn.lock | 3775 ++++------------- .../common/values.conversations.yaml.gotmpl | 4 +- .../values.conversations.yaml.gotmpl | 2 +- 116 files changed, 1544 insertions(+), 3917 deletions(-) create mode 100644 src/frontend/apps/conversations/index.html delete mode 100644 src/frontend/apps/conversations/jest.config.ts delete mode 100644 src/frontend/apps/conversations/jest.setup.ts delete mode 100644 src/frontend/apps/conversations/jest/mocks/ComponentMock.js delete mode 100644 src/frontend/apps/conversations/jest/mocks/fileMock.js delete mode 100644 src/frontend/apps/conversations/jest/mocks/svg.js delete mode 100644 src/frontend/apps/conversations/next-env.d.ts delete mode 100644 src/frontend/apps/conversations/next.config.js create mode 100644 src/frontend/apps/conversations/src/App.tsx delete mode 100644 src/frontend/apps/conversations/src/custom-next.d.ts rename src/frontend/apps/conversations/src/{pages => }/globals.css (99%) create mode 100644 src/frontend/apps/conversations/src/main.tsx delete mode 100644 src/frontend/apps/conversations/src/pages/_app.tsx delete mode 100644 src/frontend/apps/conversations/src/pages/_document.tsx delete mode 100644 src/frontend/apps/conversations/src/pages/chat/[id]/index.tsx create mode 100644 src/frontend/apps/conversations/src/pages/chat/conversation.tsx delete mode 100644 src/frontend/apps/conversations/src/pages/index.tsx delete mode 100644 src/frontend/apps/conversations/src/types/next.ts create mode 100644 src/frontend/apps/conversations/src/vite-env.d.ts create mode 100644 src/frontend/apps/conversations/vite.config.ts create mode 100644 src/frontend/apps/conversations/vitest.setup.ts rename src/frontend/packages/eslint-config-conversations/{next.mjs => vite.mjs} (85%) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed9b311..7267d2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [unreleased next] + +### Changed + +- 🏗️(front) replace Next.js with Vite and React Router + ## [unreleased] ### Added diff --git a/README.md b/README.md index a0122d2e..a70037e4 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ docs ### Stack -Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [Vercel‘s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev). We thank the contributors of all these projects for their awesome work! +Conversations is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Vite](https://vite.dev/), [React Router](https://reactrouter.com/), [Vercel‘s AI SDK](https://ai-sdk.dev/) and [Pydantic AI](https://ai.pydantic.dev). We thank the contributors of all these projects for their awesome work! ### Gov ❤️ open source diff --git a/docs/env.md b/docs/env.md index 3574cf66..684ef4de 100644 --- a/docs/env.md +++ b/docs/env.md @@ -148,13 +148,13 @@ Example: docker build -f src/frontend/Dockerfile --target frontend-production --build-arg API_ORIGIN=https://mybackend.example.com conversations-frontend:latest ``` -If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/conversations/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_`. +If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/conversations/.env` and modify it. Alternatively, you can use the listed environment variables with the prefix `VITE_`. Example: ``` cd src/frontend/apps/conversations -NODE_ENV=production NEXT_PUBLIC_API_ORIGIN=https://mybackend.example.com yarn build +VITE_API_ORIGIN=https://mybackend.example.com yarn build ``` | Option | Description | default | diff --git a/docs/examples/conversations.values.yaml b/docs/examples/conversations.values.yaml index 10158eea..ba3da66f 100644 --- a/docs/examples/conversations.values.yaml +++ b/docs/examples/conversations.values.yaml @@ -94,7 +94,7 @@ backend: frontend: envVars: PORT: 8080 - NEXT_PUBLIC_API_ORIGIN: https://conversations.127.0.0.1.nip.io + VITE_API_ORIGIN: https://conversations.127.0.0.1.nip.io replicas: 1 diff --git a/docs/system-requirements.md b/docs/system-requirements.md index 9e1b819f..e335cb93 100644 --- a/docs/system-requirements.md +++ b/docs/system-requirements.md @@ -8,7 +8,7 @@ | **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests | | **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this | -Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated. +Memory is the first bottleneck; CPU matters only when Celery or the Vite build is saturated. > **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems. @@ -21,7 +21,7 @@ Memory is the first bottleneck; CPU matters only when Celery or the Next.js buil | Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) | | MinIO | **2 GB (dev) / 32 GB (prod)** | Pre-allocates 1–2 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) | | Django API | **0.8 – 1.5 GB** | Empirical in-house metrics | -| Next.js frontend | **0.5 – 1 GB** | Dev build chain | +| Vite frontend | **0.5 – 1 GB** | Dev build chain | | Nginx | **< 100 MB** | Static reverse-proxy footprint | [1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption" @@ -42,12 +42,12 @@ Production deployments differ significantly from development environments. The t | Redis | **256 MB – 2 GB** | Session storage and caching; scales with active user sessions | | Object Storage (optional) | **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO | | Django API (+ Celery) | **1 – 3 GB** | Production workloads with background tasks and higher concurrency | -| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead | +| Static Files (Nginx) | **< 200 MB** | Serves Vite build output and static assets; no development overhead | | Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving | ### Production Architecture Notes -- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed) +- **Frontend**: Uses pre-built Vite static assets served by Nginx (no Node.js runtime needed) - **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak - **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable - **Database**: Consider PostgreSQL clustering or managed database services for high availability @@ -83,7 +83,7 @@ Production deployments differ significantly from development environments. The t | Port | Service | |-----------|----------------------------| -| 3000 | Next.js | +| 3000 | Vite | | 8071 | Django | | 8080 | Keycloak | | 8083 | Nginx proxy | @@ -98,7 +98,7 @@ Production deployments differ significantly from development environments. The t > **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker. -**CPU** – budget one vCPU per busy container until Celery or Next.js builds saturate. +**CPU** – budget one vCPU per busy container until Celery or Vite builds saturate. **Disk** – SSD; add 10 GB extra for the Docker layer cache. diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py index b66f62a1..cb16eed9 100644 --- a/src/backend/core/authentication/views.py +++ b/src/backend/core/authentication/views.py @@ -9,10 +9,9 @@ from core.authentication.backends import OIDCRoleAccessDenied # Frontend path shown to users whose account is not allowed to access the app. -# Keep the trailing slash: the frontend is a Next.js static export with -# trailingSlash=true, so the canonical URL is /unauthorized/. Redirecting to the -# bare path makes the frontend nginx issue a directory redirect that leaks its -# internal listen port (e.g. :8080) and breaks behind the TLS ingress. +# The frontend is a client-routed single-page app: nginx serves index.html for +# this path and the router renders the access-denied page. The trailing slash is +# kept so links minted before the SPA migration keep resolving to the same URL. ACCESS_DENIED_PATH = "/unauthorized/" diff --git a/src/backend/core/tests/authentication/test_views.py b/src/backend/core/tests/authentication/test_views.py index a5d5c990..460cad7c 100644 --- a/src/backend/core/tests/authentication/test_views.py +++ b/src/backend/core/tests/authentication/test_views.py @@ -12,10 +12,8 @@ def test_callback_redirects_denied_user_to_unauthorized_page(rf, monkeypatch): """A role-denied user is redirected to the frontend access-denied page. - The target must keep its trailing slash: the frontend is a Next.js static - export with trailingSlash=true, so /unauthorized/ is the canonical URL. A - bare /unauthorized would make the frontend nginx issue a directory redirect - that leaks its internal port and breaks behind the TLS ingress. + The target keeps its trailing slash so links minted before the frontend + became a single-page app keep resolving to the same URL. """ def raise_denied(self, request): # pylint: disable=unused-argument diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index a11b51b1..a2e343a4 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -29,6 +29,11 @@ FROM frontend-deps AS conversations-dev WORKDIR /home/frontend/apps/conversations +# The container runs as the host user (DOCKER_USER) while node_modules is +# root-owned from the build above. Vite writes its dependency-optimization cache +# to node_modules/.vite, so that directory has to stay writable whatever the uid. +RUN rm -rf node_modules/.vite && chmod a+w node_modules + EXPOSE 3000 CMD [ "yarn", "dev"] @@ -40,10 +45,10 @@ FROM conversations AS conversations-builder WORKDIR /home/frontend/apps/conversations ARG API_ORIGIN -ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN} +ENV VITE_API_ORIGIN=${API_ORIGIN} ARG PRODUCT_NAME -ENV NEXT_PUBLIC_PRODUCT_NAME=${PRODUCT_NAME} +ENV VITE_PRODUCT_NAME=${PRODUCT_NAME} RUN yarn build @@ -62,7 +67,7 @@ ARG DOCKER_USER USER ${DOCKER_USER} COPY --from=conversations-builder \ - /home/frontend/apps/conversations/out \ + /home/frontend/apps/conversations/dist \ /usr/share/nginx/html COPY ./src/frontend/apps/conversations/conf/default.conf /etc/nginx/conf.d diff --git a/src/frontend/apps/conversations/.env b/src/frontend/apps/conversations/.env index f89a6e6f..965dc7c1 100644 --- a/src/frontend/apps/conversations/.env +++ b/src/frontend/apps/conversations/.env @@ -1,2 +1,2 @@ -NEXT_PUBLIC_API_ORIGIN= -NEXT_PUBLIC_PRODUCT_NAME= +VITE_API_ORIGIN= +VITE_PRODUCT_NAME= diff --git a/src/frontend/apps/conversations/.env.development b/src/frontend/apps/conversations/.env.development index 99a452e5..cea42efc 100644 --- a/src/frontend/apps/conversations/.env.development +++ b/src/frontend/apps/conversations/.env.development @@ -1,2 +1,2 @@ -NEXT_PUBLIC_API_ORIGIN=http://localhost:8071 -NEXT_PUBLIC_PRODUCT_NAME= +VITE_API_ORIGIN=http://localhost:8071 +VITE_PRODUCT_NAME= diff --git a/src/frontend/apps/conversations/.env.test b/src/frontend/apps/conversations/.env.test index 9a4d5142..eed05678 100644 --- a/src/frontend/apps/conversations/.env.test +++ b/src/frontend/apps/conversations/.env.test @@ -1 +1 @@ -NEXT_PUBLIC_API_ORIGIN=http://test.jest +VITE_API_ORIGIN=http://test.jest diff --git a/src/frontend/apps/conversations/.gitignore b/src/frontend/apps/conversations/.gitignore index 0abcbb52..8987a527 100644 --- a/src/frontend/apps/conversations/.gitignore +++ b/src/frontend/apps/conversations/.gitignore @@ -9,9 +9,8 @@ # testing /coverage -# next.js -/.next/ -/out/ +# vite +/dist/ # production /build diff --git a/src/frontend/apps/conversations/.prettierignore b/src/frontend/apps/conversations/.prettierignore index 15b20443..d6e87ad2 100644 --- a/src/frontend/apps/conversations/.prettierignore +++ b/src/frontend/apps/conversations/.prettierignore @@ -1,2 +1,2 @@ -next-env.d.ts +dist service-worker.js diff --git a/src/frontend/apps/conversations/README.md b/src/frontend/apps/conversations/README.md index c4033664..52f06d98 100644 --- a/src/frontend/apps/conversations/README.md +++ b/src/frontend/apps/conversations/README.md @@ -1,36 +1,33 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +This is the Conversations front-end: a [React](https://react.dev/) single-page app built with [Vite](https://vite.dev/) and routed with [React Router](https://reactrouter.com/) in declarative mode. ## Getting Started -First, run the development server: +Run the development server: ```bash -npm run dev -# or yarn dev -# or -pnpm dev -# or -bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Routes are declared in `src/App.tsx`; their page components live in `src/pages/`. -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. +## Commands -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! +```bash +yarn dev # development server (Vite) +yarn build # format/style/type checks, then a production build into dist/ +yarn start # serve the production build +yarn lint # tsc --noEmit + eslint +yarn test # Vitest +``` -## Deploy on Vercel +## Environment variables -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +Build-time variables must use the `VITE_` prefix and are read through +`import.meta.env`. They are set in `.env`, `.env.development` and `.env.test`: -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +| Variable | Description | +| ------------------- | ---------------------------------------------------------------- | +| `VITE_API_ORIGIN` | Backend origin; falls back to the current origin when left empty | +| `VITE_PRODUCT_NAME` | Product name shown in the UI | diff --git a/src/frontend/apps/conversations/conf/default.conf b/src/frontend/apps/conversations/conf/default.conf index ff047abf..33bd02ea 100644 --- a/src/frontend/apps/conversations/conf/default.conf +++ b/src/frontend/apps/conversations/conf/default.conf @@ -10,16 +10,10 @@ server { root /usr/share/nginx/html; + # Single-page app: every unknown path falls back to index.html so the + # client-side router can handle it. Unknown routes therefore return HTTP 200 + # and the app renders its own 404 page. location / { - try_files $uri index.html $uri/ =404; - } - - location ~ "^/chat/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$" { - try_files $uri /chat/[id]/index.html; - } - - error_page 404 /404.html; - location = /404.html { - internal; + try_files $uri $uri/ /index.html; } } diff --git a/src/frontend/apps/conversations/eslint.config.mjs b/src/frontend/apps/conversations/eslint.config.mjs index 8202741a..db8a9a00 100644 --- a/src/frontend/apps/conversations/eslint.config.mjs +++ b/src/frontend/apps/conversations/eslint.config.mjs @@ -1,6 +1,5 @@ -import { nextConfig } from 'eslint-config-conversations/next.mjs'; +import { viteConfig } from 'eslint-config-conversations/vite.mjs'; -export default nextConfig({ +export default viteConfig({ tsconfigRootDir: import.meta.dirname, - nextRootDir: import.meta.dirname, }); diff --git a/src/frontend/apps/conversations/index.html b/src/frontend/apps/conversations/index.html new file mode 100644 index 00000000..da948956 --- /dev/null +++ b/src/frontend/apps/conversations/index.html @@ -0,0 +1,12 @@ + + + + + + L'Assistant + + +
+ + + diff --git a/src/frontend/apps/conversations/jest.config.ts b/src/frontend/apps/conversations/jest.config.ts deleted file mode 100644 index 05bfb0f3..00000000 --- a/src/frontend/apps/conversations/jest.config.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Config } from 'jest'; -import nextJest from 'next/jest.js'; - -const createJestConfig = nextJest({ - dir: './', -}); - -// Add any custom config to be passed to Jest -const config: Config = { - coverageProvider: 'v8', - moduleNameMapper: { - '^@/docs/(.*)$': '/src/features/docs/$1', - '^@/(.*)$': '/src/$1', - }, - setupFilesAfterEnv: ['/jest.setup.ts'], - testEnvironment: 'jsdom', -}; - -const jestConfig = async () => { - const nextJestConfig = await createJestConfig(config)(); - return { - ...nextJestConfig, - moduleNameMapper: { - '\\.svg$': '/jest/mocks/svg.js', - '^.+\\.svg\\?url$': `/jest/mocks/fileMock.js`, - BlockNoteEditor: `/jest/mocks/ComponentMock.js`, - 'custom-blocks': `/jest/mocks/ComponentMock.js`, - ...nextJestConfig.moduleNameMapper, - }, - }; -}; - -export default jestConfig; diff --git a/src/frontend/apps/conversations/jest.setup.ts b/src/frontend/apps/conversations/jest.setup.ts deleted file mode 100644 index 564d8a6d..00000000 --- a/src/frontend/apps/conversations/jest.setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -import '@testing-library/jest-dom'; -import * as dotenv from 'dotenv'; - -dotenv.config({ path: './.env.test' }); diff --git a/src/frontend/apps/conversations/jest/mocks/ComponentMock.js b/src/frontend/apps/conversations/jest/mocks/ComponentMock.js deleted file mode 100644 index 812a08b1..00000000 --- a/src/frontend/apps/conversations/jest/mocks/ComponentMock.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -export const ComponentMock = () => { - return
My component mocked
; -}; diff --git a/src/frontend/apps/conversations/jest/mocks/fileMock.js b/src/frontend/apps/conversations/jest/mocks/fileMock.js deleted file mode 100644 index 28a24984..00000000 --- a/src/frontend/apps/conversations/jest/mocks/fileMock.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - src: '/img.jpg', - height: 40, - width: 40, - blurDataURL: 'data:image/png;base64,imagedata', -}; - -if ( - (typeof exports.default === 'function' || - (typeof exports.default === 'object' && exports.default !== null)) && - typeof exports.default.__esModule === 'undefined' -) { - Object.defineProperty(exports.default, '__esModule', { value: true }); - Object.assign(exports.default, exports); - module.exports = exports.default; -} diff --git a/src/frontend/apps/conversations/jest/mocks/svg.js b/src/frontend/apps/conversations/jest/mocks/svg.js deleted file mode 100644 index 0b7fc5b8..00000000 --- a/src/frontend/apps/conversations/jest/mocks/svg.js +++ /dev/null @@ -1,3 +0,0 @@ -const nameMock = 'svg'; -export default nameMock; -export const ReactComponent = 'svg'; diff --git a/src/frontend/apps/conversations/next-env.d.ts b/src/frontend/apps/conversations/next-env.d.ts deleted file mode 100644 index 7996d352..00000000 --- a/src/frontend/apps/conversations/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/src/frontend/apps/conversations/next.config.js b/src/frontend/apps/conversations/next.config.js deleted file mode 100644 index a9a4e806..00000000 --- a/src/frontend/apps/conversations/next.config.js +++ /dev/null @@ -1,52 +0,0 @@ -const crypto = require('crypto'); -const path = require('path'); - -const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8); - -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: 'export', - trailingSlash: true, - images: { - unoptimized: true, - }, - compiler: { - styledComponents: true, - }, - generateBuildId: () => buildId, - env: { - NEXT_PUBLIC_BUILD_ID: buildId, - }, - turbopack: { - root: path.resolve(__dirname, '../..'), - rules: { - '*.svg': { - loaders: ['@svgr/webpack'], - as: '*.js', - }, - }, - resolveAlias: { - 'micromark-extension-math': 'micromark-extension-llm-math', - '@gouvfr-lasuite/cunningham-react': '@gouvfr-lasuite/cunningham-react', - '@gouvfr-lasuite/ui-kit/node_modules/@gouvfr-lasuite/cunningham-react': - '@gouvfr-lasuite/cunningham-react', - }, - }, - webpack: (config) => { - config.resolve.alias = { - ...(config.resolve.alias || {}), - '@gouvfr-lasuite/cunningham-react': path.resolve( - __dirname, - '../../node_modules/@gouvfr-lasuite/cunningham-react', - ), - '@gouvfr-lasuite/ui-kit/node_modules/@gouvfr-lasuite/cunningham-react': - path.resolve( - __dirname, - '../../node_modules/@gouvfr-lasuite/cunningham-react', - ), - }; - return config; - }, -}; - -module.exports = nextConfig; diff --git a/src/frontend/apps/conversations/package.json b/src/frontend/apps/conversations/package.json index 5514c61b..7447f90b 100644 --- a/src/frontend/apps/conversations/package.json +++ b/src/frontend/apps/conversations/package.json @@ -3,17 +3,17 @@ "version": "0.0.19", "private": true, "scripts": { - "dev": "next dev", - "build": "prettier --check . && yarn stylelint && next build", + "dev": "vite", + "build": "prettier --check . && yarn stylelint && tsc --noEmit && vite 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", + "start": "vite preview", "lint": "tsc --noEmit && eslint src/", "lint:fix": "tsc --noEmit && eslint src/ --fix", "prettier": "prettier --write .", "stylelint": "stylelint \"**/*.css\"", - "test": "jest", - "test:watch": "jest --watch" + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@ai-sdk/react": "1.2.12", @@ -25,7 +25,7 @@ "@gouvfr-lasuite/cunningham-tokens": "^3.1.0", "@gouvfr-lasuite/integration": "1.0.3", "@gouvfr-lasuite/ui-kit": "0.23.2", - "@sentry/nextjs": "10.58.0", + "@sentry/react": "10.58.0", "@shikijs/rehype": "^4.2.0", "@tanstack/react-query": "5.101.0", "clsx": "2.1.1", @@ -36,7 +36,6 @@ "lottie-react": "^2.4.1", "luxon": "3.7.2", "micromark-extension-llm-math": "3.1.1-20250610", - "next": "16.2.7", "posthog-js": "1.399.1", "react": "19.2.6", "react-aria-components": "1.18.0", @@ -44,6 +43,7 @@ "react-i18next": "17.0.8", "react-intersection-observer": "10.0.3", "react-markdown": "10.1.0", + "react-router": "7.18.1", "rehype-katex": "7.0.1", "remark-gfm": "4.0.1", "remark-math": "6.0.0", @@ -53,30 +53,30 @@ "zustand": "5.0.14" }, "devDependencies": { - "@svgr/webpack": "8.1.0", "@tanstack/react-query-devtools": "5.101.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", - "@types/jest": "30.0.0", "@types/lodash": "4.17.24", "@types/luxon": "3.7.1", "@types/node": "*", "@types/react": "19.2.15", "@types/react-dom": "19.2.3", - "dotenv": "17.4.2", + "@vitejs/plugin-react": "6.0.3", + "@vitest/coverage-v8": "4.1.10", "eslint": "^9", "eslint-config-conversations": "*", - "eslint-config-next": "16.2.6", "fetch-mock": "9.11.0", - "jest": "30.4.2", - "jest-environment-jsdom": "30.4.1", + "jsdom": "29.1.1", "prettier": "3.8.4", "stylelint": "17.13.0", "stylelint-config-standard": "40.0.0", "stylelint-prettier": "5.0.3", - "typescript": "*" + "typescript": "*", + "vite": "8.1.5", + "vite-plugin-svgr": "5.2.0", + "vitest": "4.1.10" }, "resolutions": { "@types/react": "19.2.17", diff --git a/src/frontend/apps/conversations/src/App.tsx b/src/frontend/apps/conversations/src/App.tsx new file mode 100644 index 00000000..6a7e8f60 --- /dev/null +++ b/src/frontend/apps/conversations/src/App.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next'; +import { BrowserRouter, Outlet, Route, Routes } from 'react-router'; + +import { AppProvider, productName } from '@/core/'; +import { useCunninghamTheme } from '@/cunningham'; +import { MainLayout, PageLayout } from '@/layouts'; + +import Page401 from './pages/401'; +import Page403 from './pages/403'; +import Page404 from './pages/404'; +import ActivationPage from './pages/activation'; +import ChatPage from './pages/chat'; +import ChatConversationPage from './pages/chat/conversation'; +import HomePage from './pages/home'; +import LoginPage from './pages/login'; +import UnauthorizedPage from './pages/unauthorized'; + +const MainLayoutRoute = () => ( + + + +); + +const PageLayoutRoute = () => ( + + + +); + +const AppHead = () => { + const { t } = useTranslation(); + const { componentTokens } = useCunninghamTheme(); + const favicon = (componentTokens as Record).favicon as + | { 'png-light': string; 'png-dark': string } + | undefined; + + return ( + <> + {productName} + + + {favicon && ( + <> + + + + + )} + + ); +}; + +export function App() { + return ( + + + + + }> + } /> + } /> + } /> + + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/src/frontend/apps/conversations/src/api/__tests__/config.test.ts b/src/frontend/apps/conversations/src/api/__tests__/config.test.ts index cb9bf268..aa410624 100644 --- a/src/frontend/apps/conversations/src/api/__tests__/config.test.ts +++ b/src/frontend/apps/conversations/src/api/__tests__/config.test.ts @@ -10,7 +10,8 @@ describe('config', () => { }); it('uses env origin if available', () => { - process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com'; + vi.stubEnv('VITE_API_ORIGIN', 'https://env.example.com'); expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/'); + vi.unstubAllEnvs(); }); }); diff --git a/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx b/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx index e4706367..0ea1c3f2 100644 --- a/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx +++ b/src/frontend/apps/conversations/src/api/__tests__/helpers.test.tsx @@ -21,8 +21,8 @@ const createWrapper = () => { describe('helpers', () => { it('fetches and paginates correctly', async () => { - const mockAPI = jest - .fn, [{ page: number; query: string }]>() + const mockAPI = vi + .fn<(params: { page: number; query: string }) => Promise>() .mockResolvedValueOnce({ results: [{ id: 1 }], next: 'url?page=2', diff --git a/src/frontend/apps/conversations/src/api/config.ts b/src/frontend/apps/conversations/src/api/config.ts index 916585e6..b1926c41 100644 --- a/src/frontend/apps/conversations/src/api/config.ts +++ b/src/frontend/apps/conversations/src/api/config.ts @@ -2,14 +2,14 @@ * Returns the base URL for the backend API. * * Priority: - * 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined. + * 1. Uses VITE_API_ORIGIN from environment variables if defined. * 2. Falls back to the browser's window.location.origin if in a browser environment. * 3. Defaults to an empty string if executed in a non-browser environment without the env variable. * * @returns The backend base URL as a string. */ export const backendUrl = () => - process.env.NEXT_PUBLIC_API_ORIGIN || + import.meta.env.VITE_API_ORIGIN || (typeof window !== 'undefined' ? window.location.origin : ''); /** diff --git a/src/frontend/apps/conversations/src/components/Link.tsx b/src/frontend/apps/conversations/src/components/Link.tsx index 73fa86a3..63ed836b 100644 --- a/src/frontend/apps/conversations/src/components/Link.tsx +++ b/src/frontend/apps/conversations/src/components/Link.tsx @@ -1,5 +1,5 @@ -import { useRouter } from 'next/navigation'; import { memo, useCallback, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router'; import styled, { RuleSet } from 'styled-components'; interface StyledLinkProps { @@ -20,26 +20,24 @@ interface Props extends React.AnchorHTMLAttributes { } /** - * Link that avoids re-renders from Next.js router context. + * Link that avoids re-renders from the router context. * - * Use instead of Next.js `Link` in large lists (sidebars, tables) where + * Use instead of the router `Link` in large lists (sidebars, tables) where * router-triggered re-renders cause performance issues. * - * Warning: No automatic prefetching. - * */ export const StyledLink = memo(function StyledLink({ href, onClick, ...props }: Props) { - const router = useRouter(); - const routerRef = useRef(router); + const navigate = useNavigate(); + const navigateRef = useRef(navigate); // avoid rerenders useEffect(() => { - routerRef.current = router; - }, [router]); + navigateRef.current = navigate; + }, [navigate]); // Memoized click handler to maintain stable reference across re-renders. // Necessary for memo() to work correctly @@ -56,7 +54,7 @@ export const StyledLink = memo(function StyledLink({ e.preventDefault(); onClick?.(e); - routerRef.current.push(href); + void navigateRef.current(href); }, [href, onClick, props.target], ); diff --git a/src/frontend/apps/conversations/src/components/Loader.tsx b/src/frontend/apps/conversations/src/components/Loader.tsx index 609d0d07..1af65a56 100644 --- a/src/frontend/apps/conversations/src/components/Loader.tsx +++ b/src/frontend/apps/conversations/src/components/Loader.tsx @@ -1,17 +1,20 @@ -import dynamic from 'next/dynamic'; +import { Suspense, lazy } from 'react'; -const Lottie = dynamic(() => import('lottie-react'), { ssr: false }); import searchingAnimation from '@/assets/lotties/searching'; +const Lottie = lazy(() => import('lottie-react')); + export function Loader() { return (
- + + +
); } diff --git a/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx b/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx index fed9dc98..6e60d301 100644 --- a/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx +++ b/src/frontend/apps/conversations/src/components/__tests__/Link.test.tsx @@ -3,17 +3,16 @@ import userEvent from '@testing-library/user-event'; import { StyledLink } from '../Link'; -const mockPush = jest.fn(); +const mockNavigate = vi.hoisted(() => vi.fn()); -jest.mock('next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - }), +vi.mock('react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => mockNavigate, })); describe('StyledLink', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should render a link with the correct href', () => { @@ -23,18 +22,18 @@ describe('StyledLink', () => { expect(link).toHaveAttribute('href', '/test-path'); }); - it('should navigate using router.push on click', async () => { + it('should navigate on click', async () => { const user = userEvent.setup(); render(Test Link); const link = screen.getByRole('link', { name: 'Test Link' }); await user.click(link); - expect(mockPush).toHaveBeenCalledWith('/test-path'); + expect(mockNavigate).toHaveBeenCalledWith('/test-path'); }); it('should call onClick prop when clicked', async () => { - const handleClick = jest.fn(); + const handleClick = vi.fn(); const user = userEvent.setup(); render( @@ -54,7 +53,7 @@ describe('StyledLink', () => { const link = screen.getByRole('link', { name: 'Test Link' }); fireEvent.click(link, { metaKey: true }); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it('should allow default behavior when ctrl key is pressed', () => { @@ -63,7 +62,7 @@ describe('StyledLink', () => { const link = screen.getByRole('link', { name: 'Test Link' }); fireEvent.click(link, { ctrlKey: true }); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it('should allow default behavior when shift key is pressed', () => { @@ -72,7 +71,7 @@ describe('StyledLink', () => { const link = screen.getByRole('link', { name: 'Test Link' }); fireEvent.click(link, { shiftKey: true }); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it('should allow default behavior when alt key is pressed', () => { @@ -81,7 +80,7 @@ describe('StyledLink', () => { const link = screen.getByRole('link', { name: 'Test Link' }); fireEvent.click(link, { altKey: true }); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it('should pass additional props to the anchor element', () => { diff --git a/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx b/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx index 27995bb6..5d73a810 100644 --- a/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx +++ b/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx @@ -5,13 +5,13 @@ import { ChatConversation } from '@/features/chat/types'; import { QuickSearchResultItem } from '../QuickSearchResultItem'; -jest.mock('react-i18next', () => ({ +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' }, }), })); -jest.mock('i18next', () => ({ +vi.mock('i18next', () => ({ t: (key: string) => key, })); diff --git a/src/frontend/apps/conversations/src/core/AppProvider.tsx b/src/frontend/apps/conversations/src/core/AppProvider.tsx index 98b55c20..31f8447d 100644 --- a/src/frontend/apps/conversations/src/core/AppProvider.tsx +++ b/src/frontend/apps/conversations/src/core/AppProvider.tsx @@ -1,12 +1,13 @@ +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { QueryCache, QueryClient, QueryClientProvider, } from '@tanstack/react-query'; -import dynamic from 'next/dynamic'; import { useEffect } from 'react'; import { isAPIError } from '@/api'; +import { ToastProvider } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth'; import { useResponsiveStore } from '@/stores'; @@ -14,20 +15,6 @@ import { useResponsiveStore } from '@/stores'; import { ConfigProvider } from './config'; import { KEY_CONFIG } from './config/api/useConfig'; -// Client-only providers -const ToastProviderNoSSR = dynamic( - () => import('@/components').then((mod) => ({ default: mod.ToastProvider })), - { ssr: false, loading: () => null }, -); - -const CunninghamProviderNoSSR = dynamic( - () => - import('@gouvfr-lasuite/cunningham-react').then((mod) => ({ - default: mod.CunninghamProvider, - })), - { ssr: false }, -); - const isMaintenanceError = (error: unknown): boolean => isAPIError(error) && error.status === 503 && @@ -78,13 +65,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) { return ( - + - + {children} - + - + ); } diff --git a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx index 5192e9f2..e9cbb44e 100644 --- a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx @@ -1,5 +1,4 @@ import { Loader } from '@gouvfr-lasuite/cunningham-react'; -import Head from 'next/head'; import { PropsWithChildren, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -102,9 +101,11 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { return ( <> {conf?.FRONTEND_CSS_URL && ( - - - + )} {children} diff --git a/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx b/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx index 989ee037..7256b323 100644 --- a/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx +++ b/src/frontend/apps/conversations/src/core/config/__tests__/ConfigProvider.test.tsx @@ -1,22 +1,23 @@ import { render, waitFor } from '@testing-library/react'; import i18n from 'i18next'; +import type { Mock } from 'vitest'; import { AppWrapper } from '@/tests/utils'; import { ConfigProvider } from '../ConfigProvider'; import { useConfig } from '../api/useConfig'; -jest.mock('../api/useConfig', () => ({ - ...jest.requireActual('../api/useConfig'), - useConfig: jest.fn(), +vi.mock('../api/useConfig', async (importOriginal) => ({ + ...(await importOriginal()), + useConfig: vi.fn(), })); -jest.mock('@/features/auth', () => ({ - ...jest.requireActual('@/features/auth'), - useAuthQuery: jest.fn(() => ({ data: undefined })), +vi.mock('@/features/auth', async (importOriginal) => ({ + ...(await importOriginal()), + useAuthQuery: vi.fn(() => ({ data: undefined })), })); -const mockUseConfig = useConfig as jest.Mock; +const mockUseConfig = useConfig as Mock; const makeConfig = (overrides: Record = {}) => ({ data: { @@ -49,7 +50,7 @@ describe('ConfigProvider - initial language', () => { it('follows a non-French browser language over the instance default', async () => { await i18n.changeLanguage('de'); - const changeLanguageSpy = jest.spyOn(i18n, 'changeLanguage'); + const changeLanguageSpy = vi.spyOn(i18n, 'changeLanguage'); mockUseConfig.mockReturnValue(makeConfig({ LANGUAGE_CODE: 'fr-fr' })); render(app, { wrapper: AppWrapper }); diff --git a/src/frontend/apps/conversations/src/core/settings.ts b/src/frontend/apps/conversations/src/core/settings.ts index bbe16e6a..a95a0ee5 100644 --- a/src/frontend/apps/conversations/src/core/settings.ts +++ b/src/frontend/apps/conversations/src/core/settings.ts @@ -1,2 +1 @@ -export const productName = - process.env.NEXT_PUBLIC_PRODUCT_NAME || "L'Assistant"; +export const productName = import.meta.env.VITE_PRODUCT_NAME || "L'Assistant"; diff --git a/src/frontend/apps/conversations/src/custom-next.d.ts b/src/frontend/apps/conversations/src/custom-next.d.ts deleted file mode 100644 index 097aba3d..00000000 --- a/src/frontend/apps/conversations/src/custom-next.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -declare module '*.svg' { - import * as React from 'react'; - - const ReactComponent: React.FunctionComponent< - React.SVGProps & { - title?: string; - } - >; - - export default ReactComponent; -} - -declare module '*.svg?url' { - const content: { - src: string; - width: number; - height: number; - blurWidth: number; - blurHeight: number; - }; - export default content; -} - -namespace NodeJS { - interface ProcessEnv { - NEXT_PUBLIC_PRODUCT_NAME?: string; - NEXT_PUBLIC_API_ORIGIN?: string; - } -} diff --git a/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts b/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts index 4a17488c..91b31df5 100644 --- a/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts +++ b/src/frontend/apps/conversations/src/features/auth/__tests__/silentLogin.test.ts @@ -2,24 +2,24 @@ import { navigate } from '@/utils/system'; import { attemptSilentLogin, canAttemptSilentLogin } from '../silentLogin'; -jest.mock('@/utils/system', () => ({ - ...jest.requireActual('@/utils/system'), - navigate: jest.fn(), +vi.mock('@/utils/system', async (importOriginal) => ({ + ...(await importOriginal()), + navigate: vi.fn(), })); -const mockNavigate = jest.mocked(navigate); +const mockNavigate = vi.mocked(navigate); const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'; describe('silentLogin', () => { beforeEach(() => { localStorage.clear(); - jest.useFakeTimers(); + vi.useFakeTimers(); mockNavigate.mockClear(); }); afterEach(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); describe('canAttemptSilentLogin', () => { @@ -28,25 +28,25 @@ describe('silentLogin', () => { }); it('returns false within the retry interval', () => { - jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); attemptSilentLogin(30); - jest.setSystemTime(new Date('2026-01-01T00:00:15Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:15Z')); expect(canAttemptSilentLogin()).toBe(false); }); it('returns true after the retry interval expires', () => { - jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); attemptSilentLogin(30); - jest.setSystemTime(new Date('2026-01-01T00:00:31Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:31Z')); expect(canAttemptSilentLogin()).toBe(true); }); }); describe('attemptSilentLogin', () => { it('sets the retry throttle in localStorage', () => { - jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); attemptSilentLogin(30); const stored = localStorage.getItem(SILENT_LOGIN_RETRY_KEY); @@ -64,11 +64,11 @@ describe('silentLogin', () => { }); it('does nothing if retry is not allowed', () => { - jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); attemptSilentLogin(30); mockNavigate.mockClear(); - jest.setSystemTime(new Date('2026-01-01T00:00:10Z')); + vi.setSystemTime(new Date('2026-01-01T00:00:10Z')); attemptSilentLogin(30); expect(mockNavigate).not.toHaveBeenCalled(); diff --git a/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx b/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx index 27e97a65..b7ec1d09 100644 --- a/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx +++ b/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx @@ -1,7 +1,7 @@ import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; import { css } from 'styled-components'; import { fetchAPI } from '@/api'; @@ -35,7 +35,7 @@ export const ActivationPage = () => { const { data: activationStatus, refetch } = useActivationStatus(); const { mutate: registerNotification } = useRegisterNotification(); const { isSmallMobile, isDesktop } = useResponsiveStore(); - const router = useRouter(); + const navigate = useNavigate(); const { authenticated, user } = useAuth(); const IconKey = () => { @@ -80,9 +80,9 @@ export const ActivationPage = () => { useEffect(() => { // Redirect to home if user is authenticated and already activated if (authenticated && activationStatus?.is_activated) { - void router.push('/'); + void navigate('/'); } - }, [authenticated, activationStatus, router]); + }, [authenticated, activationStatus, navigate]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -90,7 +90,7 @@ export const ActivationPage = () => { // Verify user is still authenticated before submitting if (!authenticated) { - void router.push('/'); + void navigate('/'); return; } @@ -144,7 +144,7 @@ export const ActivationPage = () => { const handleNotificationRegister = () => { // Verify user is still authenticated before registering if (!authenticated) { - void router.push('/'); + void navigate('/'); return; } diff --git a/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx b/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx index e761fd9c..6ee869cb 100644 --- a/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx +++ b/src/frontend/apps/conversations/src/features/auth/components/Auth.tsx @@ -1,5 +1,5 @@ -import { useRouter } from 'next/router'; import { PropsWithChildren } from 'react'; +import { Navigate, useLocation } from 'react-router'; import { Box, Loader } from '@/components'; import { useConfig } from '@/core'; @@ -13,7 +13,10 @@ import { getAuthUrl, gotoLogin } from '../utils'; export const Auth = ({ children }: PropsWithChildren) => { const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } = useAuth(); - const { replace, pathname } = useRouter(); + const location = useLocation(); + // URLs may carry a trailing slash (the previous static export produced them), + // so normalise before comparing against the route paths below. + const pathname = location.pathname.replace(/(.)\/$/, '$1'); const { data: config, isLoading: isConfigLoading } = useConfig(); const { data: activationStatus, isLoading: isActivationLoading } = useActivationStatus(); @@ -33,12 +36,7 @@ export const Auth = ({ children }: PropsWithChildren) => { if (authenticated) { const authUrl = getAuthUrl(); if (authUrl) { - void replace(authUrl); - return ( - - - - ); + return ; } } @@ -57,7 +55,7 @@ export const Auth = ({ children }: PropsWithChildren) => { if (config?.FRONTEND_SILENT_LOGIN_ENABLED && canAttemptSilentLogin()) { attemptSilentLogin(30); } else if (config?.FRONTEND_HOMEPAGE_FEATURE_ENABLED) { - void replace(HOME_URL); + return ; } else { gotoLogin(); } @@ -72,12 +70,7 @@ export const Auth = ({ children }: PropsWithChildren) => { * If the user is authenticated and the path is the home page, we redirect to the index. */ if (pathname === HOME_URL && authenticated) { - void replace('/'); - return ( - - - - ); + return ; } /** @@ -100,12 +93,7 @@ export const Auth = ({ children }: PropsWithChildren) => { // If activation is required but user is not activated, redirect to activation page if (activationStatus && !activationStatus.is_activated) { - void replace('/activation'); - return ( - - - - ); + return ; } } diff --git a/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx b/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx index e41166a2..751888e9 100644 --- a/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx +++ b/src/frontend/apps/conversations/src/features/auth/hooks/__tests__/useAuth.test.tsx @@ -7,7 +7,7 @@ import { AppWrapper } from '@/tests/utils'; import { useAuth } from '../useAuth'; -const trackEventMock = jest.fn(); +const trackEventMock = vi.fn(); const flag = true; class TestAnalytic extends AbstractAnalytic { public constructor() { @@ -31,19 +31,16 @@ class TestAnalytic extends AbstractAnalytic { } } -jest.mock('next/router', () => ({ - ...jest.requireActual('next/router'), - useRouter: () => ({ - pathname: '/dashboard', - replace: jest.fn(), - }), +vi.mock('react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useLocation: () => ({ pathname: '/dashboard' }), })); const dummyUser = { id: '123', email: 'test@example.com', sub: 'test-sub' }; describe('useAuth hook - trackEvent effect', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); fetchMock.restore(); }); diff --git a/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx b/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx index 8f24362b..7c66ca67 100644 --- a/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx +++ b/src/frontend/apps/conversations/src/features/auth/hooks/useAuth.tsx @@ -1,5 +1,5 @@ -import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; import { useAnalytics } from '@/libs'; @@ -14,7 +14,7 @@ const regexpUrlsAuth = [ export const useAuth = () => { const { data: user, ...authStates } = useAuthQuery(); - const { pathname } = useRouter(); + const { pathname } = useLocation(); const { trackEvent } = useAnalytics(); const [hasTracked, setHasTracked] = useState(authStates.isFetched); const [pathAllowed, setPathAllowed] = useState( diff --git a/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx b/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx index 99a5d5a1..84e0982e 100644 --- a/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx +++ b/src/frontend/apps/conversations/src/features/banner/__tests__/StatusBanner.test.tsx @@ -4,9 +4,9 @@ import { AppWrapper } from '@/tests/utils'; import { StatusBanner } from '../StatusBanner'; -const mockUseConfig = jest.fn(); +const mockUseConfig = vi.fn(); -jest.mock('@/core/config', () => ({ +vi.mock('@/core/config', () => ({ useConfig: () => mockUseConfig(), })); diff --git a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx index 6f50b751..bbfacf05 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useAssistantHealth.test.tsx @@ -95,7 +95,7 @@ describe('useAssistantHealth', () => { }); it('polls the endpoint every 60 seconds', async () => { - jest.useFakeTimers(); + vi.useFakeTimers(); fetchMock.get(`${API_BASE}assistant-health/`, { status: 200, @@ -115,7 +115,7 @@ describe('useAssistantHealth', () => { expect(callsBefore).toBeGreaterThanOrEqual(1); await act(async () => { - jest.advanceTimersByTime(60_000); + vi.advanceTimersByTime(60_000); await Promise.resolve(); }); @@ -123,6 +123,6 @@ describe('useAssistantHealth', () => { expect(fetchMock.calls().length).toBeGreaterThan(callsBefore), ); - jest.useRealTimers(); + vi.useRealTimers(); }); }); diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx index a8bc2fe0..918b413f 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -2,7 +2,6 @@ import { Message, SourceUIPart } from '@ai-sdk/ui-utils'; import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { InfiniteData, useQueryClient } from '@tanstack/react-query'; import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you -import { useRouter } from 'next/router'; import React, { useCallback, useEffect, @@ -12,6 +11,7 @@ import React, { } from 'react'; import type { ChangeEvent, FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Box, Icon, Loader, Text } from '@/components'; @@ -141,7 +141,7 @@ export const Chat = ({ setSelectedModelHrid(model.hrid); }; - const router = useRouter(); + const navigate = useNavigate(); const [files, setFiles] = useState(null); const [isUploadingFiles, setIsUploadingFiles] = useState(false); // Project of an already-loaded conversation (new chats use pendingProjectId). @@ -865,7 +865,7 @@ export const Chat = ({ setProjectId(null); setConversationId(data.id); // Update the URL to /chat/[id]/ - void router.push(`/chat/${data.id}/`); + void navigate(`/chat/${data.id}/`); // After setting the conversationId, submit the pending message setTimeout(() => { if (pendingFirstMessage) { diff --git a/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx b/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx index 526e7920..95d386f5 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx @@ -1,6 +1,6 @@ import { Button } from '@gouvfr-lasuite/cunningham-react'; -import { useRouter } from 'next/router'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; import { Box, Icon, Text } from '@/components'; import { useConfig } from '@/core'; @@ -28,7 +28,7 @@ export const ChatError = ({ onRetry, }: ChatErrorProps) => { const { t } = useTranslation(); - const router = useRouter(); + const navigate = useNavigate(); const { data: config } = useConfig(); const statusPageUrl = config?.STATUS_PAGE_URL; const isProviderError = errorType !== 'generic'; @@ -111,7 +111,7 @@ export const ChatError = ({ color="brand" variant="bordered" onClick={() => { - void router.push('/'); + void navigate('/'); }} icon={} > diff --git a/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx b/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx index a0700baf..dd2f3fff 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx @@ -1,5 +1,4 @@ import { Button } from '@gouvfr-lasuite/cunningham-react'; -import Image from 'next/image'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -57,11 +56,14 @@ export const ModelSelector = ({ position: relative; `} > - {model.human_readable_name} diff --git a/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx b/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx index 3e7f7e27..443546d0 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/SourceItem.tsx @@ -1,4 +1,3 @@ -import Image from 'next/image'; import React, { useEffect, useState } from 'react'; import { Box, StyledLink } from '@/components'; @@ -136,7 +135,7 @@ export const SourceItem: React.FC = ({ url, metadata }) => { return ( - Favicon ({ - useRouter: () => ({ push: jest.fn() }), -})); - -jest.mock('@/core', () => ({ +vi.mock('@/core', () => ({ useConfig: () => ({ data: { STATUS_PAGE_URL: mockStatusPageUrl }, }), @@ -26,7 +22,7 @@ describe('ChatError', () => { , { wrapper: AppWrapper }, ); @@ -42,7 +38,7 @@ describe('ChatError', () => { , { wrapper: AppWrapper }, ); @@ -60,7 +56,7 @@ describe('ChatError', () => { , { wrapper: AppWrapper }, ); @@ -80,7 +76,7 @@ describe('ChatError', () => { , { wrapper: AppWrapper }, ); @@ -100,7 +96,7 @@ describe('ChatError', () => { , { wrapper: AppWrapper }, ); @@ -121,7 +117,7 @@ describe('ChatError', () => { , { wrapper: AppWrapper }, ); diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx index f53b7a37..22244047 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ImageProcessingUnavailableBanner.test.tsx @@ -9,7 +9,7 @@ import { ImageProcessingUnavailableBanner } from '../ImageProcessingUnavailableB describe('ImageProcessingUnavailableBanner', () => { it('renders the headline and calls onDismiss when the user clicks the close button', async () => { const user = userEvent.setup(); - const onDismiss = jest.fn(); + const onDismiss = vi.fn(); render(, { wrapper: AppWrapper, @@ -29,7 +29,7 @@ describe('ImageProcessingUnavailableBanner', () => { it('opens a modal with the explanation when "More info" is clicked', async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: AppWrapper, }); diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx index 96caf7bf..6b58dac8 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/InputChat.test.tsx @@ -7,42 +7,42 @@ import { AppWrapper } from '@/tests/utils'; import { InputChat } from '../InputChat'; // Mock stores and hooks -jest.mock('@/stores', () => ({ +vi.mock('@/stores', () => ({ useResponsiveStore: () => ({ isDesktop: true, isMobile: false, }), })); -const mockUseConfig = jest.fn(); +const mockUseConfig = vi.fn(); -jest.mock('@/core', () => ({ +vi.mock('@/core', () => ({ useConfig: () => mockUseConfig(), useFeatureEnabled: () => true, })); -jest.mock('@/components/ToastProvider', () => ({ +vi.mock('@/components/ToastProvider', () => ({ useToast: () => ({ - showToast: jest.fn(), + showToast: vi.fn(), }), })); -jest.mock('@/features/chat/hooks/useFileDragDrop', () => ({ +vi.mock('@/features/chat/hooks/useFileDragDrop', () => ({ useFileDragDrop: () => ({ isDragActive: false, }), })); -jest.mock('@/features/chat/hooks/useFileUrls', () => ({ +vi.mock('@/features/chat/hooks/useFileUrls', () => ({ useFileUrls: () => new Map(), })); // Mock child components -jest.mock('../InputChatAction', () => ({ +vi.mock('../InputChatAction', () => ({ InputChatActions: () =>
Actions
, })); -jest.mock('../SuggestionCarousel', () => ({ +vi.mock('../SuggestionCarousel', () => ({ SuggestionCarousel: ({ blocked, banners, @@ -59,41 +59,41 @@ jest.mock('../SuggestionCarousel', () => ({ ), })); -jest.mock('../WelcomeMessage', () => ({ +vi.mock('../WelcomeMessage', () => ({ WelcomeMessage: () =>
Welcome
, })); -jest.mock('../AttachmentList', () => ({ +vi.mock('../AttachmentList', () => ({ AttachmentList: () =>
Attachments
, })); -jest.mock('../ScrollDown', () => ({ +vi.mock('../ScrollDown', () => ({ ScrollDown: () =>
Scroll Down
, })); -jest.mock('../../assets/files.svg', () => () => ( - -)); +vi.mock('../../assets/files.svg', () => ({ + default: () => , +})); -const mockUseAssistantHealth = jest.fn(); +const mockUseAssistantHealth = vi.fn(); -jest.mock('@/features/chat/api/useAssistantHealth', () => ({ +vi.mock('@/features/chat/api/useAssistantHealth', () => ({ useAssistantHealth: () => mockUseAssistantHealth(), })); const defaultProps = { messagesLength: 0, input: '', - handleInputChange: jest.fn(), - handleSubmit: jest.fn(), + handleInputChange: vi.fn(), + handleSubmit: vi.fn(), status: 'ready' as const, files: null, - setFiles: jest.fn(), + setFiles: vi.fn(), }; describe('InputChat', () => { beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); mockUseAssistantHealth.mockReturnValue({ data: { banners: [], @@ -157,7 +157,7 @@ describe('InputChat', () => { it('should call handleInputChange when typing', async () => { const user = userEvent.setup(); - const handleInputChange = jest.fn(); + const handleInputChange = vi.fn(); render( , { wrapper: AppWrapper }, @@ -203,7 +203,7 @@ describe('InputChat', () => { it('should not submit form when pressing Enter and status is streaming', async () => { const user = userEvent.setup(); - const handleSubmit = jest.fn((e) => e.preventDefault()); + const handleSubmit = vi.fn((e) => e.preventDefault()); render( { it('should not submit form when pressing Enter and status is submitted', async () => { const user = userEvent.setup(); - const handleSubmit = jest.fn((e) => e.preventDefault()); + const handleSubmit = vi.fn((e) => e.preventDefault()); render( { it('should submit form when pressing Enter', async () => { const user = userEvent.setup(); - const handleSubmit = jest.fn((e) => e.preventDefault()); + const handleSubmit = vi.fn((e) => e.preventDefault()); render(, { wrapper: AppWrapper, }); @@ -268,7 +268,7 @@ describe('InputChat', () => { it('should not submit form when pressing Shift+Enter', async () => { const user = userEvent.setup(); - const handleSubmit = jest.fn((e) => e.preventDefault()); + const handleSubmit = vi.fn((e) => e.preventDefault()); render(, { wrapper: AppWrapper, }); @@ -306,7 +306,7 @@ describe('InputChat', () => { it('should not submit form when pressing Enter during an active cooldown', async () => { const user = userEvent.setup(); - const handleSubmit = jest.fn((e) => e.preventDefault()); + const handleSubmit = vi.fn((e) => e.preventDefault()); render( { it('should submit form when pressing Enter once the cooldown is past', async () => { const user = userEvent.setup(); - const handleSubmit = jest.fn((e) => e.preventDefault()); + const handleSubmit = vi.fn((e) => e.preventDefault()); render( ({ +vi.mock('../ModelSelector', () => ({ ModelSelector: ({ onModelSelect }: { onModelSelect: () => void }) => ( -