search infinite scroll!

This commit is contained in:
Matt Dunn-Rankin 2020-04-25 01:55:48 -07:00
parent 4045844e4b
commit 5e3071db4f
4 changed files with 444 additions and 126 deletions

View file

@ -7,7 +7,12 @@ import { Delay, Heading1, useDebounce } from "./util";
import ItemList, { ItemListSkeleton } from "./ItemList"; import ItemList, { ItemListSkeleton } from "./ItemList";
import { itemAppearanceFragment } from "./OutfitPreview"; import { itemAppearanceFragment } from "./OutfitPreview";
function SearchPanel({ query, outfitState, dispatchToOutfit }) { function SearchPanel({
query,
outfitState,
dispatchToOutfit,
getScrollParent,
}) {
return ( return (
<Box color="green.800"> <Box color="green.800">
<Heading1 mb="4">Searching for "{query}"</Heading1> <Heading1 mb="4">Searching for "{query}"</Heading1>
@ -15,6 +20,7 @@ function SearchPanel({ query, outfitState, dispatchToOutfit }) {
query={query} query={query}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
getScrollParent={getScrollParent}
/> />
</Box> </Box>
); );
@ -25,29 +31,34 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true }); const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true });
const { loading, error, data, variables } = useQuery( const { loading, error, data, fetchMore, variables } = useQuery(
gql` gql`
query($query: String!, $speciesId: ID!, $colorId: ID!) { query($query: String!, $speciesId: ID!, $colorId: ID!, $offset: Int!) {
itemSearchToFit( itemSearchToFit(
query: $query query: $query
speciesId: $speciesId speciesId: $speciesId
colorId: $colorId colorId: $colorId
offset: $offset
limit: 50
) { ) {
# TODO: De-dupe this from useOutfitState? query
id items {
name # TODO: De-dupe this from useOutfitState?
thumbnailUrl id
name
thumbnailUrl
appearanceOn(speciesId: $speciesId, colorId: $colorId) { appearanceOn(speciesId: $speciesId, colorId: $colorId) {
# This enables us to quickly show the item when the user clicks it! # This enables us to quickly show the item when the user clicks it!
...AppearanceForOutfitPreview ...AppearanceForOutfitPreview
# This is used to group items by zone, and to detect conflicts when # This is used to group items by zone, and to detect conflicts when
# wearing a new item. # wearing a new item.
layers { layers {
zone { zone {
id id
label label
}
} }
} }
} }
@ -56,12 +67,40 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
${itemAppearanceFragment} ${itemAppearanceFragment}
`, `,
{ {
variables: { query: debouncedQuery, speciesId, colorId }, variables: { query: debouncedQuery, speciesId, colorId, offset: 0 },
skip: debouncedQuery === null, 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 ( return (
<Delay ms={500}> <Delay ms={500}>
<ItemListSkeleton count={8} /> <ItemListSkeleton count={8} />
@ -81,8 +120,6 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
); );
} }
const items = data.itemSearchToFit;
if (items.length === 0) { if (items.length === 0) {
return ( return (
<Text color="green.500"> <Text color="green.500">
@ -96,12 +133,56 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
} }
return ( return (
<ItemList <ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
items={items} <ItemList
outfitState={outfitState} items={items}
dispatchToOutfit={dispatchToOutfit} outfitState={outfitState}
/> dispatchToOutfit={dispatchToOutfit}
/>
{items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker>
); );
} }
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 <Box ref={containerRef}>{children}</Box>;
}
export default SearchPanel; export default SearchPanel;

View file

@ -34,10 +34,21 @@ const typeDefs = gql`
label: String! label: String!
} }
type ItemSearchResult {
query: String!
items: [Item!]!
}
type Query { type Query {
items(ids: [ID!]!): [Item!]! items(ids: [ID!]!): [Item!]!
itemSearch(query: String!): [Item!]! itemSearch(query: String!): ItemSearchResult!
itemSearchToFit(query: String!, speciesId: ID!, colorId: ID!): [Item!]! itemSearchToFit(
query: String!
speciesId: ID!
colorId: ID!
offset: Int
limit: Int
): ItemSearchResult!
petAppearance(speciesId: ID!, colorId: ID!): Appearance petAppearance(speciesId: ID!, colorId: ID!): Appearance
} }
`; `;
@ -114,17 +125,22 @@ const resolvers = {
}, },
itemSearch: async (_, { query }, { itemSearchLoader }) => { itemSearch: async (_, { query }, { itemSearchLoader }) => {
const items = await itemSearchLoader.load(query); const items = await itemSearchLoader.load(query);
return items; return { query, items };
}, },
itemSearchToFit: async ( itemSearchToFit: async (
_, _,
{ query, speciesId, colorId }, { query, speciesId, colorId, offset, limit },
{ petTypeLoader, itemSearchToFitLoader } { petTypeLoader, itemSearchToFitLoader }
) => { ) => {
const petType = await petTypeLoader.load({ speciesId, colorId }); const petType = await petTypeLoader.load({ speciesId, colorId });
const { bodyId } = petType; const { bodyId } = petType;
const items = await itemSearchToFitLoader.load({ query, bodyId }); const items = await itemSearchToFitLoader.load({
return items; query,
bodyId,
offset,
limit,
});
return { query, items };
}, },
petAppearance: async ( petAppearance: async (
_, _,

View file

@ -376,8 +376,11 @@ describe("Search", () => {
query: gql` query: gql`
query { query {
itemSearch(query: "Neopian Times") { itemSearch(query: "Neopian Times") {
id query
name items {
id
name
}
} }
} }
`, `,
@ -386,56 +389,59 @@ describe("Search", () => {
expect(res).toHaveNoErrors(); expect(res).toHaveNoErrors();
expect(res.data).toMatchInlineSnapshot(` expect(res.data).toMatchInlineSnapshot(`
Object { Object {
"itemSearch": Array [ "itemSearch": Object {
Object { "items": Array [
"id": "40431", Object {
"name": "Neopian Times Background", "id": "40431",
}, "name": "Neopian Times Background",
Object { },
"id": "59391", Object {
"name": "Neopian Times Eyrie Hat", "id": "59391",
}, "name": "Neopian Times Eyrie Hat",
Object { },
"id": "59392", Object {
"name": "Neopian Times Eyrie Shirt and Vest", "id": "59392",
}, "name": "Neopian Times Eyrie Shirt and Vest",
Object { },
"id": "59394", Object {
"name": "Neopian Times Eyrie Shoes", "id": "59394",
}, "name": "Neopian Times Eyrie Shoes",
Object { },
"id": "59393", Object {
"name": "Neopian Times Eyrie Trousers", "id": "59393",
}, "name": "Neopian Times Eyrie Trousers",
Object { },
"id": "59390", Object {
"name": "Neopian Times Eyries Paper", "id": "59390",
}, "name": "Neopian Times Eyries Paper",
Object { },
"id": "51098", Object {
"name": "Neopian Times Writing Quill", "id": "51098",
}, "name": "Neopian Times Writing Quill",
Object { },
"id": "61101", Object {
"name": "Neopian Times Zafara Handkerchief", "id": "61101",
}, "name": "Neopian Times Zafara Handkerchief",
Object { },
"id": "61100", Object {
"name": "Neopian Times Zafara Hat", "id": "61100",
}, "name": "Neopian Times Zafara Hat",
Object { },
"id": "61102", Object {
"name": "Neopian Times Zafara Shirt and Vest", "id": "61102",
}, "name": "Neopian Times Zafara Shirt and Vest",
Object { },
"id": "61104", Object {
"name": "Neopian Times Zafara Shoes", "id": "61104",
}, "name": "Neopian Times Zafara Shoes",
Object { },
"id": "61103", Object {
"name": "Neopian Times Zafara Trousers", "id": "61103",
}, "name": "Neopian Times Zafara Trousers",
], },
],
"query": "Neopian Times",
},
} }
`); `);
expect(queryFn.mock.calls).toMatchInlineSnapshot(` expect(queryFn.mock.calls).toMatchInlineSnapshot(`
@ -463,8 +469,11 @@ describe("Search", () => {
speciesId: "54" speciesId: "54"
colorId: "75" colorId: "75"
) { ) {
id query
name items {
id
name
}
} }
} }
`, `,
@ -473,36 +482,39 @@ describe("Search", () => {
expect(res).toHaveNoErrors(); expect(res).toHaveNoErrors();
expect(res.data).toMatchInlineSnapshot(` expect(res.data).toMatchInlineSnapshot(`
Object { Object {
"itemSearchToFit": Array [ "itemSearchToFit": Object {
Object { "items": Array [
"id": "40431", Object {
"name": "Neopian Times Background", "id": "40431",
}, "name": "Neopian Times Background",
Object { },
"id": "51098", Object {
"name": "Neopian Times Writing Quill", "id": "51098",
}, "name": "Neopian Times Writing Quill",
Object { },
"id": "61101", Object {
"name": "Neopian Times Zafara Handkerchief", "id": "61101",
}, "name": "Neopian Times Zafara Handkerchief",
Object { },
"id": "61100", Object {
"name": "Neopian Times Zafara Hat", "id": "61100",
}, "name": "Neopian Times Zafara Hat",
Object { },
"id": "61102", Object {
"name": "Neopian Times Zafara Shirt and Vest", "id": "61102",
}, "name": "Neopian Times Zafara Shirt and Vest",
Object { },
"id": "61104", Object {
"name": "Neopian Times Zafara Shoes", "id": "61104",
}, "name": "Neopian Times Zafara Shoes",
Object { },
"id": "61103", Object {
"name": "Neopian Times Zafara Trousers", "id": "61103",
}, "name": "Neopian Times Zafara Trousers",
], },
],
"query": "Neopian Times",
},
} }
`); `);
expect(queryFn.mock.calls).toMatchInlineSnapshot(` expect(queryFn.mock.calls).toMatchInlineSnapshot(`
@ -523,10 +535,214 @@ describe("Search", () => {
WHERE t.name LIKE ? AND t.locale=\\"en\\" AND WHERE t.name LIKE ? AND t.locale=\\"en\\" AND
(swf_assets.body_id = ? OR swf_assets.body_id = 0) (swf_assets.body_id = ? OR swf_assets.body_id = 0)
ORDER BY t.name ORDER BY t.name
LIMIT 30", LIMIT ? OFFSET ?",
Array [ Array [
"%Neopian Times%", "%Neopian Times%",
"180", "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,
], ],
], ],
] ]

View file

@ -64,10 +64,14 @@ const buildItemSearchToFitLoader = (db) =>
new DataLoader(async (queryAndBodyIdPairs) => { new DataLoader(async (queryAndBodyIdPairs) => {
// This isn't actually optimized as a batch query, we're just using a // This isn't actually optimized as a batch query, we're just using a
// DataLoader API consistency with our other loaders! // DataLoader API consistency with our other loaders!
const queryPromises = queryAndBodyIdPairs.map(async ({ query, bodyId }) => { const queryPromises = queryAndBodyIdPairs.map(
const queryForMysql = "%" + query.replace(/_%/g, "\\$0") + "%"; async ({ query, bodyId, offset, limit }) => {
const [rows, _] = await db.execute( const actualOffset = offset || 0;
`SELECT items.*, t.name FROM items 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 item_translations t ON t.item_id = items.id
INNER JOIN parents_swf_assets rel INNER JOIN parents_swf_assets rel
ON rel.parent_type = "Item" AND rel.parent_id = items.id 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 WHERE t.name LIKE ? AND t.locale="en" AND
(swf_assets.body_id = ? OR swf_assets.body_id = 0) (swf_assets.body_id = ? OR swf_assets.body_id = 0)
ORDER BY t.name ORDER BY t.name
LIMIT 30`, LIMIT ? OFFSET ?`,
[queryForMysql, bodyId] [queryForMysql, bodyId, actualLimit, actualOffset]
); );
const entities = rows.map(normalizeRow); const entities = rows.map(normalizeRow);
return entities; return entities;
}); }
);
const responses = await Promise.all(queryPromises); const responses = await Promise.all(queryPromises);