(app-desk) integrate teams panel design

- Integrate teams panel design based from the mockup.
- List teams from the API.
This commit is contained in:
Anthony LC
2024-02-13 16:03:47 +01:00
committed by Anthony LC
parent 148ea81aa9
commit 4f0465fd32
26 changed files with 453 additions and 135 deletions

View File

@@ -3,6 +3,7 @@ const config = {
default: {
theme: {
colors: {
'primary-bg': '#FAFAFA',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
@@ -259,6 +260,16 @@ const config = {
},
button: {
'border-radius': '2px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': 'var(--c--theme--colors--primary-700)',
'color-active': 'var(--c--theme--colors--primary-900)',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
},
'forms-checkbox': {
'border-radius': '0',

View File

@@ -11,8 +11,10 @@ describe('Page', () => {
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(
'Hello Desk !',
);
expect(
screen.getByRole('button', {
name: /Create a new team/i,
}),
).toBeInTheDocument();
});
});

View File

@@ -1,17 +1,32 @@
'use client';
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box } from '@/components';
import { Teams } from '@/features';
import { useCunninghamTheme } from '@/cunningham';
import { Panel } from '@/features';
const StyledButton = styled(Button)`
width: fit-content;
`;
export default function Home() {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
return (
<Box className="p-b">
<h1>{t('Hello Desk !')}</h1>
<Teams />
<Box $height="inherit" $direction="row">
<Panel />
<Box
$background={colorsTokens()['primary-bg']}
$justify="center"
$align="center"
$width="100%"
>
<StyledButton>{t('Create a new group')}</StyledButton>
</Box>
</Box>
);
}

View File

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 1020 B

View File

@@ -14,6 +14,7 @@ export interface TextProps extends BoxProps {
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
$size?: TextSizes | (string & {});
$theme?:
@@ -38,6 +39,7 @@ export interface TextProps extends BoxProps {
}
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}
${({ $size }) =>
$size &&

View File

@@ -272,6 +272,13 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--primary--color);
}
.c__button--primary:hover {
background-color: var(
--c--components--button--primary--background--color-hover
);
color: var(--c--components--button--primary--color-hover);
}
.c__button--primary:active,
.c__button--primary.c__button--active {
background-color: var(

View File

@@ -69,6 +69,7 @@
--c--theme--colors--success-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--warning-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--primary-bg: #fafafa;
--c--theme--colors--primary-150: #e5eefa;
--c--theme--colors--info-150: #e5eefa;
--c--theme--font--sizes--h1: 2.2rem;
@@ -387,6 +388,18 @@
--c--theme--font--families--base: marianne;
--c--components--alert--border-radius: 0;
--c--components--button--border-radius: 2px;
--c--components--button--primary--background--color: var(
--c--theme--colors--primary-text
);
--c--components--button--primary--background--color-hover: var(
--c--theme--colors--primary-700
);
--c--components--button--primary--background--color-active: var(
--c--theme--colors--primary-900
);
--c--components--button--primary--color: #fff;
--c--components--button--primary--color-hover: #fff;
--c--components--button--primary--color-active: #fff;
--c--components--forms-checkbox--border-radius: 0;
--c--components--forms-switch--handle-border-radius: 2px;
--c--components--forms-switch--rail-border-radius: 4px;
@@ -677,6 +690,10 @@
color: var(--c--theme--colors--danger-text);
}
.clr-primary-bg {
color: var(--c--theme--colors--primary-bg);
}
.clr-primary-150 {
color: var(--c--theme--colors--primary-150);
}
@@ -965,6 +982,10 @@
background-color: var(--c--theme--colors--danger-text);
}
.bg-primary-bg {
background-color: var(--c--theme--colors--primary-bg);
}
.bg-primary-150 {
background-color: var(--c--theme--colors--primary-150);
}

View File

@@ -73,6 +73,7 @@ export const tokens = {
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'primary-bg': '#FAFAFA',
'primary-150': '#E5EEFA',
'info-150': '#E5EEFA',
},
@@ -396,7 +397,19 @@ export const tokens = {
},
components: {
alert: { 'border-radius': '0' },
button: { 'border-radius': '2px' },
button: {
'border-radius': '2px',
primary: {
background: {
color: 'var(--c--theme--colors--primary-text)',
'color-hover': 'var(--c--theme--colors--primary-700)',
'color-active': 'var(--c--theme--colors--primary-900)',
},
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
},
'forms-checkbox': { 'border-radius': '0' },
'forms-switch': {
'handle-border-radius': '2px',

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box } from '@/components/';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import MenuItem from './MenuItems';
import IconRecent from './assets/icon-clock.svg';
import IconContacts from './assets/icon-contacts.svg';
import IconGroup from './assets/icon-group.svg';
import IconSearch from './assets/icon-search.svg';
import IconFavorite from './assets/icon-stars.svg';

View File

@@ -0,0 +1,89 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { PanelTeams } from '../components/PanelTeams';
describe('PanelTeams', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no team to display', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 0,
results: [],
});
render(<PanelTeams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Create your first team by clicking on the "Create a new team" button.',
),
).toBeInTheDocument();
});
it('renders with empty team to display', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [],
},
],
});
render(<PanelTeams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Empty team icon')).toBeInTheDocument();
});
it('renders with not team to display', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'admin',
},
],
},
],
});
render(<PanelTeams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Team icon')).toBeInTheDocument();
});
it('renders the error', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
status: 500,
});
render(<PanelTeams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Something bad happens, please refresh the page.',
),
).toBeInTheDocument();
});
});

