🔥(app-impress) remove Teams feature

Teams feature was a part of the People
project, we don't need it in the Impress project.
This commit is contained in:
Anthony LC
2024-04-03 11:30:49 +02:00
committed by Anthony LC
parent 3ba388bc49
commit 1f804ad1f9
29 changed files with 0 additions and 1392 deletions

View File

@@ -1,174 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { Panel } from '@/features/teams';
import { AppWrapper } from '@/tests/utils';
import { TeamList } from '../components/Panel/TeamList';
window.HTMLElement.prototype.scroll = function () {};
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
query: {},
}),
}));
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(<TeamList />, { 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 an empty team', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders a team with only 1 member', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'owner',
},
],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByLabelText('Empty teams icon'),
).toBeInTheDocument();
});
it('renders a non-empty team', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'admin',
},
{
id: '2',
role: 'member',
},
],
},
],
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Teams icon')).toBeInTheDocument();
});
it('renders the error', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
status: 500,
});
render(<TeamList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Something bad happens, please refresh the page.',
),
).toBeInTheDocument();
});
it('renders with team panel open', async () => {
fetchMock.mock(`/api/teams/?page=1&ordering=-created_at`, {
count: 1,
results: [],
});
render(<Panel />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', { name: 'Close the teams panel' }),
).toBeVisible();
expect(await screen.findByText('Recents')).toBeVisible();
});
it('closes and opens the team panel', async () => {
fetchMock.mock(`/api/teams/?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 teams panel',
}),
);
expect(await screen.findByText('Recents')).not.toBeVisible();
await userEvent.click(
screen.getByRole('button', {
name: 'Open the teams panel',
}),
);
expect(await screen.findByText('Recents')).toBeVisible();
});
});

View File

@@ -1,5 +0,0 @@
export * from './useCreateTeam';
export * from './useRemoveTeam';
export * from './useTeam';
export * from './useTeams';
export * from './useUpdateTeam';

View File

@@ -1,45 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_TEAM } from './useTeams';
type CreateTeamResponse = {
id: string;
name: string;
};
export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
const response = await fetchAPI(`teams/`, {
method: 'POST',
body: JSON.stringify({
name,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to create the team',
await errorCauses(response),
);
}
return response.json() as Promise<CreateTeamResponse>;
};
interface CreateTeamProps {
onSuccess: (data: CreateTeamResponse) => void;
}
export function useCreateTeam({ onSuccess }: CreateTeamProps) {
const queryClient = useQueryClient();
return useMutation<CreateTeamResponse, APIError, string>({
mutationFn: createTeam,
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM],
});
onSuccess(data);
},
});
}

View File

@@ -1,51 +0,0 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_TEAM } from './useTeams';
interface RemoveTeamProps {
teamId: string;
}
export const removeTeam = async ({
teamId,
}: RemoveTeamProps): Promise<void> => {
const response = await fetchAPI(`teams/${teamId}/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new APIError(
'Failed to delete the team',
await errorCauses(response),
);
}
};
type UseRemoveTeamOptions = UseMutationOptions<void, APIError, RemoveTeamProps>;
export const useRemoveTeam = (options?: UseRemoveTeamOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, RemoveTeamProps>({
mutationFn: removeTeam,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, context);
}
},
});
};

View File

@@ -1,32 +0,0 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Team } from '../types';
export type TeamParams = {
id: string;
};
export const getTeam = async ({ id }: TeamParams): Promise<Team> => {
const response = await fetchAPI(`teams/${id}`);
if (!response.ok) {
throw new APIError('Failed to get the team', await errorCauses(response));
}
return response.json() as Promise<Team>;
};
export const KEY_TEAM = 'team';
export function useTeam(
param: TeamParams,
queryConfig?: UseQueryOptions<Team, APIError, Team>,
) {
return useQuery<Team, APIError, Team>({
queryKey: [KEY_TEAM, param],
queryFn: () => getTeam(param),
...queryConfig,
});
}

View File

