feat(pages): add /application/[guildId]/[channelId] (#4)

This commit is contained in:
Divlo 2022-01-01 20:42:25 +01:00 committed by GitHub
parent 91e246b759
commit fdc2a2d1de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
118 changed files with 6040 additions and 2094 deletions

View File

@ -27,6 +27,7 @@
}
}
}
]
],
"@typescript-eslint/no-namespace": "off"
}
}

View File

@ -1,15 +1,15 @@
FROM node:16.11.0 AS dependencies
FROM node:16.13.1 AS dependencies
WORKDIR /usr/src/app
COPY ./package*.json ./
RUN npm clean-install
FROM node:16.11.0 AS builder
FROM node:16.13.1 AS builder
WORKDIR /usr/src/app
COPY ./ ./
COPY --from=dependencies /usr/src/app/node_modules ./node_modules
RUN npm run build
FROM node:16.11.0 AS runner
FROM node:16.13.1 AS runner
WORKDIR /usr/src/app
ENV NODE_ENV=production
COPY --from=builder /usr/src/app/next.config.js ./next.config.js

View File

@ -1,13 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import useTranslation from 'next-translate/useTranslation'
import {
CogIcon,
PlusIcon,
MenuIcon,
UsersIcon,
XIcon
} from '@heroicons/react/solid'
import { PlusIcon, MenuIcon, UsersIcon, XIcon } from '@heroicons/react/solid'
import classNames from 'classnames'
import { useMediaQuery } from 'react-responsive'
import { useSwipeable } from 'react-swipeable'
@ -15,31 +8,33 @@ import { useSwipeable } from 'react-swipeable'
import { Sidebar, DirectionSidebar } from './Sidebar'
import { IconButton } from 'components/design/IconButton'
import { IconLink } from 'components/design/IconLink'
import { Channels } from './Channels'
import { Guilds } from './Guilds/Guilds'
import { Divider } from '../design/Divider'
import { Members } from './Members'
import { useAuthentication } from 'utils/authentication'
import { API_URL } from 'utils/api'
import { useAuthentication } from 'tools/authentication'
import { API_URL } from 'tools/api'
export interface GuildsChannelsPath {
guildId: number
channelId: number
}
export interface ApplicationProps {
path:
export type ApplicationPath =
| '/application'
| '/application/guilds/join'
| '/application/guilds/create'
| '/application/users/[userId]'
| `/application/users/${number}`
| GuildsChannelsPath
export interface ApplicationProps {
path: ApplicationPath
guildLeftSidebar?: React.ReactNode
title: string
}
export const Application: React.FC<ApplicationProps> = (props) => {
const { children, path } = props
const { children, path, guildLeftSidebar, title } = props
const { t } = useTranslation()
const { user } = useAuthentication()
const [visibleSidebars, setVisibleSidebars] = useState({
@ -129,23 +124,6 @@ export const Application: React.FC<ApplicationProps> = (props) => {
}
})
const title = useMemo(() => {
if (typeof path !== 'string') {
// TODO: Returns the real name of the channel when doing APIs calls
return `# Channel ${path.channelId}`
}
if (path.startsWith('/application/users/')) {
return 'Settings'
}
if (path === '/application/guilds/join') {
return 'Join a Guild'
}
if (path === '/application/guilds/create') {
return t('application:create-a-guild')
}
return 'Application'
}, [path, t])
useEffect(() => {
setMounted(true)
}, [])
@ -163,12 +141,16 @@ export const Application: React.FC<ApplicationProps> = (props) => {
>
{!visibleSidebars.left ? <MenuIcon /> : <XIcon />}
</IconButton>
<div className='text-md text-green-800 dark:text-green-400 font-semibold'>
<div
data-cy='application-title'
className='text-md text-green-800 dark:text-green-400 font-semibold'
>
{title}
</div>
<div className='flex space-x-2'>
{title.startsWith('#') && (
<IconButton
data-cy='icon-button-right-sidebar-members'
className='p-2 h-10 w-10'
onClick={() => handleToggleSidebars('right')}
>
@ -201,7 +183,8 @@ export const Application: React.FC<ApplicationProps> = (props) => {
? '/images/data/user-default.png'
: API_URL + user.logo
}
alt='logo'
alt={"Users's profil picture"}
draggable={false}
width={48}
height={48}
/>
@ -217,26 +200,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
<Guilds path={path} />
</div>
{typeof path !== 'string' && (
<div className='flex flex-col justify-between w-full mt-2'>
<div className='text-center p-2 mx-8 mt-2'>
<h2 className='text-xl'>Guild Name</h2>
</div>
<Divider />
<div className='scrollbar-firefox-support overflow-y-auto'>
<Channels path={path} />
</div>
<Divider />
<div className='flex justify-center items-center p-2 mb-1 space-x-6'>
<IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon />
</IconButton>
<IconButton className='h-7 w-7' title='Settings'>
<CogIcon />
</IconButton>
</div>
</div>
)}
{guildLeftSidebar}
</Sidebar>
<div
@ -252,6 +216,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
{children}
</div>
{typeof path !== 'string' && (
<Sidebar
direction='right'
visible={visibleSidebars.right}
@ -259,6 +224,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
>
<Members />
</Sidebar>
)}
</main>
</>
)

View File

@ -0,0 +1,16 @@
import { Meta, Story } from '@storybook/react'
import { channelExample } from '../../../../cypress/fixtures/channels/channel'
import { Channel as Component, ChannelProps } from './Channel'
const Stories: Meta = {
title: 'Channel',
component: Component
}
export default Stories
export const Channel: Story<ChannelProps> = (arguments_) => {
return <Component {...arguments_} />
}
Channel.args = { path: { channelId: 1, guildId: 1 }, channel: channelExample }

View File

@ -0,0 +1,13 @@
import { render } from '@testing-library/react'
import { channelExample } from 'cypress/fixtures/channels/channel'
import { Channel } from './Channel'
describe('<Channel />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Channel channel={channelExample} path={{ channelId: 1, guildId: 1 }} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,32 @@
import classNames from 'classnames'
import Link from 'next/link'
import { GuildsChannelsPath } from '../../Application'
import { Channel as ChannelType } from '../../../../models/Channel'
export interface ChannelProps {
path: GuildsChannelsPath
channel: ChannelType
selected?: boolean
}
export const Channel: React.FC<ChannelProps> = (props) => {
const { channel, path, selected = false } = props
return (
<Link href={`/application/${path.guildId}/${channel.id}`}>
<a
className={classNames(
'hover:bg-gray-100 group flex items-center justify-between text-sm py-2 my-3 mx-3 transition-colors dark:hover:bg-gray-600 duration-200 rounded-lg',
{
'text-green-800 dark:text-green-400 font-semibold': selected
}
)}
>
<span className='ml-2 mr-4' data-cy='channel-name'>
# {channel.name}
</span>
</a>
</Link>
)
}

View File

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

View File

@ -1,15 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { Channels as Component, ChannelsProps } from './'
const Stories: Meta = {
title: 'Channels',
component: Component
}
export default Stories
export const Channels: Story<ChannelsProps> = (arguments_) => (
<Component {...arguments_} />
)
Channels.args = { path: { channelId: 1, guildId: 2 } }

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react'
import { Channels } from './'
describe('<Channels />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Channels path={{ channelId: 1, guildId: 2 }} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,7 +1,9 @@
import Link from 'next/link'
import classNames from 'classnames'
import InfiniteScroll from 'react-infinite-scroll-component'
import { GuildsChannelsPath } from '../Application'
import { Loader } from 'components/design/Loader'
import { Channel } from './Channel'
import { useChannels } from 'contexts/Channels'
export interface ChannelsProps {
path: GuildsChannelsPath
@ -10,27 +12,33 @@ export interface ChannelsProps {
export const Channels: React.FC<ChannelsProps> = (props) => {
const { path } = props
const { channels, hasMore, nextPage } = useChannels()
return (
<nav className='w-full'>
{new Array(100).fill(null).map((_, index) => {
return (
<Link key={index} href={`/application/${path.guildId}/${index}`}>
<a
className={classNames(
'hover:bg-gray-100 group flex items-center justify-between text-sm py-2 my-3 mx-3 transition-colors dark:hover:bg-gray-600 duration-200 rounded-lg',
{
'text-green-800 dark:text-green-400 font-semibold':
typeof path !== 'string' && path.channelId === index,
'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-white font-normal':
typeof path === 'string'
}
)}
<div
id='channels'
className='scrollbar-firefox-support overflow-y-auto flex-1 flex flex-col'
>
<span className='ml-2 mr-4'># Channel {index}</span>
</a>
</Link>
<InfiniteScroll
className='w-full channels-list'
scrollableTarget='channels'
dataLength={channels.length}
next={nextPage}
hasMore={hasMore}
loader={<Loader />}
>
{channels.map((channel) => {
const selected = channel.id === path.channelId
return (
<Channel
key={channel.id}
channel={channel}
path={path}
selected={selected}
/>
)
})}
</nav>
</InfiniteScroll>
</div>
)
}

View File

@ -4,7 +4,7 @@ import { Form } from 'react-component-form'
import TextareaAutosize from 'react-textarea-autosize'
import { AxiosResponse } from 'axios'
import { useAuthentication } from '../../../utils/authentication'
import { useAuthentication } from '../../../tools/authentication'
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { GuildComplete, guildSchema } from '../../../models/Guild'
import { Input } from '../../design/Input'

View File

@ -0,0 +1,38 @@
import { CogIcon, PlusIcon } from '@heroicons/react/solid'
import { useGuildMember } from 'contexts/GuildMember'
import { Divider } from 'components/design/Divider'
import { Channels } from 'components/Application/Channels'
import { IconButton } from 'components/design/IconButton'
import { GuildsChannelsPath } from '..'
export interface GuildLeftSidebarProps {
path: GuildsChannelsPath
}
export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
const { path } = props
const { guild } = useGuildMember()
return (
<div className='flex flex-col justify-between w-full mt-2'>
<div className='text-center p-2 mx-8 mt-2'>
<h2 data-cy='guild-left-sidebar-title' className='text-xl'>
{guild.name}
</h2>
</div>
<Divider />
<Channels path={path} />
<Divider />
<div className='flex justify-center items-center p-2 mb-1 space-x-6'>
<IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon />
</IconButton>
<IconButton className='h-7 w-7' title='Settings'>
<CogIcon />
</IconButton>
</div>
</div>
)
}

View File

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

View File

@ -1,6 +1,7 @@
import { Meta, Story } from '@storybook/react'
import { Guild as Component, GuildProps } from './Guild'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
const Stories: Meta = {
title: 'Guild',
@ -14,12 +15,7 @@ export const Guild: Story<GuildProps> = (arguments_) => {
}
Guild.args = {
guild: {
id: 1,
name: 'GuildExample',
description: 'guild example.',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
membersCount: 1
...guildExample,
defaultChannelId: 1
}
}

View File

@ -1,19 +1,15 @@
import { render } from '@testing-library/react'
import { Guild } from './Guild'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
describe('<Guild />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Guild
guild={{
id: 1,
name: 'GuildExample',
description: 'guild example.',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
membersCount: 1
...guildExample,
defaultChannelId: 1
}}
/>
)

View File

@ -0,0 +1,35 @@
import Image from 'next/image'
import { GuildWithDefaultChannelId } from '../../../../models/Guild'
import { IconLink } from '../../../design/IconLink'
export interface GuildProps {
guild: GuildWithDefaultChannelId
selected?: boolean
}
export const Guild: React.FC<GuildProps> = (props) => {
const { guild, selected } = props
return (
<IconLink
className='mt-2'
href={`/application/${guild.id}/${guild.defaultChannelId}`}
selected={selected}
title={guild.name}
>
<div className='pl-[6px]'>
<Image
className='rounded-full'
src={
guild.icon != null ? guild.icon : '/images/data/guild-default.png'
}
alt='logo'
width={48}
height={48}
draggable={false}
/>
</div>
</IconLink>
)
}

View File

@ -1,15 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { Guilds as Component, GuildsProps } from './'
const Stories: Meta = {
title: 'Guilds',
component: Component
}
export default Stories
export const Guilds: Story<GuildsProps> = (arguments_) => (
<Component {...arguments_} />
)
Guilds.args = { path: { channelId: 1, guildId: 2 } }

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react'
import { Guilds } from './'
describe('<Guilds />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<Guilds path={{ channelId: 1, guildId: 2 }} />
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,34 +1,37 @@
import Image from 'next/image'
import InfiniteScroll from 'react-infinite-scroll-component'
import { ApplicationProps } from '../Application'
import { IconLink } from '../../design/IconLink'
import { Loader } from 'components/design/Loader'
import { Guild } from './Guild'
import { useGuilds } from 'contexts/Guilds'
import { GuildsChannelsPath } from '..'
export interface GuildsProps extends ApplicationProps {}
export interface GuildsProps {
path: GuildsChannelsPath | string
}
export const Guilds: React.FC<GuildsProps> = (props) => {
const { path } = props
const { guilds, hasMore, nextPage } = useGuilds()
return (
<div className='min-w-[92px] mt-[130px] pt-2 h-full border-r-2 border-gray-500 dark:border-white/20 space-y-2 scrollbar-firefox-support overflow-y-auto'>
{new Array(100).fill(null).map((_, index) => {
return (
<IconLink
key={index}
href={`/application/${index}/0`}
selected={typeof path !== 'string' && path.guildId === index}
title='Guild Name'
<div
id='guilds-list'
className='min-w-[92px] mt-[130px] pt-2 h-full border-r-2 border-gray-500 dark:border-white/20 space-y-2 scrollbar-firefox-support overflow-y-auto'
>
<div className='pl-[6px]'>
<Image
src='/images/icons/Thream.png'
alt='logo'
width={48}
height={48}
/>
</div>
</IconLink>
)
<InfiniteScroll
className='guilds-list'
dataLength={guilds.length}
next={nextPage}
hasMore={hasMore}
scrollableTarget='guilds-list'
loader={<Loader />}
>
{guilds.map((guild) => {
const selected = typeof path !== 'string' && path.guildId === guild.id
return <Guild key={guild.id} guild={guild} selected={selected} />
})}
</InfiniteScroll>
</div>
)
}

View File

@ -0,0 +1,21 @@
import { Meta, Story } from '@storybook/react'
import { GuildPublic as Component, GuildPublicProps } from './GuildPublic'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
const Stories: Meta = {
title: 'GuildPublic',
component: Component
}
export default Stories
export const GuildPublic: Story<GuildPublicProps> = (arguments_) => {
return <Component {...arguments_} />
}
GuildPublic.args = {
guild: {
...guildExample,
membersCount: 1
}
}

View File

@ -0,0 +1,18 @@
import { render } from '@testing-library/react'
import { GuildPublic } from './GuildPublic'
import { guildExample } from '../../../../cypress/fixtures/guilds/guild'
describe('<GuildPublic />', () => {
it('should render successfully', () => {
const { baseElement } = render(
<GuildPublic
guild={{
...guildExample,
membersCount: 1
}}
/>
)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,19 +1,19 @@
import Image from 'next/image'
import useTranslation from 'next-translate/useTranslation'
import { GuildPublic } from 'models/Guild'
import { GuildPublic as GuildPublicType } from '../../../../models/Guild'
export interface GuildProps {
guild: GuildPublic
export interface GuildPublicProps {
guild: GuildPublicType
}
export const Guild: React.FC<GuildProps> = (props) => {
export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
const { guild } = props
const { t } = useTranslation()
return (
<div
key={guild.id}
className='max-w-sm flex flex-col items-center justify-center border-gray-500 dark:border-gray-700 p-4 cursor-pointer rounded shadow-lg border transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none'
>
<div className='max-w-sm flex flex-col items-center justify-center border-gray-500 dark:border-gray-700 p-4 cursor-pointer rounded shadow-lg border transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none'>
<Image
className='rounded-full'
src={guild.icon != null ? guild.icon : '/images/data/guild-default.png'}
@ -28,7 +28,7 @@ export const Guild: React.FC<GuildProps> = (props) => {
<p className='text-base w-11/12 mx-auto'>{guild.description}</p>
</div>
<p className='flex flex-col text-green-800 dark:text-green-400 mt-4'>
{guild.membersCount} members
{guild.membersCount} {t('application:members')}
</p>
</div>
)

View File

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

View File

@ -1,49 +1,31 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import useTranslation from 'next-translate/useTranslation'
import { useEffect, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { useAuthentication } from 'utils/authentication'
import { GuildPublic } from 'models/Guild'
import { useAuthentication } from 'tools/authentication'
import { GuildPublic as GuildPublicType } from 'models/Guild'
import { Loader } from 'components/design/Loader'
import { useFetchState } from 'hooks/useFetchState'
import { Guild } from './Guild'
import { GuildPublic } from './GuildPublic'
import { usePagination } from 'hooks/usePagination'
export const JoinGuildsPublic: React.FC = () => {
const [guilds, setGuilds] = useState<GuildPublic[]>([])
const [hasMore, setHasMore] = useState(true)
const [inputSearch, setInputSearch] = useState('')
const [fetchState, setFetchState] = useFetchState('idle')
const afterId = useRef<number | null>(null)
const [search, setSearch] = useState('')
const { authentication } = useAuthentication()
const { t } = useTranslation()
const fetchGuilds = useCallback(async (): Promise<void> => {
if (fetchState !== 'idle') {
return
}
setFetchState('loading')
const { data } = await authentication.api.get<GuildPublic[]>(
`/guilds/public?limit=20&search=${inputSearch}${
afterId.current != null ? `&after=${afterId.current}` : ''
}`
)
afterId.current = data.length > 0 ? data[data.length - 1].id : null
setGuilds((oldGuilds) => {
return [...oldGuilds, ...data]
const { items, hasMore, nextPage, resetPagination } =
usePagination<GuildPublicType>({
api: authentication.api,
url: '/guilds/public'
})
setHasMore(data.length > 0)
setFetchState('idle')
}, [authentication, fetchState, setFetchState, inputSearch])
useEffect(() => {
afterId.current = null
setGuilds([])
fetchGuilds().catch((error) => {
console.error(error)
})
}, [inputSearch]) // eslint-disable-line react-hooks/exhaustive-deps
resetPagination()
nextPage({ search })
}, [resetPagination, nextPage, search])
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setInputSearch(event.target.value)
setSearch(event.target.value)
}
return (
@ -54,19 +36,19 @@ export const JoinGuildsPublic: React.FC = () => {
className='w-10/12 sm:w-8/12 md:w-6/12 lg:w-5/12 bg-white dark:bg-[#3B3B3B] border-gray-500 dark:border-gray-700 p-3 my-6 mt-16 mx-auto rounded-md border'
type='search'
name='search-guild'
placeholder='🔎 Search...'
placeholder={`🔎 ${t('application:search')}...`}
/>
<div className='w-full flex items-center justify-center p-12'>
<InfiniteScroll
className='guilds-list max-w-[1600px] grid grid-cols-1 xl:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-8 !overflow-hidden'
dataLength={guilds.length}
next={fetchGuilds}
className='guilds-public-list max-w-[1600px] grid grid-cols-1 xl:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-8 !overflow-hidden'
dataLength={items.length}
next={nextPage}
scrollableTarget='application-page-content'
hasMore={hasMore}
loader={<Loader />}
>
{guilds.map((guild) => {
return <Guild guild={guild} key={guild.id} />
{items.map((guild) => {
return <GuildPublic guild={guild} key={guild.id} />
})}
</InfiniteScroll>
</div>

View File

@ -0,0 +1,16 @@
import { Meta, Story } from '@storybook/react'
import { Member as Component, MemberProps } from './Member'
import { memberExampleComplete } from '../../../../cypress/fixtures/members/member'
const Stories: Meta = {
title: 'Member',
component: Component
}
export default Stories
export const Member: Story<MemberProps> = (arguments_) => {
return <Component {...arguments_} />
}
Member.args = { member: memberExampleComplete }

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Member } from './Member'
import { memberExampleComplete } from '../../../../cypress/fixtures/members/member'
describe('<Member />', () => {
it('should render successfully', () => {
const { baseElement } = render(<Member member={memberExampleComplete} />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,45 @@
import Image from 'next/image'
import Link from 'next/link'
import { MemberWithPublicUser } from '../../../../models/Member'
import { API_URL } from '../../../../tools/api'
export interface MemberProps {
member: MemberWithPublicUser
}
export const Member: React.FC<MemberProps> = (props) => {
const { member } = props
return (
<Link href={`/application/users/${member.user.id}`}>
<a>
<div className='flex items-center cursor-pointer py-2 px-4 pr-10 rounded hover:bg-gray-300 dark:hover:bg-gray-900'>
<div className='min-w-[50px] flex rounded-full'>
<Image
src={
member.user.logo == null
? '/images/data/user-default.png'
: API_URL + member.user.logo
}
alt={"Users's profil picture"}
height={50}
width={50}
draggable={false}
className='rounded-full'
/>
</div>
<div className='max-w-[145px] ml-4'>
<p
data-cy='member-user-name'
className='overflow-hidden whitespace-nowrap overflow-ellipsis'
>
{member.user.name}
</p>
{member.user.status != null && <span>{member.user.status}</span>}
</div>
</div>
</a>
</Link>
)
}

View File

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

View File

@ -1,10 +0,0 @@
import { render } from '@testing-library/react'
import { Members } from './Members'
describe('<Members />', () => {
it('should render successfully', () => {
const { baseElement } = render(<Members />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -1,57 +1,36 @@
import Image from 'next/image'
import useTranslation from 'next-translate/useTranslation'
import InfiniteScroll from 'react-infinite-scroll-component'
import { Divider } from '../../design/Divider'
import { Loader } from 'components/design/Loader'
import { useMembers } from 'contexts/Members'
import { Member } from './Member'
import { capitalize } from '../../../tools/utils/capitalize'
export const Members: React.FC = () => {
const { members, hasMore, nextPage } = useMembers()
const { t } = useTranslation()
return (
<>
<div className='mb-2'>
<h1 className='text-center pt-2 my-2 text-xl'>Members</h1>
<h1 data-cy='members-title' className='text-center pt-2 my-2 text-xl'>
{capitalize(t('application:members'))}
</h1>
<Divider />
</div>
<div className='flex items-center cursor-pointer py-2 px-4 pr-10 rounded hover:bg-gray-300 dark:hover:bg-gray-900'>
<div className='min-w-[50px] flex rounded-full border-2 border-green-500'>
<Image
src='/images/data/divlo.png'
alt={"Users's profil picture"}
height={50}
width={50}
draggable='false'
className='rounded-full'
/>
</div>
<div className='max-w-[145px] ml-4'>
<p className='overflow-hidden whitespace-nowrap overflow-ellipsis'>
Walidouxssssssssssss
</p>
<span className='text-green-600 dark:text-green-400'>Online</span>
</div>
</div>
{new Array(100).fill(null).map((_, index) => {
return (
<div
key={index}
className='flex items-center cursor-pointer py-2 px-4 pr-10 rounded opacity-40 hover:bg-gray-300 dark:hover:bg-gray-900'
<InfiniteScroll
className='members-list'
dataLength={members.length}
next={nextPage}
hasMore={hasMore}
loader={<Loader />}
>
<div className='min-w-[50px] flex rounded-full border-2 border-transparent drop-shadow-md'>
<Image
src='/images/data/divlo.png'
alt={"Users's profil picture"}
height={50}
width={50}
draggable='false'
className='rounded-full'
/>
</div>
<div className='max-w-[145px] ml-4'>
<p className='overflow-hidden whitespace-nowrap overflow-ellipsis'>
Walidouxssssssssssssssssssssssssssssss
</p>
<span className='text-red-800 dark:text-red-400'>Offline</span>
</div>
</div>
)
{members.map((member) => {
return <Member key={member.id} member={member} />
})}
</InfiniteScroll>
</>
)
}

View File

@ -0,0 +1,16 @@
import { Meta, Story } from '@storybook/react'
import { Message as Component, MessageProps } from './Message'
import { messageExampleComplete } from '../../../../cypress/fixtures/messages/message'
const Stories: Meta = {
title: 'Message',
component: Component
}
export default Stories
export const Message: Story<MessageProps> = (arguments_) => {
return <Component {...arguments_} />
}
Message.args = { message: messageExampleComplete }

View File

@ -0,0 +1,61 @@
import Image from 'next/image'
import Link from 'next/link'
import date from 'date-and-time'
import { MessageWithMember } from '../../../../models/Message'
import { API_URL } from '../../../../tools/api'
import { MessageContent } from './MessageContent'
export interface MessageProps {
message: MessageWithMember
}
export const Message: React.FC<MessageProps> = (props) => {
const { message } = props
return (
<div className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900'>
<Link href={`/application/users/${message.member.user.id}`}>
<a>
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'>
<div className='w-10 h-10 drop-shadow-md'>
<Image
className='rounded-full'
src={
message.member.user.logo == null
? '/images/data/user-default.png'
: API_URL + message.member.user.logo
}
alt={"Users's profil picture"}
width={50}
height={50}
draggable={false}
/>
</div>
</div>
</a>
</Link>
<div className='w-full'>
<div className='w-max flex items-center'>
<Link href={`/application/users/${message.member.user.id}`}>
<a>
<span
data-cy='message-member-user-name'
className='font-bold text-gray-900 dark:text-gray-200'
>
{message.member.user.name}
</span>
</a>
</Link>
<span
data-cy='message-date'
className='text-gray-500 dark:text-gray-200 text-xs ml-4 select-none'
>
{date.format(new Date(message.createdAt), 'DD/MM/YYYY - HH:mm:ss')}
</span>
</div>
<MessageContent message={message} />
</div>
</div>
)
}

View File

@ -0,0 +1,46 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import gfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from '../../../../Emoji'
import { MessageWithMember } from '../../../../../models/Message'
export interface MessageContentProps {
message: MessageWithMember
}
export const MessageContent: React.FC<MessageContentProps> = (props) => {
const { message } = props
const isMessageWithOnlyOneEmoji = useMemo(() => {
return isStringWithOnlyOneEmoji(message.value)
}, [message.value])
if (isMessageWithOnlyOneEmoji) {
return (
<div>
<p>
<Emoji value={message.value} size={40} />
</p>
</div>
)
}
return (
<ReactMarkdown
disallowedElements={['table']}
unwrapDisallowed
remarkPlugins={[[gfm], [remarkBreaks]]}
rehypePlugins={[emojiPlugin]}
linkTarget='_blank'
components={{
emoji: (props) => {
return <Emoji value={props.value} size={20} />
}
}}
>
{message.value}
</ReactMarkdown>
)
}

View File

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

View File

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

View File

@ -1,12 +0,0 @@
import { Meta, Story } from '@storybook/react'
import { Messages as Component } from './'
const Stories: Meta = {
title: 'Messages',
component: Component
}
export default Stories
export const Messages: Story = (arguments_) => <Component {...arguments_} />

View File

@ -1,82 +1,45 @@
import Image from 'next/image'
import TextareaAutosize from 'react-textarea-autosize'
import InfiniteScroll from 'react-infinite-scroll-component'
import { Loader } from 'components/design/Loader'
import { Message } from './Message'
import { useMessages } from 'contexts/Messages'
import { Emoji } from 'components/Emoji'
export const Messages: React.FC = () => {
return (
<>
<div className='w-full scrollbar-firefox-support overflow-y-auto transition-all'>
{new Array(20).fill(null).map((_, index) => {
const { messages, hasMore, nextPage } = useMessages()
if (messages.length === 0) {
return (
<div
key={index}
className='p-4 flex transition hover:bg-gray-200 dark:hover:bg-gray-900'
id='messages'
className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col text-center mt-8 text-lg'
>
<div className='w-12 h-12 mr-4 flex flex-shrink-0 items-center justify-center'>
<div className='w-10 h-10 drop-shadow-md'>
<Image
className='rounded-full'
src='/images/data/user-default.png'
alt='logo'
width={50}
height={50}
/>
</div>
</div>
<div className='w-full'>
<div className='w-max flex items-center'>
<span className='font-bold text-gray-900 dark:text-gray-200'>
Divlo
</span>
<span className='text-gray-500 dark:text-gray-200 text-xs ml-4 select-none'>
06/04/2021 - 22:28:40
</span>
</div>
<div className='text-gray-800 dark:text-gray-300 font-paragraph mt-1 break-words'>
<p>Message {index}</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
Eum debitis voluptatum itaque quaerat. Nemo optio voluptas
quas mollitia rerum commodi laboriosam voluptates et sit
quo. Repudiandae eius at inventore magnam. Voluptas nisi
maxime laborum architecto fuga a consequuntur reiciendis
rerum beatae hic possimus, omnis dolorum libero, illo
dolorem assumenda. Repellat, ad!
Nothing to show here! <Emoji value=':ghost:' size={20} />
</p>
<p>Start chatting to kill this Ghost!</p>
</div>
)
}
return (
<div
id='messages'
className='w-full scrollbar-firefox-support overflow-y-auto transition-all flex-1 flex flex-col-reverse'
>
<InfiniteScroll
scrollableTarget='messages'
className='messages-list'
dataLength={messages.length}
next={nextPage}
inverse
hasMore={hasMore}
loader={<Loader />}
>
{messages.map((message) => {
return <Message key={message.id} message={message} />
})}
</InfiniteScroll>
</div>
</div>
</div>
)
})}
</div>
<div className='p-6 pb-4'>
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
<form className='w-full h-full flex items-center'>
<TextareaAutosize
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
placeholder='Write a message...'
wrap='soft'
maxRows={6}
/>
</form>
<div className='h-full flex items-center justify-around pr-6'>
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'>
🙂
</button>
<button className='relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
<input
type='file'
className='absolute w-full h-full opacity-0 cursor-pointer'
/>
<svg width='25' height='25' viewBox='0 0 22 22'>
<path
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill='currentColor'
/>
</svg>
</button>
</div>
</div>
</div>
</>
)
}

View File

@ -1,3 +1,4 @@
import useTranslation from 'next-translate/useTranslation'
import { PlusSmIcon, ArrowDownIcon } from '@heroicons/react/solid'
import classNames from 'classnames'
import Image from 'next/image'
@ -10,6 +11,8 @@ export interface PopupGuildProps {
export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
const { className } = props
const { t } = useTranslation()
return (
<div
className={classNames(
@ -21,16 +24,16 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
image={
<Image
src='/images/svg/design/create-guild.svg'
alt='Create a guild'
alt={t('application:create-a-guild')}
draggable='false'
width={230}
height={230}
/>
}
description='Create your own guild and manage everything within a few clicks !'
description={t('application:create-a-guild-description')}
link={{
icon: <PlusSmIcon className='w-8 h-8 mr-2' />,
text: 'Create a Guild',
text: t('application:create-a-guild'),
href: '/application/guilds/create'
}}
/>
@ -38,16 +41,16 @@ export const PopupGuild: React.FC<PopupGuildProps> = (props) => {
image={
<Image
src='/images/svg/design/join-guild.svg'
alt='Join a Guild'
alt={t('application:join-a-guild')}
draggable='false'
width={200}
height={200}
/>
}
description='Talk, meet and have fun with new friends by joining any interesting guild !'
description={t('application:join-a-guild-description')}
link={{
icon: <ArrowDownIcon className='w-6 h-6 mr-2' />,
text: 'Join a Guild',
text: t('application:join-a-guild'),
href: '/application/guilds/join'
}}
/>

View File

@ -19,7 +19,9 @@ export const PopupGuildCard: React.FC<PopupGuildCardProps> = (props) => {
{image}
</div>
<div className='flex justify-between flex-col h-1/2 w-full bg-gray-700 rounded-b-2xl mt-2 shadow-sm'>
<p className='text-gray-200 mt-6 text-center px-8'>{description}</p>
<p className='text-gray-200 text-sm mt-6 text-center px-8'>
{description}
</p>
<Link href={link.href}>
<a className='flex justify-center items-center w-4/5 h-10 rounded-2xl transition duration-200 ease-in-out text-white font-bold tracking-wide bg-green-400 self-center mb-6 hover:bg-green-600'>
{link.icon}

View File

@ -1,14 +1,15 @@
import { Meta, Story } from '@storybook/react'
import { Members as Component } from './Members'
import { SendMessage as Component } from './SendMessage'
const Stories: Meta = {
title: 'Members',
title: 'SendMessage',
component: Component
}
export default Stories
export const Members: Story = (arguments_) => {
export const SendMessage: Story = (arguments_) => {
return <Component {...arguments_} />
}
SendMessage.args = {}

View File

@ -1,10 +1,10 @@
import { render } from '@testing-library/react'
import { Messages } from './'
import { SendMessage } from './SendMessage'
describe('<Messages />', () => {
describe('<SendMessage />', () => {
it('should render successfully', () => {
const { baseElement } = render(<Messages />)
const { baseElement } = render(<SendMessage />)
expect(baseElement).toBeTruthy()
})
})

View File

@ -0,0 +1,38 @@
import useTranslation from 'next-translate/useTranslation'
import TextareaAutosize from 'react-textarea-autosize'
export const SendMessage: React.FC = () => {
const { t } = useTranslation()
return (
<div className='p-6 pb-4'>
<div className='w-full h-full py-1 flex rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-200'>
<form className='w-full h-full flex items-center'>
<TextareaAutosize
className='w-full scrollbar-firefox-support p-2 px-6 my-2 bg-transparent outline-none font-paragraph tracking-wide resize-none'
placeholder={t('application:write-a-message')}
wrap='soft'
maxRows={6}
/>
</form>
<div className='h-full flex items-center justify-around pr-6'>
<button className='w-full h-full flex items-center justify-center p-1 text-2xl transition hover:-translate-y-1'>
🙂
</button>
<button className='relative w-full h-full flex items-center justify-center p-1 text-green-800 dark:text-green-400 transition hover:-translate-y-1'>
<input
type='file'
className='absolute w-full h-full opacity-0 cursor-pointer'
/>
<svg width='25' height='25' viewBox='0 0 22 22'>
<path
d='M11 0C4.925 0 0 4.925 0 11C0 17.075 4.925 22 11 22C17.075 22 22 17.075 22 11C22 4.925 17.075 0 11 0ZM12 15C12 15.2652 11.8946 15.5196 11.7071 15.7071C11.5196 15.8946 11.2652 16 11 16C10.7348 16 10.4804 15.8946 10.2929 15.7071C10.1054 15.5196 10 15.2652 10 15V12H7C6.73478 12 6.48043 11.8946 6.29289 11.7071C6.10536 11.5196 6 11.2652 6 11C6 10.7348 6.10536 10.4804 6.29289 10.2929C6.48043 10.1054 6.73478 10 7 10H10V7C10 6.73478 10.1054 6.48043 10.2929 6.29289C10.4804 6.10536 10.7348 6 11 6C11.2652 6 11.5196 6.10536 11.7071 6.29289C11.8946 6.48043 12 6.73478 12 7V10H15C15.2652 10 15.5196 10.1054 15.7071 10.2929C15.8946 10.4804 16 10.7348 16 11C16 11.2652 15.8946 11.5196 15.7071 11.7071C15.5196 11.8946 15.2652 12 15 12H12V15Z'
fill='currentColor'
/>
</svg>
</button>
</div>
</div>
</div>
)
}

View File

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

View File

@ -1,5 +1,8 @@
import { Meta, Story } from '@storybook/react'
import { user, userSettings } from '../../../cypress/fixtures/users/user'
import {
userExample,
userSettingsExample
} from '../../../cypress/fixtures/users/user'
import { UserProfile as Component, UserProfileProps } from './UserProfile'
@ -15,7 +18,7 @@ export const UserProfile: Story<UserProfileProps> = (arguments_) => {
}
UserProfile.args = {
user: {
...user,
settings: userSettings
...userExample,
settings: userSettingsExample
}
}

View File

@ -11,11 +11,11 @@ import { Button } from '../design/Button'
import { FormState } from '../design/FormState'
import { AuthenticationForm } from './'
import { userSchema } from '../../models/User'
import { api } from 'utils/api'
import { api } from 'tools/api'
import {
Tokens,
Authentication as AuthenticationClass
} from '../../utils/authentication'
} from '../../tools/authentication'
import { useForm, HandleSubmitCallback } from '../../hooks/useForm'
export interface AuthenticationProps {

View File

@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/react'
import { Emoji as Component, EmojiProps } from './Emoji'
const Stories: Meta = {
title: 'Emoji',
component: Component
}
export default Stories
export const Emoji: Story<EmojiProps> = (arguments_) => {
return <Component {...arguments_} />
}
Emoji.args = { value: ':wave:', size: 20 }

View File

@ -0,0 +1,24 @@
import { Emoji as EmojiMart } from 'emoji-mart'
import { EMOJI_SET } from './emojiPlugin'
export interface EmojiProps {
value: string
size: number
}
export const Emoji: React.FC<EmojiProps> = (props) => {
const { value, size } = props
return (
<EmojiMart
set={EMOJI_SET}
emoji={value}
size={size}
tooltip
fallback={() => {
return <>{value}</>
}}
/>
)
}

View File

@ -0,0 +1,15 @@
import { Meta, Story } from '@storybook/react'
import { EmojiPicker as Component, EmojiPickerProps } from './EmojiPicker'
const Stories: Meta = {
title: 'EmojiPicker',
component: Component
}
export default Stories
export const EmojiPicker: Story<EmojiPickerProps> = (arguments_) => {
return <Component {...arguments_} />
}
EmojiPicker.args = { onClick: (emoji, event) => console.log(emoji, event) }

View File

@ -0,0 +1,28 @@
import 'emoji-mart/css/emoji-mart.css'
import { EmojiData, Picker } from 'emoji-mart'
import { useTheme } from 'next-themes'
import { EMOJI_SET } from '../emojiPlugin'
export type EmojiPickerOnClick = (
emoji: EmojiData,
event: React.MouseEvent<HTMLElement, MouseEvent>
) => void
export interface EmojiPickerProps {
onClick?: EmojiPickerOnClick
}
export const EmojiPicker: React.FC<EmojiPickerProps> = (props) => {
const { theme } = useTheme()
return (
<Picker
set={EMOJI_SET}
theme={theme as 'light' | 'dark' | 'auto'}
onClick={props.onClick}
showPreview={false}
showSkinTones={false}
/>
)
}

View File

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

View File

@ -0,0 +1,71 @@
import { visit } from 'unist-util-visit'
import { Plugin, Transformer } from 'unified'
import { Literal, Parent } from 'unist'
import type { ElementContent } from 'hast'
import type { EmojiSet } from 'emoji-mart'
import { emojiRegex } from './isStringWithOnlyOneEmoji'
export const EMOJI_SET: EmojiSet = 'twitter'
const extractText = (
string: string,
start: number,
end: number
): ElementContent => {
return {
type: 'text',
value: string.slice(start, end)
}
}
export const emojiPlugin: Plugin<[], Literal<string>> = () => {
const transformer: Transformer<Literal<string>> = (tree) => {
visit<Literal<string>, string>(
tree,
'text',
(node, position, parent: Parent<ElementContent> | null) => {
if (typeof node.value !== 'string') {
return
}
position = position ?? 0
const definition: ElementContent[] = []
let lastIndex = 0
const match = emojiRegex.exec(node.value)
if (match != null) {
const value = match[0]
if (match.index !== lastIndex) {
definition.push(extractText(node.value, lastIndex, match.index))
}
definition.push({
type: 'element',
tagName: 'emoji',
properties: { value },
children: []
})
lastIndex = match.index + value.length
if (lastIndex !== node.value.length) {
definition.push(
extractText(node.value, lastIndex, node.value.length)
)
}
if (parent != null) {
const last = parent.children.slice(position + 1)
parent.children = parent.children.slice(0, position)
parent.children = parent.children.concat(definition)
parent.children = parent.children.concat(last)
}
}
}
)
}
return transformer
}
declare global {
namespace JSX {
interface IntrinsicElements {
emoji: { value: string }
}
}
}

View File

@ -0,0 +1,4 @@
export * from './Emoji'
export * from './EmojiPicker'
export * from './emojiPlugin'
export * from './isStringWithOnlyOneEmoji'

View File

@ -0,0 +1,18 @@
import { isStringWithOnlyOneEmoji } from './isStringWithOnlyOneEmoji'
describe('components/Emoji/isStringWithOnlyOneEmoji', () => {
it('returns true with a string with only one emoji', () => {
expect(isStringWithOnlyOneEmoji(':wave:')).toBeTruthy()
expect(isStringWithOnlyOneEmoji(':smile:')).toBeTruthy()
})
it('returns false with a string with multiple emoji or with text', () => {
expect(isStringWithOnlyOneEmoji(':wave: :smile:')).toBeFalsy()
expect(isStringWithOnlyOneEmoji(':wave: some text')).toBeFalsy()
expect(isStringWithOnlyOneEmoji('some text :wave:')).toBeFalsy()
})
it('returns false with a string without emoji', () => {
expect(isStringWithOnlyOneEmoji('some text')).toBeFalsy()
})
})

View File

@ -0,0 +1,6 @@
export const emojiRegex = /:\+1:|:-1:|:[\w-]+:/
export const isStringWithOnlyOneEmoji = (value: string): boolean => {
const result = emojiRegex.exec(value)
return result != null && result.input === result[0]
}

View File

@ -1,7 +1,7 @@
import Link from 'next/link'
import useTranslation from 'next-translate/useTranslation'
import { API_VERSION } from '../../utils/api'
import { API_VERSION } from '../../tools/api'
import { VersionLink } from './VersionLink'
export interface FooterProps {

View File

@ -5,15 +5,18 @@ export interface IconLinkProps {
selected?: boolean
href: string
title?: string
className?: string
}
export const IconLink: React.FC<IconLinkProps> = (props) => {
const { children, selected, href, title } = props
const { children, selected, href, title, className } = props
return (
<div className='w-full flex justify-center group'>
<Link href={href}>
<a className='w-full flex justify-center relative group' title={title}>
<div
className={classNames('w-full flex justify-center group', className)}
>
{children}
<div className='absolute flex items-center w-3 h-12 left-0'>
<span
@ -25,8 +28,8 @@ export const IconLink: React.FC<IconLinkProps> = (props) => {
)}
></span>
</div>
</div>
</a>
</Link>
</div>
)
}

View File

@ -1,15 +1,54 @@
export interface ChannelType {
id: number
name: string
description: string
createdAt: string
updatedAt: string
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { Channel } from 'models/Channel'
import { GuildsChannelsPath } from 'components/Application'
export interface Channels {
channels: Channel[]
hasMore: boolean
nextPage: NextPage
}
export const channelExample: ChannelType = {
id: 4,
name: 'Channel 4',
description: '',
createdAt: '',
updatedAt: ''
const defaultChannelsContext = {} as any
const ChannelsContext = createContext<Channels>(defaultChannelsContext)
export interface ChannelsProviderProps {
path: GuildsChannelsPath
}
export const ChannelsProvider: React.FC<ChannelsProviderProps> = (props) => {
const { path, children } = props
const { authentication } = useAuthentication()
const {
items: channels,
hasMore,
nextPage,
resetPagination
} = usePagination<Channel>({
api: authentication.api,
url: `/guilds/${path.guildId}/channels`
})
useEffect(() => {
resetPagination()
nextPage()
}, [nextPage, resetPagination])
return (
<ChannelsContext.Provider value={{ channels, hasMore, nextPage }}>
{children}
</ChannelsContext.Provider>
)
}
export const useChannels = (): Channels => {
const channels = useContext(ChannelsContext)
if (channels === defaultChannelsContext) {
throw new Error('useChannels must be used within ChannelsProvider')
}
return channels
}

51
contexts/GuildMember.tsx Normal file
View File

@ -0,0 +1,51 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { Guild } from 'models/Guild'
import { Member } from 'models/Member'
import { GuildsChannelsPath } from 'components/Application'
import { useAuthentication } from 'tools/authentication'
export interface GuildMember {
guild: Guild
member: Member
}
export interface GuildMemberProps {
guildMember: GuildMember
path: GuildsChannelsPath
}
const defaultGuildMemberContext = {} as any
const GuildMemberContext = createContext<GuildMember>(defaultGuildMemberContext)
export const GuildMemberProvider: React.FC<GuildMemberProps> = (props) => {
const [guildMember, setGuildMember] = useState(props.guildMember)
const { authentication } = useAuthentication()
useEffect(() => {
const fetchGuildMember = async (): Promise<void> => {
const { data } = await authentication.api.get(
`/guilds/${props.path.guildId}`
)
setGuildMember(data)
}
fetchGuildMember().catch((error) => {
console.error(error)
})
}, [props.path, authentication.api])
return (
<GuildMemberContext.Provider value={guildMember}>
{props.children}
</GuildMemberContext.Provider>
)
}
export const useGuildMember = (): GuildMember => {
const guildMember = useContext(GuildMemberContext)
if (guildMember === defaultGuildMemberContext) {
throw new Error('useGuildMember must be used within GuildMemberProvider')
}
return guildMember
}

49
contexts/Guilds.tsx Normal file
View File

@ -0,0 +1,49 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { GuildWithDefaultChannelId } from 'models/Guild'
export interface Guilds {
guilds: GuildWithDefaultChannelId[]
hasMore: boolean
nextPage: NextPage
}
const defaultGuildsContext = {} as any
const GuildsContext = createContext<Guilds>(defaultGuildsContext)
export const GuildsProvider: React.FC = (props) => {
const { children } = props
const { authentication } = useAuthentication()
const {
items: guilds,
hasMore,
nextPage,
resetPagination
} = usePagination<GuildWithDefaultChannelId>({
api: authentication.api,
url: '/guilds'
})
useEffect(() => {
resetPagination()
nextPage()
}, [nextPage, resetPagination])
return (
<GuildsContext.Provider value={{ guilds, hasMore, nextPage }}>
{children}
</GuildsContext.Provider>
)
}
export const useGuilds = (): Guilds => {
const guilds = useContext(GuildsContext)
if (guilds === defaultGuildsContext) {
throw new Error('useGuilds must be used within GuildsProvider')
}
return guilds
}

54
contexts/Members.tsx Normal file
View File

@ -0,0 +1,54 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { MemberWithPublicUser } from 'models/Member'
import { GuildsChannelsPath } from 'components/Application'
export interface Members {
members: MemberWithPublicUser[]
hasMore: boolean
nextPage: NextPage
}
const defaultMembersContext = {} as any
const MembersContext = createContext<Members>(defaultMembersContext)
export interface MembersProviderProps {
path: GuildsChannelsPath
}
export const MembersProviders: React.FC<MembersProviderProps> = (props) => {
const { children, path } = props
const { authentication } = useAuthentication()
const {
items: members,
hasMore,
nextPage,
resetPagination
} = usePagination<MemberWithPublicUser>({
api: authentication.api,
url: `/guilds/${path.guildId}/members`
})
useEffect(() => {
resetPagination()
nextPage()
}, [nextPage, resetPagination])
return (
<MembersContext.Provider value={{ members, hasMore, nextPage }}>
{children}
</MembersContext.Provider>
)
}
export const useMembers = (): Members => {
const members = useContext(MembersContext)
if (members === defaultMembersContext) {
throw new Error('useMembers must be used within MembersProvider')
}
return members
}

60
contexts/Messages.tsx Normal file
View File

@ -0,0 +1,60 @@
import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication'
import { MessageWithMember } from 'models/Message'
import { GuildsChannelsPath } from 'components/Application'
export interface Messages {
messages: MessageWithMember[]
hasMore: boolean
nextPage: NextPage
}
const defaultMessagesContext = {} as any
const MessagesContext = createContext<Messages>(defaultMessagesContext)
export interface MessagesProviderProps {
path: GuildsChannelsPath
}
export const MessagesProvider: React.FC<MessagesProviderProps> = (props) => {
const { path, children } = props
const { authentication } = useAuthentication()
const {
items: messages,
hasMore,
nextPage,
resetPagination
} = usePagination<MessageWithMember>({
api: authentication.api,
url: `/channels/${path.channelId}/messages`,
inverse: true
})
useEffect(() => {
resetPagination()
nextPage(undefined, () => {
const messagesDiv = window.document.getElementById(
'messages'
) as HTMLDivElement
messagesDiv.scrollTo(0, messagesDiv.scrollHeight)
})
}, [nextPage, resetPagination])
return (
<MessagesContext.Provider value={{ messages, hasMore, nextPage }}>
{children}
</MessagesContext.Provider>
)
}
export const useMessages = (): Messages => {
const messages = useContext(MessagesContext)
if (messages === defaultMessagesContext) {
throw new Error('useMessages must be used within a MessagesProvider')
}
return messages
}

View File

@ -0,0 +1,14 @@
import { Handler } from '../../handler'
import { channelExample } from '../channel'
export const getChannelWithChannelIdHandler: Handler = {
method: 'GET',
url: `/channels/${channelExample.id}`,
response: {
statusCode: 200,
body: {
channel: channelExample
}
}
}

View File

@ -0,0 +1,15 @@
import { Handler } from '../../../handler'
import {
messageExampleComplete,
messageExampleComplete2
} from '../../../messages/message'
import { channelExample } from '../../channel'
export const getMessagesWithChannelIdHandler: Handler = {
method: 'GET',
url: `/channels/${channelExample.id}/messages`,
response: {
statusCode: 200,
body: [messageExampleComplete, messageExampleComplete2]
}
}

View File

@ -1,9 +1,15 @@
import { guild } from '../guilds/guild'
import { guildExample } from '../guilds/guild'
export const channel = {
export const channelExample = {
id: 1,
name: 'general',
guildId: guild.id,
guildId: guildExample.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
export const channelExample2 = {
...channelExample,
id: 2,
name: 'general2'
}

View File

@ -0,0 +1,12 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { channelExample, channelExample2 } from '../../../channels/channel'
export const getChannelsWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}/channels`,
response: {
statusCode: 200,
body: [channelExample, channelExample2]
}
}

View File

@ -0,0 +1,16 @@
import { Handler } from '../../handler'
import { guildExample } from '../guild'
import { memberExampleComplete } from '../../members/member'
export const getGuildMemberWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}`,
response: {
statusCode: 200,
body: {
guild: guildExample,
member: memberExampleComplete
}
}
}

View File

@ -0,0 +1,12 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { memberExampleComplete } from '../../../members/member'
export const getMembersWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}/members`,
response: {
statusCode: 200,
body: [memberExampleComplete]
}
}

View File

@ -0,0 +1,15 @@
import { Handler } from '../handler'
import { guildExample, guildExample2 } from './guild'
export const getGuildsHandler: Handler = {
method: 'GET',
url: '/guilds',
response: {
statusCode: 200,
body: [
{ ...guildExample, defaultChannelId: 1 },
{ ...guildExample2, defaultChannelId: 2 }
]
}
}

View File

@ -1,4 +1,6 @@
export const guild = {
import { Guild } from '../../../models/Guild'
export const guildExample: Guild = {
id: 1,
name: 'GuildExample',
description: 'guild example.',
@ -7,7 +9,8 @@ export const guild = {
updatedAt: new Date().toISOString()
}
export const guild2 = {
...guild,
export const guildExample2: Guild = {
...guildExample,
id: 2,
name: 'app'
}

View File

@ -1,8 +1,8 @@
import { Handler } from '../handler'
import { guild } from './guild'
import { channel } from '../channels/channel'
import { memberComplete } from '../members/member'
import { guildExample } from './guild'
import { channelExample } from '../channels/channel'
import { memberExampleComplete } from '../members/member'
export const postGuildsHandler: Handler = {
method: 'POST',
@ -11,9 +11,9 @@ export const postGuildsHandler: Handler = {
statusCode: 201,
body: {
guild: {
...guild,
channels: [channel],
members: [memberComplete]
...guildExample,
channels: [channelExample],
members: [memberExampleComplete]
}
}
}

View File

@ -1,6 +1,6 @@
import { Handler } from '../../handler'
import { guild, guild2 } from '../guild'
import { guildExample, guildExample2 } from '../guild'
export const getGuildsPublicEmptyHandler: Handler = {
method: 'GET',
@ -17,8 +17,8 @@ export const getGuildsPublicHandler: Handler = {
response: {
statusCode: 200,
body: [
{ ...guild, membersCount: 1 },
{ ...guild2, membersCount: 1 }
{ ...guildExample, membersCount: 1 },
{ ...guildExample2, membersCount: 1 }
]
}
}
@ -28,6 +28,6 @@ export const getGuildsPublicSearchHandler: Handler = {
url: '/guilds/public',
response: {
statusCode: 200,
body: [{ ...guild2, membersCount: 1 }]
body: [{ ...guildExample2, membersCount: 1 }]
}
}

View File

@ -3,7 +3,7 @@ import { postUsersRefreshTokenHandler } from './users/refresh-token/post'
export interface Handler {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
url: string
url: `/${string}`
response: {
body: any
statusCode: number

View File

@ -1,16 +1,16 @@
import { guild } from '../guilds/guild'
import { user } from '../users/user'
import { guildExample } from '../guilds/guild'
import { userExample } from '../users/user'
export const member = {
export const memberExample = {
id: 1,
isOwner: true,
userId: user.id,
guildId: guild.id,
userId: userExample.id,
guildId: guildExample.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
export const memberComplete = {
...member,
user
export const memberExampleComplete = {
...memberExample,
user: userExample
}

View File

@ -0,0 +1,25 @@
import { channelExample } from '../channels/channel'
import { memberExampleComplete } from '../members/member'
export const messageExample = {
id: 1,
value: 'Hello, world!',
type: 'text' as 'text' | 'file',
mimetype: 'text/plain',
memberId: memberExampleComplete.id,
channelId: channelExample.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
export const messageExampleComplete = {
...messageExample,
member: memberExampleComplete
}
export const messageExampleComplete2 = {
...messageExample,
id: 2,
value: 'Second message',
member: memberExampleComplete
}

View File

@ -1,6 +1,6 @@
import { Handler } from '../../handler'
import { user, userSettings } from '../user'
import { userExample, userSettingsExample } from '../user'
export const getUsersCurrentHandler: Handler = {
method: 'GET',
@ -9,8 +9,8 @@ export const getUsersCurrentHandler: Handler = {
statusCode: 200,
body: {
user: {
...user,
settings: userSettings,
...userExample,
settings: userSettingsExample,
currentStrategy: 'local',
strategies: ['local']
}

View File

@ -1,6 +1,6 @@
import { Handler } from '../../handler'
import { user, userSettings } from '../user'
import { userExample, userSettingsExample } from '../user'
export const postUsersSignupHandler: Handler = {
method: 'POST',
@ -9,8 +9,8 @@ export const postUsersSignupHandler: Handler = {
statusCode: 201,
body: {
user: {
...user,
settings: userSettings
...userExample,
settings: userSettingsExample
}
}
}

View File

@ -1,7 +1,7 @@
import { UserSettings } from '../../../models/UserSettings'
import { User } from '../../../models/User'
export const user: User = {
export const userExample: User = {
id: 1,
name: 'Divlo',
email: 'contact@divlo.fr',
@ -17,7 +17,7 @@ export const user: User = {
updatedAt: '2021-10-20T20:59:08.485Z'
}
export const userSettings: UserSettings = {
export const userSettingsExample: UserSettings = {
id: 1,
language: 'en',
theme: 'dark',

View File

@ -0,0 +1,43 @@
import { channelExample } from '../../../fixtures/channels/channel'
import { guildExample } from '../../../fixtures/guilds/guild'
import { userExample } from '../../../fixtures/users/user'
import { getGuildsHandler } from '../../../fixtures/guilds/get'
import { authenticationHandlers } from '../../../fixtures/handler'
import { getGuildMemberWithGuildIdHandler } from '../../../fixtures/guilds/[guildId]/get'
import { getChannelWithChannelIdHandler } from '../../../fixtures/channels/[channelId]/get'
const applicationPaths = [
'/application',
`/application/users/${userExample.id}`,
'/application/guilds/create',
'/application/guilds/join',
`/application/${guildExample.id}/${channelExample.id}`
]
describe('Common > application/authentication', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should redirect the user to `/authentication/signin` if not signed in', () => {
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', '/authentication/signin')
}
})
it('should not redirect the user if signed in', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsHandler,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', applicationPath)
}
})
})

View File

@ -1,10 +1,143 @@
import date from 'date-and-time'
import {
channelExample,
channelExample2
} from '../../../../fixtures/channels/channel'
import { guildExample } from '../../../../fixtures/guilds/guild'
import { getGuildMemberWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/get'
import { getChannelWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/get'
import { authenticationHandlers } from '../../../../fixtures/handler'
import { getGuildsHandler } from '../../../../fixtures/guilds/get'
import { getChannelsWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/channels/get'
import { getMessagesWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/messages/get'
import {
messageExampleComplete,
messageExampleComplete2
} from '../../../../fixtures/messages/message'
import { getMembersWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/members/get'
import { memberExampleComplete } from '../../../../fixtures/members/member'
import { API_URL } from '../../../../../tools/api'
describe('Pages > /application/[guildId]/[channelId]', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should succeeds and display the guilds in left sidebar correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getGuildsHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getGuildsHandler.url}*`).as('getGuildsHandler')
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getGuildsHandler', '@nextStaticAndImages']).then(() => {
cy.get('[data-cy=application-title]').should(
'have.text',
`# ${channelExample.name}`
)
cy.get('[data-cy=guild-left-sidebar-title]').should(
'have.text',
guildExample.name
)
cy.get('.guilds-list').children().should('have.length', 2)
})
})
it('should succeeds and display the channels in left sidebar correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getChannelsWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getChannelsWithGuildIdHandler.url}*`).as(
'getChannelsWithGuildIdHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getChannelsWithGuildIdHandler', '@nextStaticAndImages']).then(
() => {
cy.get('.channels-list').children().should('have.length', 2)
cy.get('.channels-list [data-cy=channel-name]:first').should(
'have.text',
`# ${channelExample.name}`
)
cy.get('.channels-list [data-cy=channel-name]:last').should(
'have.text',
`# ${channelExample2.name}`
)
}
)
})
it('should succeeds and display the messages correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getMessagesWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getMessagesWithChannelIdHandler.url}*`).as(
'getMessagesWithChannelIdHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getMessagesWithChannelIdHandler', '@nextStaticAndImages']).then(
() => {
cy.get('.messages-list').children().should('have.length', 2)
cy.get('.messages-list p:first').should(
'have.text',
messageExampleComplete.value
)
cy.get(
'.messages-list [data-cy=message-member-user-name]:first'
).should('have.text', messageExampleComplete.member.user.name)
cy.get('.messages-list [data-cy=message-date]:first').should(
'have.text',
date.format(
new Date(messageExampleComplete.createdAt),
'DD/MM/YYYY - HH:mm:ss'
)
)
cy.get('.messages-list p:last').should(
'have.text',
messageExampleComplete2.value
)
}
)
})
it('should succeeds and display the members in right sidebar correctly', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getMembersWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getMembersWithGuildIdHandler.url}*`).as(
'getMembersWithGuildIdHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit(`/application/${guildExample.id}/${channelExample.id}`)
cy.wait(['@getMembersWithGuildIdHandler', '@nextStaticAndImages']).then(
() => {
cy.get('.members-list').should('not.be.visible')
cy.get('[data-cy=icon-button-right-sidebar-members]').click()
cy.get('.members-list').should('be.visible')
cy.get('[data-cy=members-title]').should('have.text', 'Member(s)')
cy.get('.members-list').children().should('have.length', 1)
cy.get('.members-list [data-cy=member-user-name]:first').should(
'have.text',
memberExampleComplete.user.name
)
}
)
})
it('should redirect the user to `/application` if `guildId` or `channelId` are not numbers', () => {
cy.task('startMockServer', authenticationHandlers).setCookie(
'refreshToken',
@ -14,4 +147,26 @@ describe('Pages > /application/[guildId]/[channelId]', () => {
.location('pathname')
.should('eq', '/application')
})
it("should redirect the user to `/404` if `guildId` doesn't exist", () => {
cy.task('startMockServer', [
...authenticationHandlers,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/123/${channelExample.id}`, {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
it("should redirect the user to `/404` if `channelId` doesn't exist", () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/123`, { failOnStatusCode: false })
.location('pathname')
.should('eq', '/404')
})
})

View File

@ -1,5 +1,8 @@
import { channel } from '../../../../fixtures/channels/channel'
import { guild } from '../../../../fixtures/guilds/guild'
import { getChannelWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/get'
import { getGuildsHandler } from '../../../../fixtures/guilds/get'
import { getGuildMemberWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/get'
import { channelExample } from '../../../../fixtures/channels/channel'
import { guildExample } from '../../../../fixtures/guilds/guild'
import { postGuildsHandler } from '../../../../fixtures/guilds/post'
import { authenticationHandlers } from '../../../../fixtures/handler'
@ -11,15 +14,19 @@ describe('Pages > /application/guilds/create', () => {
it('should succeeds and create the guild', () => {
cy.task('startMockServer', [
...authenticationHandlers,
postGuildsHandler
postGuildsHandler,
getGuildsHandler,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/create')
cy.get('[data-cy=application-title]').should('have.text', 'Create a Guild')
cy.get('#error-name').should('not.exist')
cy.get('[data-cy=input-name]').type(guild.name)
cy.get('[data-cy=input-name]').type(guildExample.name)
cy.get('[data-cy=submit]').click()
cy.location('pathname').should(
'eq',
`/application/${guild.id}/${channel.id}`
`/application/${guildExample.id}/${channelExample.id}`
)
})
@ -30,7 +37,7 @@ describe('Pages > /application/guilds/create', () => {
)
cy.visit('/application/guilds/create')
cy.get('#error-name').should('not.exist')
cy.get('[data-cy=input-name]').type(guild.name)
cy.get('[data-cy=input-name]').type(guildExample.name)
cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
})

View File

@ -1,45 +1,39 @@
import { guildExample, guildExample2 } from '../../../../fixtures/guilds/guild'
import {
getGuildsPublicEmptyHandler,
getGuildsPublicHandler,
getGuildsPublicSearchHandler
} from '../../../../fixtures/guilds/public/get'
import { authenticationHandlers } from '../../../../fixtures/handler'
import { API_URL } from '../../../../../tools/api'
describe('Pages > /application/guilds/join', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should shows no guild if there are no public guilds', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsPublicEmptyHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/join')
cy.get('.guilds-list').children().should('have.length', 0)
})
it('should shows loader with internal api server error', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/guilds/join')
cy.get('.guilds-list').children().should('have.length', 1)
cy.get('[data-testid=progress-spinner]').should('be.visible')
})
it('should shows all the guilds', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsPublicHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/join')
cy.get('.guilds-list').children().should('have.length', 2)
cy.get('.guilds-list [data-cy=guild-name]:first').should(
'have.text',
'GuildExample'
cy.intercept(`${API_URL}${getGuildsPublicHandler.url}*`).as(
'getGuildsPublicHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.visit('/application/guilds/join')
cy.wait(['@getGuildsPublicHandler', '@nextStaticAndImages']).then(() => {
cy.get('[data-cy=application-title]').should('have.text', 'Join a Guild')
cy.get('.guilds-public-list').children().should('have.length', 2)
cy.get('.guilds-public-list [data-cy=guild-name]:first').should(
'have.text',
guildExample.name
)
cy.get('.guilds-public-list [data-cy=guild-name]:last').should(
'have.text',
guildExample2.name
)
})
})
it('should shows the searched guild', () => {
@ -48,8 +42,41 @@ describe('Pages > /application/guilds/join', () => {
getGuildsPublicSearchHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application/guilds/join')
cy.get('[data-cy=search-guild-input]').type('app')
cy.get('.guilds-list').children().should('have.length', 1)
cy.get('.guilds-list [data-cy=guild-name]:first').should('have.text', 'app')
cy.intercept(`${API_URL}${getGuildsPublicHandler.url}*`).as(
'getGuildsPublicHandler'
)
cy.intercept(`/_next/*`).as('nextStaticAndImages')
cy.wait(['@getGuildsPublicHandler', '@nextStaticAndImages']).then(() => {
cy.get('[data-cy=search-guild-input]').type(guildExample2.name)
cy.get('.guilds-public-list').children().should('have.length', 1)
cy.get('.guilds-public-list [data-cy=guild-name]:first').should(
'have.text',
guildExample2.name
)
})
})
it('should shows no guild if there are no public guilds', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsPublicEmptyHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${getGuildsPublicEmptyHandler.url}*`).as(
'getGuildsPublicEmptyHandler'
)
cy.visit('/application/guilds/join')
cy.wait('@getGuildsPublicEmptyHandler').then(() => {
cy.get('.guilds-public-list').children().should('have.length', 0)
})
})
it('should shows loader with internal api server error', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/guilds/join')
cy.get('.guilds-public-list').children().should('have.length', 1)
cy.get('[data-testid=progress-spinner]').should('be.visible')
})
})

View File

@ -1,35 +1,32 @@
import { authenticationHandlers } from '../../../fixtures/handler'
const applicationPaths = [
'/application',
'/application/users/0',
'/application/guilds/create',
'/application/guilds/join',
'/application/0/0'
]
describe('Pages > /application', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should redirect the user to `/authentication/signin` if not signed in', () => {
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', '/authentication/signin')
}
})
it('should not redirect the user if signed in', () => {
cy.task('startMockServer', authenticationHandlers).setCookie(
it('should redirect user to `/application/guilds/create` on click on "Create a Guild"', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
cy.visit('/application')
cy.get('[data-cy=application-title]').should('have.text', 'Application')
cy.get('a[href="/application/guilds/create"]')
.click()
.location('pathname')
.should('eq', applicationPath)
}
.should('eq', '/application/guilds/create')
})
it('should redirect user to `/application/guilds/join` on click on "Join a Guild"', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application')
cy.get('a[href="/application/guilds/join"]')
.click()
.location('pathname')
.should('eq', '/application/guilds/join')
})
})

View File

@ -1,5 +1,5 @@
import { postUsersResetPasswordHandler } from '../../../fixtures/users/reset-password/post'
import { user } from '../../../fixtures/users/user'
import { userExample } from '../../../fixtures/users/user'
describe('Pages > /authentication/forgot-password', () => {
beforeEach(() => {
@ -10,7 +10,7 @@ describe('Pages > /authentication/forgot-password', () => {
it('should succeeds and sends a password-reset request', () => {
cy.task('startMockServer', [postUsersResetPasswordHandler])
cy.get('#message').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=submit]').click()
cy.get('#message').should(
'have.text',
@ -20,7 +20,7 @@ describe('Pages > /authentication/forgot-password', () => {
it('should fails with unreachable api server', () => {
cy.get('#message').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
})

View File

@ -3,7 +3,7 @@ import {
postUsersSigninHandler,
postUsersSigninInvalidCredentialsHandler
} from 'cypress/fixtures/users/signin/post'
import { user } from '../../../fixtures/users/user'
import { userExample } from '../../../fixtures/users/user'
describe('Pages > /authentication/signin', () => {
beforeEach(() => {
@ -18,7 +18,7 @@ describe('Pages > /authentication/signin', () => {
])
cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click()
cy.location('pathname').should('eq', '/application')
@ -27,7 +27,7 @@ describe('Pages > /authentication/signin', () => {
it('should fails with unreachable api server', () => {
cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.')
@ -42,7 +42,7 @@ describe('Pages > /authentication/signin', () => {
])
cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click()
cy.get('#message').should(

View File

@ -1,4 +1,4 @@
import { user } from '../../../fixtures/users/user'
import { userExample } from '../../../fixtures/users/user'
import {
postUsersSignupHandler,
postUsersSignupAlreadyUsedHandler
@ -15,8 +15,8 @@ describe('Pages > /authentication/signup', () => {
cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-name]').type(user.name)
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-name]').type(userExample.name)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click()
cy.get('#message').should(
@ -30,8 +30,8 @@ describe('Pages > /authentication/signup', () => {
cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-name]').type(user.name)
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-name]').type(userExample.name)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Name or Email already used.')
@ -44,8 +44,8 @@ describe('Pages > /authentication/signup', () => {
cy.get('#error-name').should('not.exist')
cy.get('#error-email').should('not.exist')
cy.get('#error-password').should('not.exist')
cy.get('[data-cy=input-name]').type(user.name)
cy.get('[data-cy=input-email]').type(user.email)
cy.get('[data-cy=input-name]').type(userExample.name)
cy.get('[data-cy=input-email]').type(userExample.email)
cy.get('[data-cy=input-password]').type('randompassword')
cy.get('[data-cy=submit]').click()
cy.get('#message').should('have.text', 'Error: Internal Server Error.')

View File

@ -1,5 +1,7 @@
import { getLocal } from 'mockttp'
import { API_DEFAULT_PORT } from '../../tools/api'
/// <reference types="cypress" />
/** @type {import('mockttp').Mockttp | null} */
@ -17,7 +19,7 @@ module.exports = (on, config) => {
server = getLocal({
cors: true
})
await server.start(8080)
await server.start(API_DEFAULT_PORT)
for (const handler of handlers) {
await server[handler.method.toLowerCase()](handler.url).thenJson(
handler.response.statusCode,

View File

@ -5,7 +5,7 @@ import type { FormDataObject, HandleForm } from 'react-component-form'
import type { ErrorObject } from 'ajv'
import { FetchState, useFetchState } from '../useFetchState'
import { ajv } from '../../utils/ajv'
import { ajv } from '../../tools/ajv'
import { getErrorTranslationKey } from './getErrorTranslationKey'
interface Errors {

85
hooks/usePagination.ts Normal file
View File

@ -0,0 +1,85 @@
import { useState, useRef, useCallback } from 'react'
import { AxiosInstance } from 'axios'
import { FetchState } from './useFetchState'
export interface Query {
[key: string]: string
}
export type NextPageAsync = (query?: Query) => Promise<void>
export type NextPage = (query?: Query, callback?: () => void) => void
export interface UsePaginationOptions {
api: AxiosInstance
url: string
inverse?: boolean
}
export interface UsePaginationResult<T> {
items: T[]
nextPage: NextPage
resetPagination: () => void
hasMore: boolean
}
export const usePagination = <T extends { id: number }>(
options: UsePaginationOptions
): UsePaginationResult<T> => {
const { api, url, inverse = false } = options
const [items, setItems] = useState<T[]>([])
const [hasMore, setHasMore] = useState(true)
const fetchState = useRef<FetchState>('idle')
const afterId = useRef<number | null>(null)
const nextPageAsync: NextPageAsync = useCallback(
async (query) => {
if (fetchState.current !== 'idle') {
return
}
fetchState.current = 'loading'
const searchParameters = new URLSearchParams(query)
searchParameters.append('limit', '20')
if (afterId.current != null) {
searchParameters.append('after', afterId.current.toString())
}
const { data: newItems } = await api.get<T[]>(
`${url}?${searchParameters.toString()}`
)
if (!inverse) {
afterId.current =
newItems.length > 0 ? newItems[newItems.length - 1].id : null
} else {
afterId.current = newItems.length > 0 ? newItems[0].id : null
}
setItems((oldItems) => {
return inverse ? [...newItems, ...oldItems] : [...oldItems, ...newItems]
})
setHasMore(newItems.length > 0)
fetchState.current = 'idle'
},
[api, url, inverse]
)
const nextPage: NextPage = useCallback(
(query, callback) => {
nextPageAsync(query)
.then(() => {
if (callback != null) {
callback()
}
})
.catch((error) => {
console.error(error)
})
},
[nextPageAsync]
)
const resetPagination = useCallback((): void => {
afterId.current = null
setItems([])
}, [])
return { items, hasMore, nextPage, resetPagination }
}

View File

@ -13,6 +13,7 @@
"/application/users/[userId]": ["application", "errors"],
"/application/guilds/create": ["application", "errors"],
"/application/guilds/join": ["application", "errors"],
"/application": ["application", "errors"]
"/application": ["application", "errors"],
"/application/[guildId]/[channelId]": ["application", "errors"]
}
}

View File

@ -1,5 +1,11 @@
{
"website": "Website",
"create": "Create",
"create-a-guild": "Create a Guild"
"create-a-guild": "Create a Guild",
"create-a-guild-description": "Create your own guild and manage everything.",
"join-a-guild": "Join a Guild",
"join-a-guild-description": "Talk, collaborate, share and have fun with your friends by joining an already existing guild!",
"members": "member(s)",
"search": "Search",
"write-a-message": "Write a message..."
}

View File

@ -1,5 +1,11 @@
{
"website": "Site web",
"create": "Crée",
"create-a-guild": "Crée une Guilde"
"create": "Créer",
"create-a-guild": "Créer une Guilde",
"create-a-guild-description": "Créez votre propre guilde et gérez tout.",
"join-a-guild": "Rejoindre une Guilde",
"join-a-guild-description": "Discutez, collaborez, partagez et amusez-vous avec vos amis en rejoignant une guilde déjà existante!",
"members": "membre(s)",
"search": "Rechercher",
"write-a-message": "Écrire un message..."
}

View File

@ -1,9 +1,7 @@
import { Type } from '@sinclair/typebox'
import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils'
export const types = [Type.Literal('text')]
export const channelSchema = {
id,
name: Type.String({ minLength: 1, maxLength: 20 }),
@ -11,3 +9,5 @@ export const channelSchema = {
updatedAt: date.updatedAt,
guildId: id
}
const channelObjectSchema = Type.Object(channelSchema)
export type Channel = Static<typeof channelObjectSchema>

View File

@ -12,20 +12,30 @@ export const guildSchema = {
createdAt: date.createdAt,
updatedAt: date.updatedAt
}
export const guildObjectSchema = Type.Object(guildSchema)
export type Guild = Static<typeof guildObjectSchema>
export const guildWithDefaultChannelIdSchema = {
...guildSchema,
defaultChannelId: id
}
export const guildWithDefaultChannelObjectSchema = Type.Object(
guildWithDefaultChannelIdSchema
)
export type GuildWithDefaultChannelId = Static<
typeof guildWithDefaultChannelObjectSchema
>
export const guildCompleteSchema = {
...guildSchema,
channels: Type.Array(Type.Object(channelSchema)),
members: Type.Array(Type.Object(memberSchema))
}
export const guildCompleteObjectSchema = Type.Object(guildCompleteSchema)
export type GuildComplete = Static<typeof guildCompleteObjectSchema>
export const guildPublicObjectSchema = Type.Object({
...guildSchema,
membersCount: Type.Integer({ min: 1 })
})
export const guildCompleteObjectSchema = Type.Object(guildCompleteSchema)
export type GuildComplete = Static<typeof guildCompleteObjectSchema>
export type GuildPublic = Static<typeof guildPublicObjectSchema>

View File

@ -1,6 +1,7 @@
import { Type } from '@sinclair/typebox'
import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils'
import { UserPublicWithoutSettings } from './User'
export const memberSchema = {
id,
@ -10,3 +11,9 @@ export const memberSchema = {
userId: id,
guildId: id
}
const memberObjectSchema = Type.Object(memberSchema)
export type Member = Static<typeof memberObjectSchema>
export interface MemberWithPublicUser extends Member {
user: UserPublicWithoutSettings
}

View File

@ -1,6 +1,7 @@
import { Type } from '@sinclair/typebox'
import { Type, Static } from '@sinclair/typebox'
import { date, id } from './utils'
import { MemberWithPublicUser } from './Member'
export const types = [Type.Literal('text'), Type.Literal('file')]
@ -21,3 +22,9 @@ export const messageSchema = {
memberId: id,
channelId: id
}
const messageObjectSchema = Type.Object(messageSchema)
export type Message = Static<typeof messageObjectSchema>
export interface MessageWithMember extends Message {
member: MemberWithPublicUser
}

View File

@ -50,6 +50,9 @@ export const userPublicWithoutSettingsSchema = {
createdAt: date.createdAt,
updatedAt: date.updatedAt
}
export const userPublicWithoutSettingsObjectSchema = Type.Object(
userPublicWithoutSettingsSchema
)
export const userPublicSchema = {
...userPublicWithoutSettingsSchema,
@ -66,4 +69,7 @@ export const userCurrentSchema = Type.Object({
export type User = Static<typeof userObjectSchema>
export type UserPublic = Static<typeof userPublicObjectSchema>
export type UserPublicWithoutSettings = Static<
typeof userPublicWithoutSettingsObjectSchema
>
export type UserCurrent = Static<typeof userCurrentSchema>

View File

@ -2,6 +2,7 @@ const nextPWA = require('next-pwa')
const nextTranslate = require('next-translate')
const { createSecureHeaders } = require('next-secure-headers')
/** @type {import("next").NextConfig} */
module.exports = nextTranslate(
nextPWA({
images: {

5656
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,6 +43,7 @@
"axios": "0.24.0",
"classnames": "2.3.1",
"date-and-time": "2.0.1",
"emoji-mart": "3.0.1",
"next": "12.0.7",
"next-pwa": "5.4.4",
"next-themes": "0.0.15",
@ -51,12 +52,17 @@
"react-component-form": "2.0.0",
"react-dom": "17.0.2",
"react-infinite-scroll-component": "6.1.0",
"react-markdown": "7.1.1",
"react-responsive": "8.2.0",
"react-swipeable": "6.2.0",
"react-textarea-autosize": "8.3.3",
"read-pkg": "7.0.0",
"remark-breaks": "3.0.2",
"remark-gfm": "3.0.1",
"sharp": "0.29.3",
"socket.io-client": "4.4.0",
"unified": "10.1.1",
"unist-util-visit": "4.1.0",
"universal-cookie": "4.0.4"
},
"devDependencies": {
@ -73,17 +79,20 @@
"@testing-library/jest-dom": "5.16.1",
"@testing-library/react": "12.1.2",
"@types/date-and-time": "0.13.0",
"@types/jest": "27.0.3",
"@types/emoji-mart": "3.0.9",
"@types/hast": "2.3.4",
"@types/jest": "27.4.0",
"@types/node": "17.0.5",
"@types/react": "17.0.38",
"@types/react-responsive": "8.0.5",
"@types/unist": "2.0.6",
"@typescript-eslint/eslint-plugin": "4.33.0",
"autoprefixer": "10.4.0",
"autoprefixer": "10.4.1",
"cypress": "9.2.0",
"dockerfilelint": "1.8.0",
"editorconfig-checker": "4.0.2",
"eslint": "7.32.0",
"eslint-config-next": "11.1.2",
"eslint-config-next": "12.0.7",
"eslint-config-prettier": "8.3.0",
"eslint-config-standard-with-typescript": "21.0.1",
"eslint-plugin-import": "2.25.3",
@ -91,7 +100,7 @@
"eslint-plugin-prettier": "4.0.0",
"eslint-plugin-promise": "5.1.1",
"eslint-plugin-storybook": "0.5.5",
"eslint-plugin-unicorn": "39.0.0",
"eslint-plugin-unicorn": "40.0.0",
"husky": "7.0.4",
"jest": "27.4.5",
"lint-staged": "12.1.4",
@ -105,8 +114,8 @@
"serve": "13.0.2",
"start-server-and-test": "1.14.0",
"storybook-tailwind-dark-mode": "1.0.11",
"tailwindcss": "3.0.7",
"typescript": "4.5.4",
"tailwindcss": "3.0.8",
"typescript": "4.4.4",
"vercel": "23.1.2",
"webpack": "5.65.0"
}

Some files were not shown because too many files have changed in this diff Show More