From 199d0b4b854265d7540c34c433c719ade13e5af0 Mon Sep 17 00:00:00 2001 From: Tanner Gill Date: Sun, 29 Sep 2024 10:00:02 -0700 Subject: [PATCH] WIP --- loading_suggestion.patch | 214 +++++++++++++++++++++++++ src/App.tsx | 8 +- src/api/firebase.ts | 35 +++- src/views/authenticated/List.tsx | 28 +++- src/views/authenticated/ManageList.tsx | 19 ++- 5 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 loading_suggestion.patch diff --git a/loading_suggestion.patch b/loading_suggestion.patch new file mode 100644 index 0000000..8cd256d --- /dev/null +++ b/loading_suggestion.patch @@ -0,0 +1,214 @@ +diff --git a/src/App.tsx b/src/App.tsx +index 2b4f35f..6ef7f04 100644 +--- a/src/App.tsx ++++ b/src/App.tsx +@@ -48,7 +48,7 @@ export function App() { + * This custom hook takes our token and fetches the data for our list. + * Check ./api/firestore.js for its implementation. + */ +- const data = useShoppingListData(listPath); ++ const listState = useShoppingListData(listPath); + + return ( + <> +@@ -66,11 +66,13 @@ export function App() { + }> + } ++ element={} + /> + } ++ element={ ++ ++ } + /> + + +diff --git a/src/api/firebase.ts b/src/api/firebase.ts +index 8d7a524..4f2ce8e 100644 +--- a/src/api/firebase.ts ++++ b/src/api/firebase.ts +@@ -103,6 +103,17 @@ const ListItemModel = t.type({ + + export type ListItem = t.TypeOf; + ++export interface ListDataLoading { ++ type: "loading"; ++} ++ ++export interface ListData { ++ type: "data"; ++ items: ListItem[]; ++} ++ ++export type ListState = ListDataLoading | ListData; ++ + /** + * A custom hook that subscribes to a shopping list in our Firestore database + * and returns new data whenever the list changes. +@@ -111,10 +122,19 @@ export type ListItem = t.TypeOf; + export function useShoppingListData(listPath: string | null) { + // Start with an empty array for our data. + /** @type {import('firebase/firestore').DocumentData[]} */ +- const [data, setData] = useState([]); ++ const [state, setState] = useState({ ++ type: "loading", ++ }); + + useEffect(() => { +- if (!listPath) return; ++ if (!listPath) { ++ // If we don't have a listPath, there's inherently no data: no need to switch to a loading state. ++ setState({ type: "data", items: [] }); ++ return; ++ } ++ ++ // If the listPath has changed, anticipating some loading. ++ setState({ type: "loading" }); + + // When we get a listPath, we use it to subscribe to real-time updates + // from Firestore. +@@ -131,6 +151,8 @@ export function useShoppingListData(listPath: string | null) { + + const decoded = ListItemModel.decode(item); + if (isLeft(decoded)) { ++ // If we failed to decode the data, we don't want to leave the app in a loading state that will never resolve. ++ setState({ type: "data", items: [] }); + throw Error( + `Could not validate data: ${PathReporter.report(decoded).join("\n")}`, + ); +@@ -139,13 +161,16 @@ export function useShoppingListData(listPath: string | null) { + return decoded.right; + }); + +- // Update our React state with the new data. +- setData(nextData); ++ // Once we've received and deserialize the data, we can update our state. ++ setState({ ++ type: "data", ++ items: nextData, ++ }); + }); + }, [listPath]); + + // Return the data so it can be used by our React components. +- return data; ++ return state; + } + + // Designed to replace Firestore's User type in most contexts. +diff --git a/src/views/authenticated/List.tsx b/src/views/authenticated/List.tsx +index b48a442..64e2c31 100644 +--- a/src/views/authenticated/List.tsx ++++ b/src/views/authenticated/List.tsx +@@ -1,25 +1,28 @@ + import { useState, useMemo } from "react"; + import { ListItemCheckBox } from "../../components/ListItem"; + import { FilterListInput } from "../../components/FilterListInput"; +-import { ListItem, comparePurchaseUrgency } from "../../api"; ++import { ListState, comparePurchaseUrgency } from "../../api"; + import { useNavigate } from "react-router-dom"; + + interface Props { +- data: ListItem[]; ++ listState: ListState; + listPath: string | null; + } + +-export function List({ data: unfilteredListItems, listPath }: Props) { ++export function List({ listState, listPath }: Props) { + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(""); + + const filteredListItems = useMemo(() => { +- return unfilteredListItems ++ if (listState.type === "loading") { ++ return []; ++ } ++ return listState.items + .filter((item) => + item.name.toLowerCase().includes(searchTerm.toLowerCase()), + ) + .sort(comparePurchaseUrgency); +- }, [searchTerm, unfilteredListItems]); ++ }, [searchTerm, listState]); + + const Header = () => { + return ( +@@ -33,8 +36,19 @@ export function List({ data: unfilteredListItems, listPath }: Props) { + return
; + } + ++ if (listState.type === "loading") { ++ return ( ++ <> ++
++
++

Loading your list...

++
++ ++ ); ++ } ++ + // Early return if the list is empty +- if (unfilteredListItems.length === 0) { ++ if (listState.items.length === 0) { + return ( + <> +
+@@ -61,7 +75,7 @@ export function List({ data: unfilteredListItems, listPath }: Props) { +
+
+
+- {unfilteredListItems.length > 0 && ( ++ {listState.items.length > 0 && ( + { + return ( +

+@@ -20,10 +20,21 @@ export function ManageList({ listPath, data }: Props) { + return

; + } + ++ if (listState.type === "loading") { ++ return ( ++ <> ++
++
++

Loading your list...

++
++ ++ ); ++ } ++ + return ( +
+
+- ++ + +
+ ); diff --git a/src/App.tsx b/src/App.tsx index 2b4f35f..6ef7f04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,7 +48,7 @@ export function App() { * This custom hook takes our token and fetches the data for our list. * Check ./api/firestore.js for its implementation. */ - const data = useShoppingListData(listPath); + const listState = useShoppingListData(listPath); return ( <> @@ -66,11 +66,13 @@ export function App() { }> } + element={} /> } + element={ + + } /> diff --git a/src/api/firebase.ts b/src/api/firebase.ts index 8d7a524..4f2ce8e 100644 --- a/src/api/firebase.ts +++ b/src/api/firebase.ts @@ -103,6 +103,17 @@ const ListItemModel = t.type({ export type ListItem = t.TypeOf; +export interface ListDataLoading { + type: "loading"; +} + +export interface ListData { + type: "data"; + items: ListItem[]; +} + +export type ListState = ListDataLoading | ListData; + /** * A custom hook that subscribes to a shopping list in our Firestore database * and returns new data whenever the list changes. @@ -111,10 +122,19 @@ export type ListItem = t.TypeOf; export function useShoppingListData(listPath: string | null) { // Start with an empty array for our data. /** @type {import('firebase/firestore').DocumentData[]} */ - const [data, setData] = useState([]); + const [state, setState] = useState({ + type: "loading", + }); useEffect(() => { - if (!listPath) return; + if (!listPath) { + // If we don't have a listPath, there's inherently no data: no need to switch to a loading state. + setState({ type: "data", items: [] }); + return; + } + + // If the listPath has changed, anticipating some loading. + setState({ type: "loading" }); // When we get a listPath, we use it to subscribe to real-time updates // from Firestore. @@ -131,6 +151,8 @@ export function useShoppingListData(listPath: string | null) { const decoded = ListItemModel.decode(item); if (isLeft(decoded)) { + // If we failed to decode the data, we don't want to leave the app in a loading state that will never resolve. + setState({ type: "data", items: [] }); throw Error( `Could not validate data: ${PathReporter.report(decoded).join("\n")}`, ); @@ -139,13 +161,16 @@ export function useShoppingListData(listPath: string | null) { return decoded.right; }); - // Update our React state with the new data. - setData(nextData); + // Once we've received and deserialize the data, we can update our state. + setState({ + type: "data", + items: nextData, + }); }); }, [listPath]); // Return the data so it can be used by our React components. - return data; + return state; } // Designed to replace Firestore's User type in most contexts. diff --git a/src/views/authenticated/List.tsx b/src/views/authenticated/List.tsx index b48a442..64e2c31 100644 --- a/src/views/authenticated/List.tsx +++ b/src/views/authenticated/List.tsx @@ -1,25 +1,28 @@ import { useState, useMemo } from "react"; import { ListItemCheckBox } from "../../components/ListItem"; import { FilterListInput } from "../../components/FilterListInput"; -import { ListItem, comparePurchaseUrgency } from "../../api"; +import { ListState, comparePurchaseUrgency } from "../../api"; import { useNavigate } from "react-router-dom"; interface Props { - data: ListItem[]; + listState: ListState; listPath: string | null; } -export function List({ data: unfilteredListItems, listPath }: Props) { +export function List({ listState, listPath }: Props) { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(""); const filteredListItems = useMemo(() => { - return unfilteredListItems + if (listState.type === "loading") { + return []; + } + return listState.items .filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase()), ) .sort(comparePurchaseUrgency); - }, [searchTerm, unfilteredListItems]); + }, [searchTerm, listState]); const Header = () => { return ( @@ -33,8 +36,19 @@ export function List({ data: unfilteredListItems, listPath }: Props) { return
; } + if (listState.type === "loading") { + return ( + <> +
+
+

Loading your list...

+
+ + ); + } + // Early return if the list is empty - if (unfilteredListItems.length === 0) { + if (listState.items.length === 0) { return ( <>
@@ -61,7 +75,7 @@ export function List({ data: unfilteredListItems, listPath }: Props) {
- {unfilteredListItems.length > 0 && ( + {listState.items.length > 0 && ( { return (

@@ -20,10 +20,21 @@ export function ManageList({ listPath, data }: Props) { return

; } + if (listState.type === "loading") { + return ( + <> +
+
+

Loading your list...

+
+ + ); + } + return (
- +
);