(front) projects management UI

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
This commit is contained in:
Laurent Paoletti
2026-03-16 11:49:27 +01:00
parent d72e0197cc
commit 3aa2cf87f7
74 changed files with 2593 additions and 86 deletions
+1
View File
@@ -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
@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6562 3.50635C20.6529 3.50638 21.4094 3.76446 21.9258 4.28076C22.4422 4.79717 22.7002 5.54793 22.7002 6.53271V17.4761C22.7002 18.4549 22.4422 19.2028 21.9258 19.7192C21.4094 20.2356 20.6529 20.4936 19.6562 20.4937H4.35254C3.35596 20.4936 2.59657 20.2355 2.07422 19.7192C1.55781 19.2028 1.2998 18.4549 1.2998 17.4761V6.53271C1.2998 5.54192 1.55781 4.79117 2.07422 4.28076C2.59657 3.76451 3.35601 3.50641 4.35254 3.50635H19.6562ZM10.0186 11.1987C9.8204 11.1987 9.649 11.2704 9.50488 11.4146C9.36679 11.5527 9.29785 11.7241 9.29785 11.9282C9.2979 12.1323 9.36682 12.3066 9.50488 12.4507C9.64298 12.5947 9.81443 12.6665 10.0186 12.6665H13.5137C13.7175 12.6664 13.8884 12.5945 14.0264 12.4507C14.1704 12.3066 14.2431 12.1323 14.2432 11.9282C14.2432 11.7241 14.1705 11.5527 14.0264 11.4146C13.8884 11.2706 13.7176 11.1988 13.5137 11.1987H10.0186ZM6.17188 7.07275C5.93188 6.92288 5.71546 6.87798 5.52344 6.93799C5.33144 6.99806 5.18127 7.11272 5.07324 7.28076C4.97141 7.4487 4.9359 7.6345 4.96582 7.83838C5.00185 8.04254 5.12758 8.20837 5.34375 8.33447L7.35254 9.5415L5.35254 10.7661C5.13055 10.8981 5.00192 11.0632 4.96582 11.2612C4.93581 11.4593 4.97123 11.6457 5.07324 11.8198C5.17531 11.9939 5.32254 12.1113 5.51465 12.1714C5.71277 12.2314 5.93173 12.1857 6.17188 12.0356L8.79297 10.3882C9.00307 10.2561 9.13914 10.0818 9.19922 9.86572C9.26526 9.64959 9.26147 9.43621 9.18945 9.22607C9.1234 9.0099 8.99113 8.8385 8.79297 8.7124L6.17188 7.07275Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -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';
@@ -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';
@@ -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';
@@ -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(<CunninghamProvider>{component}</CunninghamProvider>);
const makeConversation = (
overrides?: Partial<ChatConversation>,
): 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(<QuickSearchResultItem conversation={conversation} />);
expect(screen.getByText('Test conversation')).toBeInTheDocument();
});
it('renders relative date when conversation has no project', () => {
const conversation = makeConversation({ project: null });
renderWithProviders(<QuickSearchResultItem conversation={conversation} />);
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(<QuickSearchResultItem conversation={conversation} />);
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
});
});
@@ -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 },
@@ -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';
@@ -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';
@@ -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';
@@ -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');
});
});
@@ -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()}`);
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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<ProjectsResponse>
>([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 (
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
<Box $direction="row" $align="center" $gap="10px">
<Box
$display="flex"
$align="center"
$justify="center"
$width="32px"
$height="32px"
style={{ color: iconColor, flexShrink: 0 }}
>
<IconComp
width={32}
height={32}
style={{ fill: 'currentColor', display: 'block' }}
/>
</Box>
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
{project.title}
</Text>
</Box>
</Box>
);
});
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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>): 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<ProjectsResponse> = {
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(
<QueryClientProvider client={queryClient}>
<CunninghamProvider>
<ProjectWelcomeMessage fallback={<span>Welcome fallback</span>} />
</CunninghamProvider>
</QueryClientProvider>,
);
};
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();
});
});
@@ -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<PendingChatState>((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 }),
}));
@@ -8,6 +8,7 @@ export interface ChatConversation {
created_at: string;
updated_at: string;
title?: string;
project?: string | null;
}
export interface ChatProjectConversation {
id: string;
@@ -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';
@@ -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';
@@ -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/';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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 = () => {
@@ -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';
@@ -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 (
<Box
$display="flex"
@@ -102,7 +106,11 @@ export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({
$height="18px"
style={{ color: iconColor, marginRight: '4px', flexShrink: 0 }}
>
<IconComp width={18} height={18} style={{ fill: 'currentColor', display: 'block' }} />
<IconComp
width={18}
height={18}
style={{ fill: 'currentColor', display: 'block' }}
/>
</Box>
);
})()}
@@ -119,6 +127,7 @@ export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({
<Box
className="pinned-actions"
role="toolbar"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
@@ -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';
@@ -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';
@@ -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) => {
@@ -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';
@@ -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) => {
@@ -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';
@@ -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={
<>
<Button
<Button
aria-label={t('Confirm deletion')}
color="error"
variant="bordered"
@@ -60,13 +54,11 @@ export const ModalRemoveProject = ({
<Button
aria-label={t('Close the modal')}
color="brand"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
</>
}
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 })}
</Text>
}
>
@@ -87,8 +79,11 @@ export const ModalRemoveProject = ({
data-testid="delete-project-confirm"
>
<Text $size="sm" $variation="600">
{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 },
)}
</Text>
</Box>
</Modal>
@@ -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';
@@ -77,16 +77,10 @@ export const ProjectItemActions = ({ project }: ProjectItemActionsProps) => {
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveProject
onClose={deleteModal.close}
project={project}
/>
<ModalRemoveProject onClose={deleteModal.close} project={project} />
)}
{settingsModal.isOpen && (
<ModalProjectSettings
onClose={settingsModal.close}
project={project}
/>
<ModalProjectSettings onClose={settingsModal.close} project={project} />
)}
</>
);
@@ -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';
@@ -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 && (
<Box
data-testid="left-panel-desktop"
$css={`
height: 100vh;
width: 300px;
min-width: 300px;
overflow: hidden;
border-right: 1px solid var(--c--contextuals--border--surface--primary);
background-color: var(--c--contextuals--background--surface--tertiary);
`}
className="--docs--left-panel-desktop"
>
<Box
$css={css`
flex: 0 0 auto;
`}
>
<LeftPanelHeader />
</Box>
<LeftPanelContent />
<SeparatedSection showSeparator={true} />
<Box $padding={{ all: 'sm' }}>
<SettingsButton />
</Box>
</Box>
)}
{!isDesktop && (
<>
{isPanelOpen && <MobileLeftPanelStyle />}
<Box
$hasTransition
$css={css`
z-index: 1000;
overflow: hidden;
width: ${isPanelOpen ? '100%' : '200px'};
height: calc(100dvh - 52px);
border-right: 1px solid
var(--c--contextuals--border--surface-primary);
position: fixed;
top: 52px;
left: ${isPanelOpen ? '0' : '-300px'};
background-color: var(
--c--contextuals--background--surface--secondary
);
`}
className="--docs--left-panel-mobile"
>
<Box
data-testid="left-panel-mobile"
$css={css`
width: 100%;
justify-content: center;
align-items: center;
gap: ${spacingsTokens['base']};
`}
>
<LeftPanelHeader />
<LeftPanelContent />
<SeparatedSection showSeparator={false}>
<Box
$css={css`
display: flex;
position: absolute;
bottom: 0;
height: 52px;
width: 100%;
gap: ${spacingsTokens['base']};
border-top: 1px solid
var(--c--contextuals--border--surface--primary);
`}
$justify="space-between"
$align="center"
$direction="row"
$padding={{ horizontal: 'sm' }}
$gap={spacingsTokens['sm']}
>
<ButtonLogin />
<Box
$direction="row"
$gap={spacingsTokens['sm']}
$align="center"
>
<LanguagePicker />
<SettingsButton />
</Box>
</Box>
</SeparatedSection>
</Box>
</Box>
</>
)}
</>
);
};
@@ -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 (
<Box
$width="100%"
$css={`
overflow-y: auto;
overflow-x: hidden;
position: relative;
height: calc(100dvh - ${isDesktop ? '52px' : '104px'});
`}
>
{authenticated && (
<>
<LeftPanelProjects /> <LeftPanelConversations />
</>
)}
</Box>
);
};
@@ -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 (
<ConversationRow
conversationId={conversation.id}
isActive={isCurrentConversation}
actions={<ConversationItemActions conversation={conversation} />}
>
<SimpleConversationItem
showAccesses
showUpdatedAt={showUpdatedAt}
conversation={conversation}
/>
</ConversationRow>
);
},
);
@@ -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 (
<Box
$padding={{ horizontal: 'xs' }}
$gap={spacingsTokens['2xs']}
$height="50vh"
data-testid="left-panel-favorites"
>
<Text
$size="xs"
$theme="neutral"
$textTransform="uppercase"
$variation="tertiary"
$padding={{ horizontal: 'xs', bottom: '6px' }}
$weight="500"
>
{t('Your chats')}
</Text>
<InfiniteScroll
hasMore={conversations.hasNextPage}
isLoading={conversations.isFetchingNextPage}
next={() => void conversations.fetchNextPage()}
>
{favoriteConversations.map((conversation) => (
<LeftPanelConversationItem
key={conversation.id}
isCurrentConversation={conversation.id === id}
conversation={conversation}
/>
))}
</InfiniteScroll>
</Box>
);
};
@@ -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 && (
<Box $width="100%" className="--docs--left-panel-header">
<LeftPanelHeaderActions
onNewChat={goToHome}
onSearch={() => {
if (!isDesktop) {
setPanelOpen(false);
}
setIsSearchModalOpen(true);
}}
onCreateProject={createProjectModal.open}
/>
{children}
</Box>
)}
{/* Mount only when needed to avoid running hooks while closed */}
{authenticated && isSearchModalOpen && (
<LeftPanelSearchModal onClose={() => setIsSearchModalOpen(false)} />
)}
{authenticated && createProjectModal.isOpen && (
<ModalProjectForm onClose={createProjectModal.close} />
)}
</>
);
};
@@ -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 (
<Box
$direction="column"
$gap="2px"
$align="center"
$margin={{ top: 'base' }}
$padding={{ horizontal: 'sm' }}
>
<LeftPanelMenuItem
icon={
<Box $css={iconWrapperCss} $display="flex" $align="center">
<NewChatIcon aria-hidden />
</Box>
}
label={t('New chat')}
onClick={onNewChat}
aria-label={t('New chat')}
/>
<LeftPanelMenuItem
icon={
<Box $css={iconWrapperCss} $display="flex" $align="center">
<Icon iconName="search" aria-hidden />
</Box>
}
label={t('Search for a chat')}
onClick={onSearch}
aria-label={t('Search for a chat')}
/>
<LeftPanelMenuItem
icon={
<Box $css={iconWrapperCss} $display="flex" $align="center">
<FolderPlusIcon aria-hidden width="24" height="24" />
</Box>
}
label={t('Create project')}
onClick={onCreateProject}
aria-label={t('Create project')}
/>
</Box>
);
};
@@ -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 (
<Box
as="button"
type="button"
$direction="row"
$align="center"
$gap={spacingsTokens.xs}
$padding={{ horizontal: 'xs', vertical: 'xs' }}
$css={menuItemCss}
onClick={onClick}
aria-label={ariaLabel}
>
<Box $shrink={0} $display="flex" $align="center">
{icon}
</Box>
<Text $css={labelCss} $shrink={1} $minWidth={0}>
{label}
</Text>
</Box>
);
};
@@ -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 (
<Box>
{/* Project header - click to toggle */}
<Box
$direction="row"
$align="center"
$padding={{ horizontal: 'xs', vertical: '4px' }}
$gap="2px"
$justify="space-between"
$css={headerStyles}
onClick={toggleOpen}
role="button"
aria-expanded={isOpen}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleOpen();
}
}}
>
<Box $direction="row" $align="center" $gap="2px" $css="min-width: 0;">
<Icon
iconName={isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right'}
$theme="greyscale"
$variation="600"
$size="18px"
/>
<Box
$display="flex"
$align="center"
$justify="center"
$width="18px"
$height="18px"
style={{ color: iconColor, marginRight: '4px', flexShrink: 0 }}
>
<IconComponent
width={18}
height={18}
style={{ fill: 'currentColor', display: 'block' }}
/>
</Box>
<Text
aria-label={project.title}
$size="sm"
$variation="primary"
$weight="500"
$css={titleTextStyles}
>
{project.title}
</Text>
</Box>
<div
className="pinned-actions"
role="group"
style={{ display: 'flex', alignItems: 'center', gap: 0 }}
>
<ProjectItemActions project={project} />
<Button
aria-label={t('New conversation in project')}
onClick={handleNewConversation}
color="info"
variant="bordered"
size="nano"
icon={<Icon iconName="add" $size="18px" $theme="primary" />}
/>
</div>
</Box>
{/* Conversations list */}
{isOpen && (
<Box $padding={{ left: '28px' }} $css={conversationListStyles}>
{project.conversations.length === 0 ? (
<Text
$size="xs"
$variation="tertiary"
$padding={{ horizontal: 'xs', vertical: '4px' }}
>
{t('No conversations')}
</Text>
) : (
project.conversations.map((conv) => (
<ConversationRow
key={conv.id}
conversationId={conv.id}
isActive={conv.id === currentConversationId}
actions={<ConversationItemActions conversation={conv} />}
>
<Text $size="sm" $variation="primary" $weight="400">
{conv.title || t('Untitled conversation')}
</Text>
</ConversationRow>
))
)}
</Box>
)}
</Box>
);
});
@@ -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 (
<Box
$padding={{ horizontal: 'xs' }}
$margin={{ vertical: 'base' }}
$gap={spacingsTokens['2xs']}
data-testid="left-panel-projects"
>
<Text
$size="xs"
$theme="neutral"
$textTransform="uppercase"
$variation="tertiary"
$padding={{ horizontal: 'xs', bottom: '6px' }}
$weight="500"
>
{t('Projects')}
</Text>
<div>
{mainProjects.map((prj) => (
<LeftPanelProjectItem
key={prj.id}
currentConversationId={typeof id === 'string' ? id : undefined}
project={prj}
/>
))}
</div>
</Box>
);
};
@@ -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<ChatConversation> = 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: <InView onChange={() => void fetchNextPage()} /> }]
: [],
};
}, [data, hasNextPage, fetchNextPage, t, search]);
return (
<QuickSearch
placeholder={t('Search for a chat')}
onFilter={handleInputSearch}
inputValue={search}
>
<Box>
{search && (
<QuickSearchGroup
onSelect={handleSelect}
group={conversationsData}
renderElement={(conversation) => (
<LeftPanelConversationItem
conversation={conversation}
isCurrentConversation={
conversation.id === currentConversationId
}
/>
)}
/>
)}
</Box>
</QuickSearch>
);
};
@@ -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<ReturnType<typeof setTimeout>>(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) => (
<QuickSearchResultItem conversation={conversation} />
),
[],
);
const endActionsContent = useMemo(
() =>
hasNextPage
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
: [],
[hasNextPage, fetchNextPage],
);
const conversationsData: QuickSearchData<ChatConversation> = 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 (
<Modal
isOpen
closeOnClickOutside
onClose={handleClose}
size={ModalSize.MEDIUM}
title={t('Search for a chat')}
>
<Box className="quick-search-container" $padding={{ top: 'xs' }}>
<QuickSearch
placeholder={t('Type a keyword')}
onFilter={handleInputChange}
inputValue={inputValue}
loading={showLoader}
hasResults={conversationsData.elements.length > 0}
>
<Box>
{searchQuery && (
<QuickSearchGroup
onSelect={handleSelect}
group={conversationsData}
renderElement={renderElement}
/>
)}
</Box>
</QuickSearch>
</Box>
</Modal>
);
};
@@ -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(<CunninghamProvider>{component}</CunninghamProvider>);
const makeProject = (overrides?: Partial<ChatProject>): 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(<LeftPanelProjectItem project={makeProject()} />);
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(<LeftPanelProjectItem project={makeProject()} />);
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(
<LeftPanelProjectItem
project={makeProject()}
currentConversationId="conv-1"
/>,
);
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(<LeftPanelProjectItem project={makeProject()} />);
await user.click(
screen.getByRole('button', { name: 'New conversation in project' }),
);
expect(usePendingChatStore.getState().projectId).toBe('proj-1');
expect(mockPush).toHaveBeenCalledWith('/chat/');
});
});
@@ -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 (
<Box
$direction="column"
$gap="sm"
$padding={{ all: 'xs' }}
aria-label={t('Choose icon and color')}
>
<Box $css={gridCss}>
{iconEntries.map(([key, IconComp]) => (
<Box
key={key}
as="button"
type="button"
$css={icon === key ? iconSelectedCss : iconCellCss}
onClick={() => onIconChange(key)}
aria-label={key}
aria-pressed={icon === key}
>
<IconComp width={24} height={24} />
</Box>
))}
</Box>
<HorizontalSeparator $withPadding={false} />
<Box $css={gridCss}>
{colorEntries.map(([key, token]) => (
<Box
key={key}
as="button"
type="button"
$css={color === key ? colorSelectedCss : colorCellCss}
onClick={() => onColorChange(key)}
aria-label={key}
aria-pressed={color === key}
>
<Box
$css={css`
width: 32px;
height: 32px;
border-radius: 50%;
`}
style={{
backgroundColor:
colorsTokens[token as keyof typeof colorsTokens],
}}
/>
</Box>
))}
</Box>
</Box>
);
};
@@ -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 (
<Modal
isOpen
closeOnClickOutside
onClose={onClose}
aria-label={
isEditing
? t('Project settings')
: t('Content modal to create a project')
}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="brand"
variant="bordered"
onClick={onClose}
>
{t('Cancel')}
</Button>
<Button
aria-label={
isEditing ? t('Save project settings') : t('Create project')
}
color="brand"
variant="primary"
type="submit"
form={formId}
disabled={!form.name.trim() || isPending}
>
{isEditing ? t('Save') : t('Create project')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{isEditing ? t('Project settings') : t('New project')}
</Text>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<Box
className={
isEditing
? '--conversations--modal-project-settings'
: '--conversations--modal-create-project'
}
onKeyDown={(e) => e.stopPropagation()}
>
<form
onSubmit={handleSubmit}
id={formId}
data-testid={formId}
className="mt-s"
>
<Box $gap={spacingsTokens['base']} $direction="column">
<Box $direction="column">
<Text $size="sm" $variation="600" $weight="500">
{t('Project avatar and name')}
</Text>
<Box $direction="row" $align="center" $gap={spacingsTokens['xs']}>
<DropButton
button={
<Box $css={avatarButtonCss} style={{ color: iconColor }}>
<IconComp
width={24}
height={24}
style={{ fill: 'currentColor' }}
/>
</Box>
}
label={t('Choose icon and color')}
>
<IconColorPicker
icon={form.icon}
color={form.color}
onIconChange={(value) =>
dispatch({ type: 'SET_ICON', value })
}
onColorChange={(value) =>
dispatch({ type: 'SET_COLOR', value })
}
/>
</DropButton>
<Input
type="text"
label={t('Project name')}
maxLength={100}
value={form.name}
fullWidth={true}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch({ type: 'SET_NAME', value: e.target.value })
}
required
/>
</Box>
</Box>
<Box $direction="column">
<Text $size="sm" $variation="600" $weight="500">
{t('Instructions (experimental)')}
</Text>
<TextArea
rows={6}
maxLength={4000} //cf. LLM_INSTRUCTIONS_MAX_LENGTH
value={form.instructions}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
dispatch({ type: 'SET_INSTRUCTIONS', value: e.target.value })
}
placeholder={t(
'Example: Be concise and maintain a professional tone.',
)}
/>
<Text $size="sm" $variation="600">
{t(
'Use this field to provide any additional context the Assistant may need. This feature is being tested. It may not work as expected.',
)}
</Text>
</Box>
</Box>
</form>
</Box>
</Modal>
);
};
@@ -0,0 +1,91 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { t } from 'i18next';
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import { Box, Text, useToast } from '@/components';
import { useRemoveProject } from '@/features/chat/api/useRemoveProject';
interface ModalRemoveProjectProps {
onClose: () => void;
project: { id: string; title: string };
}
export const ModalRemoveProject = ({
onClose,
project,
}: ModalRemoveProjectProps) => {
const { showToast } = useToast();
const { push } = useRouter();
const pathname = usePathname();
const { mutate: removeProject } = useRemoveProject({
onSuccess: () => {
showToast('success', t('The project has been deleted.'), undefined, 4000);
if (pathname === '/') {
onClose();
} else {
void push('/');
}
},
});
return (
<Modal
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-label={t('Content modal to delete project')}
rightActions={
<>
<Button
aria-label={t('Confirm deletion')}
color="error"
variant="bordered"
fullWidth
onClick={() =>
removeProject({
projectId: project.id,
})
}
>
{t('Delete anyway')}
</Button>
<Button
aria-label={t('Close the modal')}
color="brand"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{t('Delete {{title}}', { title: project.title })}
</Text>
}
>
<Box
className="--conversations--modal-remove-project"
data-testid="delete-project-confirm"
>
<Text $size="sm" $variation="600">
{t(
'Are you sure you want to delete the "{{title}}" project? All associated conversations and embedded' +
' documents will be permanently lost.',
{ title: project.title },
)}
</Text>
</Box>
</Modal>
);
};
@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { ChatProject } from '@/features/chat/types';
import { useOwnModal } from '@/features/left-panel/hooks/useModalHook';
import { ModalProjectForm } from './ModalProjectForm';
import { ModalRemoveProject } from './ModalRemoveProject';
interface ProjectItemActionsProps {
project: ChatProject;
}
export const ProjectItemActions = ({ project }: ProjectItemActionsProps) => {
const { t } = useTranslation();
const deleteModal = useOwnModal();
const settingsModal = useOwnModal();
const options: DropdownMenuOption[] = [
{
label: t('Project settings'),
icon: 'settings',
callback: settingsModal.open,
disabled: false,
testId: `project-item-actions-settings-${project.id}`,
},
{
label: t('Delete project'),
icon: 'delete',
callback: deleteModal.open,
disabled: false,
testId: `project-item-actions-remove-${project.id}`,
},
];
const dropdownLabel = useMemo(
() =>
t('Actions list for project {{title}}', {
title: project.title,
}),
[t, project.title],
);
return (
<>
<DropdownMenu
options={options}
label={dropdownLabel}
buttonCss={css`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 4px;
&:hover {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
) !important;
}
&:focus-visible {
outline: 2px solid
var(--c--contextuals--content--semantic--brand--tertiary);
outline-offset: 2px;
}
`}
>
<Icon
data-testid={`project-item-actions-button-${project.id}`}
iconName="more_horiz"
$theme="brand"
$variation="tertiary"
/>
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveProject onClose={deleteModal.close} project={project} />
)}
{settingsModal.isOpen && (
<ModalProjectForm onClose={settingsModal.close} project={project} />
)}
</>
);
};
@@ -0,0 +1,205 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { useToast } from '@/components';
import { ChatProject } from '@/features/chat/types';
import { ModalProjectForm } from '../ModalProjectForm';
jest.mock('@/components', () => ({
...jest.requireActual('@/components'),
useToast: jest.fn(),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
const API_BASE = 'http://test.jest/api/v1.0/';
const renderWithProviders = (component: React.ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<CunninghamProvider>{component}</CunninghamProvider>
</QueryClientProvider>,
);
};
describe('ModalProjectForm', () => {
const mockOnClose = jest.fn();
const mockShowToast = jest.fn();
const existingProject: ChatProject = {
id: 'proj-123',
title: 'My Project',
icon: 'star',
color: 'color_3',
llm_instructions: 'Be helpful',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
conversations: [],
};
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
(useToast as jest.Mock).mockReturnValue({
showToast: mockShowToast,
});
});
it('submits correct payload in create mode', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
status: 201,
body: {
id: 'new-id',
title: 'Test',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
created_at: '',
updated_at: '',
conversations: [],
},
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Test',
);
await user.click(screen.getByRole('button', { name: 'Create project' }));
await waitFor(() => expect(fetchMock.called()).toBe(true));
const [, options] = fetchMock.lastCall()!;
const body = JSON.parse(options!.body as string) as Record<string, unknown>;
expect(body).toEqual({
title: 'Test',
icon: 'folder',
color: 'color_6',
});
});
it('submits correct payload in edit mode', async () => {
const user = userEvent.setup();
fetchMock.patch(`${API_BASE}projects/proj-123/`, 200);
renderWithProviders(
<ModalProjectForm onClose={mockOnClose} project={existingProject} />,
);
const input = screen.getByRole('textbox', { name: 'Project name' });
await user.clear(input);
await user.type(input, 'Updated');
await user.click(
screen.getByRole('button', { name: 'Save project settings' }),
);
await waitFor(() => expect(fetchMock.called()).toBe(true));
const [url, options] = fetchMock.lastCall()!;
expect(url).toContain('projects/proj-123/');
const body = JSON.parse(options!.body as string) as Record<string, unknown>;
expect(body).toEqual({
title: 'Updated',
icon: 'star',
color: 'color_3',
llm_instructions: 'Be helpful',
});
});
it('disables submit button when name is empty or whitespace', async () => {
const user = userEvent.setup();
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
const submitButton = screen.getByRole('button', { name: 'Create project' });
// Initially disabled (empty name)
expect(submitButton).toBeDisabled();
// Type whitespace only - still disabled
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
' ',
);
expect(submitButton).toBeDisabled();
});
it('shows success toast and closes modal on create', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
status: 201,
body: {
id: 'new-id',
title: 'Test',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
created_at: '',
updated_at: '',
conversations: [],
},
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Test',
);
await user.click(screen.getByRole('button', { name: 'Create project' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'success',
'The project has been created.',
undefined,
4000,
);
});
expect(mockOnClose).toHaveBeenCalled();
});
it('shows API error message on failed create', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
status: 400,
body: { title: ['A project with this title already exists.'] },
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Duplicate',
);
await user.click(screen.getByRole('button', { name: 'Create project' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'error',
'A project with this title already exists.',
undefined,
4000,
);
});
expect(mockOnClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,143 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { useToast } from '@/components';
import { ModalRemoveProject } from '../ModalRemoveProject';
jest.mock('@/components', () => ({
...jest.requireActual('@/components'),
useToast: jest.fn(),
}));
const mockPush = jest.fn();
jest.mock('next/router', () => ({
useRouter: () => ({ push: mockPush }),
}));
let mockPathname = '/';
jest.mock('next/navigation', () => ({
usePathname: () => mockPathname,
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) => {
if (options) {
return Object.entries(options).reduce(
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
key,
);
}
return key;
},
}),
}));
jest.mock('i18next', () => ({
t: (key: string, options?: Record<string, string>) => {
if (options) {
return Object.entries(options).reduce(
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
key,
);
}
return key;
},
}));
const API_BASE = 'http://test.jest/api/v1.0/';
const renderWithProviders = (component: React.ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<CunninghamProvider>{component}</CunninghamProvider>
</QueryClientProvider>,
);
};
describe('ModalRemoveProject', () => {
const mockOnClose = jest.fn();
const mockShowToast = jest.fn();
const project = { id: 'proj-123', title: 'My Project' };
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
mockPathname = '/';
(useToast as jest.Mock).mockReturnValue({
showToast: mockShowToast,
});
});
it('sends DELETE request on confirm button click', async () => {
const user = userEvent.setup();
fetchMock.delete(`${API_BASE}projects/proj-123/`, 204);
renderWithProviders(
<ModalRemoveProject onClose={mockOnClose} project={project} />,
);
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => expect(fetchMock.called()).toBe(true));
const [url, options] = fetchMock.lastCall()!;
expect(url).toContain('projects/proj-123/');
expect(options!.method).toBe('DELETE');
});
it('shows success toast and navigates home on success when not on home', async () => {
const user = userEvent.setup();
mockPathname = '/chat/some-id';
fetchMock.delete(`${API_BASE}projects/proj-123/`, 204);
renderWithProviders(
<ModalRemoveProject onClose={mockOnClose} project={project} />,
);
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'success',
'The project has been deleted.',
undefined,
4000,
);
});
expect(mockPush).toHaveBeenCalledWith('/');
expect(mockOnClose).not.toHaveBeenCalled();
});
it('shows success toast and closes modal when already on home', async () => {
const user = userEvent.setup();
mockPathname = '/';
fetchMock.delete(`${API_BASE}projects/proj-123/`, 204);
renderWithProviders(
<ModalRemoveProject onClose={mockOnClose} project={project} />,
);
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'success',
'The project has been deleted.',
undefined,
4000,
);
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockPush).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,63 @@
import { ComponentType, SVGProps } from 'react';
import BookIcon from '@/assets/icons/uikit-custom/book-filled.svg';
import BookmarkIcon from '@/assets/icons/uikit-custom/bookmark-filled.svg';
import CarIcon from '@/assets/icons/uikit-custom/car-filled.svg';
import ChartIcon from '@/assets/icons/uikit-custom/chart-filled.svg';
import CheckmarkIcon from '@/assets/icons/uikit-custom/checkmark-filled.svg';
import EuroIcon from '@/assets/icons/uikit-custom/euro-filled.svg';
import FileIcon from '@/assets/icons/uikit-custom/file-filled.svg';
import FolderIcon from '@/assets/icons/uikit-custom/folder-filled.svg';
import GearIcon from '@/assets/icons/uikit-custom/gear-rounded-filled.svg';
import JusticeIcon from '@/assets/icons/uikit-custom/justice-filled.svg';
import KeyIcon from '@/assets/icons/uikit-custom/key-filled.svg';
import LaSuiteIcon from '@/assets/icons/uikit-custom/lasuite-filled.svg';
import MegaphoneIcon from '@/assets/icons/uikit-custom/megaphone-filled.svg';
import MusicIcon from '@/assets/icons/uikit-custom/music-filled.svg';
import PaletteIcon from '@/assets/icons/uikit-custom/palette-filled.svg';
import PersoIcon from '@/assets/icons/uikit-custom/perso-filled.svg';
import PhotoIcon from '@/assets/icons/uikit-custom/picture-filled.svg';
import PuzzleIcon from '@/assets/icons/uikit-custom/puzzle-filled.svg';
import StarIcon from '@/assets/icons/uikit-custom/star-filled.svg';
import TerminalIcon from '@/assets/icons/uikit-custom/terminal-filled.svg';
export const PROJECT_COLORS: Record<string, string> = {
color_1: 'red-500',
color_2: 'warning-400',
color_3: 'orange-500',
color_4: 'brown-350',
color_5: 'green-650',
color_6: 'blue-1-500',
color_7: 'blue-2-500',
color_8: 'pink-300',
color_9: 'yellow-500',
color_10: 'purple-500',
};
export const defaultIconColor = 'blue-1-500';
export const PROJECT_ICONS: Record<
string,
ComponentType<SVGProps<SVGSVGElement>>
> = {
folder: FolderIcon,
file: FileIcon,
perso: PersoIcon,
gear: GearIcon,
megaphone: MegaphoneIcon,
star: StarIcon,
bookmark: BookmarkIcon,
chart: ChartIcon,
photo: PhotoIcon,
euro: EuroIcon,
key: KeyIcon,
justice: JusticeIcon,
book: BookIcon,
puzzle: PuzzleIcon,
palette: PaletteIcon,
terminal: TerminalIcon,
car: CarIcon,
music: MusicIcon,
checkmark: CheckmarkIcon,
la_suite: LaSuiteIcon,
};
@@ -1,2 +1 @@
export * from './components';
export * from './stores';
@@ -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';
@@ -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 { Box, Text, ToggleSwitch, useToast } from '@/components';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { ReactElement, useEffect } from 'react';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { ReactElement } from 'react';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import Image from 'next/image';
import { ReactElement } from 'react';
@@ -1,3 +1,4 @@
import '@gouvfr-lasuite/ui-kit/style';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { useTranslation } from 'react-i18next';
@@ -6,7 +7,7 @@ import { AppProvider, productName } from '@/core/';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { NextPageWithLayout } from '@/types/next';
import "@gouvfr-lasuite/ui-kit/style";
import './globals.css';
type AppPropsWithLayout = AppProps & {
@@ -1,5 +1,5 @@
// import { CunninghamProvider } from '@openfun/cunningham-react';
import { CunninghamProvider } from "@gouvfr-lasuite/ui-kit";
// import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { CunninghamProvider } from '@gouvfr-lasuite/ui-kit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PropsWithChildren } from 'react';
@@ -0,0 +1,156 @@
import { expect, test } from '@playwright/test';
import { randomName } from './common';
test.describe('Projects', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks the create project button is visible', async ({ page }) => {
const createButton = page.getByRole('button', { name: 'Create project' });
await expect(createButton).toBeVisible();
});
test('it creates a project', async ({ page, browserName }) => {
const [projectName] = randomName('project', browserName, 1);
const createButton = page.getByRole('button', { name: 'Create project' });
await createButton.click();
// Modal should open
const modal = page.getByRole('dialog', {
name: 'Content modal to create a project',
});
await expect(modal).toBeVisible();
// Fill the project name
const nameInput = modal.getByRole('textbox', { name: 'Project name' });
await nameInput.fill(projectName);
// Submit
const submitButton = modal.getByRole('button', { name: 'Create project' });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Toast confirmation
await expect(
page.getByText('The project has been created.'),
).toBeVisible();
// Project should appear in the left panel
await expect(page.getByText(projectName)).toBeVisible();
});
test('it edits a project', async ({ page, browserName }) => {
const [projectName] = randomName('project-edit', browserName, 1);
const updatedName = `${projectName}-updated`;
// Create a project first
await page.getByRole('button', { name: 'Create project' }).click();
const createModal = page.getByRole('dialog', {
name: 'Content modal to create a project',
});
await createModal.getByRole('textbox', { name: 'Project name' }).fill(projectName);
await createModal.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByText('The project has been created.')).toBeVisible();
// Hover the project item to reveal actions
const projectItem = page.getByText(projectName);
await projectItem.hover();
// Open the actions menu
const actionsButton = page.getByLabel(
`Actions list for project ${projectName}`,
);
await actionsButton.click();
// Click settings
await page.getByText('Project settings').click();
// Edit the name
const settingsModal = page.getByRole('dialog', {
name: 'Project settings',
});
await expect(settingsModal).toBeVisible();
const nameInput = settingsModal.getByRole('textbox', {
name: 'Project name',
});
await nameInput.clear();
await nameInput.fill(updatedName);
await settingsModal.getByRole('button', { name: 'Save' }).click();
// Toast confirmation
await expect(
page.getByText('The project has been updated.'),
).toBeVisible();
// Updated name should appear
await expect(page.getByText(updatedName)).toBeVisible();
});
test('it deletes a project', async ({ page, browserName }) => {
const [projectName] = randomName('project-delete', browserName, 1);
// Create a project first
await page.getByRole('button', { name: 'Create project' }).click();
const createModal = page.getByRole('dialog', {
name: 'Content modal to create a project',
});
await createModal.getByRole('textbox', { name: 'Project name' }).fill(projectName);
await createModal.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByText('The project has been created.')).toBeVisible();
// Hover and open actions
await page.getByText(projectName).hover();
await page
.getByLabel(`Actions list for project ${projectName}`)
.click();
// Click delete
await page.getByText('Delete project').click();
// Confirm deletion
const deleteModal = page.getByTestId('delete-project-confirm');
await expect(deleteModal).toBeVisible();
await expect(
deleteModal.getByText(`Are you sure you want to delete the "${projectName}" project?`, { exact: false }),
).toBeVisible();
await page.getByRole('button', { name: 'Confirm deletion' }).click();
// Toast confirmation
await expect(
page.getByText('The project has been deleted.'),
).toBeVisible();
// Project should no longer be visible
await expect(page.getByText(projectName)).toBeHidden();
});
test('it expands and collapses a project', async ({ page, browserName }) => {
const [projectName] = randomName('project-toggle', browserName, 1);
// Create a project first
await page.getByRole('button', { name: 'Create project' }).click();
const createModal = page.getByRole('dialog', {
name: 'Content modal to create a project',
});
await createModal.getByRole('textbox', { name: 'Project name' }).fill(projectName);
await createModal.getByRole('button', { name: 'Create project' }).click();
await expect(page.getByText('The project has been created.')).toBeVisible();
// Click the project to expand it
const projectHeader = page.getByRole('button', { name: projectName, exact: true });
await projectHeader.click();
// Should show "No conversations" when expanded
await expect(page.getByText('No conversations')).toBeVisible();
// Click again to collapse
await projectHeader.click();
await expect(page.getByText('No conversations')).toBeHidden();
});
});