View File

@@ -1,41 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { Teams } from '..';
describe('Teams', () => {
afterEach(() => {
fetchMock.restore();
});
it('checks Teams rendering', async () => {
fetchMock.mock(`/api/teams/`, {
results: [
{
id: '1',
name: 'Team 1',
},
{
id: '2',
name: 'Team 2',
},
],
});
render(<Teams />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByRole('button', {
name: 'Create Team',
}),
).toBeInTheDocument();
expect(screen.getByText(/Team 1/)).toBeInTheDocument();
expect(screen.getByText(/Team 2/)).toBeInTheDocument();
});
});

View File

@@ -2,9 +2,22 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIList, fetchAPI } from '@/api';
enum Role {
MEMBER = 'member',
ADMIN = 'administrator',
OWNER = 'owner',
}
interface Access {
id: string;
role: Role;
user: string;
}
interface TeamResponse {
id: string;
name: string;
accesses: Access[];
}
type TeamsResponse = APIList<TeamResponse>;

View File

@@ -0,0 +1,19 @@
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_178_17838)">
<path
d="M16.25 8.75H13.75V13.75H8.75V16.25H13.75V21.25H16.25V16.25H21.25V13.75H16.25V8.75ZM15 2.5C8.1 2.5 2.5 8.1 2.5 15C2.5 21.9 8.1 27.5 15 27.5C21.9 27.5 27.5 21.9 27.5 15C27.5 8.1 21.9 2.5 15 2.5ZM15 25C9.4875 25 5 20.5125 5 15C5 9.4875 9.4875 5 15 5C20.5125 5 25 9.4875 25 15C25 20.5125 20.5125 25 15 25Z"
fill="#000091"
/>
</g>
<defs>
<clipPath id="clip0_178_17838">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 633 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,19 @@
<svg
width="30"
height="30"
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="#000091"
/>
</g>
<defs>
<clipPath id="clip0_178_17837">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { PanelActions } from './PanelActions';
import { PanelTeams } from './PanelTeams';
export const Panel = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$width="28rem"
$css={`
border-right: 1px solid ${colorsTokens()['primary-300']};
`}
$height="inherit"
aria-label="Teams panel"
>
<Box
className="p-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>
<PanelTeams />
</Box>
);
};

View File

@@ -0,0 +1,38 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { default as IconAdd } from '../assets/icon-add.svg?url';
import { default as IconSort } from '../assets/icon-sort.svg?url';
export const PanelActions = () => {
const { t } = useTranslation();
return (
<Box
$direction="row"
$gap="1rem"
$css={`
& > button {
padding: 0;
}
`}
>
<Button
aria-label={t('Sort the teams')}
icon={<Image priority src={IconSort} alt={t('Sort teams icon')} />}
color="tertiary"
className="c__button-no-bg p-0 m-0"
/>
<Button
aria-label={t('Add a team')}
icon={<Image priority src={IconAdd} alt={t('Add team icon')} />}
color="tertiary"
className="c__button-no-bg p-0 m-0"
/>
</Box>
);
};

View File

