Break collection pages into hooks (#414)

This refactors the collection pages (teams, saved and profiles) into a
bunch of hooks that handle various chunks of functionality. This way,
the actual "pages" have significantly less logic and significantly less
repeated code.

* **useFavorites** handles favoriting teams
* **useFilterState** handles the URL query filters
* **usePaginationState** simply holds data pertaining to pagination
* **useFetchTeams** handles fetching and parsing team data from the
server
* **useTeamFilter** pulls all other states together and handles some
logic that is closest to the page

Co-authored-by: Justin Edmund <383021+jedmund@users.noreply.github.com>
This commit is contained in:
Justin Edmund 2024-04-21 00:46:04 -07:00 committed by GitHub
parent 4dc2279d68
commit fc616aab01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 812 additions and 769 deletions

View file

@ -1,4 +1,5 @@
{
"git.ignoreLimitWarning": true,
"i18n-ally.localesPaths": ["public/locales"]
"i18n-ally.localesPaths": ["public/locales"],
"i18n-ally.keystyle": "nested"
}

View file

@ -19,7 +19,6 @@ interface Props {
defaultFilterset: FilterSet
persistFilters?: boolean
children: React.ReactNode
scrolled: boolean
element?: number
raid?: string
raidGroups: RaidGroup[]
@ -32,6 +31,8 @@ const FilterBar = (props: Props) => {
// Set up translation
const { t } = useTranslation('common')
const [scrolled, setScrolled] = useState(false)
const [currentRaid, setCurrentRaid] = useState<Raid>()
const [recencyOpen, setRecencyOpen] = useState(false)
@ -44,7 +45,7 @@ const FilterBar = (props: Props) => {
// Set up classes object for showing shadow on scroll
const classes = classNames({
[styles.filterBar]: true,
[styles.shadow]: props.scrolled,
[styles.shadow]: scrolled,
})
const filterButtonClasses = classNames({
@ -52,6 +53,17 @@ const FilterBar = (props: Props) => {
filtersActive: !matchesDefaultFilters,
})
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
function handleScroll() {
if (window.scrollY > 90) setScrolled(true)
else setScrolled(false)
}
// Convert raid slug to Raid object on mount
useEffect(() => {
const raid = appState.raidGroups

View file

@ -27,7 +27,7 @@ import type { SearchableObject, SearchableObjectArray } from '~types'
import styles from './index.module.scss'
import CrossIcon from '~public/icons/Cross.svg'
import classNames from 'classnames'
import useDidMountEffect from '~utils/useDidMountEffect'
import useDidMountEffect from '~hooks/useDidMountEffect'
interface Props extends DialogProps {
send: (object: SearchableObject, position: number) => any

50
hooks/useFavorites.tsx Normal file
View file

@ -0,0 +1,50 @@
import clonedeep from 'lodash.clonedeep'
import api from '~utils/api'
import { PageContextObj } from '~types'
export const useFavorites = (
parties: Party[],
setParties: (value: Party[]) => void
) => {
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
else saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId }).then((response) => {
if (response.status == 201) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId }).then((response) => {
if (response.status == 200) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
return {
toggleFavorite,
}
}

146
hooks/useFetchTeams.tsx Normal file
View file

@ -0,0 +1,146 @@
import { useCallback, useState } from 'react'
import api from '~utils/api'
export const useFetchTeams = (
currentPage: number,
filters: { [key: string]: any },
parties: Party[],
setParties: (value: Party[]) => void,
setTotalPages: (value: number) => void,
setRecordCount: (value: number) => void
) => {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(false)
function parseTeams(response: { [key: string]: any }, replace: boolean) {
const { parties, meta } = response
setTotalPages(meta.total_pages)
setRecordCount(meta.count)
if (replace) {
replaceResults(parties)
setIsLoading(false)
} else appendResults(parties)
}
function parseError(error: any) {
setIsLoading(false)
setError(true)
}
function processTeams(list: Party[], shouldReplace: boolean) {
if (shouldReplace) {
replaceResults(list)
} else {
appendResults(list)
}
}
function replaceResults(list: Party[]) {
if (list.length > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)))
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
function createParams() {
return {
params: Object.entries(filters).reduce((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value
}
return acc
}, {} as { [key: string]: any }),
}
}
const fetchTeams = useCallback(
({ replace } = { replace: false }) => {
if (replace) setIsLoading(true)
const params = createParams()
api.endpoints.parties
.getAll(params)
.then((response) => {
const formedResponse = {
parties: response.data.results,
meta: response.data.meta,
}
return parseTeams(formedResponse, replace)
})
.catch(parseError)
},
[filters, currentPage]
)
const fetchProfile = useCallback(
({
username,
replace,
}: {
username: string | undefined
replace: boolean
}) => {
if (replace) setIsLoading(true)
const params = createParams()
if (username && !Array.isArray(username)) {
api.endpoints.users
.getOne({
id: username,
params: params,
})
.then((response) => {
const formedResponse = {
parties: response.data.profile.parties,
meta: response.data.meta,
}
return parseTeams(formedResponse, replace)
})
.catch(parseError)
}
},
[currentPage, filters]
)
const fetchSaved = useCallback(
({ replace } = { replace: false }) => {
if (replace) setIsLoading(true)
const params = createParams()
api
.savedTeams(params)
.then((response) => {
const formedResponse = {
parties: response.data.results,
meta: response.data.meta,
}
return parseTeams(formedResponse, replace)
})
.catch(parseError)
},
[filters, currentPage]
)
return {
fetchTeams,
fetchProfile,
fetchSaved,
processTeams,
isLoading,
setIsLoading,
error,
}
}

56
hooks/useFilterState.tsx Normal file
View file

@ -0,0 +1,56 @@
import { useState, useEffect } from 'react'
import { getCookie } from 'cookies-next'
import { useQueryState } from 'nuqs'
import { defaultFilterset } from '~utils/defaultFilters'
import { parseElement, serializeElement } from '~utils/parseElement'
import type { PageContextObj } from '~types'
export const useFilterState = (context?: PageContextObj) => {
const [element, setElement] = useQueryState('element', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raid, setRaid] = useQueryState('raid', {
defaultValue: 'all',
history: 'push',
parse: (query: string) => {
const raids = context?.raidGroups.flatMap((group) => group.raids)
const raid = raids?.find((r: Raid) => r.slug === query)
return raid ? raid.id : 'all'
},
serialize: (value) => value,
})
const [recency, setRecency] = useQueryState('recency', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseInt(query),
serialize: (value) => `${value}`,
})
const [advancedFilters, setAdvancedFilters] = useState(defaultFilterset)
useEffect(() => {
const filtersCookie = getCookie('filters')
const filters = filtersCookie
? JSON.parse(filtersCookie as string)
: defaultFilterset
setAdvancedFilters(filters)
}, [])
return {
element,
setElement,
raid,
setRaid,
recency,
setRecency,
advancedFilters,
setAdvancedFilters,
}
}

View file

@ -0,0 +1,16 @@
import { useState } from 'react'
export const usePaginationState = () => {
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [recordCount, setRecordCount] = useState(0)
return {
currentPage,
setCurrentPage,
totalPages,
setTotalPages,
recordCount,
setRecordCount,
}
}

182
hooks/useTeamFilter.tsx Normal file
View file

@ -0,0 +1,182 @@
// Libraries
import { useState, useCallback, useEffect } from 'react'
import { getCookie } from 'cookies-next'
// Hooks
import { useFetchTeams } from './useFetchTeams'
import { useFilterState } from './useFilterState'
import { usePaginationState } from './usePaginationState'
import useDidMountEffect from './useDidMountEffect'
// Utils
import { CollectionPage } from '~utils/enums'
import { convertAdvancedFilters } from '~utils/convertAdvancedFilters'
import { defaultFilterset } from '~utils/defaultFilters'
// Types
import type { PageContextObj, PaginationObject } from '~types'
export const useTeamFilter = (
page: CollectionPage,
context?: PageContextObj
) => {
const [mounted, setMounted] = useState(false)
const [parties, setParties] = useState<Party[]>([])
function constructFilters({
element,
raid,
recency,
currentPage,
filterSet,
}: {
element: number
raid: string
recency: number
currentPage: number
filterSet: FilterSet
}) {
const filters: { [key: string]: any } = {
element: element !== -1 ? element : undefined,
raid: raid === 'all' ? undefined : raid,
recency: recency !== -1 ? recency : undefined,
page: currentPage,
...convertAdvancedFilters(filterSet),
}
Object.keys(filters).forEach(
(key) => filters[key] === undefined && delete filters[key]
)
return filters
}
const {
element,
setElement,
raid,
setRaid,
recency,
setRecency,
advancedFilters,
setAdvancedFilters,
} = useFilterState(context)
const {
currentPage,
setCurrentPage,
totalPages,
setTotalPages,
recordCount,
setRecordCount,
} = usePaginationState()
const {
fetchTeams,
fetchProfile,
fetchSaved,
processTeams,
isLoading: isFetching,
setIsLoading: setFetching,
error: fetchError,
} = useFetchTeams(
currentPage,
constructFilters({
element,
raid,
recency,
currentPage,
filterSet: advancedFilters,
}),
parties,
setParties,
setTotalPages,
setRecordCount
)
// Update the advancedFilters state based on cookies when the component mounts
useEffect(() => {
const filtersCookie = getCookie('filters')
const filters = filtersCookie
? JSON.parse(filtersCookie as string)
: defaultFilterset
setAdvancedFilters(filters)
}, [])
// Handle pagination object updates from fetchTeams
const setPagination = useCallback((value: PaginationObject) => {
setTotalPages(value.totalPages)
setRecordCount(value.count)
}, [])
useDidMountEffect(() => {}, [currentPage])
useEffect(() => {
if (context && context.teams && context.pagination) {
setTotalPages(context.pagination.totalPages)
setRecordCount(context.pagination.count)
// processTeams(context.teams, context.pagination.count, true)
}
setCurrentPage(1)
setFetching(false)
}, [])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
if (mounted) fetch(true)
setMounted(true)
}, [element, raid, recency, advancedFilters])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
if (currentPage > 1) fetch(false)
else if (currentPage == 1 && mounted) fetch(true)
setMounted(true)
}, [currentPage])
function fetch(replace: boolean) {
switch (page) {
case CollectionPage.Teams:
return fetchTeams({ replace: replace })
case CollectionPage.Profile:
return fetchProfile({
username: context?.user?.username,
replace: replace,
})
case CollectionPage.Saved:
return fetchSaved({
replace: replace,
})
}
}
return {
// Expose the states and setters for the component to use
element,
setElement,
raid,
setRaid,
recency,
setRecency,
advancedFilters,
setAdvancedFilters,
parties,
setParties,
isFetching,
setFetching,
fetchError,
fetchTeams,
processTeams,
currentPage,
setCurrentPage,
totalPages,
recordCount,
setPagination,
}
}

View file

@ -1,37 +1,42 @@
import React, { useCallback, useEffect, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { useQueryState } from 'nuqs'
// Libraries
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import InfiniteScroll from 'react-infinite-scroll-component'
import api from '~utils/api'
import extractFilters from '~utils/extractFilters'
// Hooks
import { useFavorites } from '~hooks/useFavorites'
import { useTeamFilter } from '~hooks/useTeamFilter'
import useDidMountEffect from '~hooks/useDidMountEffect'
// Utils
import fetchLatestVersion from '~utils/fetchLatestVersion'
import { setHeaders } from '~utils/userToken'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { convertAdvancedFilters } from '~utils/convertAdvancedFilters'
import { CollectionPage } from '~utils/enums'
import { permissiveFilterset } from '~utils/defaultFilters'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { setHeaders } from '~utils/userToken'
import {
fetchRaidGroupsAndFilters,
fetchUserProfile,
parseAdvancedFilters,
} from '~utils/serverSideUtils'
// Types
import type { AxiosError } from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { PageContextObj, ResponseStatus } from '~types'
// Components
import ErrorSection from '~components/ErrorSection'
import FilterBar from '~components/filters/FilterBar'
import GridRep from '~components/reps/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import LoadingRep from '~components/reps/LoadingRep'
import ErrorSection from '~components/ErrorSection'
import FilterBar from '~components/filters/FilterBar'
import ProfileHead from '~components/head/ProfileHead'
import UserInfo from '~components/filters/UserInfo'
import type { AxiosError } from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import type {
FilterObject,
PageContextObj,
PaginationObject,
ResponseStatus,
} from '~types'
interface Props {
context?: PageContextObj
version: AppUpdate
@ -52,144 +57,48 @@ const ProfileRoute: React.FC<Props> = ({
// Import translations
const { t } = useTranslation('common')
// Set up app-specific states
const [mounted, setMounted] = useState(false)
const [scrolled, setScrolled] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const {
element,
setElement,
raid,
setRaid,
recency,
setRecency,
advancedFilters,
setAdvancedFilters,
currentPage,
setCurrentPage,
totalPages,
recordCount,
parties,
setParties,
isFetching,
setFetching,
fetchError,
fetchTeams,
processTeams,
setPagination,
} = useTeamFilter(CollectionPage.Profile, context)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState('element', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raid, setRaid] = useQueryState('raid', {
defaultValue: 'all',
history: 'push',
parse: (query: string) => {
const raids = context?.raidGroups.flatMap((group) => group.raids)
const raid = raids?.find((r: Raid) => r.slug === query)
return raid ? raid.id : 'all'
},
serialize: (value) => value,
})
const [recency, setRecency] = useQueryState('recency', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseInt(query),
serialize: (value) => `${value}`,
})
const [advancedFilters, setAdvancedFilters] =
useState<FilterSet>(permissiveFilterset)
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
query === 'all'
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ''
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
return name
}
const { toggleFavorite } = useFavorites(parties, setParties)
// Set the initial parties from props
useEffect(() => {
if (context && context.teams && context.pagination) {
setTotalPages(context.pagination.totalPages)
setRecordCount(context.pagination.count)
replaceResults(context.pagination.count, context.teams)
appState.raidGroups = context.raidGroups
appState.version = version
useDidMountEffect(() => {
if (context) {
if (context.teams && context.pagination) {
processTeams(context.teams, true)
setPagination(context.pagination)
appState.raidGroups = context.raidGroups
appState.version = version
}
}
setCurrentPage(1)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
// TODO: Put an alert here
console.error('There was an error.')
}
}, [])
const fetchProfile = useCallback(
({ replace }: { replace: boolean }) => {
if (replace) setIsLoading(true)
const filters = {
params: {
element: element != -1 ? element : undefined,
raid: raid === 'all' ? undefined : raid,
recency: recency != -1 ? recency : undefined,
page: currentPage,
...advancedFilters,
},
}
if (username && !Array.isArray(username)) {
api.endpoints.users
.getOne({
id: username,
params: { ...filters },
})
.then((response) => {
const results = response.data.profile.parties
const meta = response.data.meta
setTotalPages(meta.total_pages)
setRecordCount(meta.count)
if (replace) {
setIsLoading(false)
replaceResults(meta.count, results)
} else appendResults(results)
})
.catch((error) => handleError(error))
}
},
[currentPage, username, parties, element, raid, recency, advancedFilters]
)
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)))
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
setFetching(false)
}, [context])
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
@ -197,26 +106,6 @@ const ProfileRoute: React.FC<Props> = ({
setRaids(raids)
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
if (mounted) {
fetchProfile({ replace: true })
}
setMounted(true)
}, [username, element, raid, recency, advancedFilters])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
// Current page changed
if (currentPage > 1) fetchProfile({ replace: false })
else if (currentPage == 1) fetchProfile({ replace: true })
setMounted(true)
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters(filters: FilterSet) {
if (filters.element == 0) setElement(0, { shallow: true })
@ -230,10 +119,6 @@ const ProfileRoute: React.FC<Props> = ({
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90) setScrolled(true)
else setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
@ -249,7 +134,7 @@ const ProfileRoute: React.FC<Props> = ({
else return <div />
}
// TODO: Add save functions
// Page component rendering methods
function renderParties() {
return parties.map((party, i) => {
@ -257,12 +142,14 @@ const ProfileRoute: React.FC<Props> = ({
<GridRep
party={party}
key={`party-${i}`}
loading={isLoading}
loading={isFetching}
onClick={goTo}
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
/>
)
})
}
function renderLoading(number: number) {
return (
<GridRepCollection>
@ -293,7 +180,6 @@ const ProfileRoute: React.FC<Props> = ({
onAdvancedFilter={receiveAdvancedFilters}
onFilter={receiveFilters}
persistFilters={false}
scrolled={scrolled}
element={element}
raid={raid}
raidGroups={context.raidGroups}
@ -337,46 +223,24 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
const version = await fetchLatestVersion()
try {
// Fetch and organize raids
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
// We don't pre-load advanced filters here
const { raidGroups, filters } = await fetchRaidGroupsAndFilters(query)
// Create filter object
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...permissiveFilterset },
}
// Set up empty variables
let user: User | undefined = undefined
let teams: Party[] | undefined = undefined
let pagination: PaginationObject = emptyPaginationObject
let context: PageContextObj | undefined = undefined
// Perform a request only if we received a username
if (query.username) {
const response = await api.endpoints.users.getOne({
id: query.username,
params,
})
const { user, teams, pagination } = await fetchUserProfile(
query.username,
filters
)
// Assign values to pass to props
user = response.data.profile
if (response.data.profile.parties) teams = response.data.profile.parties
else teams = []
pagination.count = response.data.meta.count
pagination.totalPages = response.data.meta.total_pages
pagination.perPage = response.data.meta.per_page
}
// Consolidate data into context object
const context: PageContextObj = {
user: user,
teams: teams,
raidGroups: raidGroups,
pagination: pagination,
context = {
user: user,
teams: teams,
raidGroups: raidGroups,
pagination: pagination,
}
}
// Pass to the page component as props

View file

@ -1,37 +1,41 @@
import React, { useCallback, useEffect, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { useQueryState } from 'nuqs'
// Libraries
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import clonedeep from 'lodash.clonedeep'
import InfiniteScroll from 'react-infinite-scroll-component'
import api from '~utils/api'
import { setHeaders } from '~utils/userToken'
import extractFilters from '~utils/extractFilters'
// Hooks
import { useFavorites } from '~hooks/useFavorites'
import { useTeamFilter } from '~hooks/useTeamFilter'
import useDidMountEffect from '~hooks/useDidMountEffect'
// Utils
import fetchLatestVersion from '~utils/fetchLatestVersion'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { convertAdvancedFilters } from '~utils/convertAdvancedFilters'
import { CollectionPage } from '~utils/enums'
import { permissiveFilterset } from '~utils/defaultFilters'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { setHeaders } from '~utils/userToken'
import {
fetchRaidGroupsAndFilters,
fetchSaved,
parseAdvancedFilters,
} from '~utils/serverSideUtils'
// Types
import type { AxiosError } from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import type { PageContextObj, ResponseStatus } from '~types'
// Components
import ErrorSection from '~components/ErrorSection'
import FilterBar from '~components/filters/FilterBar'
import GridRep from '~components/reps/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import LoadingRep from '~components/reps/LoadingRep'
import FilterBar from '~components/filters/FilterBar'
import SavedHead from '~components/head/SavedHead'
import type { AxiosError } from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import type {
FilterObject,
PageContextObj,
PaginationObject,
ResponseStatus,
} from '~types'
interface Props {
context?: PageContextObj
version: AppUpdate
@ -51,148 +55,48 @@ const SavedRoute: React.FC<Props> = ({
// Import translations
const { t } = useTranslation('common')
// Set up app-specific states
const [mounted, setMounted] = useState(false)
const [scrolled, setScrolled] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const {
element,
setElement,
raid,
setRaid,
recency,
setRecency,
advancedFilters,
setAdvancedFilters,
currentPage,
setCurrentPage,
totalPages,
recordCount,
parties,
setParties,
isFetching,
setFetching,
fetchError,
fetchTeams,
processTeams,
setPagination,
} = useTeamFilter(CollectionPage.Saved, context)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState('element', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raid, setRaid] = useQueryState('raid', {
defaultValue: 'all',
history: 'push',
parse: (query: string) => {
const raids = context?.raidGroups.flatMap((group) => group.raids)
const raid = raids?.find((r: Raid) => r.slug === query)
return raid ? raid.id : 'all'
},
serialize: (value) => value,
})
const [recency, setRecency] = useQueryState('recency', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseInt(query),
serialize: (value) => `${value}`,
})
const [advancedFilters, setAdvancedFilters] =
useState<FilterSet>(permissiveFilterset)
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
query === 'all'
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ''
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
return name
}
const { toggleFavorite } = useFavorites(parties, setParties)
// Set the initial parties from props
useEffect(() => {
if (context && context.teams && context.pagination) {
setTotalPages(context.pagination.totalPages)
setRecordCount(context.pagination.count)
replaceResults(context.pagination.count, context.teams)
appState.raidGroups = context.raidGroups
appState.version = version
useDidMountEffect(() => {
if (context) {
if (context.teams && context.pagination) {
processTeams(context.teams, true)
setPagination(context.pagination)
appState.raidGroups = context.raidGroups
appState.version = version
}
}
setCurrentPage(1)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error('There was an error.')
}
}, [])
const fetchTeams = useCallback(
({ replace }: { replace: boolean }) => {
if (replace) setIsLoading(true)
const filters: {
[key: string]: any
} = {
element: element !== -1 ? element : undefined,
raid: raid === 'all' ? undefined : raid,
recency: recency !== -1 ? recency : undefined,
page: currentPage,
...advancedFilters,
}
Object.keys(filters).forEach(
(key) => filters[key] === undefined && delete filters[key]
)
const params = {
params: {
...filters,
},
}
api
.savedTeams(params)
.then((response) => {
const results = response.data.results
const meta = response.data.meta
setTotalPages(meta.total_pages)
setRecordCount(meta.count)
if (replace) {
setIsLoading(false)
replaceResults(meta.count, results)
} else appendResults(results)
})
.catch((error) => handleError(error))
},
[currentPage, parties, element, raid, recency, advancedFilters]
)
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list)
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
setFetching(false)
}, [context])
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
@ -200,26 +104,6 @@ const SavedRoute: React.FC<Props> = ({
setRaids(raids)
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
if (mounted) {
fetchTeams({ replace: true })
}
setMounted(true)
}, [element, raid, recency, advancedFilters])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
// Current page changed
if (currentPage > 1) fetchTeams({ replace: false })
else if (currentPage == 1) fetchTeams({ replace: true })
setMounted(true)
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters(filters: FilterSet) {
if (filters.element == 0) setElement(0, { shallow: true })
@ -232,49 +116,7 @@ const SavedRoute: React.FC<Props> = ({
setAdvancedFilters(filters)
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
else saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId }).then((response) => {
if (response.status == 201) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId }).then((response) => {
if (response.status == 200) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties.splice(index, 1)
setParties(clonedParties)
}
})
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90) setScrolled(true)
else setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
@ -290,13 +132,15 @@ const SavedRoute: React.FC<Props> = ({
else return <div />
}
// Page component rendering methods
function renderParties() {
return parties.map((party, i) => {
return (
<GridRep
party={party}
key={`party-${i}`}
loading={isLoading}
loading={isFetching}
onClick={goTo}
onSave={toggleFavorite}
/>
@ -334,7 +178,6 @@ const SavedRoute: React.FC<Props> = ({
onFilter={receiveFilters}
onAdvancedFilter={receiveAdvancedFilters}
persistFilters={false}
scrolled={scrolled}
element={element}
raid={raid}
raidGroups={context.raidGroups}
@ -378,42 +221,14 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
const version = await fetchLatestVersion()
try {
// Fetch and organize raids
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
// We don't pre-load advanced filters here
const { raidGroups, filters } = await fetchRaidGroupsAndFilters(query)
const { teams, pagination } = await fetchSaved(filters)
// Create filter object
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...permissiveFilterset },
}
// Set up empty variables
let teams: Party[] | undefined = undefined
let pagination: PaginationObject = emptyPaginationObject
// Fetch initial set of saved parties
const response = await api.savedTeams(params)
// Assign values to pass to props
teams = response.data.results
pagination.count = response.data.meta.count
pagination.totalPages = response.data.meta.total_pages
pagination.perPage = response.data.meta.per_page
// Consolidate data into context object
const context: PageContextObj = {
teams: teams,
raidGroups: raidGroups,
pagination: pagination,
}
// Pass to the page component as props
return {
props: {
context: context,
version: version,
context: { teams, raidGroups, pagination },
version,
error: false,
...(await serverSideTranslations(locale, ['common'])),
},

View file

@ -1,38 +1,40 @@
import React, { useCallback, useEffect, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { getCookie } from 'cookies-next'
import { useQueryState } from 'nuqs'
// Libraries
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import clonedeep from 'lodash.clonedeep'
import axios, { AxiosResponse } from 'axios'
import InfiniteScroll from 'react-infinite-scroll-component'
import api from '~utils/api'
import { setHeaders } from '~utils/userToken'
import extractFilters from '~utils/extractFilters'
// Hooks
import { useFavorites } from '~hooks/useFavorites'
import { useTeamFilter } from '~hooks/useTeamFilter'
import useDidMountEffect from '~hooks/useDidMountEffect'
// Utils
import fetchLatestVersion from '~utils/fetchLatestVersion'
import useDidMountEffect from '~utils/useDidMountEffect'
import { appState } from '~utils/appState'
import { defaultFilterset } from '~utils/defaultFilters'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { convertAdvancedFilters } from '~utils/convertAdvancedFilters'
import { defaultFilterset } from '~utils/defaultFilters'
import { setHeaders } from '~utils/userToken'
import {
fetchParties,
fetchRaidGroupsAndFilters,
parseAdvancedFilters,
} from '~utils/serverSideUtils'
// Types
import type { NextApiRequest, NextApiResponse } from 'next'
import type { PageContextObj, ResponseStatus } from '~types'
// Components
import ErrorSection from '~components/ErrorSection'
import FilterBar from '~components/filters/FilterBar'
import GridRep from '~components/reps/GridRep'
import GridRepCollection from '~components/reps/GridRepCollection'
import LoadingRep from '~components/reps/LoadingRep'
import FilterBar from '~components/filters/FilterBar'
import TeamsHead from '~components/head/TeamsHead'
import type { AxiosError } from 'axios'
import type { NextApiRequest, NextApiResponse } from 'next'
import type {
FilterObject,
PageContextObj,
PaginationObject,
ResponseStatus,
} from '~types'
import { CollectionPage } from '~utils/enums'
interface Props {
context?: PageContextObj
@ -47,166 +49,51 @@ const TeamsRoute: React.FC<Props> = ({
error,
status,
}: Props) => {
// Set up router
const router = useRouter()
// Import translations
const { t } = useTranslation('common')
// Set up app-specific states
const [mounted, setMounted] = useState(false)
const [scrolled, setScrolled] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Set up page-specific states
const [parties, setParties] = useState<Party[]>([])
const [raids, setRaids] = useState<Raid[]>()
// Set up infinite scrolling-related states
const [recordCount, setRecordCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const {
element,
setElement,
raid,
setRaid,
recency,
setRecency,
advancedFilters,
setAdvancedFilters,
currentPage,
setCurrentPage,
totalPages,
recordCount,
parties,
setParties,
isFetching,
setFetching,
fetchError,
fetchTeams,
processTeams,
setPagination,
} = useTeamFilter(CollectionPage.Teams, context)
// Set up filter-specific query states
// Recency is in seconds
const [element, setElement] = useQueryState('element', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseElement(query),
serialize: (value) => serializeElement(value),
})
const [raid, setRaid] = useQueryState('raid', {
defaultValue: 'all',
history: 'push',
parse: (query: string) => {
const raids = context?.raidGroups.flatMap((group) => group.raids)
const raid = raids?.find((r: Raid) => r.slug === query)
return raid ? raid.id : 'all'
},
serialize: (value) => value,
})
const [recency, setRecency] = useQueryState('recency', {
defaultValue: -1,
history: 'push',
parse: (query: string) => parseInt(query),
serialize: (value) => `${value}`,
})
const [advancedFilters, setAdvancedFilters] =
useState<FilterSet>(defaultFilterset)
// Define transformers for element
function parseElement(query: string) {
let element: TeamElement | undefined =
query === 'all'
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
function serializeElement(value: number | undefined) {
let name = ''
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
return name
}
const { toggleFavorite } = useFavorites(parties, setParties)
// Set the initial parties from props
useEffect(() => {
if (context && context.teams && context.pagination) {
setTotalPages(context.pagination.totalPages)
setRecordCount(context.pagination.count)
replaceResults(context.pagination.count, context.teams)
appState.raidGroups = context.raidGroups
appState.version = version
useDidMountEffect(() => {
if (context) {
if (context.teams && context.pagination) {
processTeams(context.teams, true)
setPagination(context.pagination)
appState.raidGroups = context.raidGroups
appState.version = version
}
}
setCurrentPage(1)
setIsLoading(false)
}, [])
// Add scroll event listener for shadow on FilterBar on mount
useEffect(() => {
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Fetch the user's advanced filters
useEffect(() => {
const filtersCookie = getCookie('filters')
const filters = filtersCookie
? JSON.parse(filtersCookie as string)
: defaultFilterset
setAdvancedFilters(filters)
}, [])
// Handle errors
const handleError = useCallback((error: any) => {
if (error.response != null) {
console.error(error)
} else {
console.error('There was an error.')
}
}, [])
const fetchTeams = useCallback(
({ replace }: { replace: boolean }) => {
if (replace) setIsLoading(true)
const filters: {
[key: string]: any
} = {
element: element !== -1 ? element : undefined,
raid: raid === 'all' ? undefined : raid,
recency: recency !== -1 ? recency : undefined,
page: currentPage,
...convertAdvancedFilters(advancedFilters),
}
Object.keys(filters).forEach(
(key) => filters[key] === undefined && delete filters[key]
)
const params = {
params: {
...filters,
},
}
api.endpoints.parties
.getAll(params)
.then((response) => {
const results = response.data.results
const meta = response.data.meta
setTotalPages(meta.total_pages)
setRecordCount(meta.count)
if (replace) {
replaceResults(meta.count, results)
setIsLoading(false)
} else appendResults(results)
})
.catch((error) => handleError(error))
},
[currentPage, parties, element, raid, recency, advancedFilters]
)
function replaceResults(count: number, list: Party[]) {
if (count > 0) {
setParties(list.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)))
} else {
setParties([])
}
}
function appendResults(list: Party[]) {
setParties([...parties, ...list])
}
setFetching(false)
}, [context])
// Fetch all raids on mount, then find the raid in the URL if present
useEffect(() => {
@ -214,26 +101,6 @@ const TeamsRoute: React.FC<Props> = ({
setRaids(raids)
}, [setRaids])
// When the element, raid or recency filter changes,
// fetch all teams again.
useDidMountEffect(() => {
setCurrentPage(1)
if (mounted) {
fetchTeams({ replace: true })
}
setMounted(true)
}, [element, raid, recency, advancedFilters])
// When the page changes, fetch all teams again.
useDidMountEffect(() => {
// Current page changed
if (currentPage > 1) fetchTeams({ replace: false })
else if (currentPage == 1 && mounted) fetchTeams({ replace: true })
setMounted(true)
}, [currentPage])
// Receive filters from the filter bar
function receiveFilters(filters: FilterSet) {
if (filters.element == 0) setElement(0, { shallow: true })
@ -246,49 +113,7 @@ const TeamsRoute: React.FC<Props> = ({
setAdvancedFilters(filters)
}
// Methods: Favorites
function toggleFavorite(teamId: string, favorited: boolean) {
if (favorited) unsaveFavorite(teamId)
else saveFavorite(teamId)
}
function saveFavorite(teamId: string) {
api.saveTeam({ id: teamId }).then((response) => {
if (response.status == 201) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = true
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
function unsaveFavorite(teamId: string) {
api.unsaveTeam({ id: teamId }).then((response) => {
if (response.status == 200) {
const index = parties.findIndex((p) => p.id === teamId)
const party = parties[index]
party.favorited = false
let clonedParties = clonedeep(parties)
clonedParties[index] = party
setParties(clonedParties)
}
})
}
// Methods: Navigation
function handleScroll() {
if (window.pageYOffset > 90) setScrolled(true)
else setScrolled(false)
}
function goTo(shortcode: string) {
router.push(`/p/${shortcode}`)
@ -304,24 +129,23 @@ const TeamsRoute: React.FC<Props> = ({
else return <div />
}
// Page component rendering methods
function renderParties() {
return parties.map((party, i) => {
return (
<GridRep
party={party}
key={`party-${i}`}
loading={isLoading}
onClick={goTo}
onSave={toggleFavorite}
/>
)
})
return parties.map((party, i) => (
<GridRep
party={party}
key={`party-${i}`}
loading={isFetching}
onClick={() => goTo(party.shortcode)}
onSave={(teamId, favorited) => toggleFavorite(teamId, favorited)}
/>
))
}
function renderLoading(number: number) {
return (
<GridRepCollection>
{Array.from(Array(number)).map((x, i) => (
{Array.from({ length: number }, (_, i) => (
<LoadingRep key={`loading-${i}`} />
))}
</GridRepCollection>
@ -348,7 +172,6 @@ const TeamsRoute: React.FC<Props> = ({
onFilter={receiveFilters}
onAdvancedFilter={receiveAdvancedFilters}
persistFilters={true}
scrolled={scrolled}
element={element}
raid={raid}
raidGroups={context.raidGroups}
@ -381,65 +204,35 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Fetch latest version
const version = await fetchLatestVersion()
// Fetch user's advanced filters
const filtersCookie = getCookie('filters', { req: req, res: res })
const advancedFilters = filtersCookie ? JSON.parse(filtersCookie as string) : undefined
const convertedFilters = advancedFilters ? convertAdvancedFilters(advancedFilters) : undefined
try {
// Fetch and organize raids
let raidGroups: RaidGroup[] = await api
.raidGroups()
.then((response) => response.data)
const advancedFilters = parseAdvancedFilters(req, res)
const convertedFilters = advancedFilters
? convertAdvancedFilters(advancedFilters)
: undefined
const { raidGroups, filters } = await fetchRaidGroupsAndFilters(query)
const { teams, pagination } = await fetchParties(filters, convertedFilters)
// Create filter object
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...convertedFilters },
}
// Set up empty variables
let teams: Party[] | undefined = undefined
let pagination: PaginationObject = emptyPaginationObject
// Fetch initial set of parties
const response = await api.endpoints.parties.getAll(params)
// Assign values to pass to props
teams = response.data.results
pagination.count = response.data.meta.count
pagination.totalPages = response.data.meta.total_pages
pagination.perPage = response.data.meta.per_page
// Consolidate data into context object
const context: PageContextObj = {
teams: teams,
raidGroups: raidGroups,
pagination: pagination,
}
// Pass to the page component as props
return {
props: {
context: context,
version: version,
context: { teams, raidGroups, pagination },
version,
error: false,
...(await serverSideTranslations(locale, ['common'])),
},
}
} catch (error) {
// Extract the underlying Axios error
const axiosError = error as AxiosError
const response = axiosError.response
// Pass to the page component as props
// If error is of type AxiosError, extract the response into a variable
let response: AxiosResponse<any, any> | undefined = axios.isAxiosError(error)
? error.response
: undefined
return {
props: {
context: null,
error: true,
status: {
code: response ? response.status : -999,
text: response ? response.statusText : "unspecified_error",
code: response?.status ?? -999,
text: response?.statusText ?? 'unspecified_error',
},
...(await serverSideTranslations(locale, ['common'])),
},

View file

@ -16,3 +16,8 @@ interface FilterSet {
includes?: MentionItem[]
excludes?: MentionItem[]
}
interface ConvertedFilters extends Omit<FilterSet, 'includes' | 'excludes'> {
includes: string
excludes: string
}

View file

@ -1,21 +1,21 @@
import cloneDeep from 'lodash.clonedeep'
export function convertAdvancedFilters(filters: FilterSet) {
let copy = cloneDeep(filters)
export function convertAdvancedFilters(filters: FilterSet): ConvertedFilters {
let copy: FilterSet = cloneDeep(filters)
const includes = filterString(filters.includes || [])
const excludes = filterString(filters.excludes || [])
const includes: string = filterString(filters.includes || [])
const excludes: string = filterString(filters.excludes || [])
delete copy.includes
delete copy.excludes
delete (copy as any).includes
delete (copy as any).excludes
return {
...copy,
includes,
excludes,
}
} as ConvertedFilters
}
export function filterString(list: MentionItem[]) {
export function filterString(list: MentionItem[]): string {
return list.map((item) => item.granblue_id).join(',')
}

View file

@ -24,3 +24,9 @@ export enum AboutTabs {
Updates,
Roadmap,
}
export enum CollectionPage {
Teams,
Profile,
Saved,
}

20
utils/parseElement.tsx Normal file
View file

@ -0,0 +1,20 @@
import { elements, allElement } from '~data/elements'
export function parseElement(query: string) {
let element: TeamElement | undefined =
query === 'all'
? allElement
: elements.find((element) => element.name.en.toLowerCase() === query)
return element ? element.id : -1
}
export function serializeElement(value: number | undefined) {
let name = ''
if (value != undefined) {
if (value == -1) name = allElement.name.en.toLowerCase()
else name = elements[value].name.en.toLowerCase()
}
return name
}

77
utils/serverSideUtils.tsx Normal file
View file

@ -0,0 +1,77 @@
import { getCookie } from 'cookies-next'
import { NextApiRequest, NextApiResponse } from 'next'
import api from './api'
import extractFilters from './extractFilters'
import { FilterObject } from '~types'
import { permissiveFilterset } from './defaultFilters'
// Parse advanced filters from cookies
export function parseAdvancedFilters(
req: NextApiRequest,
res: NextApiResponse
) {
const filtersCookie = getCookie('filters', { req, res })
return filtersCookie ? JSON.parse(filtersCookie as string) : undefined
}
// Fetch raid groups and create filter object
export async function fetchRaidGroupsAndFilters(query: {
[index: string]: string
}) {
const raidGroups = await api.raidGroups().then((response) => response.data)
const filters = extractFilters(query, raidGroups)
return { raidGroups, filters }
}
// Fetch initial set of parties
export async function fetchParties(
filters: FilterObject,
convertedFilters: ConvertedFilters | undefined
) {
const params = { params: { ...filters, ...convertedFilters } }
const response = await api.endpoints.parties.getAll(params)
return {
teams: response.data.results,
pagination: {
count: response.data.meta.count,
totalPages: response.data.meta.total_pages,
perPage: response.data.meta.per_page,
},
}
}
export async function fetchUserProfile(
username: string,
filters: FilterObject
) {
const params = { params: { ...filters, ...permissiveFilterset } }
const response = await api.endpoints.users.getOne({
id: username,
params,
})
return {
user: response.data.profile,
teams: response.data.profile.parties,
pagination: {
count: response.data.meta.count,
totalPages: response.data.meta.total_pages,
perPage: response.data.meta.per_page,
},
}
}
export async function fetchSaved(filters: FilterObject) {
const params = { params: { ...filters, ...permissiveFilterset } }
const response = await api.savedTeams(params)
return {
teams: response.data.results,
pagination: {
count: response.data.meta.count,
totalPages: response.data.meta.total_pages,
perPage: response.data.meta.per_page,
},
}
}