(app-desk) add panel for mail domains

Adds a panel based on teams' one. It fetches all mail-domains
the connected user has relationships with and displays them
as a list of links redirecting to
/mail-domains/<my-domain-name>. Updates e2e tests accordingly.
This commit is contained in:
daproclaima
2024-05-02 12:30:52 +02:00
committed by Sebastien Nobour
parent 3f1b446e8e
commit 9b198d0bab
14 changed files with 467 additions and 12 deletions

View File

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

View File

@@ -0,0 +1,70 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { MailDomain } from '@/features/mail-domains/types';
type MailDomainsResponse = APIList<MailDomain>;
export enum EnumMailDomainsOrdering {
BY_CREATED_AT = 'created_at',
BY_CREATED_AT_DESC = '-created_at',
}
export type MailDomainsParams = {
ordering: EnumMailDomainsOrdering;
};
type MailDomainsAPIParams = MailDomainsParams & {
page: number;
};
export const getMailDomains = async ({
ordering,
page,
}: MailDomainsAPIParams): Promise<MailDomainsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`mail-domains/?page=${page}${orderingQuery}`);
if (!response.ok) {
throw new APIError(
'Failed to get the mail domains',
await errorCauses(response),
);
}
return response.json() as Promise<MailDomainsResponse>;
};
export const KEY_LIST_MAIL_DOMAINS = 'mail-domains';
export function useMailDomains(
param: MailDomainsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
MailDomainsResponse,
APIError,
InfiniteData<MailDomainsResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
MailDomainsResponse,
APIError,
InfiniteData<MailDomainsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_MAIL_DOMAINS, param],
queryFn: ({ pageParam }) => getMailDomains({ ...param, page: pageParam }),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}

View File

@@ -0,0 +1,13 @@
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_879_8339)">
<path
d="M18 3C9.72 3 3 9.72 3 18C3 26.28 9.72 33 18 33H25.5V30H18C11.49 30 6 24.51 6 18C6 11.49 11.49 6 18 6C24.51 6 30 11.49 30 18V20.145C30 21.33 28.935 22.5 27.75 22.5C26.565 22.5 25.5 21.33 25.5 20.145V18C25.5 13.86 22.14 10.5 18 10.5C13.86 10.5 10.5 13.86 10.5 18C10.5 22.14 13.86 25.5 18 25.5C20.07 25.5 21.96 24.66 23.31 23.295C24.285 24.63 25.965 25.5 27.75 25.5C30.705 25.5 33 23.1 33 20.145V18C33 9.72 26.28 3 18 3ZM18 22.5C15.51 22.5 13.5 20.49 13.5 18C13.5 15.51 15.51 13.5 18 13.5C20.49 13.5 22.5 15.51 22.5 18C22.5 20.49 20.49 22.5 18 22.5Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_879_8339">
<rect width="36" height="36" fill="currentColor" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 854 B

View File

@@ -0,0 +1,4 @@
<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: 500 B

View File

