diff --git a/src/frontend/apps/impress/src/features/pads/index.ts b/src/frontend/apps/impress/src/features/pads/index.ts
new file mode 100644
index 00000000..2ccbea1d
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/index.ts
@@ -0,0 +1,3 @@
+export * from './pad';
+export * from './pads-create';
+export * from './pads-panel';
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/__tests__/PanelTeams.test.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/__tests__/PanelTeams.test.tsx
new file mode 100644
index 00000000..8b32a390
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/__tests__/PanelTeams.test.tsx
@@ -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 { Panel } from '@/features/teams';
+import { AppWrapper } from '@/tests/utils';
+
+import { TeamList } from '../components/PadList';
+
+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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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();
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/api/index.ts b/src/frontend/apps/impress/src/features/pads/pads-panel/api/index.ts
new file mode 100644
index 00000000..439cceb9
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/api/index.ts
@@ -0,0 +1 @@
+export * from './usePads';
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/api/usePads.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/api/usePads.tsx
new file mode 100644
index 00000000..4e722f76
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/api/usePads.tsx
@@ -0,0 +1,70 @@
+import {
+ DefinedInitialDataInfiniteOptions,
+ InfiniteData,
+ QueryKey,
+ useInfiniteQuery,
+} from '@tanstack/react-query';
+
+import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
+import { Pad } from '@/features/pads';
+
+export enum PadsOrdering {
+ BY_CREATED_ON = 'created_at',
+ BY_CREATED_ON_DESC = '-created_at',
+}
+
+export type PadsParams = {
+ ordering: PadsOrdering;
+};
+type PadsAPIParams = PadsParams & {
+ page: number;
+};
+
+type PadsResponse = APIList;
+
+export const getPads = async ({
+ ordering,
+ page,
+}: PadsAPIParams): Promise => {
+ const orderingQuery = ordering ? `&ordering=${ordering}` : '';
+ const response = await fetchAPI(`pads/?page=${page}${orderingQuery}`);
+
+ if (!response.ok) {
+ throw new APIError('Failed to get the teams', await errorCauses(response));
+ }
+
+ return response.json() as Promise;
+};
+
+export const KEY_LIST_PAD = 'pads';
+
+export function usePads(
+ param: PadsParams,
+ queryConfig?: DefinedInitialDataInfiniteOptions<
+ PadsResponse,
+ APIError,
+ InfiniteData,
+ QueryKey,
+ number
+ >,
+) {
+ return useInfiniteQuery<
+ PadsResponse,
+ APIError,
+ InfiniteData,
+ QueryKey,
+ number
+ >({
+ initialPageParam: 1,
+ queryKey: [KEY_LIST_PAD, param],
+ queryFn: ({ pageParam }) =>
+ getPads({
+ ...param,
+ page: pageParam,
+ }),
+ getNextPageParam(lastPage, allPages) {
+ return lastPage.next ? allPages.length + 1 : undefined;
+ },
+ ...queryConfig,
+ });
+}
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-add.svg b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-add.svg
new file mode 100644
index 00000000..a198da2b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-add.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-none.svg b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-none.svg
new file mode 100644
index 00000000..9f80850e
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-none.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-open-close.svg b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-open-close.svg
new file mode 100644
index 00000000..b894e495
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-open-close.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-sort.svg b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-sort.svg
new file mode 100644
index 00000000..ac4565d3
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-sort.svg
@@ -0,0 +1,13 @@
+
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/components/PadItem.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/components/PadItem.tsx
new file mode 100644
index 00000000..df48242d
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/components/PadItem.tsx
@@ -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 { Pad } from '@/features/pads';
+
+import IconNone from '../assets/icon-none.svg';
+
+interface PadItemProps {
+ pad: Pad;
+}
+
+export const PadItem = ({ pad }: PadItemProps) => {
+ const { t } = useTranslation();
+ const { colorsTokens } = useCunninghamTheme();
+ const {
+ query: { id },
+ } = useRouter();
+
+ // There is at least 1 owner in the team
+ const hasMembers = pad.accesses.length > 1;
+ const isActive = pad.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 (
+
+
+
+ {hasMembers ? (
+
+ ) : (
+
+ )}
+
+ {pad.name}
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/components/PadList.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/components/PadList.tsx
new file mode 100644
index 00000000..1fd4dd54
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/components/PadList.tsx
@@ -0,0 +1,95 @@
+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 { Pad } from '@/features/pads';
+
+import { usePads } from '../api';
+import { usePadStore } from '../store';
+
+import { PadItem } from './PadItem';
+
+interface PanelTeamsStateProps {
+ isLoading: boolean;
+ isError: boolean;
+ pads?: Pad[];
+}
+
+const PadListState = ({ isLoading, isError, pads }: PanelTeamsStateProps) => {
+ const { t } = useTranslation();
+
+ if (isError) {
+ return (
+
+
+ {t('Something bad happens, please refresh the page.')}
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!pads?.length) {
+ return (
+
+
+ {t('0 group to display.')}
+
+
+ {t(
+ 'Create your first pad by clicking on the "Create a new pad" button.',
+ )}
+
+
+ );
+ }
+
+ return pads.map((pad) => );
+};
+
+export const PadList = () => {
+ const ordering = usePadStore((state) => state.ordering);
+ const {
+ data,
+ isError,
+ isLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = usePads({
+ ordering,
+ });
+ const containerRef = useRef(null);
+ const pads = useMemo(() => {
+ return data?.pages.reduce((acc, page) => {
+ return acc.concat(page.results);
+ }, [] as Pad[]);
+ }, [data?.pages]);
+
+ return (
+
+ {
+ void fetchNextPage();
+ }}
+ scrollContainer={containerRef.current}
+ as="ul"
+ className="p-0 mt-0"
+ role="listbox"
+ >
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/components/Panel.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/components/Panel.tsx
new file mode 100644
index 00000000..2bb6fdc1
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/components/Panel.tsx
@@ -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 { PadList } from './PadList';
+import { PanelActions } from './PanelActions';
+
+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 (
+
+ setIsOpen(!isOpen)}
+ >
+
+
+
+
+
+ {t('Recents')}
+
+
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/components/PanelActions.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/components/PanelActions.tsx
new file mode 100644
index 00000000..98b4f966
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/components/PanelActions.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Box, BoxButton, StyledLink } from '@/components';
+import { useCunninghamTheme } from '@/cunningham';
+
+import { PadsOrdering } from '../api';
+import IconAdd from '../assets/icon-add.svg';
+import IconSort from '../assets/icon-sort.svg';
+import { usePadStore } from '../store';
+
+export const PanelActions = () => {
+ const { t } = useTranslation();
+ const { changeOrdering, ordering } = usePadStore();
+ const { colorsTokens } = useCunninghamTheme();
+
+ const isSortAsc = ordering === PadsOrdering.BY_CREATED_ON;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/components/index.ts b/src/frontend/apps/impress/src/features/pads/pads-panel/components/index.ts
new file mode 100644
index 00000000..8960d84f
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/components/index.ts
@@ -0,0 +1 @@
+export * from './Panel';
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/index.ts b/src/frontend/apps/impress/src/features/pads/pads-panel/index.ts
new file mode 100644
index 00000000..0ef46430
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/index.ts
@@ -0,0 +1,2 @@
+export * from './api';
+export * from './components';
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/store/index.ts b/src/frontend/apps/impress/src/features/pads/pads-panel/store/index.ts
new file mode 100644
index 00000000..66f8c556
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/store/index.ts
@@ -0,0 +1 @@
+export * from './usePadStore';
diff --git a/src/frontend/apps/impress/src/features/pads/pads-panel/store/usePadStore.tsx b/src/frontend/apps/impress/src/features/pads/pads-panel/store/usePadStore.tsx
new file mode 100644
index 00000000..c9f5e5c9
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/pads-panel/store/usePadStore.tsx
@@ -0,0 +1,19 @@
+import { create } from 'zustand';
+
+import { PadsOrdering } from '../api/usePads';
+
+interface PadStore {
+ ordering: PadsOrdering;
+ changeOrdering: () => void;
+}
+
+export const usePadStore = create((set) => ({
+ ordering: PadsOrdering.BY_CREATED_ON_DESC,
+ changeOrdering: () =>
+ set(({ ordering }) => ({
+ ordering:
+ ordering === PadsOrdering.BY_CREATED_ON
+ ? PadsOrdering.BY_CREATED_ON_DESC
+ : PadsOrdering.BY_CREATED_ON,
+ })),
+}));
diff --git a/src/frontend/apps/impress/src/layouts/PadLayout.tsx b/src/frontend/apps/impress/src/layouts/PadLayout.tsx
index f467f86e..7af074e8 100644
--- a/src/frontend/apps/impress/src/layouts/PadLayout.tsx
+++ b/src/frontend/apps/impress/src/layouts/PadLayout.tsx
@@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
-import { Panel } from '@/features/pads-panel';
+import { Panel } from '@/features/pads';
import { MainLayout } from './MainLayout';
diff --git a/src/frontend/apps/impress/src/pages/index.tsx b/src/frontend/apps/impress/src/pages/index.tsx
index bcccdb29..ac81d420 100644
--- a/src/frontend/apps/impress/src/pages/index.tsx
+++ b/src/frontend/apps/impress/src/pages/index.tsx
@@ -1,16 +1,16 @@
import type { ReactElement } from 'react';
-import { TeamLayout } from '@/features/teams/';
+import { PadLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
-import Teams from './teams/';
+import Pads from './pads/';
const Page: NextPageWithLayout = () => {
- return ;
+ return ;
};
Page.getLayout = function getLayout(page: ReactElement) {
- return {page};
+ return {page};
};
export default Page;