🔥(app-impress) remove templates

We will generate the templates internally.
We remove the template editor.
This commit is contained in:
Anthony LC
2024-05-02 13:50:49 +02:00
committed by Anthony LC
parent b98cc01592
commit d967896874
39 changed files with 0 additions and 1466 deletions

View File

@@ -1,174 +0,0 @@
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();
});
test('it duplicates 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-duplicate',
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('Duplicate template').click();
await expect(
page.getByText('Template duplicated successfully'),
).toBeVisible();
const panel = page.getByLabel('Templates panel').first();
await expect(panel.getByText(`${randomTemplate[0]} - Copy`)).toBeVisible();
});
});

View File

@@ -35,7 +35,6 @@
"zustand": "4.5.2"
},
"devDependencies": {
"@grapesjs/react": "1.0.0",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.32.0",
"@testing-library/jest-dom": "6.4.2",

View File

@@ -6,7 +6,6 @@ import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import MenuItem from './MenuItems';
import IconPad from './assets/icon-pad.svg';
import IconTemplate from './assets/icon-template.svg';
export const Menu = () => {
const { colorsTokens } = useCunninghamTheme();
@@ -22,11 +21,6 @@ export const Menu = () => {
>
<Box className="pt-l" $direction="column" $gap="0.8rem">
<MenuItem Icon={IconPad} label={t('Pad')} href="/" alias={['/pads/']} />
<MenuItem
Icon={IconTemplate}
label={t('Template')}
href="/templates/"
/>
</Box>
</Box>
);

View File

@@ -1,20 +0,0 @@
<svg
class="svg-icon"
style="vertical-align: middle; overflow: hidden"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M139.636 139.636v744.728h744.728V139.636H139.636z m0-46.545h744.728a46.545 46.545 0 0 1 46.545 46.545v744.728a46.545 46.545 0 0 1-46.545 46.545H139.636a46.545 46.545 0 0 1-46.545-46.545V139.636a46.545 46.545 0 0 1 46.545-46.545z"
fill="currentColor"
/>
<path
d="M395.636 93.09q23.273 0 23.273 23.274v791.272q0 23.273-23.273 23.273-23.272 0-23.272-23.273V116.364q0-23.273 23.272-23.273z"
fill="currentColor"
/>
<path
d="M928.256 395.636a23.273 23.273 0 0 1-23.273 23.273H395.636a23.273 23.273 0 1 1 0-46.545h509.347a23.273 23.273 0 0 1 23.273 23.272z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 782 B

View File

@@ -1,3 +0,0 @@
export * from './template';
export * from './template-create';
export * from './template-panel';

View File

@@ -1 +0,0 @@
export * from './useCreateTemplate';

View File

@@ -1,41 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_TEMPLATE, Template } from '@/features/templates';
type CreateTemplateParam = Partial<Template>;
export const createTemplate = async (
props: CreateTemplateParam,
): Promise<Template> => {
const response = await fetchAPI(`templates/`, {
method: 'POST',
body: JSON.stringify(props),
});
if (!response.ok) {
throw new APIError(
'Failed to create the template',
await errorCauses(response),
);
}
return response.json() as Promise<Template>;
};
interface CreateTemplateProps {
onSuccess: (data: Template) => void;
}
export function useCreateTemplate({ onSuccess }: CreateTemplateProps) {
const queryClient = useQueryClient();
return useMutation<Template, APIError, CreateTemplateParam>({
mutationFn: createTemplate,
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEMPLATE],
});
onSuccess(data);
},
});
}

View File

@@ -1,77 +0,0 @@
import { Button, Switch } 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 [templatePublic, setTemplatePublic] = useState(false);
const { colorsTokens } = useCunninghamTheme();
return (
<Card
className="p-b"
$height="70%"
$justify="space-between"
$width="100%"
$maxWidth="24rem"
$minWidth="22rem"
aria-label={t('Create new template card')}
>
<Box $gap="1rem">
<Box $align="center">
<IconGroup
width={44}
color={colorsTokens()['primary-text']}
aria-label={t('icon group')}
/>
<Text as="h3" $textAlign="center">
{t('Name the template')}
</Text>
</Box>
<InputTemplateName
label={t('Template name')}
{...{ error, isError, isPending, setTemplateName }}
/>
<Switch
label={t('Is it public ?')}
labelSide="right"
onChange={() => setTemplatePublic(!templatePublic)}
/>
</Box>
<Box $justify="space-between" $direction="row" $align="center">
<StyledLink href="/">
<Button color="secondary">{t('Cancel')}</Button>
</StyledLink>
<Button
onClick={() =>
createTemplate({ title: templateName, is_public: templatePublic })
}
disabled={!templateName}
>
{t('Create the template')}
</Button>
</Box>
</Card>
);
};

