Skip to content

#60 implement infinite scroll#90

Merged
MainlyColors merged 38 commits into
mainfrom
#60-Implement-Infinite-Scroll
May 27, 2023
Merged

#60 implement infinite scroll#90
MainlyColors merged 38 commits into
mainfrom
#60-Implement-Infinite-Scroll

Conversation

@MainlyColors

@MainlyColors MainlyColors commented Apr 19, 2023

Copy link
Copy Markdown
Collaborator

Closes #60

Description

feature

  • Use Intersection Observer to trigger loading of a new batch of 20 profiles on the /directory page when the user scrolls down to the height of the last card above the bottom of the window/ul that contains all the users
  • Will not load pages past the total pages returned by /api/users
  • only triggers the load event once because the observer is removed on first intersection
  • A loading spinner is triggered if the data doesn't load instantly

Tests added

  • cypress tests added for all functionality
  • seed script is triggered before all tests in the directory infinite scroll test suite to generate 100 users

Testing

manually

  • start docker container
  • npm run dev:server
  • npm run seed - to generate a 100 users -
  • visit \directory
    • keep in mind currently discord_name has a 20% chance of being empty which is being fixed in another PR but will cause some users not to show up on the page
  • scroll down the page to load a new 20 users (repeat until no more users)
  • in the network tab can change the throttle to slow 3G to catch the loading spinner

Automatic

  • start docker container
  • npm run dev:server
  • npm run cypress:open
  • click "Start E2E Test" with Chrome
  • Tests will run and should all pass

Type of change

  • New feature (non-breaking change which adds functionality)

Screenshots

Tests passing - directory.spec.ts
image

Known issue - intercepting user api w/ cypress

The use of cypress intercept here in the directory test is intended to wait upon the response and catch the loading spinner. This works 95-99% of the time but if the loading spinner appears for less than 20ms then cypress won't be able to catch it, resulting in a failed test. Just re-run the test to be sure.

Different variations of using as(), (req) => req.alias, and even req.reply(res => res.setDelay(100)) and every possible permutation of those 3 were tried by Myself and Devon but we still got random errors.

it('shows a loading animation while fetching users', () => {
    cy.scrollTo('bottom');
    cy.intercept('/api/users?page=*').as('getUsers');

    // spinner should exist & be removed after request completes
    cy.get('[data-cy="loading"]');
    cy.wait('@getUsers');
    cy.get('[data-cy="loading"]').should('not.exist');
  });

Checklist:

  • Changes have new/updated automated tests, if applicable
  • Changes have new/updated docs, if applicable
  • I have performed a self-review of my own code
  • I have added comments on any new, hard-to-understand code
  • Changes have been manually tested
  • Changes generate no new errors or warnings

Comment thread cypress/e2e/base.spec.ts Outdated
Comment thread pages/directory/index.tsx
Comment thread cypress.config.ts Outdated
Comment thread cypress/e2e/base.spec.ts Outdated
Comment thread cypress/e2e/base.spec.ts Outdated
Comment on lines +95 to +97
cy.intercept('GET', '/api/users?page=*', req => {
req.on('response', res => res.setDelay(500));
}).as('getUsers');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intercepts the api call to add a 500 ms delay to give cypress enough time to see the loading spinner

Comment thread cypress/e2e/base.spec.ts Outdated
@MainlyColors MainlyColors requested a review from devonzara May 10, 2023 18:33

@devonzara devonzara left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the delay... I've not gotten the chance to pull it down and actually work through/test the PR, but I've added some comments/suggested changes that can be done in the interim. 👍🏼

