feat: add guilds and channels CRUD (#14)

This commit is contained in:
Divlo 2022-03-05 18:22:30 +01:00 committed by GitHub
parent 9f56a10305
commit 780788d682
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 6459 additions and 9039 deletions

View File

@ -16,6 +16,7 @@
}, },
"rules": { "rules": {
"prettier/prettier": "error", "prettier/prettier": "error",
"@next/next/no-img-element": "off" "@next/next/no-img-element": "off",
"@typescript-eslint/no-misused-promises": "off"
} }
} }

View File

@ -2,7 +2,7 @@ name: 'Analyze'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]

View File

@ -2,7 +2,7 @@ name: 'Build'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]

View File

@ -2,7 +2,7 @@ name: 'Lint'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]

View File

@ -2,7 +2,7 @@ name: 'Test'
on: on:
push: push:
branches: [master, develop] branches: [develop]
pull_request: pull_request:
branches: [master, develop] branches: [master, develop]

View File

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

View File

@ -18,9 +18,7 @@
Thream's website to stay close with your friends and communities. Thream's website to stay close with your friends and communities.
This project was bootstrapped with [create-fullstack-app](https://github.com/Divlo/create-fullstack-app). It uses [Thream/api](https://github.com/Thream/api) v1.0.0.
Using [Thream/api](https://github.com/Thream/api) v1.0.0.
## ⚙️ Getting Started ## ⚙️ Getting Started

View File

@ -14,10 +14,17 @@ import { Members } from './Members'
import { useAuthentication } from '../../tools/authentication' import { useAuthentication } from '../../tools/authentication'
import { API_URL } from '../../tools/api' import { API_URL } from '../../tools/api'
export interface GuildsChannelsPath { export interface ChannelsPath {
guildId: number
channelId: number channelId: number
} }
export interface GuildsPath {
guildId: number
}
export interface GuildsChannelsPath extends GuildsPath, ChannelsPath {}
const isGuildsChannelsPath = (path: any): path is GuildsChannelsPath => {
return path.guildId !== undefined && path.channelId !== undefined
}
export type ApplicationPath = export type ApplicationPath =
| '/application' | '/application'
@ -26,6 +33,7 @@ export type ApplicationPath =
| `/application/users/${number}` | `/application/users/${number}`
| `/application/users/settings` | `/application/users/settings`
| GuildsChannelsPath | GuildsChannelsPath
| GuildsPath
export interface ApplicationProps { export interface ApplicationProps {
path: ApplicationPath path: ApplicationPath
@ -216,7 +224,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
{children} {children}
</div> </div>
{typeof path !== 'string' && ( {isGuildsChannelsPath(path) && (
<Sidebar <Sidebar
direction='right' direction='right'
visible={visibleSidebars.right} visible={visibleSidebars.right}

View File

@ -0,0 +1,128 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { Form } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { FormState } from '../../design/FormState'
import { useGuildMember } from '../../../contexts/GuildMember'
import { Input } from '../../design/Input'
import { Button } from '../../design/Button'
import { useAuthentication } from '../../../tools/authentication'
import {
Channel,
channelSchema,
ChannelWithDefaultChannelId
} from '../../../models/Channel'
export interface ChannelSettingsProps {
channel: Channel
}
export const ChannelSettings: React.FC<ChannelSettingsProps> = (props) => {
const { t } = useTranslation()
const router = useRouter()
const { authentication } = useAuthentication()
const { guild } = useGuildMember()
const { channel } = props
const [inputValues, setInputValues] = useState({
name: channel.name
})
const {
fetchState,
message,
errors,
getErrorTranslation,
handleSubmit,
setFetchState,
setMessageTranslationKey
} = useForm({
validateSchema: {
name: channelSchema.name
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const onSubmit: HandleSubmitCallback = async (formData) => {
try {
await authentication.api.put(`/channels/${channel.id}`, formData)
setInputValues(formData as any)
await router.push(`/application/${guild.id}/${channel.id}`)
return null
} catch (error) {
return {
type: 'error',
value: 'errors:server-error'
}
}
}
const onChange: React.ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (event) => {
setInputValues((oldInputValues) => {
return {
...oldInputValues,
[event.target.name]: event.target.value
}
})
}
const handleDelete = async (): Promise<void> => {
try {
const { data } =
await authentication.api.delete<ChannelWithDefaultChannelId>(
`/channels/${channel.id}`
)
await router.push(`/application/${guild.id}/${data.defaultChannelId}`)
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
return (
<Form
onSubmit={handleSubmit(onSubmit)}
className='my-auto flex flex-col items-center justify-center py-12'
>
<div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className='mx-12 flex flex-col'>
<Input
name='name'
label={t('common:name')}
placeholder={t('common:name')}
className='!mt-0'
onChange={onChange}
value={inputValues.name}
error={getErrorTranslation(errors.name)}
data-cy='channel-name-input'
/>
</div>
</div>
</div>
<div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className='space-x-6'>
<Button type='submit' data-cy='button-save-channel-settings'>
Sauvegarder
</Button>
<Button
type='button'
color='red'
onClick={handleDelete}
data-cy='button-delete-channel-settings'
>
Supprimer
</Button>
</div>
<FormState state={fetchState} message={message} />
</div>
</Form>
)
}

View File

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

View File

@ -1,18 +1,33 @@
import Image from 'next/image' import Image from 'next/image'
import { useState } from 'react'
import { Loader } from '../../design/Loader'
export interface ConfirmGuildJoinProps { export interface ConfirmGuildJoinProps {
className?: string className?: string
handleJoinGuild: () => void handleYes: () => void | Promise<void>
handleNo: () => void | Promise<void>
} }
export const ConfirmGuildJoin: React.FC<ConfirmGuildJoinProps> = (props) => { export const ConfirmGuildJoin: React.FC<ConfirmGuildJoinProps> = (props) => {
const { className, handleJoinGuild } = props const { className, handleYes, handleNo } = props
const [isLoading, setIsLoading] = useState(false)
const handleYesLoading = async (): Promise<void> => {
setIsLoading((isLoading) => !isLoading)
await handleYes()
}
return ( return (
<div className={className}> <div className={className}>
{isLoading ? (
<Loader />
) : (
<>
<Image <Image
src='/images/svg/design/join-guild.svg' src='/images/svg/design/join-guild.svg'
alt='Joing Guild Illustration' alt='Join Guild Illustration'
height={150} height={150}
width={150} width={150}
/> />
@ -21,18 +36,20 @@ export const ConfirmGuildJoin: React.FC<ConfirmGuildJoinProps> = (props) => {
<div className='flex gap-7'> <div className='flex gap-7'>
<button <button
className='rounded-3xl bg-success px-8 py-2 transition hover:opacity-50' className='rounded-3xl bg-success px-8 py-2 transition hover:opacity-50'
onClick={handleJoinGuild} onClick={handleYesLoading}
> >
Oui Oui
</button> </button>
<button <button
className='rounded-3xl bg-error px-8 py-2 transition hover:opacity-50' className='rounded-3xl bg-error px-8 py-2 transition hover:opacity-50'
onClick={handleJoinGuild} onClick={handleNo}
> >
Non Non
</button> </button>
</div> </div>
</div> </div>
</>
)}
</div> </div>
) )
} }

View File

@ -0,0 +1,67 @@
import { useRouter } from 'next/router'
import useTranslation from 'next-translate/useTranslation'
import { Form } from 'react-component-form'
import { useAuthentication } from '../../../tools/authentication'
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { Input } from '../../design/Input'
import { Main } from '../../design/Main'
import { Button } from '../../design/Button'
import { FormState } from '../../design/FormState'
import { Channel, channelSchema } from '../../../models/Channel'
import { useGuildMember } from '../../../contexts/GuildMember'
export const CreateChannel: React.FC = () => {
const { t } = useTranslation()
const router = useRouter()
const { guild } = useGuildMember()
const { fetchState, message, errors, getErrorTranslation, handleSubmit } =
useForm({
validateSchema: {
name: channelSchema.name
},
resetOnSuccess: true
})
const { authentication } = useAuthentication()
const onSubmit: HandleSubmitCallback = async (formData) => {
try {
const { data: channel } = await authentication.api.post<Channel>(
`/guilds/${guild.id}/channels`,
formData
)
await router.push(`/application/${guild.id}/${channel.id}`)
return null
} catch (error) {
return {
type: 'error',
value: 'errors:server-error'
}
}
}
return (
<Main>
<Form className='w-4/6 max-w-xs' onSubmit={handleSubmit(onSubmit)}>
<Input
type='text'
placeholder={t('common:name')}
name='name'
label={t('common:name')}
error={getErrorTranslation(errors.name)}
data-cy='channel-name-input'
/>
<Button
className='mt-6 w-full'
type='submit'
data-cy='button-create-channel'
>
{t('application:create')}
</Button>
</Form>
<FormState id='message' state={fetchState} message={message} />
</Main>
)
}

View File

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

View File

@ -1,3 +1,4 @@
import Link from 'next/link'
import { CogIcon, PlusIcon } from '@heroicons/react/solid' import { CogIcon, PlusIcon } from '@heroicons/react/solid'
import { useGuildMember } from '../../../contexts/GuildMember' import { useGuildMember } from '../../../contexts/GuildMember'
@ -13,7 +14,7 @@ export interface GuildLeftSidebarProps {
export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => { export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
const { path } = props const { path } = props
const { guild } = useGuildMember() const { guild, member } = useGuildMember()
return ( return (
<div className='mt-2 flex w-full flex-col justify-between'> <div className='mt-2 flex w-full flex-col justify-between'>
@ -26,12 +27,22 @@ export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
<Channels path={path} /> <Channels path={path} />
<Divider /> <Divider />
<div className='mb-1 flex items-center justify-center space-x-6 p-2'> <div className='mb-1 flex items-center justify-center space-x-6 p-2'>
{member.isOwner && (
<Link href={`/application/${path.guildId}/channels/create`} passHref>
<a data-cy='link-add-channel'>
<IconButton className='h-10 w-10' title='Add a Channel'> <IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon /> <PlusIcon />
</IconButton> </IconButton>
</a>
</Link>
)}
<Link href={`/application/${path.guildId}/settings`} passHref>
<a data-cy='link-settings-guild'>
<IconButton className='h-7 w-7' title='Settings'> <IconButton className='h-7 w-7' title='Settings'>
<CogIcon /> <CogIcon />
</IconButton> </IconButton>
</a>
</Link>
</div> </div>
</div> </div>
) )

View File

@ -0,0 +1,201 @@
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { Type } from '@sinclair/typebox'
import { PhotographIcon } from '@heroicons/react/solid'
import { Form } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
import { HandleSubmitCallback, useForm } from 'hooks/useForm'
import { guildSchema } from 'models/Guild'
import { FormState } from 'components/design/FormState'
import { API_URL } from '../../../tools/api'
import { useGuildMember } from '../../../contexts/GuildMember'
import { Textarea } from '../../design/Textarea'
import { Input } from '../../design/Input'
import { Button } from '../../design/Button'
import { useAuthentication } from '../../../tools/authentication'
export const GuildSettings: React.FC = () => {
const { t } = useTranslation()
const router = useRouter()
const { authentication } = useAuthentication()
const { guild, member } = useGuildMember()
const [inputValues, setInputValues] = useState({
name: guild.name,
description: guild.description
})
const {
fetchState,
message,
errors,
getErrorTranslation,
handleSubmit,
setFetchState,
setMessageTranslationKey
} = useForm({
validateSchema: {
name: guildSchema.name,
description: Type.Optional(guildSchema.description)
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const onSubmit: HandleSubmitCallback = async (formData) => {
try {
await authentication.api.put(`/guilds/${guild.id}`, formData)
setInputValues(formData as any)
return {
type: 'success',
value: 'common:name'
}
} catch (error) {
return {
type: 'error',
value: 'errors:server-error'
}
}
}
const onChange: React.ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (event) => {
setInputValues((oldInputValues) => {
return {
...oldInputValues,
[event.target.name]: event.target.value
}
})
}
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event
) => {
setFetchState('loading')
const files = event?.target?.files
if (files != null && files.length === 1) {
const file = files[0]
const formData = new FormData()
formData.append('icon', file)
try {
await authentication.api.put(`/guilds/${guild.id}/icon`, formData)
setFetchState('idle')
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
}
const handleDelete = async (): Promise<void> => {
try {
await authentication.api.delete(`/guilds/${guild.id}`)
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
const handleLeave = async (): Promise<void> => {
try {
await authentication.api.delete(`/guilds/${guild.id}/members/leave`)
await router.push('/application')
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
return (
<Form
onSubmit={handleSubmit(onSubmit)}
className='my-auto flex flex-col items-center justify-center py-12'
>
{member.isOwner && (
<div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className='relative'>
<div className='absolute z-50 h-full w-full'>
<button className='relative flex h-full w-full items-center justify-center transition hover:scale-110'>
<input
type='file'
className='absolute h-full w-full cursor-pointer opacity-0'
onChange={handleFileChange}
/>
<PhotographIcon color='white' className='h-8 w-8' />
</button>
</div>
<div className='flex items-center justify-center rounded-full bg-black shadow-xl'>
<Image
className='rounded-full opacity-50'
src={
guild.icon == null
? '/images/data/guild-default.png'
: API_URL + guild.icon
}
alt='Profil Picture'
draggable='false'
height={125}
width={125}
/>
</div>
</div>
<div className='mx-12 flex flex-col'>
<Input
name='name'
label={t('common:name')}
placeholder={t('common:name')}
className='!mt-0'
onChange={onChange}
value={inputValues.name}
error={getErrorTranslation(errors.name)}
data-cy='guild-name-input'
/>
<Textarea
name='description'
label={'Description'}
placeholder={'Description'}
id='textarea-description'
onChange={onChange}
value={inputValues.description ?? ''}
data-cy='guild-description-input'
/>
</div>
</div>
</div>
)}
<div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className='space-x-6'>
{member.isOwner ? (
<>
<Button type='submit' data-cy='button-save-guild-settings'>
Sauvegarder
</Button>
<Button
type='button'
color='red'
onClick={handleDelete}
data-cy='button-delete-guild-settings'
>
Supprimer
</Button>
</>
) : (
<Button
color='red'
onClick={handleLeave}
data-cy='button-leave-guild-settings'
>
Quitter {guild.name}
</Button>
)}
</div>
<FormState state={fetchState} message={message} />
</div>
</Form>
)
}

View File

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

View File

@ -2,11 +2,11 @@ import InfiniteScroll from 'react-infinite-scroll-component'
import { Loader } from '../../design/Loader' import { Loader } from '../../design/Loader'
import { useGuilds } from '../../../contexts/Guilds' import { useGuilds } from '../../../contexts/Guilds'
import { GuildsChannelsPath } from '..' import { GuildsPath } from '..'
import { Guild } from './Guild' import { Guild } from './Guild'
export interface GuildsProps { export interface GuildsProps {
path: GuildsChannelsPath | string path: GuildsPath | string
} }
export const Guilds: React.FC<GuildsProps> = (props) => { export const Guilds: React.FC<GuildsProps> = (props) => {

View File

@ -1,12 +1,19 @@
import { useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import useTranslation from 'next-translate/useTranslation' import useTranslation from 'next-translate/useTranslation'
import classNames from 'classnames' import classNames from 'classnames'
import axios from 'axios'
import { Emoji } from 'components/Emoji' import { Emoji } from 'components/Emoji'
import { ConfirmGuildJoin } from 'components/Application/ConfirmGuildJoin' import { ConfirmGuildJoin } from 'components/Application/ConfirmGuildJoin'
import { API_URL } from 'tools/api'
import { GuildPublic as GuildPublicType } from '../../../../models/Guild' import {
GuildPublic as GuildPublicType,
GuildWithDefaultChannelId
} from '../../../../models/Guild'
import { useAuthentication } from '../../../../tools/authentication'
export interface GuildPublicProps { export interface GuildPublicProps {
guild: GuildPublicType guild: GuildPublicType
@ -14,10 +21,37 @@ export interface GuildPublicProps {
export const GuildPublic: React.FC<GuildPublicProps> = (props) => { export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
const { guild } = props const { guild } = props
const router = useRouter()
const { authentication } = useAuthentication()
const [isConfirmed, setIsConfirmed] = useState(false) const [isConfirmed, setIsConfirmed] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const handleIsConfirmed = (): void => {
setIsConfirmed((isConfirmed) => !isConfirmed)
}
const handleYes = async (): Promise<void> => {
try {
const { data } = await authentication.api.post<{
guild: GuildWithDefaultChannelId
}>(`/guilds/${guild.id}/members/join`)
await router.push(
`/application/${guild.id}/${data.guild.defaultChannelId}`
)
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.status === 400 &&
typeof error.response?.data.defaultChannelId === 'number'
) {
const defaultChannelId = error.response.data.defaultChannelId as number
await router.push(`/application/${guild.id}/${defaultChannelId}`)
} else {
await router.push('/application')
}
}
}
return ( return (
<div className='relative overflow-hidden rounded border border-gray-500 shadow-lg transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none dark:border-gray-700'> <div className='relative overflow-hidden rounded border border-gray-500 shadow-lg transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none dark:border-gray-700'>
<div <div
@ -25,12 +59,14 @@ export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
'flex h-full cursor-pointer flex-col items-center justify-center p-4 pt-8 transition duration-200 ease-in-out', 'flex h-full cursor-pointer flex-col items-center justify-center p-4 pt-8 transition duration-200 ease-in-out',
{ '-translate-x-full': isConfirmed } { '-translate-x-full': isConfirmed }
)} )}
onClick={() => setIsConfirmed(!isConfirmed)} onClick={handleIsConfirmed}
> >
<Image <Image
className='rounded-full' className='rounded-full'
src={ src={
guild.icon != null ? guild.icon : '/images/data/guild-default.png' guild.icon != null
? API_URL + guild.icon
: '/images/data/guild-default.png'
} }
alt='logo' alt='logo'
width={80} width={80}
@ -65,7 +101,8 @@ export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
'!left-0': isConfirmed '!left-0': isConfirmed
} }
)} )}
handleJoinGuild={() => setIsConfirmed(!isConfirmed)} handleYes={handleYes}
handleNo={handleIsConfirmed}
/> />
</div> </div>
) )

View File

@ -7,6 +7,7 @@ import { GuildPublic as GuildPublicType } from '../../../models/Guild'
import { Loader } from '../../design/Loader' import { Loader } from '../../design/Loader'
import { GuildPublic } from './GuildPublic' import { GuildPublic } from './GuildPublic'
import { usePagination } from '../../../hooks/usePagination' import { usePagination } from '../../../hooks/usePagination'
import { SocketData, handleSocketData } from '../../../tools/handleSocketData'
export const JoinGuildsPublic: React.FC = () => { export const JoinGuildsPublic: React.FC = () => {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@ -14,12 +15,22 @@ export const JoinGuildsPublic: React.FC = () => {
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const { t } = useTranslation() const { t } = useTranslation()
const { items, hasMore, nextPage, resetPagination } = const { items, hasMore, nextPage, resetPagination, setItems } =
usePagination<GuildPublicType>({ usePagination<GuildPublicType>({
api: authentication.api, api: authentication.api,
url: '/guilds/public' url: '/guilds/public'
}) })
useEffect(() => {
authentication.socket.on('guilds', (data: SocketData<GuildPublicType>) => {
handleSocketData({ data, setItems })
})
return () => {
authentication.socket.off('guilds')
}
}, [authentication.socket, setItems])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()
nextPage({ search }) nextPage({ search })

View File

@ -8,6 +8,13 @@ import { MessageWithMember } from '../../../../../models/Message'
import { Loader } from '../../../../design/Loader' import { Loader } from '../../../../design/Loader'
import { FileIcon } from './FileIcon' import { FileIcon } from './FileIcon'
const supportedImageMimetype = [
'image/png',
'image/jpg',
'image/jpeg',
'image/gif'
]
export interface FileData { export interface FileData {
blob: Blob blob: Blob
url: string url: string
@ -44,7 +51,7 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
if (file == null) { if (file == null) {
return <Loader /> return <Loader />
} }
if (message.mimetype.startsWith('image/')) { if (supportedImageMimetype.includes(message.mimetype)) {
return ( return (
<a href={file.url} target='_blank' rel='noreferrer'> <a href={file.url} target='_blank' rel='noreferrer'>
<img <img

View File

@ -155,7 +155,8 @@ export const UserProfile: React.FC<UserProfileProps> = (props) => {
'absolute top-0 left-[150%] flex h-full w-full flex-col items-center justify-center transition-all', 'absolute top-0 left-[150%] flex h-full w-full flex-col items-center justify-center transition-all',
{ 'left-[0%]': confirmation } { 'left-[0%]': confirmation }
)} )}
handleJoinGuild={handleConfirmationState} handleYes={handleConfirmationState}
handleNo={() => {}}
/> />
</div> </div>
<XIcon <XIcon

View File

@ -133,6 +133,7 @@ export const UserSettings: React.FC = () => {
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async ( const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event event
) => { ) => {
setFetchState('loading')
const files = event?.target?.files const files = event?.target?.files
if (files != null && files.length === 1) { if (files != null && files.length === 1) {
const file = files[0] const file = files[0]
@ -149,6 +150,7 @@ export const UserSettings: React.FC = () => {
logo: data.user.logo logo: data.user.logo
} }
}) })
setFetchState('idle')
} catch (error) { } catch (error) {
setFetchState('error') setFetchState('error')
setMessageTranslationKey('errors:server-error') setMessageTranslationKey('errors:server-error')

View File

@ -2,18 +2,40 @@ import { forwardRef } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
const className = const className =
'py-2 px-6 font-paragraph rounded-lg bg-transparent border border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 hover:text-white dark:hover:bg-green-400 dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:bg-green-800 focus:text-white dark:focus:bg-green-400 dark:focus:text-black' 'py-2 px-6 font-paragraph rounded-lg bg-transparent border hover:text-white dark:hover:text-black fill-current stroke-current transform transition-colors duration-300 ease-in-out focus:outline-none focus:text-white dark:focus:text-black'
export interface ButtonLinkProps extends React.ComponentPropsWithRef<'a'> {} const classNameGreen =
'border-green-800 dark:border-green-400 text-green-800 dark:text-green-400 hover:bg-green-800 focus:bg-green-800 dark:focus:bg-green-400 dark:hover:bg-green-400'
const classNameRed =
'border-red-800 dark:border-red-400 text-red-800 dark:text-red-400 hover:bg-red-800 focus:bg-red-800 dark:focus:bg-red-400 dark:hover:bg-red-400'
export type ButtonColor = 'green' | 'red'
export interface ButtonLinkProps extends React.ComponentPropsWithRef<'a'> {
color?: ButtonColor
}
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>( export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
(props, reference) => { (props, reference) => {
const { children, className: givenClassName, ...rest } = props const {
children,
className: givenClassName,
color = 'green',
...rest
} = props
return ( return (
<a <a
ref={reference} ref={reference}
className={classNames(className, givenClassName)} className={classNames(
className,
{
[classNameGreen]: color === 'green',
[classNameRed]: color === 'red'
},
givenClassName
)}
{...rest} {...rest}
> >
{children} {children}
@ -24,13 +46,30 @@ export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
ButtonLink.displayName = 'ButtonLink' ButtonLink.displayName = 'ButtonLink'
export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {} export interface ButtonProps extends React.ComponentPropsWithoutRef<'button'> {
color?: ButtonColor
}
export const Button: React.FC<ButtonProps> = (props) => { export const Button: React.FC<ButtonProps> = (props) => {
const { children, className: givenClassName, ...rest } = props const {
children,
className: givenClassName,
color = 'green',
...rest
} = props
return ( return (
<button className={classNames(className, givenClassName)} {...rest}> <button
className={classNames(
className,
{
[classNameGreen]: color === 'green',
[classNameRed]: color === 'red'
},
givenClassName
)}
{...rest}
>
{children} {children}
</button> </button>
) )

View File

@ -1,9 +1,11 @@
import { createContext, useContext, useEffect } from 'react' import { createContext, useContext, useEffect } from 'react'
import { useRouter } from 'next/router'
import { NextPage, usePagination } from 'hooks/usePagination' import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication' import { useAuthentication } from 'tools/authentication'
import { Channel } from 'models/Channel' import { Channel, ChannelWithDefaultChannelId } from 'models/Channel'
import { GuildsChannelsPath } from 'components/Application' import { GuildsChannelsPath } from 'components/Application'
import { handleSocketData, SocketData } from 'tools/handleSocketData'
export interface Channels { export interface Channels {
channels: Channel[] channels: Channel[]
@ -20,19 +22,38 @@ export interface ChannelsProviderProps {
export const ChannelsProvider: React.FC<ChannelsProviderProps> = (props) => { export const ChannelsProvider: React.FC<ChannelsProviderProps> = (props) => {
const { path, children } = props const { path, children } = props
const router = useRouter()
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const { const {
items: channels, items: channels,
hasMore, hasMore,
nextPage, nextPage,
resetPagination resetPagination,
setItems
} = usePagination<Channel>({ } = usePagination<Channel>({
api: authentication.api, api: authentication.api,
url: `/guilds/${path.guildId}/channels` url: `/guilds/${path.guildId}/channels`
}) })
useEffect(() => {
authentication.socket.on(
'channels',
async (data: SocketData<ChannelWithDefaultChannelId>) => {
handleSocketData({ data, setItems })
if (data.action === 'delete') {
await router.push(
`/application/${path.guildId}/${data.item.defaultChannelId}`
)
}
}
)
return () => {
authentication.socket.off('channels')
}
}, [authentication.socket, path.guildId, router, setItems])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()
nextPage() nextPage()

View File

@ -1,48 +1,92 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Guild } from 'models/Guild' import { GuildWithDefaultChannelId } from 'models/Guild'
import { Member } from 'models/Member' import { Member } from 'models/Member'
import { GuildsChannelsPath } from 'components/Application'
import { useAuthentication } from 'tools/authentication' import { useAuthentication } from 'tools/authentication'
import { SocketData } from 'tools/handleSocketData'
export interface GuildMember { export interface GuildMember {
guild: Guild guild: GuildWithDefaultChannelId
member: Member member: Member
} }
export interface GuildMemberResult extends GuildMember {
setGuildMember: React.Dispatch<React.SetStateAction<GuildMember>>
}
export interface GuildMemberProps { export interface GuildMemberProps {
guildMember: GuildMember guildMember: GuildMember
path: GuildsChannelsPath path: {
guildId: number
}
} }
const defaultGuildMemberContext = {} as any const defaultGuildMemberContext = {} as any
const GuildMemberContext = createContext<GuildMember>(defaultGuildMemberContext) const GuildMemberContext = createContext<GuildMemberResult>(
defaultGuildMemberContext
)
export const GuildMemberProvider: React.FC<GuildMemberProps> = (props) => { export const GuildMemberProvider: React.FC<GuildMemberProps> = (props) => {
const { path, children } = props
const router = useRouter()
const [guildMember, setGuildMember] = useState(props.guildMember) const [guildMember, setGuildMember] = useState(props.guildMember)
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
useEffect(() => { useEffect(() => {
const fetchGuildMember = async (): Promise<void> => { const fetchGuildMember = async (): Promise<void> => {
const { data } = await authentication.api.get( const { data } = await authentication.api.get(`/guilds/${path.guildId}`)
`/guilds/${props.path.guildId}`
)
setGuildMember(data) setGuildMember(data)
} }
fetchGuildMember().catch((error) => { fetchGuildMember().catch((error) => {
console.error(error) console.error(error)
}) })
}, [props.path, authentication.api]) }, [path, authentication.api])
useEffect(() => {
authentication.socket.on(
'guilds',
async (data: SocketData<GuildWithDefaultChannelId>) => {
if (data.item.id === path.guildId) {
switch (data.action) {
case 'delete':
await router.push('/application')
break
case 'update':
setGuildMember((oldGuildMember) => {
return {
...oldGuildMember,
guild: {
...oldGuildMember.guild,
...data.item
}
}
})
break
}
}
}
)
return () => {
authentication.socket.off('guilds')
}
}, [authentication.socket, path.guildId, router])
return ( return (
<GuildMemberContext.Provider value={guildMember}> <GuildMemberContext.Provider
{props.children} value={{
...guildMember,
setGuildMember
}}
>
{children}
</GuildMemberContext.Provider> </GuildMemberContext.Provider>
) )
} }
export const useGuildMember = (): GuildMember => { export const useGuildMember = (): GuildMemberResult => {
const guildMember = useContext(GuildMemberContext) const guildMember = useContext(GuildMemberContext)
if (guildMember === defaultGuildMemberContext) { if (guildMember === defaultGuildMemberContext) {
throw new Error('useGuildMember must be used within GuildMemberProvider') throw new Error('useGuildMember must be used within GuildMemberProvider')

View File

@ -3,6 +3,7 @@ import { createContext, useContext, useEffect } from 'react'
import { NextPage, usePagination } from 'hooks/usePagination' import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication' import { useAuthentication } from 'tools/authentication'
import { GuildWithDefaultChannelId } from 'models/Guild' import { GuildWithDefaultChannelId } from 'models/Guild'
import { handleSocketData, SocketData } from 'tools/handleSocketData'
export interface Guilds { export interface Guilds {
guilds: GuildWithDefaultChannelId[] guilds: GuildWithDefaultChannelId[]
@ -22,12 +23,26 @@ export const GuildsProvider: React.FC = (props) => {
items: guilds, items: guilds,
hasMore, hasMore,
nextPage, nextPage,
resetPagination resetPagination,
setItems
} = usePagination<GuildWithDefaultChannelId>({ } = usePagination<GuildWithDefaultChannelId>({
api: authentication.api, api: authentication.api,
url: '/guilds' url: '/guilds'
}) })
useEffect(() => {
authentication.socket.on(
'guilds',
(data: SocketData<GuildWithDefaultChannelId>) => {
handleSocketData({ data, setItems })
}
)
return () => {
authentication.socket.off('guilds')
}
}, [authentication.socket, setItems])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()
nextPage() nextPage()

View File

@ -4,6 +4,8 @@ import { NextPage, usePagination } from 'hooks/usePagination'
import { useAuthentication } from 'tools/authentication' import { useAuthentication } from 'tools/authentication'
import { MemberWithPublicUser } from 'models/Member' import { MemberWithPublicUser } from 'models/Member'
import { GuildsChannelsPath } from 'components/Application' import { GuildsChannelsPath } from 'components/Application'
import { handleSocketData, SocketData } from 'tools/handleSocketData'
import { User } from 'models/User'
export interface Members { export interface Members {
members: MemberWithPublicUser[] members: MemberWithPublicUser[]
@ -27,12 +29,45 @@ export const MembersProviders: React.FC<MembersProviderProps> = (props) => {
items: members, items: members,
hasMore, hasMore,
nextPage, nextPage,
resetPagination resetPagination,
setItems
} = usePagination<MemberWithPublicUser>({ } = usePagination<MemberWithPublicUser>({
api: authentication.api, api: authentication.api,
url: `/guilds/${path.guildId}/members` url: `/guilds/${path.guildId}/members`
}) })
useEffect(() => {
authentication.socket.on(
'members',
(data: SocketData<MemberWithPublicUser>) => {
handleSocketData({ data, setItems })
}
)
authentication.socket.on('users', (data: SocketData<User>) => {
setItems((oldItems) => {
const newItems = [...oldItems]
switch (data.action) {
case 'update': {
for (const member of newItems) {
if (member.user.id === data.item.id) {
member.user = data.item
break
}
}
break
}
}
return newItems
})
})
return () => {
authentication.socket.off('members')
authentication.socket.off('users')
}
}, [authentication.socket, setItems])
useEffect(() => { useEffect(() => {
resetPagination() resetPagination()
nextPage() nextPage()

View File

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

View File

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

View File

@ -0,0 +1,15 @@
import { Handler } from '../../handler'
import { channelExample, channelExample2 } from '../channel'
export const putChannelWithChannelIdHandler: Handler = {
method: 'PUT',
url: `/channels/${channelExample.id}`,
response: {
statusCode: 200,
body: {
...channelExample,
name: 'New channel name',
defaultChannelId: channelExample2.id
}
}
}

View File

@ -0,0 +1,15 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { channelExample, channelExample2 } from '../../../channels/channel'
export const postChannelsWithGuildIdHandler: Handler = {
method: 'POST',
url: `/guilds/${guildExample.id}/channels`,
response: {
statusCode: 200,
body: {
...channelExample2,
defaultChannelId: channelExample.id
}
}
}

View File

@ -0,0 +1,13 @@
import { Handler } from '../../handler'
import { guildExample } from '../guild'
export const deleteGuildWithGuildIdHandler: Handler = {
method: 'DELETE',
url: `/guilds/${guildExample.id}`,
response: {
statusCode: 200,
body: {
...guildExample
}
}
}

View File

@ -13,3 +13,18 @@ export const getGuildMemberWithGuildIdHandler: Handler = {
} }
} }
} }
export const getGuildMemberNotOwnerWithGuildIdHandler: Handler = {
method: 'GET',
url: `/guilds/${guildExample.id}`,
response: {
statusCode: 200,
body: {
guild: guildExample,
member: {
...memberExampleComplete,
isOwner: false
}
}
}
}

View File

@ -0,0 +1,19 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { memberExampleComplete } from '../../../members/member'
import { channelExample } from '../../../channels/channel'
export const postMembersWithGuildIdHandler: Handler = {
method: 'POST',
url: `/guilds/${guildExample.id}/members/join`,
response: {
statusCode: 201,
body: {
...memberExampleComplete,
guild: {
...guildExample,
defaultChannelId: channelExample.id
}
}
}
}

View File

@ -0,0 +1,14 @@
import { guildExample } from '../../guild'
import { Handler } from '../../../handler'
import { memberExample } from '../../../members/member'
export const deleteLeaveMembersWithGuildIdHandler: Handler = {
method: 'DELETE',
url: `/guilds/${guildExample.id}/members/leave`,
response: {
statusCode: 200,
body: {
...memberExample
}
}
}

View File

@ -1,7 +1,6 @@
import { channelExample } from '../../../fixtures/channels/channel' import { channelExample } from '../../../fixtures/channels/channel'
import { guildExample } from '../../../fixtures/guilds/guild' import { guildExample } from '../../../fixtures/guilds/guild'
import { userExample } from '../../../fixtures/users/user' import { userExample } from '../../../fixtures/users/user'
import { getGuildsHandler } from '../../../fixtures/guilds/get'
import { authenticationHandlers } from '../../../fixtures/handler' import { authenticationHandlers } from '../../../fixtures/handler'
import { getGuildMemberWithGuildIdHandler } from '../../../fixtures/guilds/[guildId]/get' import { getGuildMemberWithGuildIdHandler } from '../../../fixtures/guilds/[guildId]/get'
import { getChannelWithChannelIdHandler } from '../../../fixtures/channels/[channelId]/get' import { getChannelWithChannelIdHandler } from '../../../fixtures/channels/[channelId]/get'
@ -10,9 +9,13 @@ import { getUserByIdHandler } from '../../../fixtures/users/[userId]/get'
const applicationPaths = [ const applicationPaths = [
'/application', '/application',
`/application/users/${userExample.id}`, `/application/users/${userExample.id}`,
`/application/users/settings`,
'/application/guilds/create', '/application/guilds/create',
'/application/guilds/join', '/application/guilds/join',
`/application/${guildExample.id}/${channelExample.id}` `/application/${guildExample.id}/${channelExample.id}`,
`/application/${guildExample.id}/${channelExample.id}/settings`,
`/application/${guildExample.id}/channels/create`,
`/application/${guildExample.id}/settings`
] ]
describe('Common > application/authentication', () => { describe('Common > application/authentication', () => {
@ -31,7 +34,6 @@ describe('Common > application/authentication', () => {
it('should not redirect the user if signed in', () => { it('should not redirect the user if signed in', () => {
cy.task('startMockServer', [ cy.task('startMockServer', [
...authenticationHandlers, ...authenticationHandlers,
getGuildsHandler,
getGuildMemberWithGuildIdHandler, getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler, getChannelWithChannelIdHandler,
getUserByIdHandler getUserByIdHandler

View File

@ -3,34 +3,37 @@ import date from 'date-and-time'
import { import {
channelExample, channelExample,
channelExample2 channelExample2
} from '../../../../fixtures/channels/channel' } from '../../../../../fixtures/channels/channel'
import { guildExample } from '../../../../fixtures/guilds/guild' import { guildExample } from '../../../../../fixtures/guilds/guild'
import { getGuildMemberWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/get' import {
import { getChannelWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/get' getGuildMemberNotOwnerWithGuildIdHandler,
import { authenticationHandlers } from '../../../../fixtures/handler' getGuildMemberWithGuildIdHandler
import { getGuildsHandler } from '../../../../fixtures/guilds/get' } from '../../../../../fixtures/guilds/[guildId]/get'
import { getChannelsWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/channels/get' import { getChannelWithChannelIdHandler } from '../../../../../fixtures/channels/[channelId]/get'
import { getMessagesWithChannelIdHandler } from '../../../../fixtures/channels/[channelId]/messages/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 { import {
messageExampleComplete, messageExampleComplete,
messageExampleComplete2 messageExampleComplete2
} from '../../../../fixtures/messages/message' } from '../../../../../fixtures/messages/message'
import { getMembersWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/members/get' import { getMembersWithGuildIdHandler } from '../../../../../fixtures/guilds/[guildId]/members/get'
import { memberExampleComplete } from '../../../../fixtures/members/member' import { memberExampleComplete } from '../../../../../fixtures/members/member'
import { API_URL } from '../../../../../tools/api' import { API_URL } from '../../../../../../tools/api'
import { import {
getMessagesUploadsAudioHandler, getMessagesUploadsAudioHandler,
getMessagesUploadsDownloadHandler, getMessagesUploadsDownloadHandler,
getMessagesUploadsImageHandler, getMessagesUploadsImageHandler,
getMessagesUploadsVideoHandler getMessagesUploadsVideoHandler
} from '../../../../fixtures/uploads/messages/get' } from '../../../../../fixtures/uploads/messages/get'
describe('Pages > /application/[guildId]/[channelId]', () => { describe('Pages > /application/[guildId]/[channelId]', () => {
beforeEach(() => { beforeEach(() => {
cy.task('stopMockServer') cy.task('stopMockServer')
}) })
it('should succeeds and display the guilds in left sidebar correctly', () => { it('should succeeds and display the left sidebar correctly (member is owner)', () => {
cy.task('startMockServer', [ cy.task('startMockServer', [
...authenticationHandlers, ...authenticationHandlers,
getGuildMemberWithGuildIdHandler, getGuildMemberWithGuildIdHandler,
@ -50,6 +53,43 @@ describe('Pages > /application/[guildId]/[channelId]', () => {
guildExample.name guildExample.name
) )
cy.get('.guilds-list').children().should('have.length', 2) cy.get('.guilds-list').children().should('have.length', 2)
cy.get('[data-cy=link-add-channel]')
.should('be.visible')
.should(
'have.attr',
'href',
`/application/${guildExample.id}/channels/create`
)
cy.get('[data-cy=link-settings-guild]')
.should('be.visible')
.should('have.attr', 'href', `/application/${guildExample.id}/settings`)
})
})
it('should succeeds and display the left sidebar correctly (member is not owner)', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberNotOwnerWithGuildIdHandler,
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)
cy.get('[data-cy=link-add-channel]').should('not.exist')
cy.get('[data-cy=link-settings-guild]')
.should('be.visible')
.should('have.attr', 'href', `/application/${guildExample.id}/settings`)
}) })
}) })

View File

@ -0,0 +1,133 @@
import { deleteChannelWithChannelIdHandler } from '../../../../../fixtures/channels/[channelId]/delete'
import { putChannelWithChannelIdHandler } from '../../../../../fixtures/channels/[channelId]/put'
import {
channelExample,
channelExample2
} from '../../../../../fixtures/channels/channel'
import { guildExample } from '../../../../../fixtures/guilds/guild'
import {
getGuildMemberNotOwnerWithGuildIdHandler,
getGuildMemberWithGuildIdHandler
} from '../../../../../fixtures/guilds/[guildId]/get'
import {
getChannelWithChannelIdHandler,
getChannelWithChannelIdHandler2
} from '../../../../../fixtures/channels/[channelId]/get'
import { authenticationHandlers } from '../../../../../fixtures/handler'
import { API_URL } from '../../../../../../tools/api'
describe('Pages > /application/[guildId]/[channelId]/settings', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should succeeds and update the channel name', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
putChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${putChannelWithChannelIdHandler.url}*`).as(
'putChannelWithChannelIdHandler'
)
cy.visit(`/application/${guildExample.id}/${channelExample.id}/settings`)
cy.get('[data-cy=channel-name-input]')
.clear()
.type(putChannelWithChannelIdHandler.response.body.name)
cy.get('[data-cy=button-save-channel-settings]').click()
cy.wait('@putChannelWithChannelIdHandler').then(() => {
cy.location('pathname').should(
'eq',
`/application/${guildExample.id}/${channelExample.id}`
)
})
})
it('should succeeds and delete the channel', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler,
getChannelWithChannelIdHandler2,
deleteChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${deleteChannelWithChannelIdHandler.url}*`).as(
'deleteChannelWithChannelIdHandler'
)
cy.visit(`/application/${guildExample.id}/${channelExample.id}/settings`)
cy.get('[data-cy=button-delete-channel-settings]').click()
cy.wait('@deleteChannelWithChannelIdHandler').then(() => {
cy.location('pathname').should(
'eq',
`/application/${guildExample.id}/${channelExample2.id}`
)
})
})
it('should fails with too long channel name on update', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/${channelExample.id}/settings`)
cy.get('[data-cy=channel-name-input]').type(
'random channel name that is really too long for a channel name'
)
cy.get('[data-cy=button-save-channel-settings]').click()
cy.get('#error-name').should(
'have.text',
'Error: The field must contain at most 20 characters.'
)
})
it('should redirect the user to `/404` if member is not owner', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberNotOwnerWithGuildIdHandler,
getChannelWithChannelIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/${channelExample.id}/settings`, {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
it('should redirect the user to `/404` if `guildId` or `channelId` are not numbers', () => {
cy.task('startMockServer', authenticationHandlers).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/abc/abc/settings', {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
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}/settings`, {
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/settings`, {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
})

View File

@ -0,0 +1,74 @@
import { postChannelsWithGuildIdHandler } from '../../../../../fixtures/guilds/[guildId]/channels/post'
import { API_URL } from '../../../../../../tools/api'
import { channelExample2 } from '../../../../../fixtures/channels/channel'
import { guildExample } from '../../../../../fixtures/guilds/guild'
import { getGuildMemberWithGuildIdHandler } from '../../../../../fixtures/guilds/[guildId]/get'
import { authenticationHandlers } from '../../../../../fixtures/handler'
import { getChannelWithChannelIdHandler2 } from '../../../../../fixtures/channels/[channelId]/get'
describe('Pages > /application/[guildId]/channels/create', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should succeeds and create the channel', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
postChannelsWithGuildIdHandler,
getChannelWithChannelIdHandler2
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${postChannelsWithGuildIdHandler.url}*`).as(
'postChannelsWithGuildIdHandler'
)
cy.visit(`/application/${guildExample.id}/channels/create`)
cy.get('[data-cy=channel-name-input]').type(channelExample2.name)
cy.get('[data-cy=button-create-channel]').click()
cy.wait('@postChannelsWithGuildIdHandler').then(() => {
cy.location('pathname').should(
'eq',
`/application/${guildExample.id}/${channelExample2.id}`
)
})
})
it('should fails with too long channel name on update', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/channels/create`)
cy.get('[data-cy=channel-name-input]').type(
'random channel name that is really too long for a channel name'
)
cy.get('[data-cy=button-create-channel]').click()
cy.get('#error-name').should(
'have.text',
'Error: The field must contain at most 20 characters.'
)
})
it('should redirect the user to `/404` if `guildId` is not a number', () => {
cy.task('startMockServer', authenticationHandlers).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/abc/channels/create', {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
it("should redirect the user to `/404` if `guildId` doesn't exist", () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit(`/application/123/channels/create`, {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
})

View File

@ -0,0 +1,104 @@
import { deleteLeaveMembersWithGuildIdHandler } from 'cypress/fixtures/guilds/[guildId]/members/leave'
import { guildExample } from '../../../../fixtures/guilds/guild'
import {
getGuildMemberNotOwnerWithGuildIdHandler,
getGuildMemberWithGuildIdHandler
} from '../../../../fixtures/guilds/[guildId]/get'
import { authenticationHandlers } from '../../../../fixtures/handler'
import { API_URL } from '../../../../../tools/api'
import { deleteGuildWithGuildIdHandler } from '../../../../fixtures/guilds/[guildId]/delete'
describe('Pages > /application/[guildId]/settings', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should succeeds and display correctly the settings of the guild (member is owner)', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/settings`)
cy.get('[data-cy=guild-name-input]').should('have.value', guildExample.name)
cy.get('[data-cy=guild-description-input]').should(
'have.value',
guildExample.description
)
cy.get('[data-cy=button-save-guild-settings]').should('be.visible')
cy.get('[data-cy=button-delete-guild-settings]').should('be.visible')
cy.get('[data-cy=button-leave-guild-settings]').should('not.exist')
})
it('should succeeds and display correctly the settings of the guild (member is not owner)', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberNotOwnerWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit(`/application/${guildExample.id}/settings`)
cy.get('[data-cy=guild-name-input]').should('not.exist')
cy.get('[data-cy=guild-description-input]').should('not.exist')
cy.get('[data-cy=button-save-guild-settings]').should('not.exist')
cy.get('[data-cy=button-delete-guild-settings]').should('not.exist')
cy.get('[data-cy=button-leave-guild-settings]').should('be.visible')
})
it('should succeeds and leave the guild (member is not owner)', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberNotOwnerWithGuildIdHandler,
deleteLeaveMembersWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${deleteLeaveMembersWithGuildIdHandler.url}*`).as(
'deleteLeaveMembersWithGuildIdHandler'
)
cy.visit(`/application/${guildExample.id}/settings`)
cy.get('[data-cy=button-leave-guild-settings]').click()
cy.wait('@deleteLeaveMembersWithGuildIdHandler').then(() => {
cy.location('pathname').should('eq', '/application')
})
})
it('should succeeds and delete the guild', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildMemberWithGuildIdHandler,
deleteGuildWithGuildIdHandler
]).setCookie('refreshToken', 'refresh-token')
cy.intercept(`${API_URL}${deleteGuildWithGuildIdHandler.url}*`).as(
'deleteGuildWithGuildIdHandler'
)
cy.visit(`/application/${guildExample.id}/settings`)
cy.get('[data-cy=button-delete-guild-settings]').click()
cy.wait('@deleteGuildWithGuildIdHandler').then((interception) => {
expect(interception.response).to.not.be.eql(undefined)
if (interception.response !== undefined) {
expect(interception.response.statusCode).to.eq(200)
}
})
})
it('should redirect the user to `/404` if `guildId` is not a number', () => {
cy.task('startMockServer', authenticationHandlers).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application/abc/settings', {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
it("should redirect the user to `/404` if `guildId` doesn't exist", () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit(`/application/123/settings`, {
failOnStatusCode: false
})
.location('pathname')
.should('eq', '/404')
})
})

View File

@ -15,6 +15,9 @@
"/application/guilds/create": ["application", "errors"], "/application/guilds/create": ["application", "errors"],
"/application/guilds/join": ["application", "errors"], "/application/guilds/join": ["application", "errors"],
"/application": ["application", "errors"], "/application": ["application", "errors"],
"/application/[guildId]/[channelId]": ["application", "errors"] "/application/[guildId]/settings": ["application", "errors"],
"/application/[guildId]/[channelId]": ["application", "errors"],
"/application/[guildId]/[channelId]/settings": ["application", "errors"],
"/application/[guildId]/channels/create": ["application", "errors"]
} }
} }

View File

@ -11,3 +11,14 @@ export const channelSchema = {
} }
const channelObjectSchema = Type.Object(channelSchema) const channelObjectSchema = Type.Object(channelSchema)
export type Channel = Static<typeof channelObjectSchema> export type Channel = Static<typeof channelObjectSchema>
export const channelWithDefaultChannelIdSchema = {
...channelSchema,
defaultChannelId: channelSchema.id
}
export const channelWithDefaultChannelObjectSchema = Type.Object(
channelWithDefaultChannelIdSchema
)
export type ChannelWithDefaultChannelId = Static<
typeof channelWithDefaultChannelObjectSchema
>

View File

@ -24,7 +24,12 @@ module.exports = nextTranslate(
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-eval'", "'unsafe-inline'"], scriptSrc: [
"'self'",
'data:',
"'unsafe-eval'",
"'unsafe-inline'"
],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ['*', 'data:', 'blob:'], imgSrc: ['*', 'data:', 'blob:'],
mediaSrc: ['*', 'data:', 'blob:'], mediaSrc: ['*', 'data:', 'blob:'],

13822
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,19 +37,19 @@
"dependencies": { "dependencies": {
"@fontsource/montserrat": "4.5.5", "@fontsource/montserrat": "4.5.5",
"@fontsource/roboto": "4.5.3", "@fontsource/roboto": "4.5.3",
"@heroicons/react": "1.0.5", "@heroicons/react": "1.0.6",
"@sinclair/typebox": "0.23.4", "@sinclair/typebox": "0.23.4",
"ajv": "8.10.0", "ajv": "8.10.0",
"ajv-formats": "2.1.1", "ajv-formats": "2.1.1",
"axios": "0.26.0", "axios": "0.26.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"date-and-time": "2.1.2", "date-and-time": "2.2.1",
"emoji-mart": "3.0.1", "emoji-mart": "3.0.1",
"katex": "0.15.2", "katex": "0.15.2",
"next": "12.1.0", "next": "12.1.0",
"next-pwa": "5.4.4", "next-pwa": "5.4.5",
"next-themes": "0.0.15", "next-themes": "0.1.1",
"next-translate": "1.3.4", "next-translate": "1.3.5",
"pretty-bytes": "6.0.0", "pretty-bytes": "6.0.0",
"react": "17.0.2", "react": "17.0.2",
"react-component-form": "2.0.0", "react-component-form": "2.0.0",
@ -64,7 +64,7 @@
"remark-breaks": "3.0.2", "remark-breaks": "3.0.2",
"remark-gfm": "3.0.1", "remark-gfm": "3.0.1",
"remark-math": "5.1.1", "remark-math": "5.1.1",
"sharp": "0.30.1", "sharp": "0.30.2",
"socket.io-client": "4.4.1", "socket.io-client": "4.4.1",
"unified": "10.1.1", "unified": "10.1.1",
"unist-util-visit": "4.1.0", "unist-util-visit": "4.1.0",
@ -74,7 +74,7 @@
"@commitlint/cli": "16.2.1", "@commitlint/cli": "16.2.1",
"@commitlint/config-conventional": "16.2.1", "@commitlint/config-conventional": "16.2.1",
"@lhci/cli": "0.9.0", "@lhci/cli": "0.9.0",
"@saithodev/semantic-release-backmerge": "2.1.1", "@saithodev/semantic-release-backmerge": "2.1.2",
"@storybook/addon-essentials": "6.4.19", "@storybook/addon-essentials": "6.4.19",
"@storybook/addon-links": "6.4.19", "@storybook/addon-links": "6.4.19",
"@storybook/addon-postcss": "2.0.0", "@storybook/addon-postcss": "2.0.0",
@ -86,44 +86,44 @@
"@types/date-and-time": "0.13.0", "@types/date-and-time": "0.13.0",
"@types/emoji-mart": "3.0.9", "@types/emoji-mart": "3.0.9",
"@types/hast": "2.3.4", "@types/hast": "2.3.4",
"@types/jest": "27.4.0", "@types/jest": "27.4.1",
"@types/katex": "0.11.1", "@types/katex": "0.11.1",
"@types/node": "17.0.18", "@types/node": "17.0.21",
"@types/react": "17.0.39", "@types/react": "17.0.39",
"@types/react-responsive": "8.0.5", "@types/react-responsive": "8.0.5",
"@types/unist": "2.0.6", "@types/unist": "2.0.6",
"@typescript-eslint/eslint-plugin": "5.12.0", "@typescript-eslint/eslint-plugin": "5.13.0",
"@typescript-eslint/parser": "5.12.0", "@typescript-eslint/parser": "5.13.0",
"autoprefixer": "10.4.2", "autoprefixer": "10.4.2",
"cypress": "9.5.0", "cypress": "9.5.1",
"editorconfig-checker": "4.0.2", "editorconfig-checker": "4.0.2",
"eslint": "8.9.0", "eslint": "8.10.0",
"eslint-config-conventions": "1.1.0", "eslint-config-conventions": "1.1.0",
"eslint-config-next": "12.1.0", "eslint-config-next": "12.1.0",
"eslint-config-prettier": "8.4.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.25.4", "eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "4.0.0", "eslint-plugin-prettier": "4.0.0",
"eslint-plugin-promise": "6.0.0", "eslint-plugin-promise": "6.0.0",
"eslint-plugin-storybook": "0.5.7", "eslint-plugin-storybook": "0.5.7",
"eslint-plugin-unicorn": "41.0.0", "eslint-plugin-unicorn": "41.0.0",
"html-w3c-validator": "1.0.0", "html-w3c-validator": "1.1.0",
"husky": "7.0.4", "husky": "7.0.4",
"jest": "27.5.1", "jest": "27.5.1",
"lint-staged": "12.3.4", "lint-staged": "12.3.5",
"markdownlint-cli": "0.31.1", "markdownlint-cli": "0.31.1",
"mockttp": "2.6.0", "mockttp": "2.6.0",
"next-secure-headers": "2.2.0", "next-secure-headers": "2.2.0",
"plop": "3.0.5", "plop": "3.0.5",
"postcss": "8.4.6", "postcss": "8.4.7",
"prettier": "2.5.1", "prettier": "2.5.1",
"prettier-plugin-tailwindcss": "0.1.7", "prettier-plugin-tailwindcss": "0.1.8",
"semantic-release": "19.0.2", "semantic-release": "19.0.2",
"serve": "13.0.2", "serve": "13.0.2",
"start-server-and-test": "1.14.0", "start-server-and-test": "1.14.0",
"storybook-tailwind-dark-mode": "1.0.11", "storybook-tailwind-dark-mode": "1.0.11",
"tailwindcss": "3.0.23", "tailwindcss": "3.0.23",
"typescript": "4.4.4", "typescript": "4.6.2",
"vercel": "24.0.0", "vercel": "24.0.0",
"webpack": "5.69.1" "webpack": "5.70.0"
} }
} }

View File

@ -0,0 +1,92 @@
import { NextPage } from 'next'
import { Head } from 'components/Head'
import { Application } from 'components/Application'
import {
authenticationFromServerSide,
AuthenticationProvider,
PagePropsWithAuthentication
} from 'tools/authentication'
import { GuildMember, GuildMemberProvider } from 'contexts/GuildMember'
import { GuildLeftSidebar } from 'components/Application/GuildLeftSidebar'
import { ChannelSettings } from 'components/Application/ChannelSettings'
import { ChannelsProvider } from 'contexts/Channels'
import { GuildsProvider } from 'contexts/Guilds'
import { Channel } from 'models/Channel'
import { MembersProviders } from 'contexts/Members'
export interface ChannelSettingsPageProps extends PagePropsWithAuthentication {
channelId: number
guildId: number
guildMember: GuildMember
selectedChannel: Channel
}
const ChannelSettingsPage: NextPage<ChannelSettingsPageProps> = (props) => {
const { channelId, guildId, authentication, guildMember, selectedChannel } =
props
const path = {
channelId,
guildId
}
return (
<AuthenticationProvider authentication={authentication}>
<GuildsProvider>
<GuildMemberProvider guildMember={guildMember} path={path}>
<MembersProviders path={path}>
<ChannelsProvider path={path}>
<Head title='Thream | Application' />
<Application
path={path}
guildLeftSidebar={<GuildLeftSidebar path={path} />}
title={`# ${selectedChannel.name}`}
>
<ChannelSettings channel={selectedChannel} />
</Application>
</ChannelsProvider>
</MembersProviders>
</GuildMemberProvider>
</GuildsProvider>
</AuthenticationProvider>
)
}
export const getServerSideProps = authenticationFromServerSide({
shouldBeAuthenticated: true,
fetchData: async (context, api) => {
const channelId = Number(context?.params?.channelId)
const guildId = Number(context?.params?.guildId)
if (isNaN(channelId) || isNaN(guildId)) {
return {
redirect: {
destination: '/404',
permanent: false
}
}
}
const { data: guildMember } = await api.get<GuildMember>(
`/guilds/${guildId}`
)
if (!guildMember.member.isOwner) {
return {
redirect: {
destination: '/404',
permanent: false
}
}
}
const { data: selectedChannelData } = await api.get(
`/channels/${channelId}`
)
return {
channelId,
guildId,
guildMember,
selectedChannel: selectedChannelData.channel
}
}
})
export default ChannelSettingsPage

View File

@ -0,0 +1,61 @@
import { NextPage } from 'next'
import { Head } from 'components/Head'
import { Application } from 'components/Application'
import {
authenticationFromServerSide,
AuthenticationProvider,
PagePropsWithAuthentication
} from 'tools/authentication'
import { CreateChannel } from 'components/Application/CreateChannel'
import { GuildsProvider } from 'contexts/Guilds'
import { GuildMember, GuildMemberProvider } from 'contexts/GuildMember'
export interface CreateChannelPageProps extends PagePropsWithAuthentication {
guildId: number
guildMember: GuildMember
}
const CreateChannelPage: NextPage<CreateChannelPageProps> = (props) => {
const { guildId, authentication, guildMember } = props
const path = { guildId }
return (
<AuthenticationProvider authentication={authentication}>
<GuildsProvider>
<GuildMemberProvider guildMember={guildMember} path={path}>
<Head
title={`Thream | Crée un channel`}
description={'Crée un nouveau channel'}
/>
<Application path={path} title={'Crée un channel'}>
<CreateChannel />
</Application>
</GuildMemberProvider>
</GuildsProvider>
</AuthenticationProvider>
)
}
export const getServerSideProps = authenticationFromServerSide({
shouldBeAuthenticated: true,
fetchData: async (context, api) => {
const guildId = Number(context?.params?.guildId)
if (isNaN(guildId)) {
return {
redirect: {
destination: '/404',
permanent: false
}
}
}
const { data: guildMember } = await api.get(`/guilds/${guildId}`)
return {
guildId,
guildMember
}
}
})
export default CreateChannelPage

View File

@ -0,0 +1,60 @@
import { NextPage } from 'next'
import { Head } from 'components/Head'
import { Application } from 'components/Application'
import {
authenticationFromServerSide,
AuthenticationProvider,
PagePropsWithAuthentication
} from 'tools/authentication'
import { GuildMember, GuildMemberProvider } from 'contexts/GuildMember'
import { GuildsProvider } from 'contexts/Guilds'
import { GuildSettings } from 'components/Application/GuildSettings'
export interface GuildSettingsPageProps extends PagePropsWithAuthentication {
guildId: number
guildMember: GuildMember
}
const GuildSettingsPage: NextPage<GuildSettingsPageProps> = (props) => {
const { guildId, authentication, guildMember } = props
const path = { guildId }
return (
<AuthenticationProvider authentication={authentication}>
<GuildsProvider>
<GuildMemberProvider guildMember={guildMember} path={path}>
<Head title='Thream | Guild settings' />
<Application path={path} title='Guild settings'>
<GuildSettings />
</Application>
</GuildMemberProvider>
</GuildsProvider>
</AuthenticationProvider>
)
}
export const getServerSideProps = authenticationFromServerSide({
shouldBeAuthenticated: true,
fetchData: async (context, api) => {
const guildId = Number(context?.params?.guildId)
if (isNaN(guildId)) {
return {
redirect: {
destination: '/404',
permanent: false
}
}
}
const { data: guildMember } = await api.get<GuildMember>(
`/guilds/${guildId}`
)
return {
guildId,
guildMember
}
}
})
export default GuildSettingsPage

View File

@ -39,7 +39,10 @@ export const handleSocketData = <T extends Item = Item>(
case 'update': { case 'update': {
const itemIndex = newItems.findIndex((item) => item.id === data.item.id) const itemIndex = newItems.findIndex((item) => item.id === data.item.id)
if (itemIndex !== -1) { if (itemIndex !== -1) {
newItems[itemIndex] = data.item newItems[itemIndex] = {
...newItems[itemIndex],
...data.item
}
} }
break break
} }