View File

@@ -1,54 +0,0 @@
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 (
<>
<Input
fullWidth
type="text"
label={label}
defaultValue={defaultValue}
onChange={(e) => {
setTemplateName(e.target.value);
setIsInputError(false);
}}
rightIcon={<span className="material-icons">edit</span>}
state={isInputError ? 'error' : 'default'}
/>
{isError && error && <TextErrors causes={error.cause} />}
{isPending && (
<Box $align="center">
<Loader />
</Box>
)}
</>
);
};

View File

@@ -1 +0,0 @@
export * from './CardCreateTemplate';

View File

@@ -1,2 +0,0 @@
export * from './components';
export * from './api';

View File

@@ -1,174 +0,0 @@
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(<TemplateList />, { 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(<TemplateList />, { 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(<TemplateList />, { 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(<TemplateList />, { 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(<TemplateList />, { 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(<Panel />, { 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(<Panel />, { 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();
});
});

View File

@@ -1 +0,0 @@
export * from './useTemplates';

View File

@@ -1,6 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20.75 0.75H3.25C1.8625 0.75 0.75 1.875 0.75 3.25V20.75C0.75 22.125 1.8625 23.25 3.25 23.25H20.75C22.125 23.25 23.25 22.125 23.25 20.75V3.25C23.25 1.875 22.125 0.75 20.75 0.75ZM17 13.25H13.25V17C13.25 17.6875 12.6875 18.25 12 18.25C11.3125 18.25 10.75 17.6875 10.75 17V13.25H7C6.3125 13.25 5.75 12.6875 5.75 12C5.75 11.3125 6.3125 10.75 7 10.75H10.75V7C10.75 6.3125 11.3125 5.75 12 5.75C12.6875 5.75 13.25 6.3125 13.25 7V10.75H17C17.6875 10.75 18.25 11.3125 18.25 12C18.25 12.6875 17.6875 13.25 17 13.25Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 630 B

View File

@@ -1,13 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_508_5524)">
<path
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM4 12C4 7.58 7.58 4 12 4C13.85 4 15.55 4.63 16.9 5.69L5.69 16.9C4.63 15.55 4 13.85 4 12ZM12 20C10.15 20 8.45 19.37 7.1 18.31L18.31 7.1C19.37 8.45 20 10.15 20 12C20 16.42 16.42 20 12 20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_508_5524">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 578 B

View File

@@ -1,14 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle
cx="12"
cy="12"
r="11.5"
transform="rotate(-180 12 12)"
fill="white"
stroke="currentColor"
/>
<path
d="M14.1683 16.232C14.4803 15.92 14.4803 15.416 14.1683 15.104L11.0643 12L14.1683 8.896C14.4803 8.584 14.4803 8.08 14.1683 7.768C13.8563 7.456 13.3523 7.456 13.0403 7.768L9.36834 11.44C9.05634 11.752 9.05634 12.256 9.36834 12.568L13.0403 16.24C13.3443 16.544 13.8563 16.544 14.1683 16.232Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 542 B

View File

@@ -1,13 +0,0 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_17837)">
<path
d="M11.25 3.75L6.25 8.7375H10V17.5H12.5V8.7375H16.25L11.25 3.75ZM20 21.2625V12.5H17.5V21.2625H13.75L18.75 26.25L23.75 21.2625H20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_178_17837">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

View File

@@ -1,83 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import IconOpenClose from '../assets/icon-open-close.svg';
import { PanelActions } from './PanelActions';
import { TemplateList } from './TemplateList';
export const Panel = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(true);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Box
$width="100%"
$maxWidth="20rem"
$minWidth="14rem"
$css={`
position: relative;
border-right: 1px solid ${colorsTokens()['primary-300']};
transition: ${transition};
`}
$height="inherit"
aria-label="Templates panel"
{...closedOverridingStyles}
>
<BoxButton
aria-label={
isOpen
? t('Close the templates panel')
: t('Open the templates panel')
}
$color={colorsTokens()['primary-600']}
$css={`
position: absolute;
right: -1.2rem;
top: 1.03rem;
transform: rotate(${isOpen ? '0' : '180'}deg);
transition: ${transition};
`}
onClick={() => setIsOpen(!isOpen)}
>
<IconOpenClose width={24} height={24} />
</BoxButton>
<Box
$css={`
overflow: hidden;
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
>
<Box
className="pr-l pl-s pt-s pb-s"
$direction="row"
$align="center"
$justify="space-between"
$css={`
border-bottom: 1px solid ${colorsTokens()['primary-300']};
`}
>
<Text $weight="bold" $size="1.25rem">
{t('Recents')}
</Text>
<PanelActions />
</Box>
<TemplateList />
</Box>
</Box>
);
};

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { TemplatesOrdering } from '../api';
import IconAdd from '../assets/icon-add.svg';
import IconSort from '../assets/icon-sort.svg';
import { useTemplatePanelStore } from '../store';
export const PanelActions = () => {
const { t } = useTranslation();
const { changeOrdering, ordering } = useTemplatePanelStore();
const { colorsTokens } = useCunninghamTheme();
const isSortAsc = ordering === TemplatesOrdering.BY_CREATED_ON;
return (
<Box
$direction="row"
$gap="1rem"
$css={`
& button {
padding: 0;
svg {
padding: 0.1rem;
}
}
`}
>
<BoxButton
aria-label={
isSortAsc
? t('Sort the templates by creation date descendent')
: t('Sort the templates by creation date ascendent')
}
onClick={changeOrdering}
$radius="100%"
$background={isSortAsc ? colorsTokens()['primary-200'] : 'transparent'}
$color={colorsTokens()['primary-600']}
>
<IconSort
width={30}
height={30}
aria-label={t('Sort templates icon')}
/>
</BoxButton>
<StyledLink href="/templates/create">
<BoxButton
aria-label={t('Add a template')}
$color={colorsTokens()['primary-600']}
>
<IconAdd width={30} height={30} aria-label={t('Add template icon')} />
</BoxButton>
</StyledLink>
</Box>
);
};

View File

@@ -1,102 +0,0 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Template } from '@/features/templates/template';
import IconNone from '../assets/icon-none.svg';
interface TemplateItemProps {
template: Template;
}
export const TemplateItem = ({ template }: TemplateItemProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const {
query: { id },
} = useRouter();
// There is at least 1 owner in the team
const hasMembers = template.accesses.length > 1;
const isActive = template.id === id;
const commonProps = {
className: 'p-t',
width: 52,
style: {
borderRadius: '10px',
flexShrink: 0,
background: '#fff',
},
};
const activeStyle = `
border-right: 4px solid ${colorsTokens()['primary-600']};
background: ${colorsTokens()['primary-400']};
span{
color: ${colorsTokens()['primary-text']};
}
`;
const hoverStyle = `
&:hover{
border-right: 4px solid ${colorsTokens()['primary-400']};
background: ${colorsTokens()['primary-300']};
span{
color: ${colorsTokens()['primary-text']};
}
}
`;
return (
<Box
className="m-0"
as="li"
$css={`
transition: all 0.2s ease-in;
border-right: 4px solid transparent;
${isActive ? activeStyle : hoverStyle}
`}
>
<StyledLink className="p-s pt-t pb-t" href={`/templates/${template.id}`}>
<Box $align="center" $direction="row" $gap="0.5rem">
{hasMembers ? (
<IconGroup
aria-label={t(`Templates icon`)}
color={colorsTokens()['primary-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
aria-label={t(`Empty templates icon`)}
color={colorsTokens()['greyscale-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['greyscale-300']}`,
}}
/>
)}
<Text
$weight="bold"
$color={!hasMembers ? colorsTokens()['greyscale-600'] : undefined}
$css={`
min-width: 14rem;
`}
>
{template.title}
</Text>
</Box>
</StyledLink>
</Box>
);
};

View File

@@ -1,105 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { InfiniteScroll } from '@/components/InfiniteScroll';
import { Template } from '@/features/templates/template';
import { useTemplates } from '../api';
import { useTemplatePanelStore } from '../store';
import { TemplateItem } from './TemplateItem';
interface PanelTeamsStateProps {
isLoading: boolean;
isError: boolean;
templates?: Template[];
}
const TemplateListState = ({
isLoading,
isError,
templates,
}: PanelTeamsStateProps) => {
const { t } = useTranslation();
if (isError) {
return (
<Box $justify="center" className="mb-b">
<Text $theme="danger" $align="center" $textAlign="center">
{t('Something bad happens, please refresh the page.')}
</Text>
</Box>
);
}
if (isLoading) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
if (!templates?.length) {
return (
<Box $justify="center" className="m-s">
<Text as="p" className="mb-0 mt-0" $theme="greyscale" $variation="500">
{t('0 group to display.')}
</Text>
<Text as="p" $theme="greyscale" $variation="500">
{t(
'Create your first template by clicking on the "Create a new template" button.',
)}
</Text>
</Box>
);
}
return templates.map((template) => (
<TemplateItem template={template} key={template.id} />
));
};
export const TemplateList = () => {
const ordering = useTemplatePanelStore((state) => state.ordering);
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTemplates({
ordering,
});
const containerRef = useRef<HTMLDivElement>(null);
const templates = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Template[]);
}, [data?.pages]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
className="p-0 mt-0"
role="listbox"
>
<TemplateListState
isLoading={isLoading}
isError={isError}
templates={templates}
/>
</InfiniteScroll>
</Box>
);
};

