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 { itemAppearanceFragment } from "./OutfitPreview";
function SearchPanel({ query, outfitState, dispatchToOutfit }) {
function SearchPanel({
query,
outfitState,
dispatchToOutfit,
getScrollParent,
}) {
return (
<Box color="green.800">
<Heading1 mb="4">Searching for "{query}"</Heading1>
@ -15,6 +20,7 @@ function SearchPanel({ query, outfitState, dispatchToOutfit }) {
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
getScrollParent={getScrollParent}
/>
</Box>
);
@ -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 (
<Delay ms={500}>
<ItemListSkeleton count={8} />
@ -81,8 +120,6 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
);
}
const items = data.itemSearchToFit;
if (items.length === 0) {
return (
<Text color="green.500">
@ -96,12 +133,56 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
}
return (
<ItemList
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
<ItemList
items={items}
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;

View file

@ -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 (
_,

View file

@ -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,
],
],
]

View file

@ -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);