From 1dd33706c0b89455e366faecba318ad838061330 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 3 Apr 2024 11:26:42 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(app-impress)=20create=20feature=20pad?= =?UTF-8?q?s-panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a new feature pads-panel for the app impress. It will be used to display the pads in the app. --- .../apps/impress/src/features/pads/index.ts | 3 + .../pads-panel/__tests__/PanelTeams.test.tsx | 174 ++++++++++++++++++ .../src/features/pads/pads-panel/api/index.ts | 1 + .../features/pads/pads-panel/api/usePads.tsx | 70 +++++++ .../pads/pads-panel/assets/icon-add.svg | 6 + .../pads/pads-panel/assets/icon-none.svg | 13 ++ .../pads-panel/assets/icon-open-close.svg | 14 ++ .../pads/pads-panel/assets/icon-sort.svg | 13 ++ .../pads/pads-panel/components/PadItem.tsx | 102 ++++++++++ .../pads/pads-panel/components/PadList.tsx | 95 ++++++++++ .../pads/pads-panel/components/Panel.tsx | 81 ++++++++ .../pads-panel/components/PanelActions.tsx | 56 ++++++ .../pads/pads-panel/components/index.ts | 1 + .../src/features/pads/pads-panel/index.ts | 2 + .../features/pads/pads-panel/store/index.ts | 1 + .../pads/pads-panel/store/usePadStore.tsx | 19 ++ .../apps/impress/src/layouts/PadLayout.tsx | 2 +- src/frontend/apps/impress/src/pages/index.tsx | 8 +- 18 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/pads/index.ts create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/__tests__/PanelTeams.test.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/api/index.ts create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/api/usePads.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-add.svg create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-none.svg create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-open-close.svg create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/assets/icon-sort.svg create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/components/PadItem.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/components/PadList.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/components/Panel.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/components/PanelActions.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/components/index.ts create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/index.ts create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/store/index.ts create mode 100644 src/frontend/apps/impress/src/features/pads/pads-panel/store/usePadStore.tsx 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;