View File

@@ -1,2 +0,0 @@
export * from './api';
export * from './components';

View File

@@ -1 +0,0 @@
export * from './useTemplatePanelStore';

View File

@@ -1,19 +0,0 @@
import { create } from 'zustand';
import { TemplatesOrdering } from '../api/useTemplates';
interface TemplatePanelStore {
ordering: TemplatesOrdering;
changeOrdering: () => void;
}
export const useTemplatePanelStore = create<TemplatePanelStore>((set) => ({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
changeOrdering: () =>
set(({ ordering }) => ({
ordering:
ordering === TemplatesOrdering.BY_CREATED_ON
? TemplatesOrdering.BY_CREATED_ON_DESC
: TemplatesOrdering.BY_CREATED_ON,
})),
}));

View File

@@ -1 +0,0 @@
export * from './useTemplate';

View File

@@ -1,37 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Template } from '../types';
export type TemplateParams = {
id: string;
};
export const getTemplate = async ({
id,
}: TemplateParams): Promise<Template> => {
const response = await fetchAPI(`templates/${id}`);
if (!response.ok) {
throw new APIError(
'Failed to get the template',
await errorCauses(response),
);
}
return response.json() as Promise<Template>;
};
export const KEY_TEMPLATE = 'template';
export function useTemplate(
param: TemplateParams,
queryConfig?: UseQueryOptions<Template, APIError, Template>,
) {
return useQuery<Template, APIError, Template>({
queryKey: [KEY_TEMPLATE, param],
queryFn: () => getTemplate(param),
...queryConfig,
});
}

