search infinite scroll!
This commit is contained in:
parent
4045844e4b
commit
5e3071db4f
4 changed files with 444 additions and 126 deletions
|
@ -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,14 +31,18 @@ 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
|
||||
) {
|
||||
query
|
||||
items {
|
||||
# TODO: De-dupe this from useOutfitState?
|
||||
id
|
||||
name
|
||||
|
@ -53,15 +63,44 @@ 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 (
|
||||
<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;
|
||||
|
|
|
@ -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 (
|
||||
_,
|
||||
|
|
|
@ -376,17 +376,21 @@ describe("Search", () => {
|
|||
query: gql`
|
||||
query {
|
||||
itemSearch(query: "Neopian Times") {
|
||||
query
|
||||
items {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(res).toHaveNoErrors();
|
||||
expect(res.data).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"itemSearch": Array [
|
||||
"itemSearch": Object {
|
||||
"items": Array [
|
||||
Object {
|
||||
"id": "40431",
|
||||
"name": "Neopian Times Background",
|
||||
|
@ -436,6 +440,8 @@ describe("Search", () => {
|
|||
"name": "Neopian Times Zafara Trousers",
|
||||
},
|
||||
],
|
||||
"query": "Neopian Times",
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(queryFn.mock.calls).toMatchInlineSnapshot(`
|
||||
|
@ -463,17 +469,21 @@ describe("Search", () => {
|
|||
speciesId: "54"
|
||||
colorId: "75"
|
||||
) {
|
||||
query
|
||||
items {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(res).toHaveNoErrors();
|
||||
expect(res.data).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"itemSearchToFit": Array [
|
||||
"itemSearchToFit": Object {
|
||||
"items": Array [
|
||||
Object {
|
||||
"id": "40431",
|
||||
"name": "Neopian Times Background",
|
||||
|
@ -503,6 +513,8 @@ describe("Search", () => {
|
|||
"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,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
|
|
@ -64,7 +64,11 @@ 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 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
|
||||
|
@ -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);
|
||||
|
||||
return entities;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const responses = await Promise.all(queryPromises);
|
||||
|
||||
|
|
Loading…
Reference in a new issue