(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:
Anthony LC
2024-04-15 22:32:24 +02:00
committed by Anthony LC
parent 7fb8a62e63
commit 8d2a78cf8d
35 changed files with 1107 additions and 5 deletions

View File

@@ -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(

View File

@@ -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" />

View File

@@ -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

View File

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

View File

@@ -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);
},
});
}

View File

@@ -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>
);
};

View File

@@ -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>
)}
</>
);
};

View File

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

View File

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

View File

@@ -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();
});
});

View File

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

View File

@@ -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,
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

@@ -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,
})),
}));

View File

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

View File

@@ -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,
});
}

View File

@@ -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);
},
});
}

View File

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

View File

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

View File

@@ -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;
}

View 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>
);
}

View File

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

View 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;

View 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;

View 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;