diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e6b9b99a9..a947702fd 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -145,9 +145,19 @@ class TemplateSerializer(BaseResourceSerializer): class Meta: model = models.Template - fields = ["id", "title", "accesses", "abilities"] + fields = ["id", "title", "code_editor", "accesses", "abilities", "css", "code"] read_only_fields = ["id", "accesses", "abilities"] + def to_representation(self, instance): + """ + Modify the output of serialization. + """ + representation = super().to_representation(instance) + # Remove 'css' and 'code' from the representation + representation.pop("css", None) + representation.pop("code", None) + return representation + # pylint: disable=abstract-method class DocumentGenerationSerializer(serializers.Serializer): diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py index c7a572dda..4044bf772 100644 --- a/src/backend/core/migrations/0001_initial.py +++ b/src/backend/core/migrations/0001_initial.py @@ -42,6 +42,7 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), ('title', models.CharField(max_length=255, verbose_name='title')), ('description', models.TextField(blank=True, verbose_name='description')), + ('code_editor', models.JSONField(blank=True, default=dict, help_text='A JSON object with all the editor information', verbose_name='code editor')), ('code', models.TextField(blank=True, verbose_name='code')), ('css', models.TextField(blank=True, verbose_name='css')), ('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')), diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 887a2d551..6711668cc 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -272,6 +272,7 @@ def get_abilities(self, user): "destroy": RoleChoices.OWNER in roles, "manage_accesses": is_owner_or_admin, "update": is_owner_or_admin, + "partial_update": is_owner_or_admin, "retrieve": can_get, } @@ -326,6 +327,12 @@ class Template(BaseModel): title = models.CharField(_("title"), max_length=255) description = models.TextField(_("description"), blank=True) + code_editor = models.JSONField( + _("code editor"), + help_text=_("A JSON object with all the editor information"), + blank=True, + default=dict, + ) code = models.TextField(_("code"), blank=True) css = models.TextField(_("css"), blank=True) is_public = models.BooleanField( @@ -358,6 +365,7 @@ def get_abilities(self, user): "generate_document": can_get, "manage_accesses": is_owner_or_admin, "update": is_owner_or_admin, + "partial_update": is_owner_or_admin, "retrieve": can_get, } diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 078945db4..1cbafb3f5 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -21,6 +21,7 @@ def test_api_documents_retrieve_anonymous_public(): "abilities": { "destroy": False, "manage_accesses": False, + "partial_update": False, "retrieve": True, "update": False, }, @@ -60,6 +61,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public(): "abilities": { "destroy": False, "manage_accesses": False, + "partial_update": False, "retrieve": True, "update": False, }, diff --git a/src/backend/core/tests/templates/test_api_templates_retrieve.py b/src/backend/core/tests/templates/test_api_templates_retrieve.py index 8ed186dd4..8a298ddba 100644 --- a/src/backend/core/tests/templates/test_api_templates_retrieve.py +++ b/src/backend/core/tests/templates/test_api_templates_retrieve.py @@ -22,11 +22,13 @@ def test_api_templates_retrieve_anonymous_public(): "destroy": False, "generate_document": True, "manage_accesses": False, + "partial_update": False, "retrieve": True, "update": False, }, "accesses": [], "title": template.title, + "code_editor": {}, } @@ -62,11 +64,13 @@ def test_api_templates_retrieve_authenticated_unrelated_public(): "destroy": False, "generate_document": True, "manage_accesses": False, + "partial_update": False, "retrieve": True, "update": False, }, "accesses": [], "title": template.title, + "code_editor": {}, } @@ -131,6 +135,7 @@ def test_api_templates_retrieve_authenticated_related_direct(): "id": str(template.id), "title": template.title, "abilities": template.get_abilities(user), + "code_editor": {}, } @@ -244,6 +249,7 @@ def test_api_templates_retrieve_authenticated_related_team_members( "id": str(template.id), "title": template.title, "abilities": template.get_abilities(user), + "code_editor": {}, } @@ -339,6 +345,7 @@ def test_api_templates_retrieve_authenticated_related_team_administrators( "id": str(template.id), "title": template.title, "abilities": template.get_abilities(user), + "code_editor": {}, } @@ -438,4 +445,5 @@ def test_api_templates_retrieve_authenticated_related_team_owners( "id": str(template.id), "title": template.title, "abilities": template.get_abilities(user), + "code_editor": {}, } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 3544d4a0c..b4c1f5543 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -58,6 +58,7 @@ def test_models_documents_get_abilities_anonymous_public(): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, } @@ -70,6 +71,7 @@ def test_models_documents_get_abilities_anonymous_not_public(): "retrieve": False, "update": False, "manage_accesses": False, + "partial_update": False, } @@ -82,6 +84,7 @@ def test_models_documents_get_abilities_authenticated_public(): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, } @@ -94,6 +97,7 @@ def test_models_documents_get_abilities_authenticated_not_public(): "retrieve": False, "update": False, "manage_accesses": False, + "partial_update": False, } @@ -107,6 +111,7 @@ def test_models_documents_get_abilities_owner(): "retrieve": True, "update": True, "manage_accesses": True, + "partial_update": True, } @@ -119,6 +124,7 @@ def test_models_documents_get_abilities_administrator(): "retrieve": True, "update": True, "manage_accesses": True, + "partial_update": True, } @@ -134,6 +140,7 @@ def test_models_documents_get_abilities_member_user(django_assert_num_queries): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, } @@ -150,4 +157,5 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, } diff --git a/src/backend/core/tests/test_models_templates.py b/src/backend/core/tests/test_models_templates.py index dc295bc71..87d9f8d23 100644 --- a/src/backend/core/tests/test_models_templates.py +++ b/src/backend/core/tests/test_models_templates.py @@ -58,6 +58,7 @@ def test_models_templates_get_abilities_anonymous_public(): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, "generate_document": True, } @@ -71,6 +72,7 @@ def test_models_templates_get_abilities_anonymous_not_public(): "retrieve": False, "update": False, "manage_accesses": False, + "partial_update": False, "generate_document": False, } @@ -84,6 +86,7 @@ def test_models_templates_get_abilities_authenticated_public(): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, "generate_document": True, } @@ -97,6 +100,7 @@ def test_models_templates_get_abilities_authenticated_not_public(): "retrieve": False, "update": False, "manage_accesses": False, + "partial_update": False, "generate_document": False, } @@ -111,6 +115,7 @@ def test_models_templates_get_abilities_owner(): "retrieve": True, "update": True, "manage_accesses": True, + "partial_update": True, "generate_document": True, } @@ -124,6 +129,7 @@ def test_models_templates_get_abilities_administrator(): "retrieve": True, "update": True, "manage_accesses": True, + "partial_update": True, "generate_document": True, } @@ -140,6 +146,7 @@ def test_models_templates_get_abilities_member_user(django_assert_num_queries): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, "generate_document": True, } @@ -157,5 +164,12 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries): "retrieve": True, "update": False, "manage_accesses": False, + "partial_update": False, "generate_document": True, } + + +def test_models_templates_get_code_editor(): + """Check code_editor in the template model""" + template = factories.TemplateFactory(code_editor={"test": "ok"}) + assert template.code_editor == {"test": "ok"} diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 542638ccf..0aea93c5e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -45,6 +45,35 @@ export const createPad = async ( return randomPads; }; +export const createTemplate = async ( + page: Page, + templateName: string, + browserName: string, + length: number, +) => { + const menu = page.locator('menu').first(); + await menu.getByLabel(`Template button`).click(); + + const panel = page.getByLabel('Templates panel').first(); + const buttonCreate = page.getByRole('button', { + name: 'Create the template', + }); + + const randomTemplates = randomName(templateName, browserName, length); + + for (let i = 0; i < randomTemplates.length; i++) { + await panel.getByRole('button', { name: 'Add a template' }).click(); + await page.getByText('Template name').fill(randomTemplates[i]); + await expect(buttonCreate).toBeEnabled(); + await buttonCreate.click(); + await expect( + panel.locator('li').getByText(randomTemplates[i]), + ).toBeVisible(); + } + + return randomTemplates; +}; + export const addNewMember = async ( page: Page, index: number, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/menu.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/menu.spec.ts index 4bd738521..1eafa55a3 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/menu.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/menu.spec.ts @@ -10,6 +10,7 @@ test.beforeEach(async ({ page, browserName }) => { test.describe('Menu', () => { const menuItems = [ { name: 'Search', isDefault: true }, + { name: 'Template', isDefault: false }, { name: 'Favorite', isDefault: false }, { name: 'Recent', isDefault: false }, { name: 'Contacts', isDefault: false }, 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 e2bd07e5b..fa3d31e88 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/e2e/__tests__/app-impress/template-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/template-editor.spec.ts new file mode 100644 index 000000000..da0fed690 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/template-editor.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from '@playwright/test'; + +import { createTemplate, keyCloakSignIn } from './common'; + +test.beforeEach(async ({ page, browserName }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); +}); + +test.describe('Template Editor', () => { + test('checks the template editor interact correctly', async ({ + page, + browserName, + }) => { + const randomTemplate = await createTemplate( + page, + 'template-editor', + browserName, + 1, + ); + + await expect(page.locator('h2').getByText(randomTemplate[0])).toBeVisible(); + + await page.getByTitle('Open Blocks').click(); + await expect( + page.locator('.gjs-editor .gjs-block[title="Text"]'), + ).toBeVisible(); + }); + + test('checks the template editor save on changed', async ({ + page, + browserName, + }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName !== 'chromium', + 'This test failed with safary because of the dragNdrop', + ); + + const randomTemplate = await createTemplate( + page, + 'template-editor', + browserName, + 1, + ); + + await expect(page.locator('h2').getByText(randomTemplate[0])).toBeVisible(); + + const iframe = page.frameLocator('iFrame.gjs-frame'); + + await page.getByTitle('Open Blocks').click(); + await page + .locator('.gjs-editor .gjs-block[title="Text"]') + .dragTo(iframe.locator('body.gjs-dashed')); + + await iframe.getByText('Insert your text here').fill('Hello World'); + await iframe.locator('body.gjs-dashed').click(); + + // Come on the page again to check the changes are saved + await page.locator('menu').first().getByLabel(`Template button`).click(); + const panel = page.getByLabel('Templates panel').first(); + await panel.locator('li').getByText(randomTemplate[0]).click(); + + await expect(iframe.getByText('Hello World')).toBeVisible({ + timeout: 5000, + }); + }); + + test('it saves the html generated by the template', async ({ + page, + browserName, + }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName !== 'chromium', + 'This test failed with safary because of the dragNdrop', + ); + + const randomTemplate = await createTemplate( + page, + 'template-html', + browserName, + 1, + ); + + await expect(page.locator('h2').getByText(randomTemplate[0])).toBeVisible(); + + const iframe = page.frameLocator('iFrame.gjs-frame'); + + await page.getByTitle('Open Blocks').click(); + await page + .locator('.gjs-editor .gjs-block[title="Text"]') + .dragTo(iframe.locator('body.gjs-dashed')); + + await iframe.getByText('Insert your text here').fill('Hello World'); + await iframe.locator('body.gjs-dashed').click(); + + await page.getByText('Save template').click(); + await expect(page.getByText('Template save successfully')).toBeVisible(); + }); + + test('it shows a warning if body tag not present', async ({ + page, + browserName, + }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + browserName !== 'chromium', + 'This test failed with safary because of the dragNdrop', + ); + + const randomTemplate = await createTemplate( + page, + 'template-html', + browserName, + 1, + ); + + await expect(page.locator('h2').getByText(randomTemplate[0])).toBeVisible(); + + await expect( + page.getByText('The {{body}} tag is necessary to works with the pads.'), + ).toBeVisible(); + + const iframe = page.frameLocator('iFrame.gjs-frame'); + + await page.getByTitle('Open Blocks').click(); + await page + .locator('.gjs-editor .gjs-block[title="Text"]') + .dragTo(iframe.locator('body.gjs-dashed')); + + await iframe.getByText('Insert your text here').fill('{{body}}'); + await iframe.locator('body.gjs-dashed').click(); + + await expect( + page.getByText('The {{body}} tag is necessary to works with the pads.'), + ).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index a8d9eb9d5..70951af3a 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -19,6 +19,7 @@ "@blocknote/react": "0.12.4", "@openfun/cunningham-react": "2.7.0", "@tanstack/react-query": "5.28.14", + "grapesjs-blocks-basic": "1.0.2", "i18next": "23.10.1", "lodash": "4.17.21", "luxon": "3.4.4", @@ -34,6 +35,7 @@ "zustand": "4.5.2" }, "devDependencies": { + "@grapesjs/react": "1.0.0", "@svgr/webpack": "8.1.0", "@tanstack/react-query-devtools": "5.28.14", "@testing-library/jest-dom": "6.4.2", @@ -48,6 +50,7 @@ "dotenv": "16.4.5", "eslint-config-impress": "*", "fetch-mock": "9.11.0", + "grapesjs": "0.21.10", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "node-fetch": "2.7.0", diff --git a/src/frontend/apps/impress/src/components/Box.tsx b/src/frontend/apps/impress/src/components/Box.tsx index dd82c5f7c..7d8b0e952 100644 --- a/src/frontend/apps/impress/src/components/Box.tsx +++ b/src/frontend/apps/impress/src/components/Box.tsx @@ -2,6 +2,8 @@ import { ComponentPropsWithRef, ReactHTML } from 'react'; import styled from 'styled-components'; import { CSSProperties } from 'styled-components/dist/types'; +import { hideEffect, showEffect } from './Effect'; + export interface BoxProps { as?: keyof ReactHTML; $align?: CSSProperties['alignItems']; @@ -10,6 +12,7 @@ export interface BoxProps { $css?: string; $direction?: CSSProperties['flexDirection']; $display?: CSSProperties['display']; + $effect?: 'show' | 'hide'; $flex?: boolean; $gap?: CSSProperties['gap']; $height?: CSSProperties['height']; @@ -43,4 +46,12 @@ export const Box = styled('div')` ${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth};`} ${({ $minWidth }) => $minWidth && `min-width: ${$minWidth};`} ${({ $css }) => $css && `${$css};`} + ${({ $effect }) => { + switch ($effect) { + case 'show': + return showEffect; + case 'hide': + return hideEffect; + } + }} `; diff --git a/src/frontend/apps/impress/src/components/Effect.tsx b/src/frontend/apps/impress/src/components/Effect.tsx new file mode 100644 index 000000000..40983550b --- /dev/null +++ b/src/frontend/apps/impress/src/components/Effect.tsx @@ -0,0 +1,20 @@ +import { css, keyframes } from 'styled-components'; + +const show = keyframes` + 0% { transform: scaleY(0); opacity: 0; max-height: 0; } + 100% { transform: scaleY(1); opacity: 1; max-height: 150px;} +`; + +const hide = keyframes` + 0% { transform: scaleY(1); opacity: 1; max-height: 150px;} + 100% { display:none; transform: scaleY(0); opacity: 0; max-height: 0; } +`; + +export const showEffect = css` + animation: ${show} 0.3s ease-in-out; +`; + +export const hideEffect = css` + animation: ${hide} 0.3s ease-in-out; + animation-fill-mode: forwards; +`; diff --git a/src/frontend/apps/impress/src/features/menu/Menu.tsx b/src/frontend/apps/impress/src/features/menu/Menu.tsx index 79f1eda54..571c73045 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 000000000..1871d9c40 --- /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 000000000..2abeb7e5b --- /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 000000000..a4da27411 --- /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 000000000..7599ff663 --- /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 000000000..71d80b382 --- /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 000000000..8a738338c --- /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 000000000..07635cbbc --- /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 000000000..443449d1d --- /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 000000000..ba0d606e8 --- /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 000000000..2f08036d0 --- /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