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