diff --git a/src/routes/raids/ViewRaid.tsx b/src/routes/raids/ViewRaid.tsx index 58f75dd..bcdc300 100644 --- a/src/routes/raids/ViewRaid.tsx +++ b/src/routes/raids/ViewRaid.tsx @@ -6,7 +6,8 @@ import styled from '@emotion/styled' import UserStatBlock from '#components/userStatBlock' import Emoji from '#components/emoji' import LoadingSpinner from '#components/loadingSpinner' -import useDocument from '#utils/useDocument' +import { RAID_STATS } from '#utils/firestoreCollections' +import useFirestoreQuery, { to } from '#utils/useFirestoreQuery' const userStatSorts: { [key: string]: (a: UserStats, b: UserStats) => number @@ -19,12 +20,20 @@ const userStatSortNames = Object.keys(userStatSorts) const ViewRaid = () => { const { raidId } = useParams<{ raidId: string }>() - const documentData = useDocument('raid-stats', raidId) + const documentQuery = useFirestoreQuery((firestore) => + firestore.collection(RAID_STATS).withConverter(to()).doc(), + ) const [currentSort, setCurrentSort] = useState(userStatSortNames[0]) - if (documentData.state === 'success') { - const data = documentData.data + if (documentQuery.status === 'success') { + const data = documentQuery.data + + if (!data) + return ( +

Couldn`t find that Raid - did you fall into the wrong dungeon?

+ ) + return ( <$StatsView> <$Header> @@ -84,14 +93,10 @@ const ViewRaid = () => { ) - } else if (documentData.state === 'error') { - return

{JSON.stringify(documentData.error)}

+ } else if (documentQuery.status === 'error') { + return

{JSON.stringify(documentQuery.error)}

} else { - return documentData.state === 'loading' ? ( - - ) : ( -

Couldn`t find that Raid - did you fall into the wrong dungeon?

- ) + return } } diff --git a/src/routes/raids/index.tsx b/src/routes/raids/index.tsx index 8e55f72..2a7687e 100644 --- a/src/routes/raids/index.tsx +++ b/src/routes/raids/index.tsx @@ -2,13 +2,16 @@ import React from 'react' import { Link } from 'react-router-dom' import LoadingSpinner from '#components/loadingSpinner' -import useCollection from '#utils/useCollection' +import useFirestoreQuery, { to } from '#utils/useFirestoreQuery' +import { RAID_STATS } from '#utils/firestoreCollections' const AllRaids = () => { - const collectionData = useCollection('raid-stats') + const collectionQuery = useFirestoreQuery((firestore) => + firestore.collection(RAID_STATS).withConverter(to()), + ) - if (collectionData.state === 'success') { - const data = collectionData.data + if (collectionQuery.status === 'success') { + const data = collectionQuery.data return ( <>

Raids

@@ -40,7 +43,7 @@ const AllRaids = () => { ) } - return collectionData.state === 'loading' ? ( + return collectionQuery.status === 'loading' ? ( ) : ( // Theoretically this can/should never be hit... But a fun message nonetheless diff --git a/src/utils/firestoreCollections.ts b/src/utils/firestoreCollections.ts new file mode 100644 index 0000000..a6ab158 --- /dev/null +++ b/src/utils/firestoreCollections.ts @@ -0,0 +1 @@ +export const RAID_STATS = 'raid-stats' diff --git a/src/utils/useCollection/index.ts b/src/utils/useCollection/index.ts deleted file mode 100644 index 9941801..0000000 --- a/src/utils/useCollection/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useState, useEffect } from 'react' -import firestore from '#utils/useFirestore' - -function useCollection( - collectionName: string, -): UseFirestoreData[]> { - const [collectionData, setCollectionData] = useState< - UseFirestoreData[]> - >({ - state: 'loading', - data: null, - error: null, - }) - - useEffect(() => { - firestore - .collection(collectionName) - .get() - .then((snapshot) => { - if (!snapshot.empty) { - setCollectionData({ - state: 'success', - data: snapshot.docs.map>( - (s) => - ({ - id: s.id, - ...s.data(), - } as DocumentWithId), - ), - error: null, - }) - } else { - setCollectionData({ - state: 'not-found', - data: null, - error: null, - }) - } - }) - .catch((error) => { - setCollectionData({ - state: 'error', - data: null, - error, - }) - }) - }, [collectionName]) - - return collectionData -} - -export default useCollection diff --git a/src/utils/useDocument/index.ts b/src/utils/useDocument/index.ts deleted file mode 100644 index bca187e..0000000 --- a/src/utils/useDocument/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useState, useEffect } from 'react' -import firestore from '#utils/useFirestore' - -function useDocument( - collectionName: string, - documentId: string, -): UseFirestoreData> { - const [documentData, setDocumentData] = useState< - UseFirestoreData> - >({ - state: 'loading', - data: null, - error: null, - }) - - useEffect(() => { - firestore - .collection(collectionName) - .doc(documentId) - .get() - .then((snapshot) => { - if (snapshot.exists) { - setDocumentData({ - state: 'success', - data: { - ...snapshot.data(), - id: snapshot.id, - } as DocumentWithId, - error: null, - }) - } else { - setDocumentData({ - state: 'not-found', - data: null, - error: null, - }) - } - }) - .catch((error) => { - setDocumentData({ - state: 'error', - data: null, - error: error, - }) - }) - }, [collectionName, documentId]) - - return documentData -} - -export default useDocument diff --git a/src/utils/useFirestore/index.ts b/src/utils/useFirestore/index.ts deleted file mode 100644 index 8317c17..0000000 --- a/src/utils/useFirestore/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import firebase from 'firebase/app' -import 'firebase/firestore' - -const firebaseConfig = { - apiKey: 'AIzaSyCAgs6SNew9kKKFgQh7NLkqHK1n9Akq-GM', - authDomain: 'raid-stats-c1d5a.firebaseapp.com', - databaseURL: 'https://raid-stats-c1d5a-default-rtdb.firebaseio.com', - projectId: 'raid-stats-c1d5a', - storageBucket: 'raid-stats-c1d5a.appspot.com', - messagingSenderId: '47482470658', - appId: '1:47482470658:web:bd07aa5f9e1b0df3c2c21b', -} - -if (!firebase.apps.length) { - firebase.initializeApp(firebaseConfig) -} - -const firestore = firebase.firestore() - -// Comment out the following to pull from the live firestore DB -if (import.meta.env.NODE_ENV !== 'production') { - firestore.useEmulator('localhost', 8080) -} - -export default firestore diff --git a/src/utils/useFirestoreQuery.ts b/src/utils/useFirestoreQuery.ts new file mode 100644 index 0000000..7fa92c3 --- /dev/null +++ b/src/utils/useFirestoreQuery.ts @@ -0,0 +1,209 @@ +import { useReducer, useEffect } from 'react' +import useMemoCompare from './useMemoCompare' + +import firebase from 'firebase/app' +import 'firebase/firestore' + +const firebaseConfig = { + apiKey: 'AIzaSyCAgs6SNew9kKKFgQh7NLkqHK1n9Akq-GM', + authDomain: 'raid-stats-c1d5a.firebaseapp.com', + databaseURL: 'https://raid-stats-c1d5a-default-rtdb.firebaseio.com', + projectId: 'raid-stats-c1d5a', + storageBucket: 'raid-stats-c1d5a.appspot.com', + messagingSenderId: '47482470658', + appId: '1:47482470658:web:bd07aa5f9e1b0df3c2c21b', +} + +if (!firebase.apps.length) { + firebase.initializeApp(firebaseConfig) +} + +const firestore = firebase.firestore() + +// Comment out the following to pull from the live firestore DB +if (import.meta.env.NODE_ENV !== 'production') { + firestore.useEmulator('localhost', 8080) +} + +export function to() { + return { + toFirestore(data: TData): firebase.firestore.DocumentData { + return data + }, + fromFirestore( + snapshot: firebase.firestore.QueryDocumentSnapshot, + options: firebase.firestore.SnapshotOptions, + ): TData { + return snapshot.data(options) as TData + }, + } +} + +const reducer = < + TQueryResult extends FirestoreQueryResultUnion, + TData +>( + state: FirestoreQueryState, + action: FirestoreQueryStateAction, +): FirestoreQueryState => { + switch (action.type) { + case 'idle': + case 'loading': + return { status: action.type, data: null, error: null } + case 'success': + return { + status: 'success', + data: action.payload as FirestoreQueryStateData, + error: null, + } + case 'error': + return { status: 'error', data: null, error: action.payload } + default: + throw new Error('invalid action') + } +} + +export default function useFirestoreQuery< + TQuery extends FirestoreQuery, + TQueryResult extends ReturnType, + TData extends FirestoreQueryDataType +>(query: TQuery): FirestoreQueryState { + const [state, dispatch] = useReducer< + FirestoreQueryReducer + >(reducer, { + status: 'loading', + data: null, + error: null, + }) + + // Get cached Firestore query object with useMemoCompare (https://usehooks.com/useMemoCompare) + // Needed because firestore.collection().doc() will always be a new object reference + // causing effect to run -> state change -> rerender -> effect runs -> etc ... + // This is nicer than requiring hook consumer to always memoize query with useMemo. + const queryCached = useMemoCompare(query(firestore), (prevQuery) => { + if (prevQuery && query) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + query.isEqual(prevQuery) + } + + return false + }) + + useEffect(() => { + if (!queryCached) { + dispatch({ type: 'idle' }) + return + } + + dispatch({ type: 'loading' }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return queryCached.onSnapshot( + ( + snapshot: + | firebase.firestore.QuerySnapshot + | firebase.firestore.DocumentSnapshot, + ) => { + if ('docs' in snapshot) { + dispatch({ + type: 'success', + payload: getCollectionData(snapshot), + }) + } else { + const data = getDocData(snapshot) + + dispatch({ type: 'success', payload: data }) // need something better for the null case... + } + }, + (error: firebase.firestore.FirestoreError) => { + dispatch({ type: 'error', payload: error }) + }, + ) + }, [queryCached]) // Only run effect if queryCached changes + + return state +} + +function getDocData( + docSnapshot: firebase.firestore.DocumentSnapshot, +): FirestoreDocumentData | null { + return docSnapshot.exists === true + ? { id: docSnapshot.id, ...(docSnapshot.data() as TData) } + : null +} + +function getCollectionData( + collectionSnapshot: firebase.firestore.QuerySnapshot, +) { + return collectionSnapshot.docs + .map(getDocData) + .filter((d) => d) as FirestoreDocumentData[] +} + +type FirestoreDocumentData = TData & { id: string } + +type FirestoreQueryResultUnion = + | firebase.firestore.Query + | firebase.firestore.CollectionReference + | firebase.firestore.DocumentReference + +type FirestoreQuery = ( + firestore: firebase.firestore.Firestore, +) => FirestoreQueryResultUnion + +type FirestoreQueryDataType< + TQuery extends FirestoreQuery +> = TQuery extends FirestoreQuery ? TData : never + +type FirestoreQueryState< + TQueryResult extends FirestoreQueryResultUnion, + TData +> = + | { + status: 'idle' | 'loading' + data: null + error: null + } + | { + status: 'success' + data: FirestoreQueryStateData + error: null + } + | { + status: 'error' + data: null + error: Error + } + +type FirestoreQueryStateData< + TQueryResult extends FirestoreQueryResultUnion, + TData +> = TQueryResult extends firebase.firestore.DocumentReference + ? FirestoreDocumentData | null + : FirestoreDocumentData[] + +type FirestoreQueryReducer< + TQueryResult extends FirestoreQueryResultUnion, + TData +> = ( + state: FirestoreQueryState, + action: FirestoreQueryStateAction, +) => FirestoreQueryState + +type FirestoreQueryStateAction = + | { + type: 'idle' | 'loading' + } + | { + type: 'success' + payload: + | FirestoreDocumentData + | FirestoreDocumentData[] + | null + } + | { + type: 'error' + payload: Error + } diff --git a/src/utils/useMemoCompare.ts b/src/utils/useMemoCompare.ts new file mode 100644 index 0000000..b63fc8f --- /dev/null +++ b/src/utils/useMemoCompare.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react' + +export default function useMemoCompare( + next: T, + compare: (previous: T | undefined, next: T | undefined) => boolean, +) { + // Ref for storing previous value + const previousRef = useRef() + const previous = previousRef.current + + // Pass previous and next value to compare function + // to determine whether to consider them equal. + const isEqual = compare(previous, next) + + // If not equal update previousRef to next value. + // We only update if not equal so that this hook continues to return + // the same old value if compare keeps returning true. + useEffect(() => { + if (!isEqual) { + previousRef.current = next + } + }) + + // Finally, if equal then return the previous value + return isEqual ? previous : next +} diff --git a/types/utils/firestore.d.ts b/types/utils/firestore.d.ts deleted file mode 100644 index 5b71703..0000000 --- a/types/utils/firestore.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -type UseFirestoreData = - | { - state: 'loading' | 'not-found' - data: null - error: null - } - | { - state: 'success' - data: TDocument - error: null - } - | { - state: 'error' - data: null - error: Error - } - -type DocumentWithId = TDocument & { id: string } diff --git a/types/viewRaidData.d.ts b/types/viewRaidData.d.ts index ffd7178..6d5dde4 100644 --- a/types/viewRaidData.d.ts +++ b/types/viewRaidData.d.ts @@ -8,6 +8,7 @@ interface UserStats { } interface ViewRaidData { + id: string dungeon: string title: string status: 'active' | 'completed'