✨(app-impress) create Templates feature
We created the features templates. It will be used to create, edit and delete templates. It is a naive copy of pads.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<Box className="pt-l" $direction="column" $gap="0.8rem">
|
||||
<MenuItem Icon={IconSearch} label={t('Search')} href="/" />
|
||||
<MenuItem Icon={IconTemplate} label={t('Template')} href="/templates" />
|
||||
<MenuItem Icon={IconFavorite} label={t('Favorite')} href="/favorite" />
|
||||
<MenuItem Icon={IconRecent} label={t('Recent')} href="/recent" />
|
||||
<MenuItem Icon={IconContacts} label={t('Contacts')} href="/contacts" />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 23 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M2 20h4V4H2v16Zm-1 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1ZM17 20h4V4h-4v16Zm-1 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1ZM9.5 20h4V4h-4v16Zm-1 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1Z"
|
||||
></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1,3 @@
|
||||
export * from './template';
|
||||
export * from './template-create';
|
||||
export * from './template-panel';
|
||||
@@ -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<CreateTemplateResponse> => {
|
||||
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<CreateTemplateResponse>;
|
||||
};
|
||||
|
||||
interface CreateTemplateProps {
|
||||
onSuccess: (data: CreateTemplateResponse) => void;
|
||||
}
|
||||
|
||||
export function useCreateTemplate({ onSuccess }: CreateTemplateProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<CreateTemplateResponse, APIError, string>({
|
||||
mutationFn: createTemplate,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_TEMPLATE],
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<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 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box $justify="space-between" $direction="row" $align="center">
|
||||
<StyledLink href="/">
|
||||
<Button color="secondary">{t('Cancel')}</Button>
|
||||
</StyledLink>
|
||||
<Button
|
||||
onClick={() => createTemplate(templateName)}
|
||||
disabled={!templateName}
|
||||
>
|
||||
{t('Create the template')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CardCreateTemplate';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components';
|
||||
@@ -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(<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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useTemplates';
|
||||
@@ -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<Template>;
|
||||
|
||||
export const getTemplates = async ({
|
||||
ordering,
|
||||
page,
|
||||
}: TemplatesAPIParams): Promise<TemplatesResponse> => {
|
||||
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
|
||||
const response = await fetchAPI(`templates/?page=${page}${orderingQuery}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the templates',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TemplatesResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_TEMPLATE = 'templates';
|
||||
|
||||
export function useTemplates(
|
||||
param: TemplatesParams,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptions<
|
||||
TemplatesResponse,
|
||||
APIError,
|
||||
InfiniteData<TemplatesResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
) {
|
||||
return useInfiniteQuery<
|
||||
TemplatesResponse,
|
||||
APIError,
|
||||
InfiniteData<TemplatesResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>({
|
||||
initialPageParam: 1,
|
||||
queryKey: [KEY_LIST_TEMPLATE, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getTemplates({
|
||||
...param,
|
||||
page: pageParam,
|
||||
}),
|
||||
getNextPageParam(lastPage, allPages) {
|
||||
return lastPage.next ? allPages.length + 1 : undefined;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 630 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 542 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 429 B |
@@ -0,0 +1,83 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Panel';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useTemplatePanelStore';
|
||||
@@ -0,0 +1,19 @@
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useTemplate';
|
||||
@@ -0,0 +1,37 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 = Pick<Template, 'title' | 'id'>;
|
||||
|
||||
export const updateTemplate = async ({
|
||||
title,
|
||||
id,
|
||||
}: UpdateTemplateProps): Promise<Template> => {
|
||||
const response = await fetchAPI(`templates/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './TemplateEditor';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,31 @@
|
||||
export enum Role {
|
||||
MEMBER = 'member',
|
||||
ADMIN = 'administrator',
|
||||
OWNER = 'owner',
|
||||
}
|
||||
|
||||
export interface Access {
|
||||
id: string;
|
||||
abilities: {
|
||||
destroy: boolean;
|
||||
retrieve: boolean;
|
||||
set_role_to: Role[];
|
||||
update: boolean;
|
||||
};
|
||||
role: Role;
|
||||
team: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
abilities: {
|
||||
destroy: boolean;
|
||||
generate_document: boolean;
|
||||
manage_accesses: boolean;
|
||||
retrieve: boolean;
|
||||
update: boolean;
|
||||
};
|
||||
accesses: Access[];
|
||||
title: string;
|
||||
}
|
||||
26
src/frontend/apps/impress/src/layouts/TemplateLayout.tsx
Normal file
26
src/frontend/apps/impress/src/layouts/TemplateLayout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './MainLayout';
|
||||
export * from './PadLayout';
|
||||
export * from './TemplateLayout';
|
||||
|
||||
56
src/frontend/apps/impress/src/pages/templates/[id].tsx
Normal file
56
src/frontend/apps/impress/src/pages/templates/[id].tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 });
|
||||
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;
|
||||
20
src/frontend/apps/impress/src/pages/templates/create.tsx
Normal file
20
src/frontend/apps/impress/src/pages/templates/create.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
30
src/frontend/apps/impress/src/pages/templates/index.tsx
Normal file
30
src/frontend/apps/impress/src/pages/templates/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user