From 5e3071db4f0db37f61b2bbcc0ec451df0a23119c Mon Sep 17 00:00:00 2001 From: Matt Dunn-Rankin Date: Sat, 25 Apr 2020 01:55:48 -0700 Subject: [PATCH] search infinite scroll! --- src/SearchPanel.js | 131 ++++++++++--- src/server/index.js | 28 ++- src/server/index.test.js | 386 ++++++++++++++++++++++++++++++--------- src/server/loaders.js | 25 ++- 4 files changed, 444 insertions(+), 126 deletions(-) diff --git a/src/SearchPanel.js b/src/SearchPanel.js index 41b89bc..2b5e614 100644 --- a/src/SearchPanel.js +++ b/src/SearchPanel.js @@ -7,7 +7,12 @@ import { Delay, Heading1, useDebounce } from "./util"; import ItemList, { ItemListSkeleton } from "./ItemList"; import { itemAppearanceFragment } from "./OutfitPreview"; -function SearchPanel({ query, outfitState, dispatchToOutfit }) { +function SearchPanel({ + query, + outfitState, + dispatchToOutfit, + getScrollParent, +}) { return ( Searching for "{query}" @@ -15,6 +20,7 @@ function SearchPanel({ query, outfitState, dispatchToOutfit }) { query={query} outfitState={outfitState} dispatchToOutfit={dispatchToOutfit} + getScrollParent={getScrollParent} /> ); @@ -25,29 +31,34 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) { const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true }); - const { loading, error, data, variables } = useQuery( + const { loading, error, data, fetchMore, variables } = useQuery( gql` - query($query: String!, $speciesId: ID!, $colorId: ID!) { + query($query: String!, $speciesId: ID!, $colorId: ID!, $offset: Int!) { itemSearchToFit( query: $query speciesId: $speciesId colorId: $colorId + offset: $offset + limit: 50 ) { - # TODO: De-dupe this from useOutfitState? - id - name - thumbnailUrl + query + items { + # TODO: De-dupe this from useOutfitState? + id + name + thumbnailUrl - appearanceOn(speciesId: $speciesId, colorId: $colorId) { - # This enables us to quickly show the item when the user clicks it! - ...AppearanceForOutfitPreview + appearanceOn(speciesId: $speciesId, colorId: $colorId) { + # This enables us to quickly show the item when the user clicks it! + ...AppearanceForOutfitPreview - # This is used to group items by zone, and to detect conflicts when - # wearing a new item. - layers { - zone { - id - label + # This is used to group items by zone, and to detect conflicts when + # wearing a new item. + layers { + zone { + id + label + } } } } @@ -56,12 +67,40 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) { ${itemAppearanceFragment} `, { - variables: { query: debouncedQuery, speciesId, colorId }, + variables: { query: debouncedQuery, speciesId, colorId, offset: 0 }, skip: debouncedQuery === null, + notifyOnNetworkStatusChange: true, } ); - if (loading || variables.query !== query) { + const result = data && data.itemSearchToFit; + const resultQuery = result && result.query; + const items = (result && result.items) || []; + + const onScrolledToBottom = React.useCallback(() => { + if (!loading) { + fetchMore({ + variables: { + offset: items.length, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + return { + ...prev, + itemSearchToFit: { + ...prev.itemSearchToFit, + items: [ + ...prev.itemSearchToFit.items, + ...fetchMoreResult.itemSearchToFit.items, + ], + }, + }; + }, + }); + } + }, [loading, fetchMore, items.length]); + + if (resultQuery !== query || (loading && items.length === 0)) { return ( @@ -81,8 +120,6 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) { ); } - const items = data.itemSearchToFit; - if (items.length === 0) { return ( @@ -96,12 +133,56 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) { } return ( - + + + {items && loading && } + ); } +function ScrollTracker({ children, threshold, onScrolledToBottom }) { + const containerRef = React.useRef(); + const scrollParent = React.useRef(); + + const onScroll = React.useCallback( + (e) => { + const topEdgeScrollPosition = e.target.scrollTop; + const bottomEdgeScrollPosition = + topEdgeScrollPosition + e.target.clientHeight; + const remainingScrollDistance = + e.target.scrollHeight - bottomEdgeScrollPosition; + if (remainingScrollDistance < threshold) { + onScrolledToBottom(); + } + }, + [onScrolledToBottom, threshold] + ); + + React.useLayoutEffect(() => { + if (!containerRef.current) { + return; + } + for (let el = containerRef.current; el.parentNode; el = el.parentNode) { + if (el.scrollHeight > el.clientHeight) { + scrollParent.current = el; + break; + } + } + + scrollParent.current.addEventListener("scroll", onScroll); + + return () => { + if (scrollParent.current) { + scrollParent.current.removeEventListener("scroll", onScroll); + } + }; + }, [onScroll]); + + return {children}; +} + export default SearchPanel; diff --git a/src/server/index.js b/src/server/index.js index 098ad7f..94908a6 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -34,10 +34,21 @@ const typeDefs = gql` label: String! } + type ItemSearchResult { + query: String! + items: [Item!]! + } + type Query { items(ids: [ID!]!): [Item!]! - itemSearch(query: String!): [Item!]! - itemSearchToFit(query: String!, speciesId: ID!, colorId: ID!): [Item!]! + itemSearch(query: String!): ItemSearchResult! + itemSearchToFit( + query: String! + speciesId: ID! + colorId: ID! + offset: Int + limit: Int + ): ItemSearchResult! petAppearance(speciesId: ID!, colorId: ID!): Appearance } `; @@ -114,17 +125,22 @@ const resolvers = { }, itemSearch: async (_, { query }, { itemSearchLoader }) => { const items = await itemSearchLoader.load(query); - return items; + return { query, items }; }, itemSearchToFit: async ( _, - { query, speciesId, colorId }, + { query, speciesId, colorId, offset, limit }, { petTypeLoader, itemSearchToFitLoader } ) => { const petType = await petTypeLoader.load({ speciesId, colorId }); const { bodyId } = petType; - const items = await itemSearchToFitLoader.load({ query, bodyId }); - return items; + const items = await itemSearchToFitLoader.load({ + query, + bodyId, + offset, + limit, + }); + return { query, items }; }, petAppearance: async ( _, diff --git a/src/server/index.test.js b/src/server/index.test.js index 41da19b..c83e951 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -376,8 +376,11 @@ describe("Search", () => { query: gql` query { itemSearch(query: "Neopian Times") { - id - name + query + items { + id + name + } } } `, @@ -386,56 +389,59 @@ describe("Search", () => { expect(res).toHaveNoErrors(); expect(res.data).toMatchInlineSnapshot(` Object { - "itemSearch": Array [ - Object { - "id": "40431", - "name": "Neopian Times Background", - }, - Object { - "id": "59391", - "name": "Neopian Times Eyrie Hat", - }, - Object { - "id": "59392", - "name": "Neopian Times Eyrie Shirt and Vest", - }, - Object { - "id": "59394", - "name": "Neopian Times Eyrie Shoes", - }, - Object { - "id": "59393", - "name": "Neopian Times Eyrie Trousers", - }, - Object { - "id": "59390", - "name": "Neopian Times Eyries Paper", - }, - Object { - "id": "51098", - "name": "Neopian Times Writing Quill", - }, - Object { - "id": "61101", - "name": "Neopian Times Zafara Handkerchief", - }, - Object { - "id": "61100", - "name": "Neopian Times Zafara Hat", - }, - Object { - "id": "61102", - "name": "Neopian Times Zafara Shirt and Vest", - }, - Object { - "id": "61104", - "name": "Neopian Times Zafara Shoes", - }, - Object { - "id": "61103", - "name": "Neopian Times Zafara Trousers", - }, - ], + "itemSearch": Object { + "items": Array [ + Object { + "id": "40431", + "name": "Neopian Times Background", + }, + Object { + "id": "59391", + "name": "Neopian Times Eyrie Hat", + }, + Object { + "id": "59392", + "name": "Neopian Times Eyrie Shirt and Vest", + }, + Object { + "id": "59394", + "name": "Neopian Times Eyrie Shoes", + }, + Object { + "id": "59393", + "name": "Neopian Times Eyrie Trousers", + }, + Object { + "id": "59390", + "name": "Neopian Times Eyries Paper", + }, + Object { + "id": "51098", + "name": "Neopian Times Writing Quill", + }, + Object { + "id": "61101", + "name": "Neopian Times Zafara Handkerchief", + }, + Object { + "id": "61100", + "name": "Neopian Times Zafara Hat", + }, + Object { + "id": "61102", + "name": "Neopian Times Zafara Shirt and Vest", + }, + Object { + "id": "61104", + "name": "Neopian Times Zafara Shoes", + }, + Object { + "id": "61103", + "name": "Neopian Times Zafara Trousers", + }, + ], + "query": "Neopian Times", + }, } `); expect(queryFn.mock.calls).toMatchInlineSnapshot(` @@ -463,8 +469,11 @@ describe("Search", () => { speciesId: "54" colorId: "75" ) { - id - name + query + items { + id + name + } } } `, @@ -473,36 +482,39 @@ describe("Search", () => { expect(res).toHaveNoErrors(); expect(res.data).toMatchInlineSnapshot(` Object { - "itemSearchToFit": Array [ - Object { - "id": "40431", - "name": "Neopian Times Background", - }, - Object { - "id": "51098", - "name": "Neopian Times Writing Quill", - }, - Object { - "id": "61101", - "name": "Neopian Times Zafara Handkerchief", - }, - Object { - "id": "61100", - "name": "Neopian Times Zafara Hat", - }, - Object { - "id": "61102", - "name": "Neopian Times Zafara Shirt and Vest", - }, - Object { - "id": "61104", - "name": "Neopian Times Zafara Shoes", - }, - Object { - "id": "61103", - "name": "Neopian Times Zafara Trousers", - }, - ], + "itemSearchToFit": Object { + "items": Array [ + Object { + "id": "40431", + "name": "Neopian Times Background", + }, + Object { + "id": "51098", + "name": "Neopian Times Writing Quill", + }, + Object { + "id": "61101", + "name": "Neopian Times Zafara Handkerchief", + }, + Object { + "id": "61100", + "name": "Neopian Times Zafara Hat", + }, + Object { + "id": "61102", + "name": "Neopian Times Zafara Shirt and Vest", + }, + Object { + "id": "61104", + "name": "Neopian Times Zafara Shoes", + }, + Object { + "id": "61103", + "name": "Neopian Times Zafara Trousers", + }, + ], + "query": "Neopian Times", + }, } `); expect(queryFn.mock.calls).toMatchInlineSnapshot(` @@ -523,10 +535,214 @@ describe("Search", () => { WHERE t.name LIKE ? AND t.locale=\\"en\\" AND (swf_assets.body_id = ? OR swf_assets.body_id = 0) ORDER BY t.name - LIMIT 30", + LIMIT ? OFFSET ?", Array [ "%Neopian Times%", "180", + 30, + 0, + ], + ], + ] + `); + }); + + it("loads the first 10 hats that fit the Starry Zafara", async () => { + const res = await query({ + query: gql` + query { + itemSearchToFit( + query: "hat" + speciesId: "54" + colorId: "75" + offset: 0 + limit: 10 + ) { + query + items { + id + name + } + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "itemSearchToFit": Object { + "items": Array [ + Object { + "id": "74967", + "name": "17th Birthday Party Hat", + }, + Object { + "id": "49026", + "name": "Abominable Snowman Hat", + }, + Object { + "id": "67242", + "name": "Accessories Shop Wig and Hat", + }, + Object { + "id": "67242", + "name": "Accessories Shop Wig and Hat", + }, + Object { + "id": "64177", + "name": "Acorn Hat", + }, + Object { + "id": "69995", + "name": "Adventure in Pastel Hat and Wig", + }, + Object { + "id": "69995", + "name": "Adventure in Pastel Hat and Wig", + }, + Object { + "id": "62375", + "name": "Altador Cup Trophy Hat", + }, + Object { + "id": "56654", + "name": "Altador Team Hat", + }, + Object { + "id": "62322", + "name": "Altador Team Jester Hat", + }, + ], + "query": "hat", + }, + } + `); + expect(queryFn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?)", + Array [ + "54", + "75", + ], + ], + Array [ + "SELECT items.*, t.name FROM items + INNER JOIN item_translations t ON t.item_id = items.id + INNER JOIN parents_swf_assets rel + ON rel.parent_type = \\"Item\\" AND rel.parent_id = items.id + INNER JOIN swf_assets ON rel.swf_asset_id = swf_assets.id + WHERE t.name LIKE ? AND t.locale=\\"en\\" AND + (swf_assets.body_id = ? OR swf_assets.body_id = 0) + ORDER BY t.name + LIMIT ? OFFSET ?", + Array [ + "%hat%", + "180", + 10, + 0, + ], + ], + ] + `); + }); + + it("loads the next 10 hats that fit the Starry Zafara", async () => { + const res = await query({ + query: gql` + query { + itemSearchToFit( + query: "hat" + speciesId: "54" + colorId: "75" + offset: 10 + limit: 10 + ) { + query + items { + id + name + } + } + } + `, + }); + + expect(res).toHaveNoErrors(); + expect(res.data).toMatchInlineSnapshot(` + Object { + "itemSearchToFit": Object { + "items": Array [ + Object { + "id": "58733", + "name": "Apple Bobbing Bart Hat", + }, + Object { + "id": "80401", + "name": "Aurricks Finest Hat", + }, + Object { + "id": "80401", + "name": "Aurricks Finest Hat", + }, + Object { + "id": "50168", + "name": "Babaa Hat", + }, + Object { + "id": "78311", + "name": "Backwards Hat and Wig", + }, + Object { + "id": "78311", + "name": "Backwards Hat and Wig", + }, + Object { + "id": "66653", + "name": "Bagel Hat Wig", + }, + Object { + "id": "66653", + "name": "Bagel Hat Wig", + }, + Object { + "id": "51366", + "name": "Balloon Sculpture Hat", + }, + Object { + "id": "51366", + "name": "Balloon Sculpture Hat", + }, + ], + "query": "hat", + }, + } + `); + expect(queryFn.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "SELECT * FROM pet_types WHERE (species_id = ? AND color_id = ?)", + Array [ + "54", + "75", + ], + ], + Array [ + "SELECT items.*, t.name FROM items + INNER JOIN item_translations t ON t.item_id = items.id + INNER JOIN parents_swf_assets rel + ON rel.parent_type = \\"Item\\" AND rel.parent_id = items.id + INNER JOIN swf_assets ON rel.swf_asset_id = swf_assets.id + WHERE t.name LIKE ? AND t.locale=\\"en\\" AND + (swf_assets.body_id = ? OR swf_assets.body_id = 0) + ORDER BY t.name + LIMIT ? OFFSET ?", + Array [ + "%hat%", + "180", + 10, + 10, ], ], ] diff --git a/src/server/loaders.js b/src/server/loaders.js index 86ca794..94506bf 100644 --- a/src/server/loaders.js +++ b/src/server/loaders.js @@ -64,10 +64,14 @@ const buildItemSearchToFitLoader = (db) => new DataLoader(async (queryAndBodyIdPairs) => { // This isn't actually optimized as a batch query, we're just using a // DataLoader API consistency with our other loaders! - const queryPromises = queryAndBodyIdPairs.map(async ({ query, bodyId }) => { - const queryForMysql = "%" + query.replace(/_%/g, "\\$0") + "%"; - const [rows, _] = await db.execute( - `SELECT items.*, t.name FROM items + const queryPromises = queryAndBodyIdPairs.map( + async ({ query, bodyId, offset, limit }) => { + const actualOffset = offset || 0; + const actualLimit = Math.min(limit || 30, 30); + + const queryForMysql = "%" + query.replace(/_%/g, "\\$0") + "%"; + const [rows, _] = await db.execute( + `SELECT items.*, t.name FROM items INNER JOIN item_translations t ON t.item_id = items.id INNER JOIN parents_swf_assets rel ON rel.parent_type = "Item" AND rel.parent_id = items.id @@ -75,14 +79,15 @@ const buildItemSearchToFitLoader = (db) => WHERE t.name LIKE ? AND t.locale="en" AND (swf_assets.body_id = ? OR swf_assets.body_id = 0) ORDER BY t.name - LIMIT 30`, - [queryForMysql, bodyId] - ); + LIMIT ? OFFSET ?`, + [queryForMysql, bodyId, actualLimit, actualOffset] + ); - const entities = rows.map(normalizeRow); + const entities = rows.map(normalizeRow); - return entities; - }); + return entities; + } + ); const responses = await Promise.all(queryPromises);