From 3aa2cf87f70c2729dbdf13b6472471bf6f28846a Mon Sep 17 00:00:00 2001 From: Laurent Paoletti Date: Mon, 16 Mar 2026 11:49:27 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20projects=20management=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Laurent Paoletti --- CHANGELOG.md | 1 + .../icons/uikit-custom/terminal-filled.svg | 3 + .../src/components/InfiniteScroll.tsx | 2 +- .../src/components/SideModal.tsx | 2 +- .../src/components/TextErrors.tsx | 2 +- .../__tests__/QuickSearchResultItem.test.tsx | 59 ++++ .../conversations/src/core/AppProvider.tsx | 2 +- .../src/core/config/ConfigProvider.tsx | 2 +- .../auth/components/ActivationPage.tsx | 2 +- .../features/auth/components/ButtonLogin.tsx | 2 +- .../api/__tests__/useConversations.test.tsx | 37 +++ .../features/chat/api/useConversations.tsx | 2 +- .../chat/components/AttachmentList.tsx | 2 +- .../src/features/chat/components/Chat.tsx | 2 +- .../features/chat/components/ChatError.tsx | 2 +- .../chat/components/FeedbackButtons.tsx | 2 +- .../chat/components/InputChatAction.tsx | 2 +- .../chat/components/ModelSelector.tsx | 2 +- .../chat/components/ProjectWelcomeMessage.tsx | 78 +++++ .../features/chat/components/ScrollDown.tsx | 2 +- .../features/chat/components/SendButton.tsx | 2 +- .../__tests__/MessageBlock.test.tsx | 2 +- .../components/__tests__/MessageItem.test.tsx | 2 +- .../__tests__/ProjectWelcomeMessage.test.tsx | 101 +++++++ .../chat/stores/usePendingChatStore.ts | 6 +- .../conversations/src/features/chat/types.tsx | 1 + .../src/features/feedback/Feedback.tsx | 2 +- .../components/ButtonToggleLeftPanel.tsx | 2 +- .../header/components/ButtonTogglePanel.tsx | 2 +- .../src/features/header/components/Header.tsx | 2 +- .../features/header/components/LaGaufre.tsx | 2 +- .../features/home/components/HomeBanner.tsx | 4 +- .../left-panel/components/ConversationRow.tsx | 2 +- .../components/LeftPanelContent.tsx | 1 - .../components/LeftPanelHeaderActions.tsx | 2 +- .../components/LeftPanelProjectItem.tsx | 17 +- .../components/LeftPanelProjects.tsx | 2 +- .../components/LeftPanelSearchModal.tsx | 2 +- .../components/ModalCreateProject.tsx | 16 +- .../components/ModalIconColorPicker.tsx | 2 +- .../components/ModalProjectSettings.tsx | 16 +- .../components/ModalRemoveConversation.tsx | 2 +- .../components/ModalRemoveProject.tsx | 23 +- .../components/ModalRenameConversation.tsx | 7 +- .../components/ProjectItemActions.tsx | 10 +- .../ModalRenameConversation.test.tsx | 2 +- .../components/left-panel/LeftPanel.tsx | 129 +++++++++ .../left-panel/LeftPanelContent.tsx | 28 ++ .../left-panel/LeftPanelConversationItem.tsx | 35 +++ .../left-panel/LeftPanelConversations.tsx | 60 ++++ .../components/left-panel/LeftPanelHeader.tsx | 68 +++++ .../left-panel/LeftPanelHeaderActions.tsx | 74 +++++ .../left-panel/LeftPanelMenuItem.tsx | 69 +++++ .../left-panel/LeftPanelProjectItem.tsx | 197 +++++++++++++ .../left-panel/LeftPanelProjects.tsx | 56 ++++ .../components/left-panel/LeftPanelSearch.tsx | 82 ++++++ .../left-panel/LeftPanelSearchModal.tsx | 147 ++++++++++ .../__tests__/LeftPanelProjectItem.test.tsx | 115 ++++++++ .../projects/ModalIconColorPicker.tsx | 144 ++++++++++ .../components/projects/ModalProjectForm.tsx | 272 ++++++++++++++++++ .../projects/ModalRemoveProject.tsx | 91 ++++++ .../projects/ProjectItemActions.tsx | 87 ++++++ .../__tests__/ModalProjectForm.test.tsx | 205 +++++++++++++ .../__tests__/ModalRemoveProject.test.tsx | 143 +++++++++ .../components/projects/project-constants.tsx | 63 ++++ .../src/features/left-panel/index.ts | 1 - .../settings/components/SettingsButton.tsx | 2 +- .../settings/components/SettingsModal.tsx | 2 +- .../apps/conversations/src/pages/401.tsx | 2 +- .../apps/conversations/src/pages/403.tsx | 2 +- .../apps/conversations/src/pages/404.tsx | 2 +- .../apps/conversations/src/pages/_app.tsx | 3 +- .../apps/conversations/src/tests/utils.tsx | 4 +- .../app-conversations/projects.spec.ts | 156 ++++++++++ 74 files changed, 2593 insertions(+), 86 deletions(-) create mode 100644 src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg create mode 100644 src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx create mode 100644 src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx create mode 100644 src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx create mode 100644 src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelMenuItem.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjects.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearch.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearchModal.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/left-panel/__tests__/LeftPanelProjectItem.test.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalIconColorPicker.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalProjectForm.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalRemoveProject.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/ProjectItemActions.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/__tests__/ModalProjectForm.test.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/__tests__/ModalRemoveProject.test.tsx create mode 100644 src/frontend/apps/conversations/src/features/left-panel/components/projects/project-constants.tsx create mode 100644 src/frontend/apps/e2e/__tests__/app-conversations/projects.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ceb02..3c2845d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(back) add projects with custom LLM instructions +- ✨(front) projects management UI ## [0.0.14] - 2026-03-11 diff --git a/src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg new file mode 100644 index 0000000..cefb124 --- /dev/null +++ b/src/frontend/apps/conversations/src/assets/icons/uikit-custom/terminal-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx b/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx index 497ec5b..e9fbade 100644 --- a/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx +++ b/src/frontend/apps/conversations/src/components/InfiniteScroll.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { PropsWithChildren } from 'react'; import { useTranslation } from 'react-i18next'; import { InView } from 'react-intersection-observer'; diff --git a/src/frontend/apps/conversations/src/components/SideModal.tsx b/src/frontend/apps/conversations/src/components/SideModal.tsx index db6f417..7f69f72 100644 --- a/src/frontend/apps/conversations/src/components/SideModal.tsx +++ b/src/frontend/apps/conversations/src/components/SideModal.tsx @@ -1,4 +1,4 @@ -import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { ComponentPropsWithRef, PropsWithChildren } from 'react'; import { createGlobalStyle } from 'styled-components'; diff --git a/src/frontend/apps/conversations/src/components/TextErrors.tsx b/src/frontend/apps/conversations/src/components/TextErrors.tsx index 095984c..4d2cc3f 100644 --- a/src/frontend/apps/conversations/src/components/TextErrors.tsx +++ b/src/frontend/apps/conversations/src/components/TextErrors.tsx @@ -1,4 +1,4 @@ -import { Alert, VariantType } from '@openfun/cunningham-react'; +import { Alert, VariantType } from '@gouvfr-lasuite/cunningham-react'; import { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; 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 new file mode 100644 index 0000000..6588634 --- /dev/null +++ b/src/frontend/apps/conversations/src/components/quick-search/__tests__/QuickSearchResultItem.test.tsx @@ -0,0 +1,59 @@ +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; +import { render, screen } from '@testing-library/react'; + +import { ChatConversation } from '@/features/chat/types'; + +import { QuickSearchResultItem } from '../QuickSearchResultItem'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})); +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +const renderWithProviders = (component: React.ReactNode) => + render({component}); + +const makeConversation = ( + overrides?: Partial, +): ChatConversation => ({ + id: 'conv-1', + title: 'Test conversation', + messages: [], + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-05T00:00:00Z', + ...overrides, +}); + +describe('QuickSearchResultItem', () => { + it('renders conversation title and relative date when conversation has a project', () => { + const conversation = makeConversation({ + project: 'proj-1', + }); + + renderWithProviders(); + + expect(screen.getByText('Test conversation')).toBeInTheDocument(); + }); + + it('renders relative date when conversation has no project', () => { + const conversation = makeConversation({ project: null }); + + renderWithProviders(); + + expect(screen.getByText('Test conversation')).toBeInTheDocument(); + expect(screen.queryByText('\u2022')).not.toBeInTheDocument(); + }); + + it('shows "Untitled conversation" when title is empty', () => { + const conversation = makeConversation({ title: '' }); + + renderWithProviders(); + + expect(screen.getByText('Untitled conversation')).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/conversations/src/core/AppProvider.tsx b/src/frontend/apps/conversations/src/core/AppProvider.tsx index a3d837d..f28f3a9 100644 --- a/src/frontend/apps/conversations/src/core/AppProvider.tsx +++ b/src/frontend/apps/conversations/src/core/AppProvider.tsx @@ -16,7 +16,7 @@ const ToastProviderNoSSR = dynamic( const CunninghamProviderNoSSR = dynamic( () => - import('@openfun/cunningham-react').then((mod) => ({ + import('@gouvfr-lasuite/cunningham-react').then((mod) => ({ default: mod.CunninghamProvider, })), { ssr: false }, diff --git a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx index 6694f6b..743e960 100644 --- a/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/conversations/src/core/config/ConfigProvider.tsx @@ -1,4 +1,4 @@ -import { Loader } from '@openfun/cunningham-react'; +import { Loader } from '@gouvfr-lasuite/cunningham-react'; import Head from 'next/head'; import { PropsWithChildren, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; 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 d8db9c8..360f2cc 100644 --- a/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx +++ b/src/frontend/apps/conversations/src/features/auth/components/ActivationPage.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx b/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx index f3db015..57dd524 100644 --- a/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx +++ b/src/frontend/apps/conversations/src/features/auth/components/ButtonLogin.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; diff --git a/src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx new file mode 100644 index 0000000..6f140c0 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/api/__tests__/useConversations.test.tsx @@ -0,0 +1,37 @@ +import fetchMock from 'fetch-mock'; + +import { getConversations } from '../useConversations'; + +const API_BASE = 'http://test.jest/api/v1.0/'; + +describe('getConversations', () => { + beforeEach(() => { + fetchMock.restore(); + }); + + it('sends project=none when title is not provided', async () => { + fetchMock.get(`begin:${API_BASE}chats/`, { + status: 200, + body: { count: 0, results: [], next: null, previous: null }, + }); + + await getConversations({ page: 1 }); + + const url = fetchMock.lastUrl()!; + expect(url).toContain('project=none'); + expect(url).not.toContain('title='); + }); + + it('omits project param when title is provided', async () => { + fetchMock.get(`begin:${API_BASE}chats/`, { + status: 200, + body: { count: 0, results: [], next: null, previous: null }, + }); + + await getConversations({ page: 1, title: 'search term' }); + + const url = fetchMock.lastUrl()!; + expect(url).not.toContain('project='); + expect(url).toContain('title=search+term'); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx b/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx index 415a9f2..b0ec418 100644 --- a/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx +++ b/src/frontend/apps/conversations/src/features/chat/api/useConversations.tsx @@ -43,7 +43,7 @@ export const getConversations = async ( searchParams.set('title', params.title); } - searchParams.set("project", "none") + searchParams.set('project', 'none'); const response = await fetchAPI(`chats/?${searchParams.toString()}`); diff --git a/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx b/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx index eaaba04..d0d01bb 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/AttachmentList.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Box, Icon, Text } from '@/components'; 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 e1c4869..11127ae 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -1,5 +1,5 @@ import { Message, SourceUIPart } from '@ai-sdk/ui-utils'; -import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import { useRouter } from 'next/router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 3421fb1..1063e5a 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ChatError.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/router'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx b/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx index 6d3b89f..6b65e6b 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/FeedbackButtons.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx b/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx index ac0aff8..c54a24d 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/InputChatAction.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; 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 a029963..2ee28db 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import Image from 'next/image'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx b/src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx new file mode 100644 index 0000000..a46bf37 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/ProjectWelcomeMessage.tsx @@ -0,0 +1,78 @@ +import { InfiniteData, useQueryClient } from '@tanstack/react-query'; +import { type ReactNode, memo, useMemo } from 'react'; + +import { Box, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + KEY_LIST_PROJECT, + ProjectsResponse, +} from '@/features/chat/api/useProjects'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { + PROJECT_COLORS, + PROJECT_ICONS, +} from '@/features/left-panel/components/projects/project-constants'; + +const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const; +const WELCOME_MARGIN = { + horizontal: 'base', + bottom: 'md', + top: '-105px', +} as const; +const WELCOME_TEXT_MARGIN = { all: '0' } as const; + +interface ProjectWelcomeMessageProps { + fallback: ReactNode; +} + +export const ProjectWelcomeMessage = memo(function ProjectWelcomeMessage({ + fallback, +}: ProjectWelcomeMessageProps) { + const projectId = usePendingChatStore((s) => s.projectId); + const queryClient = useQueryClient(); + const { colorsTokens } = useCunninghamTheme(); + + // Look up the project from the already-fetched infinite projects cache + const project = useMemo(() => { + if (!projectId) return undefined; + const projectsData = queryClient.getQueryData< + InfiniteData + >([KEY_LIST_PROJECT, { page: 1 }]); + return projectsData?.pages + .flatMap((page) => page.results) + .find((p) => p.id === projectId); + }, [projectId, queryClient]); + + if (!project) { + return <>{fallback}; + } + + const IconComp = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; + const iconColor = + colorsTokens[PROJECT_COLORS[project.color] as keyof typeof colorsTokens] ?? + undefined; + + return ( + + + + + + + {project.title} + + + + ); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx b/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx index cd88c6f..30d0b25 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/ScrollDown.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import React, { useEffect, useState } from 'react'; import { Box, Icon } from '@/components'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx b/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx index 20cbb53..933300d 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/SendButton.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Icon, Text } from '@/components'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx index 4a8afa0..21d373b 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageBlock.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */ -import { CunninghamProvider } from '@openfun/cunningham-react'; +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { act, render, screen } from '@testing-library/react'; import { Suspense } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx index 5ed218a..47eaf30 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */ -import { CunninghamProvider } from '@openfun/cunningham-react'; +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Suspense } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx new file mode 100644 index 0000000..02d6d53 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/ProjectWelcomeMessage.test.tsx @@ -0,0 +1,101 @@ +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; +import { + InfiniteData, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; + +import { + KEY_LIST_PROJECT, + ProjectsResponse, +} from '@/features/chat/api/useProjects'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { ChatProject } from '@/features/chat/types'; + +import { ProjectWelcomeMessage } from '../ProjectWelcomeMessage'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +const makeProject = (overrides?: Partial): ChatProject => ({ + id: 'proj-1', + title: 'Design System', + icon: 'folder', + color: 'color_6', + llm_instructions: '', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + conversations: [], + ...overrides, +}); + +const renderWithCache = ( + projectId: string | null, + cachedProjects: ChatProject[], +) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + if (cachedProjects.length > 0) { + const cacheData: InfiniteData = { + pages: [ + { + count: cachedProjects.length, + results: cachedProjects, + next: null, + previous: null, + }, + ], + pageParams: [1], + }; + queryClient.setQueryData([KEY_LIST_PROJECT, { page: 1 }], cacheData); + } + + usePendingChatStore.setState({ projectId }); + + return render( + + + Welcome fallback} /> + + , + ); +}; + +describe('ProjectWelcomeMessage', () => { + beforeEach(() => { + usePendingChatStore.setState({ + projectId: null, + input: '', + files: null, + }); + }); + + it('renders project title when projectId matches a cached project', () => { + renderWithCache('proj-1', [makeProject()]); + + expect(screen.getByText('Design System')).toBeInTheDocument(); + expect(screen.queryByText('Welcome fallback')).not.toBeInTheDocument(); + }); + + it('renders fallback when no project found in cache', () => { + renderWithCache('proj-unknown', [makeProject()]); + + expect(screen.getByText('Welcome fallback')).toBeInTheDocument(); + expect(screen.queryByText('Design System')).not.toBeInTheDocument(); + }); + + it('renders fallback when projectId is null', () => { + renderWithCache(null, [makeProject()]); + + expect(screen.getByText('Welcome fallback')).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts b/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts index bcfe7a6..ccf8c10 100644 --- a/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts +++ b/src/frontend/apps/conversations/src/features/chat/stores/usePendingChatStore.ts @@ -3,13 +3,17 @@ import { create } from 'zustand'; interface PendingChatState { input: string; files: FileList | null; + projectId: string | null; setPendingChat: (input: string, files: FileList | null) => void; clearPendingChat: () => void; + setProjectId: (projectId: string | null) => void; } export const usePendingChatStore = create((set) => ({ input: '', files: null, + projectId: null, setPendingChat: (input, files) => set({ input, files }), - clearPendingChat: () => set({ input: '', files: null }), + clearPendingChat: () => set({ input: '', files: null, projectId: null }), + setProjectId: (projectId) => set({ projectId }), })); diff --git a/src/frontend/apps/conversations/src/features/chat/types.tsx b/src/frontend/apps/conversations/src/features/chat/types.tsx index 587f474..933bb7b 100644 --- a/src/frontend/apps/conversations/src/features/chat/types.tsx +++ b/src/frontend/apps/conversations/src/features/chat/types.tsx @@ -8,6 +8,7 @@ export interface ChatConversation { created_at: string; updated_at: string; title?: string; + project?: string | null; } export interface ChatProjectConversation { id: string; diff --git a/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx b/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx index ed867db..609f276 100644 --- a/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx +++ b/src/frontend/apps/conversations/src/features/feedback/Feedback.tsx @@ -4,7 +4,7 @@ import { Modal, ModalSize, useModal, -} from '@openfun/cunningham-react'; +} from '@gouvfr-lasuite/cunningham-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx b/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx index 09be548..3da9270 100644 --- a/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/ButtonToggleLeftPanel.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import LeftPanelIcon from '@/assets/icons/left-panel-bold.svg'; diff --git a/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx b/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx index 25a4ced..cb81d03 100644 --- a/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/ButtonTogglePanel.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Icon } from '@/components/'; diff --git a/src/frontend/apps/conversations/src/features/header/components/Header.tsx b/src/frontend/apps/conversations/src/features/header/components/Header.tsx index 52b1b02..e88bdb9 100644 --- a/src/frontend/apps/conversations/src/features/header/components/Header.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/Header.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useRouter } from 'next/navigation'; import { useEffect as _useEffect, useState as _useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx b/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx index dc8e9e8..1991bc7 100644 --- a/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx +++ b/src/frontend/apps/conversations/src/features/header/components/LaGaufre.tsx @@ -1,6 +1,6 @@ // import { Gaufre } from '@gouvfr-lasuite/integration'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import '@gouvfr-lasuite/integration/dist/css/gaufre.css'; -import { Button } from '@openfun/cunningham-react'; import Script from 'next/script'; import { useEffect } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx b/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx index 044c576..336ed36 100644 --- a/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx +++ b/src/frontend/apps/conversations/src/features/home/components/HomeBanner.tsx @@ -1,4 +1,4 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button } from '@gouvfr-lasuite/cunningham-react'; import _Image from 'next/image'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -7,8 +7,8 @@ import IconAssistant from '@/assets/logo/logo-beta.svg'; import { Box, Icon, Text } from '@/components'; import { productName } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; -import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; import { gotoLogin } from '@/features/auth'; +import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; import { useResponsiveStore } from '@/stores'; import BannerDarkMode from '../assets/banner-dark.svg'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx index 8c30038..30aec33 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ConversationRow.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, type ReactNode } from 'react'; +import { type ReactNode, memo, useCallback, useMemo } from 'react'; import { css } from 'styled-components'; import { Box, StyledLink } from '@/components'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx index f4c8c33..7b406eb 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelContent.tsx @@ -2,7 +2,6 @@ import { Box } from '@/components'; import { useAuth } from '@/features/auth/hooks'; import { LeftPanelConversations } from '@/features/left-panel/components/LeftPanelConversations'; import { LeftPanelProjects } from '@/features/left-panel/components/LeftPanelProjects'; - import { useResponsiveStore } from '@/stores'; export const LeftPanelContent = () => { diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx index cdeb793..276874c 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelHeaderActions.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; -import FolderPlusIcon from '@/assets/icons/uikit-custom/folder-plus.svg'; import NewChatIcon from '@/assets/icons/new-message-bold.svg'; +import FolderPlusIcon from '@/assets/icons/uikit-custom/folder-plus.svg'; import { Box, Icon } from '@/components'; import { LeftPanelMenuItem } from './LeftPanelMenuItem'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx index e0da015..72143b0 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjectItem.tsx @@ -1,14 +1,17 @@ import { memo, useCallback, useState } from 'react'; -import { css } from 'styled-components'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, Icon, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { ChatProject } from '@/features/chat/types'; import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions'; import { ConversationRow } from '@/features/left-panel/components/ConversationRow'; -import { PROJECT_COLORS, PROJECT_ICONS } from '@/features/left-panel/components/project-constants'; import { ProjectItemActions } from '@/features/left-panel/components/ProjectItemActions'; +import { + PROJECT_COLORS, + PROJECT_ICONS, +} from '@/features/left-panel/components/project-constants'; type LeftPanelProjectItemProps = { project: ChatProject; currentConversationId?: string; @@ -92,7 +95,8 @@ export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({ /> {(() => { - const IconComp = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; + const IconComp = + PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; return ( - + ); })()} @@ -119,6 +127,7 @@ export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({ e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx index 3f5e9c0..66ccace 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelProjects.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router'; import { useTranslation } from 'react-i18next'; -import { Box, InfiniteScroll, Text } from '@/components'; +import { Box, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { useInfiniteProjects } from '@/features/chat/api/useProjects'; import { LeftPanelProjectItem } from '@/features/left-panel/components/LeftPanelProjectItem'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelSearchModal.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelSearchModal.tsx index 6e482aa..9f1d36d 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelSearchModal.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/LeftPanelSearchModal.tsx @@ -1,4 +1,4 @@ -import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { keepPreviousData } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx index 3984626..649c141 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalCreateProject.tsx @@ -1,4 +1,9 @@ -import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import { + Button, + Input, + Modal, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -8,8 +13,8 @@ import { useCunninghamTheme } from '@/cunningham'; import { useCreateProject } from '@/features/chat/api/useCreateProject'; import { useOwnModal } from '@/features/left-panel/hooks/useModalHook'; -import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; import { ModalIconColorPicker } from './ModalIconColorPicker'; +import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; const textareaCss = css` width: 100%; @@ -68,12 +73,7 @@ export const ModalCreateProject = ({ onClose }: ModalCreateProjectProps) => { const { mutate: createProject, isPending } = useCreateProject({ onSuccess: () => { - showToast( - 'success', - t('The project has been created.'), - undefined, - 4000, - ); + showToast('success', t('The project has been created.'), undefined, 4000); onClose(); }, onError: (error) => { diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalIconColorPicker.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalIconColorPicker.tsx index 3a40422..5a34a0f 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalIconColorPicker.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalIconColorPicker.tsx @@ -1,4 +1,4 @@ -import { Modal, ModalSize } from '@openfun/cunningham-react'; +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx index a243c95..2cebb1d 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalProjectSettings.tsx @@ -1,4 +1,9 @@ -import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import { + Button, + Input, + Modal, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -9,8 +14,8 @@ import { useUpdateProject } from '@/features/chat/api/useUpdateProject'; import { ChatProject } from '@/features/chat/types'; import { useOwnModal } from '@/features/left-panel/hooks/useModalHook'; -import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; import { ModalIconColorPicker } from './ModalIconColorPicker'; +import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; const textareaCss = css` width: 100%; @@ -75,12 +80,7 @@ export const ModalProjectSettings = ({ const { mutate: updateProject, isPending } = useUpdateProject({ onSuccess: () => { - showToast( - 'success', - t('The project has been updated.'), - undefined, - 4000, - ); + showToast('success', t('The project has been updated.'), undefined, 4000); onClose(); }, onError: (error) => { diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx index 7127114..5da84ab 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveConversation.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { t } from 'i18next'; import { usePathname } from 'next/navigation'; import { useRouter } from 'next/router'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveProject.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveProject.tsx index 9817a58..9413573 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveProject.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRemoveProject.tsx @@ -1,6 +1,5 @@ -import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; import { t } from 'i18next'; - import { usePathname } from 'next/navigation'; import { useRouter } from 'next/router'; @@ -22,12 +21,7 @@ export const ModalRemoveProject = ({ const { mutate: removeProject } = useRemoveProject({ onSuccess: () => { - showToast( - 'success', - t('The project has been deleted.'), - undefined, - 4000, - ); + showToast('success', t('The project has been deleted.'), undefined, 4000); if (pathname === '/') { onClose(); } else { @@ -44,7 +38,7 @@ export const ModalRemoveProject = ({ aria-label={t('Content modal to delete project')} rightActions={ <> - - } size={ModalSize.MEDIUM} @@ -78,7 +70,7 @@ export const ModalRemoveProject = ({ $align="flex-start" $variation="1000" > - {t('Delete {{title}}', { title: project.title })} + {t('Delete {{title}}', { title: project.title })} } > @@ -87,8 +79,11 @@ export const ModalRemoveProject = ({ data-testid="delete-project-confirm" > - {t('Are you sure you want to delete the “{{title}}” project? All associated conversations and embedded' + - ' documents will be permanently lost.', { title: project.title })} + {t( + 'Are you sure you want to delete the “{{title}}” project? All associated conversations and embedded' + + ' documents will be permanently lost.', + { title: project.title }, + )} diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx index dc1c8e0..1eaf093 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ModalRenameConversation.tsx @@ -1,4 +1,9 @@ -import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react'; +import { + Button, + Input, + Modal, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/ProjectItemActions.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/ProjectItemActions.tsx index 1db8994..0ede065 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/ProjectItemActions.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/ProjectItemActions.tsx @@ -77,16 +77,10 @@ export const ProjectItemActions = ({ project }: ProjectItemActionsProps) => { {deleteModal.isOpen && ( - + )} {settingsModal.isOpen && ( - + )} ); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx index 2d5fa21..a9cfd99 100644 --- a/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx +++ b/src/frontend/apps/conversations/src/features/left-panel/components/__tests__/ModalRenameConversation.test.tsx @@ -1,4 +1,4 @@ -import { CunninghamProvider } from '@openfun/cunningham-react'; +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx new file mode 100644 index 0000000..3979aaa --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanel.tsx @@ -0,0 +1,129 @@ +import { useEffect } from 'react'; +import { createGlobalStyle, css } from 'styled-components'; + +import { Box, SeparatedSection } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { ButtonLogin } from '@/features/auth'; +import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; +import { LanguagePicker } from '@/features/language'; +import { SettingsButton } from '@/features/settings'; +import { useResponsiveStore } from '@/stores'; + +import { LeftPanelContent } from './LeftPanelContent'; +import { LeftPanelHeader } from './LeftPanelHeader'; + +const MobileLeftPanelStyle = createGlobalStyle` + body { + overflow: hidden; + } +`; + +export const LeftPanel = () => { + const { isDesktop } = useResponsiveStore(); + + const { spacingsTokens } = useCunninghamTheme(); + const { setPanelOpen, isPanelOpen } = useChatPreferencesStore(); + + useEffect(() => { + setPanelOpen(isDesktop ? true : false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDesktop]); + + return ( + <> + {isDesktop && ( + + + + + + + + + + + )} + + {!isDesktop && ( + <> + {isPanelOpen && } + + + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx new file mode 100644 index 0000000..5c4a5da --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelContent.tsx @@ -0,0 +1,28 @@ +import { Box } from '@/components'; +import { useAuth } from '@/features/auth/hooks'; +import { LeftPanelConversations } from '@/features/left-panel/components/left-panel/LeftPanelConversations'; +import { LeftPanelProjects } from '@/features/left-panel/components/left-panel/LeftPanelProjects'; +import { useResponsiveStore } from '@/stores'; + +export const LeftPanelContent = () => { + const { isDesktop } = useResponsiveStore(); + const { authenticated } = useAuth(); + + return ( + + {authenticated && ( + <> + + + )} + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx new file mode 100644 index 0000000..8d9c304 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversationItem.tsx @@ -0,0 +1,35 @@ +import { memo } from 'react'; + +import { ChatConversation } from '@/features/chat/types'; +import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions'; +import { ConversationRow } from '@/features/left-panel/components/ConversationRow'; +import { SimpleConversationItem } from '@/features/left-panel/components/SimpleConversationItem'; + +type LeftPanelConversationItemProps = { + conversation: ChatConversation; + isCurrentConversation: boolean; + /** Affiche le temps depuis le dernier message (ex. dans la modale de recherche) */ + showUpdatedAt?: boolean; +}; + +export const LeftPanelConversationItem = memo( + function LeftPanelConversationItem({ + conversation, + isCurrentConversation, + showUpdatedAt = false, + }: LeftPanelConversationItemProps) { + return ( + } + > + + + ); + }, +); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx new file mode 100644 index 0000000..bec62ac --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelConversations.tsx @@ -0,0 +1,60 @@ +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; + +import { Box, InfiniteScroll, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useInfiniteConversations } from '@/features/chat/api/useConversations'; +import { LeftPanelConversationItem } from '@/features/left-panel/components/left-panel/LeftPanelConversationItem'; + +export const LeftPanelConversations = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = router.query; + + const { spacingsTokens } = useCunninghamTheme(); + + const conversations = useInfiniteConversations({ + page: 1, + ordering: '-updated_at', + }); + + const favoriteConversations = + conversations.data?.pages.flatMap((page) => page.results) || []; + + if (favoriteConversations.length === 0) { + return null; + } + + return ( + + + {t('Your chats')} + + void conversations.fetchNextPage()} + > + {favoriteConversations.map((conversation) => ( + + ))} + + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx new file mode 100644 index 0000000..ef95055 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeader.tsx @@ -0,0 +1,68 @@ +import { useRouter } from 'next/navigation'; +import { PropsWithChildren, useEffect, useState } from 'react'; + +import { Box } from '@/components'; +import { useAuth } from '@/features/auth'; +import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; +import { useOwnModal } from '@/features/left-panel/hooks/useModalHook'; +import { useResponsiveStore } from '@/stores'; + +import { ModalProjectForm } from '../projects/ModalProjectForm'; + +import { LeftPanelHeaderActions } from './LeftPanelHeaderActions'; +import { LeftPanelSearchModal } from './LeftPanelSearchModal'; + +export const LeftPanelHeader = ({ children }: PropsWithChildren) => { + const router = useRouter(); + const { authenticated } = useAuth(); + const { isDesktop } = useResponsiveStore(); + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); + const createProjectModal = useOwnModal(); + + const { setPanelOpen } = useChatPreferencesStore(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setIsSearchModalOpen((open) => !open); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + const goToHome = () => { + router.push('/'); + if (!isDesktop) { + setPanelOpen(false); + } + }; + + return ( + <> + {authenticated && ( + + { + if (!isDesktop) { + setPanelOpen(false); + } + setIsSearchModalOpen(true); + }} + onCreateProject={createProjectModal.open} + /> + {children} + + )} + {/* Mount only when needed to avoid running hooks while closed */} + {authenticated && isSearchModalOpen && ( + setIsSearchModalOpen(false)} /> + )} + {authenticated && createProjectModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx new file mode 100644 index 0000000..9641eeb --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelHeaderActions.tsx @@ -0,0 +1,74 @@ +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import NewChatIcon from '@/assets/icons/new-message-bold.svg'; +import FolderPlusIcon from '@/assets/icons/uikit-custom/folder-plus.svg'; +import { Box, Icon } from '@/components'; + +import { LeftPanelMenuItem } from './LeftPanelMenuItem'; + +const iconWrapperCss = css` + color: var(--c--contextuals--content--semantic--neutral--secondary); + & svg { + fill: currentColor; + } + .material-symbols-outlined, + .material-symbols { + color: inherit; + } +`; + +type LeftPanelHeaderActionsProps = { + onNewChat: () => void; + onSearch: () => void; + onCreateProject: () => void; +}; + +export const LeftPanelHeaderActions = ({ + onNewChat, + onSearch, + onCreateProject, +}: LeftPanelHeaderActionsProps) => { + const { t } = useTranslation(); + + return ( + + + + + } + label={t('New chat')} + onClick={onNewChat} + aria-label={t('New chat')} + /> + + + + } + label={t('Search for a chat')} + onClick={onSearch} + aria-label={t('Search for a chat')} + /> + + + + } + label={t('Create project')} + onClick={onCreateProject} + aria-label={t('Create project')} + /> + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelMenuItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelMenuItem.tsx new file mode 100644 index 0000000..d95fbd6 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelMenuItem.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from 'react'; +import { css } from 'styled-components'; + +import { Box, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +const menuItemCss = css` + cursor: pointer; + border-radius: var(--c--globals--spacings--xs); + border: none; + background: transparent; + width: 100%; + text-align: left; + transition: background-color 0.2s ease; + + &:hover, + &:focus-visible { + background-color: var( + --c--contextuals--background--semantic--overlay--primary + ); + } +`; + +const labelCss = css` + color: var(--c--contextuals--content--semantic--neutral--primary); + text-overflow: ellipsis; + font-family: var(--c--globals--font--families--base); + font-size: 0.875rem; + font-weight: 500; + overflow: hidden; + white-space: nowrap; +`; + +type LeftPanelMenuItemProps = { + icon: ReactNode; + label: string; + onClick: () => void; + 'aria-label': string; +}; + +export const LeftPanelMenuItem = ({ + icon, + label, + onClick, + 'aria-label': ariaLabel, +}: LeftPanelMenuItemProps) => { + const { spacingsTokens } = useCunninghamTheme(); + + return ( + + + {icon} + + + {label} + + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx new file mode 100644 index 0000000..71fa7ac --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjectItem.tsx @@ -0,0 +1,197 @@ +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { useRouter } from 'next/router'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +// import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { ChatProject } from '@/features/chat/types'; +import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions'; +import { ConversationRow } from '@/features/left-panel/components/ConversationRow'; +import { ProjectItemActions } from '@/features/left-panel/components/projects/ProjectItemActions'; +import { + PROJECT_COLORS, + PROJECT_ICONS, +} from '@/features/left-panel/components/projects/project-constants'; +type LeftPanelProjectItemProps = { + project: ChatProject; + currentConversationId?: string; +}; + +const headerStyles = css` + border-radius: 4px; + width: 100%; + cursor: pointer; + user-select: none; + transition: background-color 0.2s cubic-bezier(1, 0, 0, 1); + .pinned-actions { + opacity: 0; + transition: opacity 0.3s cubic-bezier(1, 0, 0, 1); + } + &:hover, + &:focus-within { + background-color: var( + --c--contextuals--background--semantic--overlay--primary + ); + .pinned-actions { + opacity: 1; + } + } +`; + +const conversationListStyles = css` + overflow: hidden; +`; + +const titleTextStyles = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({ + project, + currentConversationId, +}: LeftPanelProjectItemProps) { + const { t } = useTranslation(); + const { colorsTokens } = useCunninghamTheme(); + const router = useRouter(); + const pendingProjectId = usePendingChatStore((s) => s.projectId); + const setProjectId = usePendingChatStore((s) => s.setProjectId); + + const [isOpen, setIsOpen] = useState(false); + + // Auto-open when current conversation belongs to this project or creating a new one in it + const belongsToProject = + currentConversationId && + project.conversations.some((c) => c.id === currentConversationId); + const isTargetProject = belongsToProject || pendingProjectId === project.id; + + useEffect(() => { + if (isTargetProject) { + setIsOpen(true); + } + }, [isTargetProject]); + + const IconComponent = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder; + const iconColor = + colorsTokens[PROJECT_COLORS[project.color] as keyof typeof colorsTokens] ?? + undefined; + + const toggleOpen = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleNewConversation = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setProjectId(project.id); + void router.push('/chat/'); + }, + [router, project.id, setProjectId], + ); + + return ( + + {/* Project header - click to toggle */} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleOpen(); + } + }} + > + + + + + + + + {project.title} + + + +
+ +
+
+ + {/* Conversations list */} + {isOpen && ( + + {project.conversations.length === 0 ? ( + + {t('No conversations')} + + ) : ( + project.conversations.map((conv) => ( + } + > + + {conv.title || t('Untitled conversation')} + + + )) + )} + + )} +
+ ); +}); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjects.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjects.tsx new file mode 100644 index 0000000..9bafd70 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelProjects.tsx @@ -0,0 +1,56 @@ +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useInfiniteProjects } from '@/features/chat/api/useProjects'; +import { LeftPanelProjectItem } from '@/features/left-panel/components/left-panel/LeftPanelProjectItem'; + +export const LeftPanelProjects = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { id } = router.query; + + const { spacingsTokens } = useCunninghamTheme(); + + const projects = useInfiniteProjects({ + page: 1, + }); + + const mainProjects = + projects.data?.pages.flatMap((page) => page.results) || []; + + if (mainProjects.length === 0) { + return null; + } + + return ( + + + {t('Projects')} + + +
+ {mainProjects.map((prj) => ( + + ))} +
+
+ ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearch.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearch.tsx new file mode 100644 index 0000000..96a862e --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearch.tsx @@ -0,0 +1,82 @@ +import { useParams, useRouter } from 'next/navigation'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { InView } from 'react-intersection-observer'; +import { useDebouncedCallback } from 'use-debounce'; + +import { Box } from '@/components'; +import { + QuickSearch, + QuickSearchData, + QuickSearchGroup, +} from '@/components/quick-search'; +import { useInfiniteConversations } from '@/features/chat/api/useConversations'; +import { ChatConversation } from '@/features/chat/types'; +import { LeftPanelConversationItem } from '@/features/left-panel/components/left-panel/LeftPanelConversationItem'; +import { useResponsiveStore } from '@/stores'; + +type LeftPanelSearchProps = { + onSearchChange?: (hasSearch: boolean) => void; +}; + +export const LeftPanelSearch = ({ onSearchChange }: LeftPanelSearchProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const params = useParams(); + const [search, setSearch] = useState(''); + const { isDesktop: _isDesktop } = useResponsiveStore(); + + const currentConversationId = params?.id as string; + + const { data, fetchNextPage, hasNextPage } = useInfiniteConversations({ + page: 1, + title: search, + }); + + const handleInputSearch = useDebouncedCallback((value: string) => { + setSearch(value); + onSearchChange?.(value.length > 0); + }, 300); + + const handleSelect = (conversation: ChatConversation) => { + router.push(`/chat/${conversation.id}`); + }; + + const conversationsData: QuickSearchData = useMemo(() => { + const conversations = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: conversations.length > 0 ? t('Search results') : '', + elements: search ? conversations : [], + emptyString: t('No conversation found'), + endActions: hasNextPage + ? [{ content: void fetchNextPage()} /> }] + : [], + }; + }, [data, hasNextPage, fetchNextPage, t, search]); + + return ( + + + {search && ( + ( + + )} + /> + )} + + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearchModal.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearchModal.tsx new file mode 100644 index 0000000..9f1d36d --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/LeftPanelSearchModal.tsx @@ -0,0 +1,147 @@ +import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; +import { keepPreviousData } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { InView } from 'react-intersection-observer'; +import { useDebouncedCallback } from 'use-debounce'; + +import { Box } from '@/components'; +import { + QuickSearch, + QuickSearchData, + QuickSearchGroup, + QuickSearchResultItem, +} from '@/components/quick-search'; +import { useInfiniteConversations } from '@/features/chat/api/useConversations'; +import { ChatConversation } from '@/features/chat/types'; + +type LeftPanelSearchModalProps = { + onClose: () => void; +}; + +const SEARCH_DEBOUNCE_MS = 400; +const MIN_SEARCH_LENGTH = 3; +const LOADER_DELAY_MS = 300; + +export const LeftPanelSearchModal = ({ + onClose, +}: LeftPanelSearchModalProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const [inputValue, setInputValue] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + + const debouncedSetSearchQuery = useDebouncedCallback((value: string) => { + const trimmed = value.trim(); + setSearchQuery(trimmed.length >= MIN_SEARCH_LENGTH ? trimmed : ''); + }, SEARCH_DEBOUNCE_MS); + + const handleClose = useCallback(() => { + debouncedSetSearchQuery.cancel(); + onClose(); + }, [onClose, debouncedSetSearchQuery]); + + const handleInputChange = useCallback( + (value: string) => { + setInputValue(value); + debouncedSetSearchQuery(value); + }, + [debouncedSetSearchQuery], + ); + + // Only fetch when query is non-empty; keep previous results visible during refetch + const { data, fetchNextPage, hasNextPage, isFetching } = + useInfiniteConversations( + { + page: 1, + title: searchQuery, + }, + { + enabled: Boolean(searchQuery), + placeholderData: keepPreviousData, + }, + ); + + // Defer loader display to avoid icon flicker on fast responses + const [showLoader, setShowLoader] = useState(false); + const loaderTimeout = useRef>(null); + useEffect(() => { + if (isFetching) { + loaderTimeout.current = setTimeout( + () => setShowLoader(true), + LOADER_DELAY_MS, + ); + } else { + if (loaderTimeout.current) clearTimeout(loaderTimeout.current); + setShowLoader(false); + } + return () => { + if (loaderTimeout.current) clearTimeout(loaderTimeout.current); + }; + }, [isFetching]); + + const handleSelect = useCallback( + (conversation: ChatConversation) => { + router.push(`/chat/${conversation.id}`); + handleClose(); + }, + [router, handleClose], + ); + + const renderElement = useCallback( + (conversation: ChatConversation) => ( + + ), + [], + ); + + const endActionsContent = useMemo( + () => + hasNextPage + ? [{ content: void fetchNextPage()} /> }] + : [], + [hasNextPage, fetchNextPage], + ); + + const conversationsData: QuickSearchData = useMemo(() => { + const conversations = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: conversations.length > 0 ? t('Select a chat') : '', + elements: searchQuery ? conversations : [], + emptyString: t('No conversation found'), + endActions: endActionsContent, + }; + }, [data, searchQuery, t, endActionsContent]); + + return ( + + + 0} + > + + {searchQuery && ( + + )} + + + + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/__tests__/LeftPanelProjectItem.test.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/__tests__/LeftPanelProjectItem.test.tsx new file mode 100644 index 0000000..a26e6f9 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/left-panel/__tests__/LeftPanelProjectItem.test.tsx @@ -0,0 +1,115 @@ +import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { ChatProject } from '@/features/chat/types'; + +import { LeftPanelProjectItem } from '../LeftPanelProjectItem'; + +const mockPush = jest.fn(); +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockPush }), +})); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/chat/', +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +// Stub child components that pull in heavy dependencies +jest.mock( + '@/features/left-panel/components/projects/ProjectItemActions', + () => ({ + ProjectItemActions: () => null, + }), +); +jest.mock('@/features/left-panel/components/ConversationItemActions', () => ({ + ConversationItemActions: () => null, +})); + +const renderWithProviders = (component: React.ReactNode) => + render({component}); + +const makeProject = (overrides?: Partial): ChatProject => ({ + id: 'proj-1', + title: 'Design System', + icon: 'folder', + color: 'color_6', + llm_instructions: '', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + conversations: [ + { id: 'conv-1', title: 'Colors discussion' }, + { id: 'conv-2', title: 'Typography' }, + ], + ...overrides, +}); + +describe('LeftPanelProjectItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePendingChatStore.setState({ + projectId: null, + input: '', + files: null, + }); + }); + + it('renders project title and starts collapsed', () => { + renderWithProviders(); + + expect(screen.getByText('Design System')).toBeInTheDocument(); + expect(screen.queryByText('Colors discussion')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Design System' }), + ).toHaveAttribute('aria-expanded', 'false'); + }); + + it('expands on click and shows conversations', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: 'Design System' })); + + expect(screen.getByText('Colors discussion')).toBeInTheDocument(); + expect(screen.getByText('Typography')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Design System' }), + ).toHaveAttribute('aria-expanded', 'true'); + }); + + it('auto-expands when currentConversationId matches a project conversation', () => { + renderWithProviders( + , + ); + + expect(screen.getByText('Colors discussion')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Design System' }), + ).toHaveAttribute('aria-expanded', 'true'); + }); + + it('"New conversation" button sets projectId and navigates to /chat/', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click( + screen.getByRole('button', { name: 'New conversation in project' }), + ); + + expect(usePendingChatStore.getState().projectId).toBe('proj-1'); + expect(mockPush).toHaveBeenCalledWith('/chat/'); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalIconColorPicker.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalIconColorPicker.tsx new file mode 100644 index 0000000..743b4f9 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalIconColorPicker.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, HorizontalSeparator } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; + +const iconEntries = Object.entries(PROJECT_ICONS); +const colorEntries = Object.entries(PROJECT_COLORS); + +const gridCss = css` + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + justify-items: center; +`; + +const iconCellCss = css` + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + border: none; + background-color: transparent; + color: var(--c--contextuals--content--semantic--neutral--secondary); + transition: background-color 0.15s ease; + + & svg { + fill: currentColor; + } + + &:hover { + background-color: var( + --c--contextuals--background--semantic--overlay--primary + ); + } +`; + +const iconSelectedCss = css` + ${iconCellCss} + background-color: var( + --c--contextuals--background--semantic--overlay--primary + ); +`; + +const colorCellCss = css` + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + border: none; + background-color: transparent; + transition: background-color 0.15s ease; + + &:hover { + background-color: var( + --c--contextuals--background--semantic--overlay--primary + ); + } +`; + +const colorSelectedCss = css` + ${colorCellCss} + background-color: var( + --c--contextuals--background--semantic--overlay--primary + ); +`; + +interface IconColorPickerProps { + icon: string; + color: string; + onIconChange: (icon: string) => void; + onColorChange: (color: string) => void; +} + +export const IconColorPicker = ({ + icon, + color, + onIconChange, + onColorChange, +}: IconColorPickerProps) => { + const { t } = useTranslation(); + const { colorsTokens } = useCunninghamTheme(); + + return ( + + + {iconEntries.map(([key, IconComp]) => ( + onIconChange(key)} + aria-label={key} + aria-pressed={icon === key} + > + + + ))} + + + + + + {colorEntries.map(([key, token]) => ( + onColorChange(key)} + aria-label={key} + aria-pressed={color === key} + > + + + ))} + + + ); +}; diff --git a/src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalProjectForm.tsx b/src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalProjectForm.tsx new file mode 100644 index 0000000..127dc22 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/left-panel/components/projects/ModalProjectForm.tsx @@ -0,0 +1,272 @@ +import { + Button, + Input, + Modal, + ModalSize, + TextArea, +} from '@gouvfr-lasuite/cunningham-react'; +import { useReducer } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, DropButton, Text, useToast } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useCreateProject } from '@/features/chat/api/useCreateProject'; +import { useUpdateProject } from '@/features/chat/api/useUpdateProject'; +import { ChatProject } from '@/features/chat/types'; + +import { IconColorPicker } from './ModalIconColorPicker'; +import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants'; + +interface FormState { + name: string; + icon: string; + color: string; + instructions: string; +} + +type FormAction = + | { type: 'SET_NAME'; value: string } + | { type: 'SET_ICON'; value: string } + | { type: 'SET_COLOR'; value: string } + | { type: 'SET_INSTRUCTIONS'; value: string }; + +const formReducer = (state: FormState, action: FormAction): FormState => { + switch (action.type) { + case 'SET_NAME': + return { ...state, name: action.value }; + case 'SET_ICON': + return { ...state, icon: action.value }; + case 'SET_COLOR': + return { ...state, color: action.value }; + case 'SET_INSTRUCTIONS': + return { ...state, instructions: action.value }; + } +}; + +const avatarButtonCss = css` + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #ccc; + border-radius: 8px; + background-color: transparent; + cursor: pointer; + flex-shrink: 0; + transition: border-color 0.15s ease; +`; + +interface ModalProjectFormProps { + project?: ChatProject; + onClose: () => void; +} + +export const ModalProjectForm = ({ + project, + onClose, +}: ModalProjectFormProps) => { + const isEditing = !!project; + const { showToast } = useToast(); + const { t } = useTranslation(); + + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + + const [form, dispatch] = useReducer(formReducer, { + name: project?.title ?? '', + icon: project?.icon || 'folder', + color: project?.color || 'color_6', + instructions: project?.llm_instructions || '', + }); + + const IconComp = PROJECT_ICONS[form.icon] ?? PROJECT_ICONS.folder; + const colorToken = PROJECT_COLORS[form.color]; + const iconColor = colorToken + ? (colorsTokens[colorToken as keyof typeof colorsTokens] ?? undefined) + : undefined; + + const { mutate: createProject, isPending: isCreating } = useCreateProject({ + onSuccess: () => { + showToast('success', t('The project has been created.'), undefined, 4000); + onClose(); + }, + onError: (error) => { + const errorMessage = + error.cause?.[0] || + error.message || + t('An error occurred while creating the project'); + showToast('error', errorMessage, undefined, 4000); + }, + }); + + const { mutate: updateProject, isPending: isUpdating } = useUpdateProject({ + onSuccess: () => { + showToast('success', t('The project has been updated.'), undefined, 4000); + onClose(); + }, + onError: (error) => { + const errorMessage = + error.cause?.[0] || + error.message || + t('An error occurred while updating the project'); + showToast('error', errorMessage, undefined, 4000); + }, + }); + + const isPending = isCreating || isUpdating; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedName = form.name.trim(); + if (!trimmedName) return; + + const payload = { + title: trimmedName, + icon: form.icon, + color: form.color, + llm_instructions: form.instructions.trim() || undefined, + }; + + if (isEditing) { + updateProject({ projectId: project.id, ...payload }); + } else { + createProject(payload); + } + }; + + const formId = isEditing ? 'project-settings-form' : 'create-project-form'; + + return ( + + + + + } + size={ModalSize.MEDIUM} + title={ + + {isEditing ? t('Project settings') : t('New project')} + + } + > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + e.stopPropagation()} + > +
+ + + + {t('Project avatar and name')} + + + + + + } + label={t('Choose icon and color')} + > + + dispatch({ type: 'SET_ICON', value }) + } + onColorChange={(value) => + dispatch({ type: 'SET_COLOR', value }) + } + /> + + + ) => + dispatch({ type: 'SET_NAME', value: e.target.value }) + } + required + /> + + + + + + {t('Instructions (experimental)')} + +