View File

@@ -1,60 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_TEMPLATE } from '../../template-panel';
import { Template } from '../types';
import { KEY_TEMPLATE } from './useTemplate';
type UpdateTemplateProps = {
id: Template['id'];
css?: string;
html?: string;
title?: Template['title'];
};
export const updateTemplate = async ({
id,
title,
css,
html,
}: UpdateTemplateProps): Promise<Template> => {
const response = await fetchAPI(`templates/${id}/`, {
method: 'PATCH',
body: JSON.stringify({
title,
css,
code: html,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to update the template',
await errorCauses(response),
);
}
return response.json() as Promise<Template>;
};
interface UseUpdateTemplateProps {
onSuccess: (data: Template) => void;
}
export function useUpdateTemplate({ onSuccess }: UseUpdateTemplateProps) {
const queryClient = useQueryClient();
return useMutation<Template, APIError, UpdateTemplateProps>({
mutationFn: updateTemplate,
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEMPLATE],
});
void queryClient.invalidateQueries({
queryKey: [KEY_TEMPLATE],
});
onSuccess(data);
},
});
}

View File

@@ -1,39 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Template } from '../types';
type UpdateTemplateProps = Pick<Template, 'code_editor' | 'id'>;
export const updateTemplateCodeEditor = async ({
code_editor,
id,
}: UpdateTemplateProps): Promise<Template> => {
const response = await fetchAPI(`templates/${id}/`, {
method: 'PATCH',
body: JSON.stringify({
code_editor,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to update the template',
await errorCauses(response),
);
}
return response.json() as Promise<Template>;
};
export function useUpdateTemplateCodeEditor(
onSuccess?: (data: Template) => void,
) {
return useMutation<Template, APIError, UpdateTemplateProps>({
mutationFn: updateTemplateCodeEditor,
onSuccess: (data) => {
onSuccess?.(data);
},
});
}

View File

@@ -1,124 +0,0 @@
import GjsEditor from '@grapesjs/react';
import { Alert, VariantType } from '@openfun/cunningham-react';
import grapesjs, { Editor, ProjectData } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
import pluginBlocksBasic from 'grapesjs-blocks-basic';
import { useEffect, useState } from 'react';
import { Box } from '@/components';
import { useUpdateTemplateCodeEditor } from '../api/useUpdateTemplateCodeEditor';
import { Template } from '../types';
import { TemplateTools } from './TemplateTools';
interface TemplateEditorProps {
template: Template;
}
export const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { mutate: updateCodeEditor } = useUpdateTemplateCodeEditor();
const [editor, setEditor] = useState<Editor>();
const html = editor?.getHtml();
const [showWarning, setShowWarning] = useState(!!html);
useEffect(() => {
if (!html) {
return;
}
setShowWarning(!html.includes('{{body}}'));
}, [html]);
useEffect(() => {
if (!editor?.loadProjectData || !editor?.Storage) {
return;
}
const projectData = Object.keys(template.code_editor).length
? template.code_editor
: editor.getProjectData();
editor?.loadProjectData(projectData);
editor.Storage.add('remote', {
load() {
return Promise.resolve(projectData);
},
store(data: ProjectData) {
updateCodeEditor({
code_editor: data,
id: template.id,
});
return Promise.resolve();
},
});
}, [editor, template.code_editor, template.id, updateCodeEditor]);
const onEditor = (editor: Editor) => {
setEditor(editor);
editor?.Storage.add('remote', {
load() {
return Promise.resolve(editor.getProjectData());
},
store() {
return Promise.resolve();
},
});
};
return (
<>
<TemplateTools
template={template}
html={html}
cssStyle={editor?.getCss()}
/>
<Box
className="m-b"
$css="margin-top:0;"
$effect={showWarning ? 'show' : 'hide'}
>
<Alert
type={VariantType.WARNING}
>{`The {{body}} tag is necessary to works with the pads.`}</Alert>
</Box>
{!template.abilities.partial_update && (
<Box className="m-b" $css="margin-top:0;">
<Alert
type={VariantType.WARNING}
>{`Read only, you don't have the right to update this template.`}</Alert>
</Box>
)}
<Box
className="m-b"
$overflow="auto"
$css={`
margin-top:0;
flex:1;
& .gjs-pn-panel.gjs-pn-options,
& .gjs-pn-panel.gjs-pn-views span[title='Settings'] {
display: none;
}
`}
>
<GjsEditor
grapesjs={grapesjs}
options={{
storageManager: {
type: 'remote',
},
showToolbar: false,
showDevices: false,
}}
plugins={[(editor) => pluginBlocksBasic(editor, {})]}
onEditor={onEditor}
/>
</Box>
</>
);
};

View File

@@ -1,78 +0,0 @@
import {
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useCreateTemplate } from '@/features/templates/template-create';
import { useUpdateTemplate } from '../api/useUpdateTemplate';
import { Template } from '../types';
interface TemplateToolsProps {
template: Template;
html?: string;
cssStyle?: string;
}
export const TemplateTools = ({
template,
html,
cssStyle,
}: TemplateToolsProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { mutate: updateTemplate } = useUpdateTemplate({
onSuccess: () => {
toast(t('Template save successfully'), VariantType.SUCCESS);
},
});
const { mutate: duplicateTemplate } = useCreateTemplate({
onSuccess: () => {
toast(t('Template duplicated successfully'), VariantType.SUCCESS);
},
});
return (
<Box
className="m-b mb-t mt-t"
$direction="row"
$align="center"
$justify="space-between"
>
<Text as="h2" $align="center">
{template.title}
</Text>
<Box $direction="row" $gap="2rem">
<Button
onClick={() => {
duplicateTemplate({
title: `${template.title} - ${t('Copy')}`,
code_editor: template.code_editor,
css: template.css,
code: template.code,
});
}}
color="secondary"
>
{t('Duplicate template')}
</Button>
<Button
onClick={() => {
updateTemplate({
id: template.id,
css: cssStyle,
html,
});
}}
disabled={!template.abilities.partial_update}
>
{t('Save template')}
</Button>
</Box>
</Box>
);
};

View File

@@ -1 +0,0 @@
export * from './TemplateEditor';

View File

@@ -1,3 +0,0 @@
export * from './api';
export * from './components';
export * from './types';

View File

@@ -1,26 +0,0 @@
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Panel } from '@/features/templates/template-panel';
import { MainLayout } from './MainLayout';
export function TemplateLayout({ children }: PropsWithChildren) {
const { colorsTokens } = useCunninghamTheme();
return (
<MainLayout>
<Box $height="inherit" $direction="row">
<Panel />
<Box
$background={colorsTokens()['primary-bg']}
$width="100%"
$height="inherit"
>
{children}
</Box>
</Box>
</MainLayout>
);
}