@@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { MainLayout } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { Panel } from '@/features/mail-domains/components/panel';
export function MailDomainsLayout({ children }: PropsWithChildren) {
const { colorsTokens } = useCunninghamTheme();
@@ -10,6 +11,7 @@ export function MailDomainsLayout({ children }: PropsWithChildren) {
return (
<MainLayout>
<Box $height="inherit" $direction="row">
<Panel />
<Box
$background={colorsTokens()['primary-bg']}
$width="100%"

View File

@@ -0,0 +1,103 @@
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 { MailDomain } from '@/features/mail-domains';
import { useMailDomains } from '@/features/mail-domains/api/useMailDomains';
import { useMailDomainsStore } from '@/features/mail-domains/store/useMailDomainsStore';
import { PanelMailDomains } from './PanelItem';
interface PanelMailDomainsStateProps {
isLoading: boolean;
isError: boolean;
mailDomains?: MailDomain[];
}
export const ItemList = () => {
const { ordering } = useMailDomainsStore();
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useMailDomains({ ordering });
const containerRef = useRef<HTMLDivElement>(null);
const mailDomains = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as MailDomain[]);
}, [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"
$margin={{ top: 'none' }}
$padding={{ all: 'none' }}
role="listbox"
>
<ItemListState
isLoading={isLoading}
isError={isError}
mailDomains={mailDomains}
/>
</InfiniteScroll>
</Box>
);
};
const ItemListState = ({
isLoading,
isError,
mailDomains,
}: PanelMailDomainsStateProps) => {
const { t } = useTranslation();
if (isError) {
return (
<Box $justify="center" $margin={{ bottom: 'big' }}>
<Text $theme="danger" $align="center" $textAlign="center">
{t('Something wrong happened, please refresh the page.')}
</Text>
</Box>
);
}
if (isLoading) {
return (
<Box $align="center" $margin={{ all: 'large' }}>
<Loader aria-label={t('mail domains list loading')} />
</Box>
);
}
if (!mailDomains?.length) {
return (
<Box $justify="center" $margin={{ all: 'small' }}>
<Text
as="p"
$margin={{ vertical: 'none' }}
$theme="greyscale"
$variation="500"
>
{t(`0 mail domain to display.`)}
</Text>
</Box>
);
}
return mailDomains.map((mailDomain) => (
<PanelMailDomains mailDomain={mailDomain} key={mailDomain.id} />
));
};

View File

@@ -0,0 +1,81 @@
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 { ItemList } from './ItemList';
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="mail domains panel"
{...closedOverridingStyles}
>
<BoxButton
aria-label={
isOpen
? t(`Close the mail domains panel`)
: t(`Open the mail domains 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
$padding={{ all: 'small', right: 'large' }}
$direction="row"
$align="center"
$justify="space-between"
$css={`
border-bottom: 1px solid ${colorsTokens()['primary-300']};
`}
>
<Text $weight="bold" $size="1.25rem">
{t('Mail Domains')}
</Text>
</Box>
<ItemList />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,82 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { MailDomain } from '@/features/mail-domains';
import IconMailDomains from '@/features/mail-domains/assets/icon-mail-domains.svg';
interface MailDomainProps {
mailDomain: MailDomain;
}
export const PanelMailDomains = ({ mailDomain }: MailDomainProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const {
query: { name },
} = useRouter();
const isActive = mailDomain.id === name;
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
$margin={{ all: 'none' }}
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={`/mail-domains/${mailDomain.name}`}
>
<Box $align="center" $direction="row" $gap="0.5rem">
<IconMailDomains
aria-label={t(`Mail Domains icon`)}
color={colorsTokens()['primary-500']}
className="p-t"
width="52"
style={{
borderRadius: '10px',
flexShrink: 0,
background: '#fff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text
$weight="bold"
$color={colorsTokens()['greyscale-600']}
$css={`
min-width: 14rem;
`}
>
{mailDomain.name}
</Text>
</Box>
</StyledLink>
</Box>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import { create } from 'zustand';
import { EnumMailDomainsOrdering } from '../api/useMailDomains';
interface MailDomainsStore {
ordering: EnumMailDomainsOrdering;
}
export const useMailDomainsStore = create<MailDomainsStore>(() => ({
ordering: EnumMailDomainsOrdering.BY_CREATED_AT_DESC,
}));

View File

@@ -0,0 +1,8 @@
import { UUID } from 'crypto';
export interface MailDomain {
id: UUID;
name: string;
created_at: string;
updated_at: string;
}

View File

@@ -1,16 +1,93 @@
import { expect, test } from '@playwright/test';
import { MailDomain } from 'app-desk/src/features/mail-domains';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
const currentDateIso = new Date().toISOString();
const mailDomainsFixtures: MailDomain[] = [
{
name: 'domain.fr',
id: '456ac6ca-0402-4615-8005-69bc1efde43f',
created_at: currentDateIso,
updated_at: currentDateIso,
},
{
name: 'mails.fr',
id: '456ac6ca-0402-4615-8005-69bc1efde43e',
created_at: currentDateIso,
updated_at: currentDateIso,
},
{
name: 'versailles.net',
id: '456ac6ca-0402-4615-8005-69bc1efde43g',
created_at: currentDateIso,
updated_at: currentDateIso,
},
{
name: 'paris.fr',
id: '456ac6ca-0402-4615-8005-69bc1efde43h',
created_at: currentDateIso,
updated_at: currentDateIso,
},
];
test.describe('Mails', () => {
test('checks all the elements are visible', async ({ page }) => {
await page.locator('menu').first().getByLabel(`Mails button`).click();
await expect(page.getByText('john@doe.com')).toBeVisible();
await expect(page.getByText('jane@doe.com')).toBeVisible();
test.describe('Mail domain', () => {
test.describe('checks all the elements are visible', () => {
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName);
});
test('when no mail domain exists', async ({ page }) => {
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
await route.fulfill({
json: {
count: 0,
next: null,
previous: null,
results: [],
},
});
});
await page
.locator('menu')
.first()
.getByLabel(`Mail Domains button`)
.click();
await expect(page).toHaveURL(/mail-domains/);
await expect(
page.getByLabel('mail domains panel', { exact: true }),
).toBeVisible();
await expect(page.getByText('0 mail domain to display.')).toBeVisible();
});
test('when 4 mail domains exist', async ({ page }) => {
await page.route('**/api/v1.0/mail-domains/?page=*', async (route) => {
await route.fulfill({
json: {
count: mailDomainsFixtures.length,
next: null,
previous: null,
results: mailDomainsFixtures,
},
});
});
await page
.locator('menu')
.first()
.getByLabel(`Mail Domains button`)
.click();
await expect(page).toHaveURL(/mail-domains/);
await expect(
page.getByLabel('mail domains panel', { exact: true }),
).toBeVisible();
await expect(page.getByText('0 mail domain to display.')).toHaveCount(0);
await expect(page.getByText('domain.fr')).toBeVisible();
await expect(page.getByText('mails.fr')).toBeVisible();
await expect(page.getByText('versailles.net')).toBeVisible();
await expect(page.getByText('paris.fr')).toBeVisible();
});
});
});

View File

@@ -16,10 +16,10 @@ test.describe('Menu', () => {
expectedText: 'Create a new team',
},
{
name: 'Mails',
name: 'Mail Domains',
isDefault: false,
expectedUrl: '/mails',
expectedText: 'Emails',
expectedUrl: '/mail-domains',
expectedText: 'Mail Domains',
},
];
for (const { name, isDefault, expectedUrl, expectedText } of menuItems) {