@@ -1,71 +0,0 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Team } from '../types';
export enum TeamsOrdering {
BY_CREATED_ON = 'created_at',
BY_CREATED_ON_DESC = '-created_at',
}
export type TeamsParams = {
ordering: TeamsOrdering;
};
type TeamsAPIParams = TeamsParams & {
page: number;
};
type TeamsResponse = APIList<Team>;
export const getTeams = async ({
ordering,
page,
}: TeamsAPIParams): Promise<TeamsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
if (!response.ok) {
throw new APIError('Failed to get the teams', await errorCauses(response));
}
return response.json() as Promise<TeamsResponse>;
};
export const KEY_LIST_TEAM = 'teams';
export function useTeams(
param: TeamsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
TeamsResponse,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_TEAM, param],
queryFn: ({ pageParam }) =>
getTeams({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}

View File

@@ -1,51 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Team } from '../types';
import { KEY_TEAM } from './useTeam';
import { KEY_LIST_TEAM } from './useTeams';
type UpdateTeamProps = Pick<Team, 'name' | 'id'>;
export const updateTeam = async ({
name,
id,
}: UpdateTeamProps): Promise<Team> => {
const response = await fetchAPI(`teams/${id}/`, {
method: 'PATCH',
body: JSON.stringify({
name,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to update the team',
await errorCauses(response),
);
}
return response.json() as Promise<Team>;
};
interface UseUpdateTeamProps {
onSuccess: (data: Team) => void;
}
export function useUpdateTeam({ onSuccess }: UseUpdateTeamProps) {
const queryClient = useQueryClient();
return useMutation<Team, APIError, UpdateTeamProps>({
mutationFn: updateTeam,
onSuccess: (data) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM],
});
void queryClient.invalidateQueries({
queryKey: [KEY_TEAM],
});
onSuccess(data);
},
});
}

View File

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

Before

Width:  |  Height:  |  Size: 617 B

View File

@@ -1,6 +0,0 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 34.5002V42.0002H13.5L35.62 19.8802L28.12 12.3802L6 34.5002ZM42.82 12.6802L35.32 5.18018L30.26 10.2602L37.76 17.7602L42.82 12.6802Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 259 B

View File

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

Before

Width:  |  Height:  |  Size: 578 B

View File

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

Before

Width:  |  Height:  |  Size: 500 B

View File

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

Before

Width:  |  Height:  |  Size: 429 B

View File

@@ -1,3 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 38C12 40.2 13.8 42 16 42H32C34.2 42 36 40.2 36 38V14H12V38ZM18.34 25.18C17.56 24.4 17.56 23.14 18.34 22.36C19.12 21.58 20.38 21.58 21.16 22.36L24 25.18L26.82 22.36C27.6 21.58 28.86 21.58 29.64 22.36C30.42 23.14 30.42 24.4 29.64 25.18L26.82 28L29.64 30.82C30.42 31.6 30.42 32.86 29.64 33.64C28.86 34.42 27.6 34.42 26.82 33.64L24 30.82L21.18 33.64C20.4 34.42 19.14 34.42 18.36 33.64C17.58 32.86 17.58 31.6 18.36 30.82L21.18 28L18.34 25.18ZM36 8H31L29.58 6.58C29.22 6.22 28.7 6 28.18 6H19.82C19.3 6 18.78 6.22 18.42 6.58L17 8H12C10.9 8 10 8.9 10 10C10 11.1 10.9 12 12 12H36C37.1 12 38 11.1 38 10C38 8.9 37.1 8 36 8Z" fill="#000091"/>
</svg>

Before

Width:  |  Height:  |  Size: 747 B

View File

@@ -1,66 +0,0 @@
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 { useCreateTeam } from '../api';
import { InputTeamName } from './InputTeamName';
export const CardCreateTeam = () => {
const { t } = useTranslation();
const router = useRouter();
const {
mutate: createTeam,
isError,
isPending,
error,
} = useCreateTeam({
onSuccess: (team) => {
router.push(`/teams/${team.id}`);
},
});
const [teamName, setTeamName] = 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 team 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 team')}
</Text>
</Box>
<InputTeamName
label={t('Team name')}
{...{ error, isError, isPending, setTeamName }}
/>
</Box>
<Box $justify="space-between" $direction="row" $align="center">
<StyledLink href="/">
<Button color="secondary">{t('Cancel')}</Button>
</StyledLink>
<Button onClick={() => createTeam(teamName)} disabled={!teamName}>
{t('Create the team')}
</Button>
</Box>
</Card>
);
};