View File

@@ -1,3 +1,2 @@
export * from './MainLayout';
export * from './PadLayout';
export * from './TemplateLayout';

View File

@@ -1,67 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { Box } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { TemplateEditor, useTemplate } from '@/features/templates/template';
import { TemplateLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const {
query: { id },
} = useRouter();
if (typeof id !== 'string') {
throw new Error('Invalid template id');
}
return <Template id={id} />;
};
interface TemplateProps {
id: string;
}
const Template = ({ id }: TemplateProps) => {
const {
data: template,
isLoading,
isError,
error,
} = useTemplate(
{ id },
{
queryKey: ['template', { id }],
staleTime: 0,
},
);
const navigate = useNavigate();
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
return null;
}
return <TextErrors causes={error.cause} />;
}
if (isLoading || !template) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
return <TemplateEditor template={template} />;
};
Page.getLayout = function getLayout(page: ReactElement) {
return <TemplateLayout>{page}</TemplateLayout>;
};
export default Page;

View File

@@ -1,20 +0,0 @@
import { ReactElement } from 'react';
import { Box } from '@/components';
import { CardCreateTemplate } from '@/features/templates/';
import { TemplateLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
return (
<Box className="p-l" $justify="center" $align="start" $height="inherit">
<CardCreateTemplate />
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <TemplateLayout>{page}</TemplateLayout>;
};
export default Page;

View File

@@ -1,30 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, StyledLink } from '@/components';
import { TemplateLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<Box $align="center" $justify="center" $height="inherit">
<StyledLink href="/templates/create">
<StyledButton>{t('Create a new template')}</StyledButton>
</StyledLink>
</Box>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <TemplateLayout>{page}</TemplateLayout>;
};
export default Page;

View File

@@ -1482,11 +1482,6 @@
dependencies:
tslib "^2.4.0"
"@grapesjs/react@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@grapesjs/react/-/react-1.0.0.tgz#bfa938128db7b69ca1c23439b66decf4eaa0ca9a"
integrity sha512-HHttzxwgvhbxQqqsiiQzb+OJtKFl8yWI9Yp3IgPgiKXqwY19lEJWSkCukvfqxugbYRxi0M1uERLAt209AmY5vQ==
"@humanwhocodes/config-array@^0.11.14":
version "0.11.14"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b"