Comment thread cypress.config.ts Outdated
Comment on lines +13 to +16
env: {
bash: 'C:\\Program Files\\Git\\bin\\bash.exe',
comSpec: 'C:\\Windows\\system32\\cmd.exe',
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer needed as part of this PR.

Comment on lines +8 to +18
before(() => {
// clear Db
cy.truncateDatabase();

// fill DB with 100 users
cy.createUsers(100);

// get total pages for tests
cy.request('http://localhost:3000/api/users').then(response => {
totalPages = response.body.totalPages;
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably remove these 3 comments as they don't really add any additional information.

Comment thread cypress/e2e/directory.spec.ts Outdated
Comment on lines +28 to +31
i < totalPages
? cy.get('@ulChildren').should('have.length', PAGE_LIMIT * i)
: // last page not guaranteed to be full
cy.get('@ulChildren').should('have.length.within', PAGE_LIMIT * (i - 1), PAGE_LIMIT * i);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: formatting

Suggested change
i < totalPages
? cy.get('@ulChildren').should('have.length', PAGE_LIMIT * i)
: // last page not guaranteed to be full
cy.get('@ulChildren').should('have.length.within', PAGE_LIMIT * (i - 1), PAGE_LIMIT * i);
i < totalPages ?
cy.get('@ulChildren').should('have.length', PAGE_LIMIT * i) :
// last page not guaranteed to be full
cy.get('@ulChildren').should('have.length.within', PAGE_LIMIT * (i - 1), PAGE_LIMIT * i);

Comment thread cypress/e2e/directory.spec.ts Outdated
Comment on lines +38 to +42
// last page won't have a spinner
if (i === totalPages) break;
// check spinner happened & removed
cy.get('[data-cy="loading"]'); //check if exists
cy.get('[data-cy="loading"]').should('not.exist'); // removed after data loaded

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: formatting, empty line after breaks/continues & same line comments should typically be avoided

Suggested change
// last page won't have a spinner
if (i === totalPages) break;
// check spinner happened & removed
cy.get('[data-cy="loading"]'); //check if exists
cy.get('[data-cy="loading"]').should('not.exist'); // removed after data loaded
// last page won't have a spinner
if (i === totalPages) break;
// spinner should exist & be removed after request completes
cy.get('[data-cy="loading"]');
cy.get('[data-cy="loading"]').should('not.exist');

Also, is this missing a cy.wait or something? How does it know when the request is complete?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@devonzara Cypress will keep retrying their commands for 4 secs each before it declares it failed. That's why a cy.wait isn't required

Comment thread cypress/e2e/directory.spec.ts Outdated
Comment on lines +56 to +58
cy.get('[data-cy="loading"]'); //check if exists
cy.wait('@getUsers');
cy.get('[data-cy="loading"]').should('not.exist'); // removed after data loaded

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: same as above, inline comments

Comment thread pages/directory/index.tsx Outdated
users: response.data.users,
};
} catch (err) {
console.error(err);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid using console on the client side as it will fail without notifying the user... see linked comments on #87 and the approach they took to address it.

#87 (comment)
#87 (comment)

Comment thread pages/directory/index.tsx
Comment thread pages/directory/index.tsx Outdated
setTotalPageNum(total);
setUserData([...userData, ...users]);
} catch (err) {
console.error(err);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, we should avoid using console in the client-side code.

Comment thread server/scripts/resetDB.ts Outdated
@MainlyColors MainlyColors requested a review from devonzara May 16, 2023 17:07

@timmyichen timmyichen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor comments, nothing major, i think this is almost ready to go!

Comment thread cypress/e2e/directory.spec.ts Outdated
Comment on lines +25 to +28
i < totalPages ?
cy.get('@ulChildren').should('have.length', PAGE_LIMIT * i) :
// last page not guaranteed to be full
cy.get('@ulChildren').should('have.length.within', PAGE_LIMIT * (i - 1), PAGE_LIMIT * i);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stylistic choice, but we should reserve ternary statements for values, rather than actual execution. an if/else would be preferred here

Comment thread pages/directory/index.tsx
return { users, totalPages, error: null };
} catch (error) {
return { users: [], error: error.message };
return { users: [], totalPages: 1, error: error.message };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is totalPages set to 1 here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I set it to 1 because if there are no users then there would only be 1 page and we set up the users API to throw an error on calling page=0

Comment thread pages/directory/index.tsx Outdated
const [currentPage, setCurrentPage] = React.useState(1);
const [totalPageNum, setTotalPageNum] = React.useState(totalPages);
const [isLoading, setIsLoading] = React.useState(false);
const [open, setOpen] = React.useState(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would prefer to have this be named a little more specifically (if we have more than one alert/modal/etc, what this opens becomes ambiguous). showError, setShowError as a sample alternative

Comment thread pages/directory/index.tsx Outdated
Comment on lines +58 to +61
if (currentPage + 1 > totalPageNum) {
setIsLoading(false);
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this check happen before we even set isLoading? that way we can just setIsLoading(true) right before we start fetching the next page of users

Comment thread pages/directory/index.tsx Outdated

return (
<Container maxWidth="lg" className="pt-4">
<ErrorPopup open={open} setOpen={setOpen} message="Page out of range" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A note - Page out of range isn't the only possible error we could get - but I think error handling in general can be worked on later so I won't block on this :P

Comment thread pages/directory/index.tsx Outdated
}
}, intersectionOptions);

setIsLoading(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this meant to be inside of the observer callback?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I put it there for a reason I can't remember, but within the observer callback makes more sense 👍

@MainlyColors MainlyColors requested a review from timmyichen May 22, 2023 16:33

@devonzara devonzara left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, mostly nits and questions about error handling remain.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think this might be better named ErrorToast or even just Toast as Popup is a bit ambiguous and Error limits the scope of its future use.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree on ErrorToast for now, I think configuring the component to handle situations that haven't occurred yet and may never occur goes against YAGNI. In the future, if we need the toast to do more then we can add the functionality then 😎

Comment thread cypress/e2e/directory.spec.ts Outdated

cy.intercept('GET', '/api/users?page=*', req => {
req.on('response', res => res.setDelay(500));
}).as('getUsers');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is the .as() alias necessary if we don't have to wait for it?

@MainlyColors MainlyColors May 23, 2023

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While researching about .as() more and if cy.wait() was necessary, I found the correct way to actually pause the response while waiting for the loading spinner

      cy.intercept('/api/users?page=*', req => {
        /**
       * If set, `cy.wait` can be used to await the request/response
       * cycle to complete for this request via `cy.wait('@alias')`.
       */
        req.alias = 'getUsers';
      }).as('getUsers');
      
      cy.get('[data-cy="loading"]');
      // here cypress releases the response
      cy.wait('@getUsers');
      cy.get('[data-cy="loading"]').should('not.exist');

docs: https://docs.cypress.io/api/commands/intercept#Request-object-properties

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MainlyColors Correct me if I'm wrong, but I believe .as() and req.alias = have similar behaviors, and are therefore redundant when used together. Given that dynamic alias assignment is not a requirement in this context, using .as() alone should suffice. No?


// spinner should exist & be removed after request completes
cy.get('[data-cy="loading"]');
cy.wait('@getUsers');

@devonzara devonzara May 22, 2023

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this cy.wait necessary? If so, what's different here vs the previous test? If cy.wait isn't necessary in either case, can we get rid of it and the .as() alias above?

Comment thread cypress/e2e/directory.spec.ts Outdated
message: 'Request failed with status code 400',
},
});
// req.on('before:response', res => (res.statusCode = 400));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: lingering line of commented code

Comment thread cypress/e2e/directory.spec.ts Outdated
},
});
// req.on('before:response', res => (res.statusCode = 400));
}).as('getUsers');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: unused .as() alias

Comment thread pages/directory/index.tsx
users: response.data.users,
};
} catch (err) {
if (err.code === 'ERR_BAD_REQUEST') {

@devonzara devonzara May 22, 2023

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if other errors occur?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know all the errors that were possible, Ill look into it more

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every possible error doesn't need to be explicitly handled; the user doesn't always need to know what the error was.

Some errors may be network related and work themselves out, others might be things we need to address... either way, communicating that an error has occurred to the user instead of leaving them uncertain results in a better user experience.

Comment thread pages/directory/index.tsx Outdated
setTotalPageNum(total);
setUserData([...userData, ...users]);
} catch (err) {
if (err.message === 'Page out of range') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this conditional is necessary... or, if kept - should we provide an else condition with a generic error?

@MainlyColors MainlyColors requested a review from devonzara May 23, 2023 23:15

@Caleb-Cohen Caleb-Cohen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@devonzara devonzara left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for taking this on!

@MainlyColors MainlyColors merged commit 0d50ffb into main May 27, 2023
@MainlyColors MainlyColors deleted the #60-Implement-Infinite-Scroll branch May 27, 2023 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Infinite Scroll

4 participants