✨(front) projects management UI
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
This commit is contained in:
@@ -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';
|
||||
|
||||
+59
@@ -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';
|
||||
|
||||
|
||||
+37
@@ -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';
|
||||
|
||||
+78
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
+101
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
+13
-4
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
+8
-8
@@ -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
-1
@@ -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';
|
||||
|
||||
|
||||
+8
-8
@@ -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
-1
@@ -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';
|
||||
|
||||
+9
-14
@@ -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>
|
||||
|
||||
+6
-1
@@ -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';
|
||||
|
||||
|
||||
+2
-8
@@ -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
-1
@@ -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';
|
||||
|
||||
|
||||
+129
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
+35
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
+60
@@ -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>
|
||||
);
|
||||
};
|
||||
+68
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+74
@@ -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>
|
||||
);
|
||||
};
|
||||
+69
@@ -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>
|
||||
);
|
||||
};
|
||||
+197
@@ -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>
|
||||
);
|
||||
});
|
||||
+56
@@ -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>
|
||||
);
|
||||
};
|
||||
+82
@@ -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>
|
||||
);
|
||||
};
|
||||
+147
@@ -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>
|
||||
);
|
||||
};
|
||||
+115
@@ -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/');
|
||||
});
|
||||
});
|
||||
+144
@@ -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>
|
||||
);
|
||||
};
|
||||
+272
@@ -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>
|
||||
);
|
||||
};
|
||||
+91
@@ -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>
|
||||
);
|
||||
};
|
||||
+87
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+205
@@ -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();
|
||||
});
|
||||
});
|
||||
+143
@@ -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();
|
||||
});
|
||||
});
|
||||
+63
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user