From c979e6540ff272772d7cfd3d9f06afd7bf22a8ce Mon Sep 17 00:00:00 2001 From: SrJuggernaut Date: Tue, 30 Jul 2024 18:08:53 -0600 Subject: [PATCH] refactor: team applications --- src/app/api/team-applications/[id]/route.ts | 44 ---- src/app/api/team-applications/route.ts | 43 ---- .../_components/TeamApplications.tsx | 2 +- .../teamApplications/ApplicationsFilter.tsx | 48 ++++ .../ApplicationsList.tsx | 232 +++++++----------- .../teamApplications/RoleSelector.tsx | 46 ++++ .../teamApplications/StatusSelector.tsx | 46 ++++ .../teamApplications/StatusUpdater.tsx | 21 ++ src/app/equipo/page.tsx | 7 +- src/app/equipo/unirse/ApplyForm.tsx | 16 +- src/app/equipo/unirse/page.tsx | 4 +- src/utilities/teamApplication.ts | 34 +-- 12 files changed, 268 insertions(+), 275 deletions(-) delete mode 100644 src/app/api/team-applications/[id]/route.ts delete mode 100644 src/app/api/team-applications/route.ts create mode 100644 src/app/dashboard/_components/teamApplications/ApplicationsFilter.tsx rename src/app/dashboard/_components/{ => teamApplications}/ApplicationsList.tsx (52%) create mode 100644 src/app/dashboard/_components/teamApplications/RoleSelector.tsx create mode 100644 src/app/dashboard/_components/teamApplications/StatusSelector.tsx create mode 100644 src/app/dashboard/_components/teamApplications/StatusUpdater.tsx diff --git a/src/app/api/team-applications/[id]/route.ts b/src/app/api/team-applications/[id]/route.ts deleted file mode 100644 index 205529e..0000000 --- a/src/app/api/team-applications/[id]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { handleError } from '@/utilities/apiRoutes' -import { teamApplicationDataSchema, teamApplicationParamsSchema, type TeamApplicationRouteData } from '@/utilities/teamApplication' -import { deleteTeamApplication, getTeamApplication, updateTeamApplication } from 'entgamers-database/backend/teamApplication' -import { NextResponse } from 'next/server' - -export const GET = async (Request: Request, { params }: TeamApplicationRouteData): Promise => { - try { - const { id } = await teamApplicationParamsSchema.validate(params) - const teamApplication = await getTeamApplication({ where: { id } }) - if (teamApplication === null) { - return new NextResponse(null, { status: 404 }) - } - return NextResponse.json(teamApplication, { status: 200 }) - } catch (error) { - return handleError(error) - } -} - -export const PUT = async (Request: Request, { params }: TeamApplicationRouteData): Promise => { - try { - const body: unknown = await Request.json() - const { id } = await teamApplicationParamsSchema.validate(params) - const teamApplicationData = await teamApplicationDataSchema.validate(body) - - const updatedTeamApplication = await updateTeamApplication({ where: { id }, data: teamApplicationData }) - - const response = NextResponse.json(updatedTeamApplication, { status: 200 }) - return response - } catch (error) { - return handleError(error) - } -} - -export const DELETE = async (Request: Request, { params }: TeamApplicationRouteData): Promise => { - try { - const { id } = await teamApplicationParamsSchema.validate(params) - - await deleteTeamApplication({ where: { id } }) - - return new NextResponse(null, { status: 204 }) - } catch (error) { - return handleError(error) - } -} diff --git a/src/app/api/team-applications/route.ts b/src/app/api/team-applications/route.ts deleted file mode 100644 index 75e5e6f..0000000 --- a/src/app/api/team-applications/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { handleError } from '@/utilities/apiRoutes' -import { teamApplicationDataSchema, teamApplicationSearchParamsSchema } from '@/utilities/teamApplication' -import { createTeamApplication, getTeamApplications, type TeamApplication } from 'entgamers-database/backend/teamApplication' -import { NextResponse, type NextRequest } from 'next/server' - -export const GET = async (request: NextRequest): Promise => { - try { - const searchParams = request.nextUrl.searchParams.entries() - const validatedParams = await teamApplicationSearchParamsSchema.validate(Object.fromEntries(searchParams)) - const getTeamApplicationsParams = { - skip: validatedParams.skip, - take: validatedParams.take, - where: { - name: validatedParams['where[name]'], - email: validatedParams['where[email]'], - discord: validatedParams['where[discord]'], - role: validatedParams['where[role]'] as TeamApplication['role' ] | undefined, - status: validatedParams['where[status]'] - } - } - - const teamApplications = await getTeamApplications(getTeamApplicationsParams) - - const response = NextResponse.json(teamApplications, { status: 200 }) - return response - } catch (error) { - return handleError(error) - } -} - -export const POST = async (request: NextRequest): Promise => { - try { - const body = await request.json() - const createTeamApplicationData = await teamApplicationDataSchema.validate(body) - - const teamApplication = await createTeamApplication({ data: createTeamApplicationData }) - - const response = NextResponse.json(teamApplication, { status: 201 }) - return response - } catch (error) { - return handleError(error) - } -} diff --git a/src/app/dashboard/_components/TeamApplications.tsx b/src/app/dashboard/_components/TeamApplications.tsx index 2a0d547..26d0854 100644 --- a/src/app/dashboard/_components/TeamApplications.tsx +++ b/src/app/dashboard/_components/TeamApplications.tsx @@ -1,4 +1,4 @@ -import ApplicationsList from '@/app/dashboard/_components/ApplicationsList' +import ApplicationsList from '@/app/dashboard/_components/teamApplications/ApplicationsList' import Typography from '@/components/ui/Typography' import { type FC } from 'react' diff --git a/src/app/dashboard/_components/teamApplications/ApplicationsFilter.tsx b/src/app/dashboard/_components/teamApplications/ApplicationsFilter.tsx new file mode 100644 index 0000000..062d632 --- /dev/null +++ b/src/app/dashboard/_components/teamApplications/ApplicationsFilter.tsx @@ -0,0 +1,48 @@ +import DebouncedInput from '@/components/ui/form/DebouncedInput' +import { type Column } from '@tanstack/react-table' +import { type TeamApplication, type TeamApplicationRole, type TeamApplicationStatus } from 'entgamers-database/types/teamApplications' +import { type FC } from 'react' +import RoleSelector from './RoleSelector' +import StatusSelector from './StatusSelector' + +export interface ApplicationsFilterProps { + column: Column +} + +const ApplicationsFilter: FC = ({ column }) => { + const columnFilterValue = column.getFilterValue() + switch (column.id) { + case 'status': + return ( + { column.setFilterValue(value) }} + allowEmpty + /> + ) + case 'role': + return ( + { column.setFilterValue(value) }} + allowEmpty + /> + ) + default: + return ( + { column.setFilterValue(value) }} + placeholder="Buscar..." + className="w-36 border shadow rounded" + list={column.id + 'list'} + /> + ) + } +} + +export default ApplicationsFilter diff --git a/src/app/dashboard/_components/ApplicationsList.tsx b/src/app/dashboard/_components/teamApplications/ApplicationsList.tsx similarity index 52% rename from src/app/dashboard/_components/ApplicationsList.tsx rename to src/app/dashboard/_components/teamApplications/ApplicationsList.tsx index 5840046..0679a07 100644 --- a/src/app/dashboard/_components/ApplicationsList.tsx +++ b/src/app/dashboard/_components/teamApplications/ApplicationsList.tsx @@ -1,120 +1,40 @@ import IconButton from '@/components/ui/IconButton' import { Table, TableBody, TableCell, TableContainer, TableHead, TableHeadCell, TableRow } from '@/components/ui/Table' -import DebouncedInput from '@/components/ui/form/DebouncedInput' import useManageError from '@/hooks/useManageError' import { css } from '@/styled-system/css' import { formatDate } from '@/utilities/date' import { type TeamApplication, type TeamApplicationList } from '@/utilities/teamApplication' import { faChevronLeft, faChevronRight, faSort, faSortAsc, faSortDesc } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { rankItem } from '@tanstack/match-sorter-utils' -import { createColumnHelper, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type Column, type FilterFn, type Table as TableType } from '@tanstack/react-table' -import { useCallback, useEffect, useMemo, useState, type FC, type ReactNode } from 'react' +import { createColumnHelper, flexRender, getCoreRowModel, useReactTable, type ColumnFiltersState, type PaginationState, type RowData, type SortingState } from '@tanstack/react-table' +import { getAllTeamApplications, updateTeamApplication } from 'entgamers-database/frontend/database/teamApplications' +import { Query } from 'entgamers-database/lib/appwrite' +import { useEffect, useState, type FC } from 'react' +import ApplicationsFilter from './ApplicationsFilter' +import StatusUpdater from './StatusUpdater' -const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { - // Rank the item - const itemRank = rankItem(row.getValue(columnId), value as string) - - // Store the itemRank info - addMeta({ - itemRank - }) - - // Return if the item should be filtered in/out - return itemRank.passed -} - -function Filter ({ - column, - table -}: { - column: Column - table: TableType -}): ReactNode { - const firstValue = table - .getPreFilteredRowModel() - .flatRows[0]?.getValue(column.id) - - const columnFilterValue = column.getFilterValue() - - const sortedUniqueValues = useMemo(() => typeof firstValue === 'number' - ? [] - // eslint-disable-next-line @typescript-eslint/require-array-sort-compare - : Array.from(column.getFacetedUniqueValues().keys()).sort() - , [column.getFacetedUniqueValues()]) - - return typeof firstValue === 'number' - ? ( -
-
- { column.setFilterValue((old: [number, number]) => [value, old?.[1]]) } - } - placeholder={`Min ${ - ((column.getFacetedMinMaxValues()?.[0]) != null) - ? `(${column.getFacetedMinMaxValues()?.[0]})` - : '' - }`} - className="w-24 border shadow rounded" - /> - { column.setFilterValue((old: [number, number]) => [old?.[0], value]) } - } - placeholder={`Max ${ - ((column.getFacetedMinMaxValues()?.[1]) != null) - ? `(${column.getFacetedMinMaxValues()?.[1]})` - : '' - }`} - className="w-24 border shadow rounded" - /> -
-
-
- ) - : ( - <> - - {sortedUniqueValues.slice(0, 5000).map((value: any) => ( - - { column.setFilterValue(value) }} - placeholder={`Search... (${column.getFacetedUniqueValues().size})`} - className="w-36 border shadow rounded" - list={column.id + 'list'} - /> -
- - ) +declare module '@tanstack/table-core' { + interface TableMeta { + updateRow: (id: string, value: Partial) => Promise + } } const columnHelper = createColumnHelper() const columns = [ - columnHelper.accessor('id', { - header: 'ID' + columnHelper.accessor('$id', { + header: 'ID', + enableColumnFilter: false }), columnHelper.accessor('status', { header: 'Estado', - enableSorting: false + cell: StatusUpdater, + getUniqueValues () { + return ['Pending', 'Accepted', 'Rejected'] + } }), columnHelper.accessor('role', { - header: 'Rol', - enableSorting: false + header: 'Rol' }), columnHelper.accessor('name', { header: 'Nombre' @@ -129,14 +49,16 @@ const columns = [ columnHelper.accessor('discord', { header: 'Discord' }), - columnHelper.accessor('createdAt', { + columnHelper.accessor('$createdAt', { header: 'Creado', + enableColumnFilter: false, cell: (info) => { return formatDate(new Date(info.getValue())) } }), - columnHelper.accessor('updatedAt', { + columnHelper.accessor('$updatedAt', { header: 'Actualizado', + enableColumnFilter: false, cell: (info) => { return formatDate(new Date(info.getValue())) } @@ -145,46 +67,65 @@ const columns = [ const ApplicationsList: FC = () => { const { manageError } = useManageError() - const [applications, setApplications] = useState([]) + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) + const [sorting, setSorting] = useState([{ id: '$createdAt', desc: true }]) + const [applications, setApplications] = useState({ total: 0, documents: [] }) + const [columnFilters, setColumnFilters] = useState([{ id: 'status', value: 'Pending' }]) const table = useReactTable({ - data: applications, + data: applications.documents, columns, - filterFns: { - fuzzy: fuzzyFilter - }, initialState: { columnVisibility: { - id: false - }, - sorting: [{ id: 'createdAt', desc: true }] + $id: false, + email: false + } }, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel() + state: { + pagination, + sorting, + columnFilters + }, + meta: { + updateRow: async (id: string, value: Partial) => { + const updatedTeamApplication = await updateTeamApplication(id, value) + const newApplications = applications.documents.map((application) => application.$id === updatedTeamApplication.$id ? updatedTeamApplication : application) + setApplications({ total: applications.total, documents: newApplications }) + } + }, + manualPagination: true, + rowCount: applications.total, + onPaginationChange: setPagination, + enableSorting: true, + manualSorting: true, + onSortingChange: setSorting, + manualFiltering: true, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel() }) - const getTeamApplications = useCallback(async (controller?: AbortController) => { - const callController = controller ?? new AbortController() - const response = await fetch('/api/team-applications', { signal: callController.signal }) - if (response.ok) { - const teamApplicationList: TeamApplicationList = await response.json() - setApplications(teamApplicationList.teamApplications) - } - }, []) - useEffect(() => { - const controller = new AbortController() - getTeamApplications(controller) + const query: string[] = [ + Query.limit(pagination.pageSize), + Query.offset(pagination.pageIndex * pagination.pageSize) + ] + if (sorting.length > 0) { + const sort: string = sorting[0].desc ? Query.orderDesc(sorting[0].id) : Query.orderAsc(sorting[0].id) + query.push(sort) + } + if (columnFilters.length > 0) { + const filter: string[] = columnFilters.map((columnFilter) => { + return Query.contains(columnFilter.id, columnFilter.value as string) + }) + query.push(...filter) + } + getAllTeamApplications(query) + .then((applicationList) => { setApplications(applicationList) }) .catch((error) => { if (error instanceof Error && error.name === 'AbortError') return manageError(error, 'Error al obtener las aplicaciones', 'Error desconocido al obtener las aplicaciones', 'error') }) - return () => { - controller.abort() - } - }, []) + }, [pagination, sorting, columnFilters]) // TODO: Better UI Controls for: column visibility. Quantity selector. return ( @@ -218,6 +159,7 @@ const ApplicationsList: FC = () => { [data-is-resizing]': { backgroundColor: 'border' @@ -228,26 +170,30 @@ const ApplicationsList: FC = () => {
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) } - {header.column.getCanSort() && ( - - - - )} -
-
+
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) } + {header.column.getCanSort() && ( + + + + )} +
{header.column.getCanFilter() ? ( -
- -
+ ) : null } diff --git a/src/app/dashboard/_components/teamApplications/RoleSelector.tsx b/src/app/dashboard/_components/teamApplications/RoleSelector.tsx new file mode 100644 index 0000000..e0ccde4 --- /dev/null +++ b/src/app/dashboard/_components/teamApplications/RoleSelector.tsx @@ -0,0 +1,46 @@ +import { css } from '@/styled-system/css' +import { type TeamApplicationRole } from 'entgamers-database/types/teamApplications' +import { type FC } from 'react' + +export interface RoleSelectorProps { + id: string + value: TeamApplicationRole + onChange: (role: TeamApplicationRole) => void + allowEmpty?: boolean +} + +const RoleSelector: FC = ({ id, value, onChange, allowEmpty }) => { + /* TODO: Change for Select UI Component when it's ready */ + return ( + + ) +} + +export default RoleSelector diff --git a/src/app/dashboard/_components/teamApplications/StatusSelector.tsx b/src/app/dashboard/_components/teamApplications/StatusSelector.tsx new file mode 100644 index 0000000..3aee1eb --- /dev/null +++ b/src/app/dashboard/_components/teamApplications/StatusSelector.tsx @@ -0,0 +1,46 @@ +import { css } from '@/styled-system/css' +import { type TeamApplicationStatus } from 'entgamers-database/types/teamApplications' +import { type FC } from 'react' + +export interface StatusSelectorProps { + id: string + value: TeamApplicationStatus + onChange: (status: TeamApplicationStatus) => void + allowEmpty?: boolean +} + +const StatusSelector: FC = ({ id, value, onChange, allowEmpty }) => { + /* TODO: Change for Select UI Component when it's ready */ + return ( + + ) +} + +export default StatusSelector diff --git a/src/app/dashboard/_components/teamApplications/StatusUpdater.tsx b/src/app/dashboard/_components/teamApplications/StatusUpdater.tsx new file mode 100644 index 0000000..7c9972a --- /dev/null +++ b/src/app/dashboard/_components/teamApplications/StatusUpdater.tsx @@ -0,0 +1,21 @@ +import { type CellContext } from '@tanstack/react-table' +import { type TeamApplication, type TeamApplicationStatus } from 'entgamers-database/types/teamApplications' +import { type FC } from 'react' +import StatusSelector from './StatusSelector' + +const StatusUpdater: FC> = ({ cell: { id, row }, table }) => { + return ( + <> + { + table.options.meta?.updateRow(row.original.$id, { status }) + .catch(console.error) + }} + /> + + ) +} + +export default StatusUpdater diff --git a/src/app/equipo/page.tsx b/src/app/equipo/page.tsx index ff3e39e..64f1e1b 100644 --- a/src/app/equipo/page.tsx +++ b/src/app/equipo/page.tsx @@ -4,8 +4,9 @@ import { Container } from '@/styled-system/jsx' import { center } from '@/styled-system/patterns' import { button, card } from '@/styled-system/recipes' import { getClanMembers } from 'entgamers-database/backend/clanes' -import { ADMIN_CLAN_ID, COLLABORATOR_CLAN_ID, MODERATOR_CLAN_ID, ensureAdministrativeClans } from 'entgamers-database/backend/clanes/administrative' -import { getUser, type UserList } from 'entgamers-database/backend/users' +import { getUser } from 'entgamers-database/backend/users' +import { ADMIN_CLAN_ID, COLLABORATOR_CLAN_ID, MODERATOR_CLAN_ID } from 'entgamers-database/lib/env' +import { type UserList } from 'entgamers-database/types/user' import NextImage from 'next/image' import NextLink from 'next/link' import { type Models } from 'node-appwrite' @@ -18,7 +19,7 @@ interface GetTeamsResponse { } const getTeams = async (): Promise => { - await ensureAdministrativeClans() + // await ensureAdministrativeClans() const adminMembers: Models.MembershipList = await getClanMembers(ADMIN_CLAN_ID) const moderatorMembers: Models.MembershipList = await getClanMembers(MODERATOR_CLAN_ID) diff --git a/src/app/equipo/unirse/ApplyForm.tsx b/src/app/equipo/unirse/ApplyForm.tsx index ddf1edd..d3b3ebd 100644 --- a/src/app/equipo/unirse/ApplyForm.tsx +++ b/src/app/equipo/unirse/ApplyForm.tsx @@ -8,11 +8,12 @@ import { useAppDispatch } from '@/hooks/useAppDispatch' import useManageError from '@/hooks/useManageError' import { addAlert } from '@/state/feedbackSlice' import { css } from '@/styled-system/css' -import { teamApplicationDataSchema, type TeamApplicationData } from '@/utilities/teamApplication' +import { teamApplicationDataSchema } from '@/utilities/teamApplication' import { faChevronRight } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon, type FontAwesomeIconProps } from '@fortawesome/react-fontawesome' import { nanoid } from '@reduxjs/toolkit' import { ADMIN_CLAN_ID, COLLABORATOR_CLAN_ID, MODERATOR_CLAN_ID } from 'entgamers-database/frontend/clanes/administrative' +import { createTeamApplication, type TeamApplicationData } from 'entgamers-database/frontend/database/teamApplications' import { useFormik } from 'formik' import { AnimatePresence, motion } from 'framer-motion' import { useSearchParams } from 'next/navigation' @@ -23,24 +24,17 @@ const ApplyForm: FC = () => { const { manageError } = useManageError() const dispatch = useAppDispatch() - const formik = useFormik({ + const formik = useFormik >({ initialValues: { name: '', email: '', discord: '', message: '', - role: 'Moderator', - status: 'Pending' + role: 'Moderator' }, onSubmit: async (values) => { try { - await fetch('/api/team-applications', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(values) - }) + await createTeamApplication(values) dispatch(addAlert({ id: nanoid(), title: 'Formulario enviado', diff --git a/src/app/equipo/unirse/page.tsx b/src/app/equipo/unirse/page.tsx index 72a139a..1e436e7 100644 --- a/src/app/equipo/unirse/page.tsx +++ b/src/app/equipo/unirse/page.tsx @@ -1,9 +1,11 @@ import Typography from '@/components/ui/Typography' import { Container } from '@/styled-system/jsx' +import { ensureTeamApplicationsCollection } from 'entgamers-database/backend/database/teamApplications' import { type FC } from 'react' import ApplyForm from './ApplyForm' -const EquipoUnirsePage: FC = () => { +const EquipoUnirsePage: FC = async () => { + await ensureTeamApplicationsCollection() return ( Únete al Bosque diff --git a/src/utilities/teamApplication.ts b/src/utilities/teamApplication.ts index 69fc684..35c48e4 100644 --- a/src/utilities/teamApplication.ts +++ b/src/utilities/teamApplication.ts @@ -1,45 +1,21 @@ -import { type PaginationOptions } from '@/types/api' -import { type TeamApplication, type TeamApplicationList } from 'entgamers-database/backend/teamApplication' -import { number, object, string, type ObjectSchema } from 'yup' +import type { TeamApplication, TeamApplicationList } from 'entgamers-database/types/teamApplications' +import { object, string, type ObjectSchema } from 'yup' export interface TeamApplicationDynamicParams { id: string } -export interface TeamApplicationRouteData { - params: TeamApplicationDynamicParams -} - -export interface TeamApplicationSearchParams extends PaginationOptions { - 'where[name]'?: string - 'where[email]'?: string - 'where[discord]'?: string - 'where[role]'?: string - 'where[status]'?: string -} - -export type TeamApplicationData = Omit export { type TeamApplication, type TeamApplicationList } -export const teamApplicationDataSchema: ObjectSchema = object({ +export const teamApplicationDataSchema = object({ name: string().required('El nombre es obligatorio'), email: string().email('Invalid email').required('El email es obligatorio'), discord: string().required('El discord es obligatorio'), - message: string().required('El mensaje es obligatorio'), - role: string().oneOf(['Admin', 'Moderator', 'Collaborator', 'User'], 'Rol inválido').required('El rol es obligatorio'), + message: string().required('El mensaje es obligatorio').max(4096, 'El mensaje debe ser menor a 4096 caracteres'), + role: string().oneOf(['Admin', 'Moderator', 'Collaborator'], 'Role inválido').required('El rol es obligatorio'), status: string().default('Pending').oneOf(['Pending', 'Accepted', 'Rejected'], 'Status inválido') }) export const teamApplicationParamsSchema: ObjectSchema = object({ id: string().required('El id es obligatorio') }) - -export const teamApplicationSearchParamsSchema: ObjectSchema = object({ - skip: number().optional().transform((value) => Number(value)), - take: number().optional().transform((value) => Number(value)), - 'where[name]': string().optional(), - 'where[email]': string().optional(), - 'where[discord]': string().optional(), - 'where[role]': string().optional().oneOf(['Admin', 'Moderator', 'Collaborator', 'User'], 'Rol inválido'), - 'where[status]': string().optional().oneOf(['Pending', 'Accepted', 'Rejected'], 'Status inválido') -}).partial()