diff --git a/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts index e2bd07e5..fa3d31e8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts @@ -94,11 +94,7 @@ test.describe('Pad Editor', () => { await expect(page.getByText('[test markdown]')).toBeVisible(); await page.getByText('[test markdown]').dblclick(); - await page - .getByRole('button', { - name: 'M', - }) - .click(); + await page.locator('button[data-test="convertMarkdown"]').click(); await expect(page.getByText('[test markdown]')).toBeHidden(); await expect( diff --git a/src/frontend/apps/impress/src/features/menu/Menu.tsx b/src/frontend/apps/impress/src/features/menu/Menu.tsx index 79f1eda5..571c7304 100644 --- a/src/frontend/apps/impress/src/features/menu/Menu.tsx +++ b/src/frontend/apps/impress/src/features/menu/Menu.tsx @@ -10,6 +10,7 @@ import IconRecent from './assets/icon-clock.svg'; import IconContacts from './assets/icon-contacts.svg'; import IconSearch from './assets/icon-search.svg'; import IconFavorite from './assets/icon-stars.svg'; +import IconTemplate from './assets/icon-template.svg'; export const Menu = () => { const { colorsTokens } = useCunninghamTheme(); @@ -25,6 +26,7 @@ export const Menu = () => { > + diff --git a/src/frontend/apps/impress/src/features/menu/assets/icon-template.svg b/src/frontend/apps/impress/src/features/menu/assets/icon-template.svg new file mode 100644 index 00000000..1871d9c4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/menu/assets/icon-template.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/templates/index.ts b/src/frontend/apps/impress/src/features/templates/index.ts new file mode 100644 index 00000000..2abeb7e5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/index.ts @@ -0,0 +1,3 @@ +export * from './template'; +export * from './template-create'; +export * from './template-panel'; diff --git a/src/frontend/apps/impress/src/features/templates/template-create/api/useCreateTemplate.tsx b/src/frontend/apps/impress/src/features/templates/template-create/api/useCreateTemplate.tsx new file mode 100644 index 00000000..a4da2741 --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-create/api/useCreateTemplate.tsx @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { KEY_LIST_TEMPLATE } from '@/features/templates'; + +type CreateTemplateResponse = { + id: string; + title: string; +}; + +export const createTemplate = async ( + title: string, +): Promise => { + const response = await fetchAPI(`templates/`, { + method: 'POST', + body: JSON.stringify({ + title, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to create the template', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +interface CreateTemplateProps { + onSuccess: (data: CreateTemplateResponse) => void; +} + +export function useCreateTemplate({ onSuccess }: CreateTemplateProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createTemplate, + onSuccess: (data) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_TEMPLATE], + }); + onSuccess(data); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/templates/template-create/components/CardCreateTemplate.tsx b/src/frontend/apps/impress/src/features/templates/template-create/components/CardCreateTemplate.tsx new file mode 100644 index 00000000..7599ff66 --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-create/components/CardCreateTemplate.tsx @@ -0,0 +1,69 @@ +import { Button } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import IconGroup from '@/assets/icons/icon-group2.svg'; +import { Box, Card, StyledLink, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { useCreateTemplate } from '../api/useCreateTemplate'; + +import { InputTemplateName } from './InputTemplateName'; + +export const CardCreateTemplate = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { + mutate: createTemplate, + isError, + isPending, + error, + } = useCreateTemplate({ + onSuccess: (pad) => { + router.push(`/templates/${pad.id}`); + }, + }); + const [templateName, setTemplateName] = useState(''); + const { colorsTokens } = useCunninghamTheme(); + + return ( + + + + + + {t('Name the template')} + + + + + + + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/templates/template-create/components/InputTemplateName.tsx b/src/frontend/apps/impress/src/features/templates/template-create/components/InputTemplateName.tsx new file mode 100644 index 00000000..71d80b38 --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-create/components/InputTemplateName.tsx @@ -0,0 +1,54 @@ +import { Input, Loader } from '@openfun/cunningham-react'; +import { useEffect, useState } from 'react'; + +import { APIError } from '@/api'; +import { Box, TextErrors } from '@/components'; + +interface InputTemplateNameProps { + error: APIError | null; + isError: boolean; + isPending: boolean; + label: string; + setTemplateName: (newTemplateName: string) => void; + defaultValue?: string; +} + +export const InputTemplateName = ({ + defaultValue, + error, + isError, + isPending, + label, + setTemplateName, +}: InputTemplateNameProps) => { + const [isInputError, setIsInputError] = useState(isError); + + useEffect(() => { + if (isError) { + setIsInputError(true); + } + }, [isError]); + + return ( + <> + { + setTemplateName(e.target.value); + setIsInputError(false); + }} + rightIcon={edit} + state={isInputError ? 'error' : 'default'} + /> + {isError && error && } + {isPending && ( + + + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/templates/template-create/components/index.ts b/src/frontend/apps/impress/src/features/templates/template-create/components/index.ts new file mode 100644 index 00000000..8a738338 --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-create/components/index.ts @@ -0,0 +1 @@ +export * from './CardCreateTemplate'; diff --git a/src/frontend/apps/impress/src/features/templates/template-create/index.ts b/src/frontend/apps/impress/src/features/templates/template-create/index.ts new file mode 100644 index 00000000..07635cbb --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-create/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/src/frontend/apps/impress/src/features/templates/template-panel/__tests__/PanelTemplates.test.tsx b/src/frontend/apps/impress/src/features/templates/template-panel/__tests__/PanelTemplates.test.tsx new file mode 100644 index 00000000..443449d1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-panel/__tests__/PanelTemplates.test.tsx @@ -0,0 +1,174 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; + +import { AppWrapper } from '@/tests/utils'; + +import { Panel } from '../components/Panel'; +import { TemplateList } from '../components/TemplateList'; + +window.HTMLElement.prototype.scroll = function () {}; + +jest.mock('next/router', () => ({ + ...jest.requireActual('next/router'), + useRouter: () => ({ + query: {}, + }), +})); + +describe('PanelTemplates', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('renders with no template to display', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + count: 0, + results: [], + }); + + render(, { wrapper: AppWrapper }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + expect( + await screen.findByText( + 'Create your first template by clicking on the "Create a new template" button.', + ), + ).toBeInTheDocument(); + }); + + it('renders an empty template', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + count: 1, + results: [ + { + id: '1', + name: 'Template 1', + accesses: [], + }, + ], + }); + + render(, { wrapper: AppWrapper }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + expect( + await screen.findByLabelText('Empty templates icon'), + ).toBeInTheDocument(); + }); + + it('renders a template with only 1 member', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + count: 1, + results: [ + { + id: '1', + name: 'Template 1', + accesses: [ + { + id: '1', + role: 'owner', + }, + ], + }, + ], + }); + + render(, { wrapper: AppWrapper }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + expect( + await screen.findByLabelText('Empty templates icon'), + ).toBeInTheDocument(); + }); + + it('renders a non-empty template', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + count: 1, + results: [ + { + id: '1', + name: 'Template 1', + accesses: [ + { + id: '1', + role: 'admin', + }, + { + id: '2', + role: 'member', + }, + ], + }, + ], + }); + + render(, { wrapper: AppWrapper }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + expect(await screen.findByLabelText('Templates icon')).toBeInTheDocument(); + }); + + it('renders the error', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + status: 500, + }); + + render(, { wrapper: AppWrapper }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + expect( + await screen.findByText( + 'Something bad happens, please refresh the page.', + ), + ).toBeInTheDocument(); + }); + + it('renders with template panel open', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + count: 1, + results: [], + }); + + render(, { wrapper: AppWrapper }); + + expect( + screen.getByRole('button', { name: 'Close the templates panel' }), + ).toBeVisible(); + + expect(await screen.findByText('Recents')).toBeVisible(); + }); + + it('closes and opens the template panel', async () => { + fetchMock.mock(`/api/templates/?page=1&ordering=-created_at`, { + count: 1, + results: [], + }); + + render(, { wrapper: AppWrapper }); + + expect(await screen.findByText('Recents')).toBeVisible(); + + await userEvent.click( + screen.getByRole('button', { + name: 'Close the templates panel', + }), + ); + + expect(await screen.findByText('Recents')).not.toBeVisible(); + + await userEvent.click( + screen.getByRole('button', { + name: 'Open the templates panel', + }), + ); + + expect(await screen.findByText('Recents')).toBeVisible(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/templates/template-panel/api/index.ts b/src/frontend/apps/impress/src/features/templates/template-panel/api/index.ts new file mode 100644 index 00000000..ba0d606e --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-panel/api/index.ts @@ -0,0 +1 @@ +export * from './useTemplates'; diff --git a/src/frontend/apps/impress/src/features/templates/template-panel/api/useTemplates.tsx b/src/frontend/apps/impress/src/features/templates/template-panel/api/useTemplates.tsx new file mode 100644 index 00000000..2f08036d --- /dev/null +++ b/src/frontend/apps/impress/src/features/templates/template-panel/api/useTemplates.tsx @@ -0,0 +1,73 @@ +import { + DefinedInitialDataInfiniteOptions, + InfiniteData, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { Template } from '@/features/templates/template'; + +export enum TemplatesOrdering { + BY_CREATED_ON = 'created_at', + BY_CREATED_ON_DESC = '-created_at', +} + +export type TemplatesParams = { + ordering: TemplatesOrdering; +}; +type TemplatesAPIParams = TemplatesParams & { + page: number; +}; + +type TemplatesResponse = APIList