@@ -0,0 +1,88 @@
import { Loader } from '@openfun/cunningham-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useTeams } from '../api/useTeams';
import IconNone from '../assets/icon-none.svg';
export const PanelTeams = () => {
const { data, isPending, isError } = useTeams();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
if (isPending) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
if (isError) {
return (
<Box $justify="center" className="m-b">
<Text $theme="danger" $align="center" $textAlign="center">
{t('Something bad happens, please refresh the page.')}
</Text>
</Box>
);
}
if (!data.count) {
return (
<Box $justify="center" className="m-b">
<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 team by clicking on the "Create a new team" button.',
)}
</Text>
</Box>
);
}
return (
<Box as="ul" $gap="1rem" className="p-s mt-t">
{data?.results.map((team) => (
<Box
as="li"
key={team.id}
$direction="row"
$align="center"
$gap="0.5rem"
>
{team.accesses.length ? (
<IconGroup
className="p-t"
width={36}
aria-label={t(`Team icon`)}
color={colorsTokens()['primary-500']}
style={{
borderRadius: '10px',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
className="p-t"
width={36}
aria-label={t(`Empty team icon`)}
color={colorsTokens()['greyscale-500']}
style={{
borderRadius: '10px',
border: `1px solid ${colorsTokens()['greyscale-300']}`,
}}
/>
)}
<Text $weight="bold">{team.name}</Text>
</Box>
))}
</Box>
);
};

View File

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

View File

@@ -1,49 +1 @@
import { Button, Field, Input, Loader } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateTeam } from './api/useCreateTeam';
import { useTeams } from './api/useTeams';
export const Teams = () => {
const { data, isPending } = useTeams();
const { mutate: createTeam } = useCreateTeam();
const [teamName, setTeamName] = useState('');
const { t } = useTranslation();
if (isPending) {
return (
<div>
<Loader />
</div>
);
}
return (
<>
<Field>
<Input
type="text"
label={t('Team name')}
onChange={(e) => setTeamName(e.target.value)}
/>
<Button fullWidth onClick={() => createTeam(teamName)} className="mt-s">
{t('Create Team')}
</Button>
</Field>
<section>
<ul>
{data?.results.map((post, index) => (
<li key={post.id}>
<div>
<span>
{index + 1}. {post.name}
</span>
</div>
</li>
))}
</ul>
</section>
</>
);
};
export * from './components/Panel';

View File

@@ -24,7 +24,8 @@
"{{label}} button": "Bouton {{label}}",
"{{label}} icon": "Icône {{label}}",
"Team name": "Nom de léquipe",
"Create Team": "Créer une équipe"
"Create Team": "Créer une équipe",
"Create a new team": "Créer un nouveau groupe"
}
}
}

View File

@@ -1,29 +0,0 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await keyCloakSignIn(page);
});
test.describe('App', () => {
test('should display the main elements', async ({ page }) => {
await expect(page.locator('header').first()).toContainText('Desk');
await expect(page.getByLabel('Team name')).toBeVisible();
});
test('creates 2 teams and displayed them', async ({ page }) => {
await page.getByLabel('Team name').fill('My new team');
await page.click('button:has-text("Create Team")');
await page.getByLabel('Team name').fill('My second new team');
await page.click('button:has-text("Create Team")');
await expect(
page.locator('li').getByText('My new team').first(),
).toBeVisible();
await expect(
page.locator('li').getByText('My second new team').first(),
).toBeVisible();
});
});

View File

@@ -24,7 +24,7 @@ test.describe('Header', () => {
);
await expect(header.locator('h2').getByText('Desk')).toHaveCSS(
'font-family',
'marianne',
/Marianne/i,
);
await expect(

View File

@@ -10,7 +10,9 @@ test.beforeEach(async ({ page }) => {
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(
page.locator('h1').first().getByText('Hello Desk !'),
page.getByRole('button', {
name: 'Create a new team',
}),
).toBeVisible();
const header = page.locator('header').first();
@@ -19,7 +21,9 @@ test.describe('Language', () => {
await expect(header.getByRole('combobox').getByText('FR')).toBeVisible();
await expect(
page.locator('h1').first().getByText('Bonjour Desk !'),
page.getByRole('button', {
name: 'Créer un nouveau groupe',
}),
).toBeVisible();
});
});

View File

@@ -46,7 +46,9 @@ test.describe('Menu', () => {
page,
}) => {
await expect(
page.locator('h1').first().getByText('Hello Desk !'),
page.getByRole('button', {
name: 'Create a new team',
}),
).toBeVisible();
const menu = page.locator('menu').first();
@@ -57,11 +59,15 @@ test.describe('Menu', () => {
// eslint-disable-next-line playwright/no-conditional-in-test
if (isDefault) {
await expect(
page.locator('h1').first().getByText('Hello Desk !'),
page.getByRole('button', {
name: 'Create a new team',
}),
).toBeVisible();
} else {
await expect(
page.locator('h1').first().getByText('Hello Desk !'),
page.getByRole('button', {
name: 'Create a new team',
}),
).toBeHidden();
const reg = new RegExp(name.toLowerCase());

View File

@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
import { keyCloakSignIn } from "./common";
test.beforeEach(async ({ page }) => {
await page.goto("/");
await keyCloakSignIn(page);
});
test.describe("Teams", () => {
test("checks all the elements are visible", async ({ page }) => {
const panel = page.getByLabel("Teams panel").first();
await expect(panel.getByText("Recents")).toBeVisible();
await expect(
panel.getByRole("button", {
name: "Sort the teams",
}),
).toBeVisible();
await expect(
panel.getByRole("button", {
name: "Add a team",
}),
).toBeVisible();
await expect(
panel.getByText(
'Create your first team by clicking on the "Create a new team" button.',
),
).toBeVisible();
});
});