View File

@@ -1,54 +0,0 @@
import { Input, Loader } from '@openfun/cunningham-react';
import { useEffect, useState } from 'react';
import { APIError } from '@/api';
import { Box, TextErrors } from '@/components';
interface InputTeamNameProps {
error: APIError | null;
isError: boolean;
isPending: boolean;
label: string;
setTeamName: (newTeamName: string) => void;
defaultValue?: string;
}
export const InputTeamName = ({
defaultValue,
error,
isError,
isPending,
label,
setTeamName,
}: InputTeamNameProps) => {
const [isInputError, setIsInputError] = useState(isError);
useEffect(() => {
if (isError) {
setIsInputError(true);
}
}, [isError]);
return (
<>
<Input
fullWidth
type="text"
label={label}
defaultValue={defaultValue}
onChange={(e) => {
setTeamName(e.target.value);
setIsInputError(false);
}}
rightIcon={<span className="material-icons">edit</span>}
state={isInputError ? 'error' : 'default'}
/>
{isError && error && <TextErrors causes={error.cause} />}
{isPending && (
<Box $align="center">
<Loader />
</Box>
)}
</>
);
};

View File

@@ -1,119 +0,0 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text, TextErrors } from '@/components';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useRemoveTeam } from '../api/useRemoveTeam';
import IconRemove from '../assets/icon-trash.svg';
import { Team } from '../types';
interface ModalRemoveTeamProps {
onClose: () => void;
team: Team;
}
export const ModalRemoveTeam = ({ onClose, team }: ModalRemoveTeamProps) => {
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const router = useRouter();
const {
mutate: removeTeam,
isError,
error,
} = useRemoveTeam({
onSuccess: () => {
toast(t('The team has been removed.'), VariantType.SUCCESS, {
duration: 4000,
});
router.push('/');
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<Button
aria-label={t('Confirm deletion')}
color="primary"
fullWidth
onClick={() =>
removeTeam({
teamId: team.id,
})
}
>
{t('Confirm deletion')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconRemove width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" className="m-0">
{t('Deleting the {{teamName}} team', { teamName: team.name })}
</Text>
</Box>
}
>
<Box className="mb-xl" aria-label={t('Content modal to delete the team')}>
<Text as="p" className="mb-b">
{t('Are you sure you want to delete {{teamName}} team?', {
teamName: team.name,
})}
</Text>
{isError && <TextErrors className="mb-s" causes={error.cause} />}
<Text
as="p"
className="p-s"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
$align="center"
$radius="2px"
>
<IconGroup
className="p-t"
aria-label={t(`Teams icon`)}
color={colorsTokens()['primary-500']}
width={58}
style={{
borderRadius: '8px',
backgroundColor: '#ffffff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text $theme="primary" $weight="bold" $size="l">
{team.name}
</Text>
</Text>
</Box>
</Modal>
);
};

View File

@@ -1,98 +0,0 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useState } from 'react';
import { Box, Text } from '@/components';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useUpdateTeam } from '../api';
import IconEdit from '../assets/icon-edit.svg';
import { Team } from '../types';
import { InputTeamName } from './InputTeamName';
interface ModalUpdateTeamProps {
onClose: () => void;
team: Team;
}
export const ModalUpdateTeam = ({ onClose, team }: ModalUpdateTeamProps) => {
const { colorsTokens } = useCunninghamTheme();
const [teamName, setTeamName] = useState(team.name);
const { toast } = useToastProvider();
const {
mutate: updateTeam,
isError,
isPending,
error,
} = useUpdateTeam({
onSuccess: () => {
toast(t('The team has been updated.'), VariantType.SUCCESS, {
duration: 4000,
});
onClose();
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<Button
aria-label={t('Validate the modification')}
color="primary"
fullWidth
onClick={() =>
updateTeam({
name: teamName,
id: team.id,
})
}
>
{t('Validate the modification')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconEdit width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" className="m-0">
{t('Update team {{teamName}}', { teamName: team.name })}
</Text>
</Box>
}
>
<Box className="mb-xl" aria-label={t('Content modal to update the team')}>
<Text as="p" className="mb-b">
{t('Enter the new name of the selected team')}
</Text>
<InputTeamName
label={t('New name...')}
defaultValue={team.name}
{...{ error, isError, isPending, setTeamName }}
/>
</Box>
</Modal>
);
};

View File

@@ -1,80 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import IconOpenClose from '@/features/teams/assets/icon-open-close.svg';
import { PanelActions } from './PanelActions';
import { TeamList } from './TeamList';
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="Teams panel"
{...closedOverridingStyles}
>
<BoxButton
aria-label={
isOpen ? t('Close the teams panel') : t('Open the teams 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>
<TeamList />
</Box>
</Box>
);
};

View File

@@ -1,55 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { TeamsOrdering } from '@/features/teams/api/';
import IconAdd from '@/features/teams/assets/icon-add.svg';
import IconSort from '@/features/teams/assets/icon-sort.svg';
import { useTeamStore } from '@/features/teams/store/useTeamsStore';
export const PanelActions = () => {
const { t } = useTranslation();
const { changeOrdering, ordering } = useTeamStore();
const { colorsTokens } = useCunninghamTheme();
const isSortAsc = ordering === TeamsOrdering.BY_CREATED_ON;
return (
<Box
$direction="row"
$gap="1rem"
$css={`
& button {
padding: 0;
svg {
padding: 0.1rem;
}
}
`}
>
<BoxButton
aria-label={
isSortAsc
? t('Sort the teams by creation date descendent')
: t('Sort the teams 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 teams icon')} />
</BoxButton>
<StyledLink href="/teams/create">
<BoxButton
aria-label={t('Add a team')}
$color={colorsTokens()['primary-600']}
>
<IconAdd width={30} height={30} aria-label={t('Add team icon')} />
</BoxButton>
</StyledLink>
</Box>
);
};

View File

@@ -1,101 +0,0 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Team } from '@/features/teams/';
import IconNone from '@/features/teams/assets/icon-none.svg';
interface TeamProps {
team: Team;
}
export const TeamItem = ({ team }: TeamProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const {
query: { id },
} = useRouter();
// There is at least 1 owner in the team
const hasMembers = team.accesses.length > 1;
const isActive = team.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={`/teams/${team.id}`}>
<Box $align="center" $direction="row" $gap="0.5rem">
{hasMembers ? (
<IconGroup
aria-label={t(`Teams icon`)}
color={colorsTokens()['primary-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
aria-label={t(`Empty teams 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;
`}
>
{team.name}
</Text>
</Box>
</StyledLink>
</Box>
);
};

View File

@@ -1,92 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { InfiniteScroll } from '@/components/InfiniteScroll';
import { Team, useTeamStore, useTeams } from '@/features/teams/';
import { TeamItem } from './TeamItem';
interface PanelTeamsStateProps {
isLoading: boolean;
isError: boolean;
teams?: Team[];
}
const TeamListState = ({ isLoading, isError, teams }: 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 (!teams?.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 team by clicking on the "Create a new team" button.',
)}
</Text>
</Box>
);
}
return teams.map((team) => <TeamItem team={team} key={team.id} />);
};
export const TeamList = () => {
const ordering = useTeamStore((state) => state.ordering);
const {
data,
isError,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTeams({
ordering,
});
const containerRef = useRef<HTMLDivElement>(null);
const teams = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Team[]);
}, [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"
>
<TeamListState isLoading={isLoading} isError={isError} teams={teams} />
</InfiniteScroll>
</Box>
);
};

View File

@@ -1,78 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, Text } from '@/components';
import { Role, Team } from '../types';
import { ModalRemoveTeam } from './ModalRemoveTeam';
import { ModalUpdateTeam } from './ModalUpdateTeam';
interface TeamActionsProps {
currentRole: Role;
team: Team;
}
export const TeamActions = ({ currentRole, team }: TeamActionsProps) => {
const { t } = useTranslation();
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
if (currentRole === Role.MEMBER) {
return null;
}
return (
<>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the team options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
onClick={() => {
setIsModalUpdateOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">edit</span>}
>
<Text $theme="primary">{t('Update the team')}</Text>
</Button>
{currentRole === Role.OWNER && (
<Button
onClick={() => {
setIsModalRemoveOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
>
<Text $theme="primary">{t('Delete the team')}</Text>
</Button>
)}
</Box>
</DropButton>
{isModalUpdateOpen && (
<ModalUpdateTeam
onClose={() => setIsModalUpdateOpen(false)}
team={team}
/>
)}
{isModalRemoveOpen && (
<ModalRemoveTeam
onClose={() => setIsModalRemoveOpen(false)}
team={team}
/>
)}
</>
);
};

View File

@@ -1,103 +0,0 @@
import { DateTime, DateTimeFormatOptions } from 'luxon';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group2.svg';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Role, Team } from '../types';
import { ModalUpdateTeam } from './ModalUpdateTeam';
import { TeamActions } from './TeamActions';
const format: DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
year: 'numeric',
};
interface TeamInfoProps {
team: Team;
currentRole: Role;
}
export const TeamInfo = ({ team, currentRole }: TeamInfoProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { i18n } = useTranslation();
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const created_at = DateTime.fromISO(team.created_at)
.setLocale(i18n.language)
.toLocaleString(format);
const updated_at = DateTime.fromISO(team.updated_at)
.setLocale(i18n.language)
.toLocaleString(format);
return (
<>
<Card className="m-b" style={{ paddingBottom: 0 }}>
<Box $css="align-self: flex-end;" className="m-t" $position="absolute">
<TeamActions currentRole={currentRole} team={team} />
</Box>
<Box className="m-b" $direction="row" $align="center" $gap="1.5rem">
<IconGroup
width={44}
color={colorsTokens()['primary-text']}
aria-label={t('icon group')}
style={{
flexShrink: 0,
alignSelf: 'start',
}}
/>
<Box>
<Text as="h3" $weight="bold" $size="1.25rem" className="mt-0">
{t('Members of “{{teamName}}“', {
teamName: team.name,
})}
</Text>
<Text $size="m">
{t('Add people to the “{{teamName}}“ group.', {
teamName: team.name,
})}
</Text>
</Box>
</Box>
<Box
className="p-s"
$gap="3rem"
$direction="row"
$justify="start"
$css={`
border-top: 1px solid ${colorsTokens()['card-border']};
padding-left: 1.5rem;
`}
>
<Text $size="s" as="p">
{t('{{count}} member', { count: team.accesses.length })}
</Text>
<Text $size="s" $display="inline" as="p">
{t('Created at')}&nbsp;
<Text $weight="bold" $display="inline">
{created_at}
</Text>
</Text>
<Text $size="s" $display="inline" as="p">
{t('Last update at')}&nbsp;
<Text $weight="bold" $display="inline">
{updated_at}
</Text>
</Text>
</Box>
</Card>
{isModalUpdateOpen && (
<ModalUpdateTeam
onClose={() => setIsModalUpdateOpen(false)}
team={team}
/>
)}
</>
);
};

View File

@@ -1,25 +0,0 @@
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { MainLayout } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { Panel } from '@/features/teams';
export function TeamLayout({ 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,4 +0,0 @@
export * from './CardCreateTeam';
export * from './Panel/Panel';
export * from './TeamInfo';
export * from './TeamLayout';

View File

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

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
import { Access } from '@/features/members';
export enum Role {
MEMBER = 'member',
ADMIN = 'administrator',
OWNER = 'owner',
}
export interface Team {
id: string;
name: string;
accesses: Access[];
created_at: string;
updated_at: string;
abilities: {
delete: boolean;
get: boolean;
manage_accesses: boolean;
patch: boolean;
put: boolean;
};
}