-
Notifications
You must be signed in to change notification settings - Fork 706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Infinite scrolling pagination #2264
Conversation
It's usually better to review finished PRs but I am okay giving some early feedback |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some comments here, the major two things I would change is having the page
in the Catalog state and use the total number of pages that the API should return to know if you are in the latest page or not.
dashboard/src/actions/charts.ts
Outdated
} catch (e) { | ||
dispatch(errorChart(new FetchError(e.message))); | ||
const latestElement = Array.from(records.keys()).pop() || 1; | ||
if (records.get(page) === false && page <= latestElement) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't undestand the condition page <= latestElement
, shouldn't be page
bigger than the latest page in the records? (BTW, I would change latestElement
for lastPage
if that's what it means).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is just an extra condition for my unfruitful attempts to avoid the problems of duplicating requests.
The rationale behind this is latestElement
is being set in receiveCharts
: here we set to false
the next page that should be requested (otherwise, it should be undefined
).
So, if is properly processes page 6, it will set 7: false
. During the next request, (records.get(7) === false && 7 <= 7)
If something is wrong, the map could possibly include 7:false, 8:false
, the latestElement
would be 8 whereas page
is still 7, and that's precisely what this change is trying to prevent.
I'm sure there exists a better solution for the root cause.
Regarding the renaming, it's not the lastPage
, but the lastPendingPage
, but I'm ok with whichever name.
dashboard/src/actions/charts.ts
Outdated
dispatch(requestCharts(page, query)); | ||
try { | ||
const charts = await Chart.fetchCharts(cluster, namespace, repos, page, size, query); | ||
dispatch(receiveCharts(charts, charts.length === 0)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think charts.length === 0
is a mechanism to know if the list has reached the end (it may be the result of asking for a wrong query).
In any case, I am pretty sure that the API endpoint returns the number of pages in the response (at least it was that way last time I checked it). You should compare that number with the current page instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree, I'm going to change this condition and leverage from the totalPages
value already present in the API response. Totally, right!
useEffect(() => { | ||
fetchCharts(cluster, namespace, reposFilter, page, size, searchFilter); | ||
}, [fetchCharts, cluster, namespace, reposFilter, page, size, searchFilter]); | ||
clusterUpdate.current = cluster; | ||
namespaceUpdate.current = namespace; | ||
reposFilterUpdate.current = reposFilter; | ||
searchFilterUpdate.current = searchFilter; | ||
resetRequestCharts(); | ||
}, [resetRequestCharts, cluster, namespace, reposFilter, searchFilter]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure if calling resetRequestCharts
is the correct approach but the useRef
s are not needed for this as far as I can see.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand the problem here, if you are in page 3 and then you click in a repo filter, you want to reset the page counter, right? (So you request the first page again). Or you are trying some performance improvement?
What I would do, to try to simplify this a bit (and potentially avoid the refs
), would be to have the page
in the component state (rather than in redux), and everytime there is a change in the filters, reset the page. I am not sure if that'd trigger two requests though (one for the page: 0 and old filters and one for page: 0 and the new filters or just the second).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I used the useRef
beacuse of I explained in #2264 (comment).
Mmm, but, although we store page
in the component state, we will still have it as one of the dependencies of the useEffect
, so, regardless of how it is stored, it would cause a refresh whenever it is updated, wouldn't it?
I don't know, anyway I will give it a try for sure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, but that's what we want, no? If we are at page: 2, repo: ""
and we change it to repo: "bitnami
and at the same time (this is what I don't know if it's possible) page: 0
we would do a single fetchChart
with page: 0, repo: "bitnami"
. Isn't that correct?
const debouncedFetchCharts = useCallback( | ||
debounce(() => { | ||
fetchCharts(cluster, namespace, reposFilter, page, size, records, searchFilter); | ||
}, 500), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, I am not sure useCallback + debounce
works as you expect, double check it if it's really doing anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, thanks for double-checking this. You will need to add some tests for this though. Also, I think that the usage of debounce
is legitimate but useCallback
seems a hack for fixing the problem of not re-requesting the same page (in theory we should be taking the count of the pages we have already requested, and in fact we are, and use that to decide to trigger or not the function).
In any case, I am fine with this as long as you ensure it with a test.
// Enable scrolling just if every parameter is already updated | ||
clusterUpdate.current === cluster && | ||
namespaceUpdate.current === namespace && | ||
reposFilterUpdate.current === reposFilter && | ||
searchFilterUpdate.current === searchFilter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand these conditions, I believe is trying to avoid duplicated calls but can you elaborate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I'd want to explain it a little bit more as it is just another attempt to avoid dups:
Whenever a change occurs and it is detected by the useEffect
, I (try to) prevent these values to be actually updated before triggering the reset. It is being done by means of the useRef
. The good point here is that mutating the .current
won't cause a re-render.
If we remove these conditions, duplicated requests would appear.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's fine then but as above, you'd need to ensure this with a test. First, comment this code, and write a test ensure it's failing (there are more than one request for the same params), then enable the functional code and the test should pass.
dashboard/src/reducers/charts.ts
Outdated
case getType(actions.charts.requestChartsCategories): | ||
return { ...state, isFetching: true }; | ||
case getType(actions.charts.receiveCharts): | ||
state.records.set(state.page, true); | ||
state.records.set(state.page + 1, false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do you need this line? Isn't the default false
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think so; when there is no key
, the map.get(xxx)
will return undefined
instead of false
(and this behavior is being used in #2264 (comment))
dashboard/src/reducers/charts.ts
Outdated
items: action.payload, | ||
hasFinished: action?.meta, | ||
items: uniqBy([...state.items, ...action.payload], "id"), // TODO(agamez): handle undesired requests to avoid this workaround | ||
page: action?.meta ? state.page : state.page + 1, // if action.meta==true, it's the last chunk |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would include the page in the payload
dashboard/src/reducers/charts.ts
Outdated
return { | ||
...state, | ||
isFetching: false, | ||
items: action.payload, | ||
hasFinished: action?.meta, | ||
items: uniqBy([...state.items, ...action.payload], "id"), // TODO(agamez): handle undesired requests to avoid this workaround |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep, +1 to the TODO, we should get rid of those duplicated requests
Thank you, as usual, for your comments :) Regarding the The second point has been addressed, a much better approach to detect the end of the pagination! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @antgamdia this is looking better! As a summary, I have two major comments:
- There are many tests missing to ensure the behavior you are coding.
- The decision to fetch or no charts should be made in the Catalog. Thanks to that, you may be able to remove (or not) the pages from the state.
Let me know what you think or if you are stuck testing something.
{ | ||
type: getType(actions.charts.receiveCharts), | ||
payload: { items: response, page: 1 }, | ||
meta: false, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you don't need the meta
field now.
dashboard/src/actions/charts.ts
Outdated
const lastPendingPage = Array.from(records.keys()).pop() || 1; | ||
if (records.get(page) === false && page <= lastPendingPage) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following the conversation at #2264 (comment)
As a general comment, code should not be added "hoping" to behave somehow, it has to be proven to behave that way with unit tests. I don't see any unit tests enforcing the behavior of this so I'd assume this is not needed. Code that is not "protected" by unit tests is damned to be broken in the future when we don't remember what's doing and we decide to clean it up.
Regarding this issue in particular, I don't think is the duty of the function fetchCharts
to decide if it should fetchCharts
or not based on the state. It's the duty of the caller, in this case the Catalog
component, to not request twice a page (and it's easier to add safeguards and tests there).
dashboard/src/actions/charts.ts
Outdated
dispatch(requestCharts(page, query)); | ||
try { | ||
const response = await Chart.fetchCharts(cluster, namespace, repos, page, size, query); | ||
dispatch(receiveCharts({ items: response.data, page }, response?.meta?.totalPages <= page)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would also add the totalPages
to the payload unless using the meta parameter as you are doing gives some benefit, that way, if in the future you need to add more info, you don't need to guess if it's in one parameter or the other.
useEffect(() => { | ||
fetchCharts(cluster, namespace, reposFilter, page, size, searchFilter); | ||
}, [fetchCharts, cluster, namespace, reposFilter, page, size, searchFilter]); | ||
clusterUpdate.current = cluster; | ||
namespaceUpdate.current = namespace; | ||
reposFilterUpdate.current = reposFilter; | ||
searchFilterUpdate.current = searchFilter; | ||
resetRequestCharts(); | ||
}, [resetRequestCharts, cluster, namespace, reposFilter, searchFilter]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, but that's what we want, no? If we are at page: 2, repo: ""
and we change it to repo: "bitnami
and at the same time (this is what I don't know if it's possible) page: 0
we would do a single fetchChart
with page: 0, repo: "bitnami"
. Isn't that correct?
const debouncedFetchCharts = useCallback( | ||
debounce(() => { | ||
fetchCharts(cluster, namespace, reposFilter, page, size, records, searchFilter); | ||
}, 500), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, thanks for double-checking this. You will need to add some tests for this though. Also, I think that the usage of debounce
is legitimate but useCallback
seems a hack for fixing the problem of not re-requesting the same page (in theory we should be taking the count of the pages we have already requested, and in fact we are, and use that to decide to trigger or not the function).
In any case, I am fine with this as long as you ensure it with a test.
// Enable scrolling just if every parameter is already updated | ||
clusterUpdate.current === cluster && | ||
namespaceUpdate.current === namespace && | ||
reposFilterUpdate.current === reposFilter && | ||
searchFilterUpdate.current === searchFilter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's fine then but as above, you'd need to ensure this with a test. First, comment this code, and write a test ensure it's failing (there are more than one request for the same params), then enable the functional code and the test should pass.
dashboard/src/reducers/charts.ts
Outdated
// only update the records if the received page matches the requested one | ||
const areMatchingPages = action.payload.page === state.page; | ||
if (areMatchingPages) { | ||
state.records.set(state.page, true); // mark the current page as successfully retrieved | ||
state.records.set(state.page + 1, false); // mark the next page as pending (not undefined) | ||
} | ||
return { | ||
...state, | ||
isFetching: false, | ||
items: action.payload, | ||
hasFinished: action?.meta, | ||
items: areMatchingPages | ||
? uniqBy([...state.items, ...action.payload.items], "id") | ||
: state.items, // if pages don't match, ignore payload to avoid duplicates | ||
page: action?.meta ? action.payload.page : action.payload.page + 1, // if action.meta==true, it's the last chunk | ||
records: state.records, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can simplify all this if we store items
as a map. If we store:
items[action.payload.page] = action.payload.items
You will get unique items per page and, I think, you won't need the records
since you already have a map.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm ok with this simplification general aim, but, don't you think it will increase complexity in the view? I mean, the operations would be pretty the same, but, I'm unsure of moving them to the component.
records: new Map<number, boolean>() | ||
.set(1, true) | ||
.set(2, true) | ||
.set(3, false), | ||
}); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd add a couple more of tests, one to check that receiving two pages of charts includes all the charts as items
and another checking that if you add the same page twice the items are not duplicated.
dashboard/src/shared/types.ts
Outdated
@@ -95,6 +104,7 @@ export interface IChartAttributes { | |||
|
|||
export interface IChartState { | |||
isFetching: boolean; | |||
hasFinished: boolean; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpipck: hasFinished
could mean anything, a better name would be finishedFetching
Again, thank you for your comments! Just for the record, as you suggested in 1-1 conversations, we could do something like: const [pagesRequested, setPagesRequested] = useState([] as number[]);
if (
entry.isIntersecting &&
!isFilteringOnlyOperators &&
!hasFinished &&
!pagesRequested.include(page)
) {
setPagesRequested(pagesRequested.concat(page));
debouncedFetchCharts();
} |
- hasFinished is substituted by totalPages and it is passed as part of the payload of requestCharts - hasFinished is renamed to hasFinishedFetching: false, in the chart state - delete unused query param at the requestCharts action - edit tests to match the aforementioned non-functional changes
- If a filter was not present after a change (i.e., a different ns with different repos) the "catalog is empty" msg appeared
As I mentioned in the (offline) discussion, I have not been able to move the This situation also prevented me to write some test cases, I will ping you to discuss a little bit more on how to address them.
Apart from these three major (at least for me) tests, the PR will be finished. |
@@ -62,33 +81,6 @@ describe("fetchCharts", () => { | |||
); | |||
}); | |||
|
|||
it("fetches charts with query", async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why have you deleted this test? (even if the query is not sent in the action it's useful to ensure it's being used)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The action no longer has the "query" as part of the payload. Why would we have to still have this test for a non-existent parameter?
export const requestCharts = createAction("REQUEST_CHARTS", resolve => {
return (page?: number) => resolve(page);
});
Instead, they have been replaced by others testing the page
parameter; namely:
fetches charts from a repo (first page)
fetches charts from a repo (middle page)
fetches charts from a repo (last page)
fetches charts from a repo (already processed page)
fetches charts from a repo (not-yet-requested page)
fetches charts from a repo (already-requested page)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
basically to ensure that the URL contains the query
parameter
@@ -70,14 +71,15 @@ const chartsReducer = ( | |||
state.records.set(state.page, true); // mark the current page as successfully retrieved | |||
state.records.set(state.page + 1, false); // mark the next page as pending (not undefined) | |||
} | |||
const isLastPage = action.payload.page >= action.payload.totalPages; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't this be page === totalPages
? (not sure why the >=
, if that's possible.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, in theory, we reach the end when the page===totalPages
. It was just to cover the edge case in which two receiveCharts
arrive at the same time and, "lastPage" and "lastPage+1" are both marked to be fetched. So I directly consider lastPage+n
as the same case.
However, it should occur and, if it does, we can just ignore the subsequent lastPage+n
requests.
<div className="endPageMessage"> | ||
<LoadingWrapper loaded={false} /> | ||
<span> | ||
{!charts.length && !csvs.length | ||
? "Loading catalog..." | ||
: "Scroll down to discover more applications"} | ||
</span> | ||
</div> | ||
)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
two minor things here:
- I would use the small loading spinner, I think there is a property for that
- Would remove the messages (including "no remaining applications"), it's clear that it's loading when the loading wrapper is shown and it's weird when using filters. For example if I search "wordpress" I see the message "scroll down..." but there is no need to scroll down, is just a matter of letting it load.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- I'll test the small spinner out, I remembered to have seen this prop and will add some screenshots for us to compare.
- I'm not sure about that, searching for WordPress perhaps results in a few charts (and "no remaining applications" won't be displayed if searching). However, search results can possibly be paginated, so these messages do make sense for me. Again, I will change it and will post here some gifs for us to decide.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't get your point here, this is how it was working so far. What if only operators, and not charts, are present? |
Not a problem at all! And thank you for checking manually the code :)
Yes, it was something I also wanted to discuss. For this PR, I can hardcoded it to 50, that's great. However, perhaps we should move it to the |
Pagination pair progr
The previous fix didn't completely solve the issue. Now charts and operators are rendered at the same time during the page 1 invocation. Added corresponding tests.
After a bunch of test cases and minor bug fixes after addressing the previous comments and code suggestions, there is a stable version. There are no duplicated requests and the overall UX should be also ok. Given that the end of the iteration is coming, I would suggest prioritizing the essential pending aspects you may consider to this PR to be merged. And, next, creating an issue for documenting the further actions for further iterations. See what you think :) First render:
Scrollinguser scrolls down and, because |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! The functional code looks good to me (caveat below), I just have some minor comments regarding the tests.
iii) there is an issue when filter values include a comma.
I'm able to reproduce that bug and it's quite important. Can you open another PR to fix it before doing a release? It should be easy to fix (just ignore commas for the search filter).
@@ -62,33 +81,6 @@ describe("fetchCharts", () => { | |||
); | |||
}); | |||
|
|||
it("fetches charts with query", async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
basically to ensure that the URL contains the query
parameter
dashboard/src/actions/charts.ts
Outdated
items: response.data, | ||
page, | ||
totalPages: response.meta.totalPages, | ||
} as IReceiveChartsActionPayload), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIk you don't need the as IReceiveChartsActionPayload
if the object you are passing contains all the fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this is not necessary at all, but I just wanted to type the object so that we can get transpiler errors when changing its props in the future. However, I'm ok removing it.
hasFinishedFetching: false, | ||
selected: {} as IChartState["selected"], | ||
deployed: {} as IChartState["deployed"], | ||
items: [], | ||
categories: [], | ||
updatesInfo: {}, | ||
page: 1, | ||
size: 0, | ||
size: 20, | ||
records: new Map<number, boolean>().set(1, false), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can remove hasFinishedFetching
and records
from here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, records
was unnoticedly left there. But, hasFinishedFetching
is still part of the IChartState
, so it should remain, shouldn't it?
Edit: ah, you perhaps meant page
, since it is now in the catalog state.
spyOnUseState = jest | ||
.spyOn(React, "useState") | ||
// @ts-ignore | ||
.mockImplementation((init: any) => [init, setState]); | ||
spyOnUseEffect = jest | ||
.spyOn(React, "useEffect") | ||
// @ts-ignore | ||
.mockImplementation((f, n) => f(mockUseEffect(n))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you should not mock useState
useEffect
or any of the internals of the component (among other reasons, because you are changing the behavior of the component). The only thing you are interested here is that fetchCharts
is called once, with the proper page
expect(resetRequestCharts).toHaveBeenNthCalledWith(1); | ||
}); | ||
|
||
it("sets state params when fetching charts", () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not sure what this test is ensuring, is it the same than the previous one? In any case, the same comment applies, you should not test/ensure the component internals but the outputs
expect(resetRequestCharts).toHaveBeenCalledWith(); | ||
}); | ||
|
||
it("sets state params when charts have been fetched", () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same, here you can test for example that the items
are translated to CatalogItems
expect(resetRequestCharts).toHaveBeenNthCalledWith(1); | ||
}); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would add a test that checks that the page is set to 1 and that the resetCharts
function is called when one of the filters change (if changing the page is not easy, at least check that the reset method is called).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this test is still not there, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ups!! I totally forgot and missed this comment, sorry!
isFetching: false, | ||
hasFinishedFetching: false, | ||
items: [chartItem], | ||
// page: 3, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove comment
isFetching: false, | ||
hasFinishedFetching: false, | ||
items: [chartItem], | ||
// page: 3, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same
expect(resetRequestCharts).toHaveBeenNthCalledWith(1); | ||
}); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to check if it's possible to mock the observer to test that at least we are changing the page. I managed to do so:
it("changes page", () => {
const setState = jest.fn();
const setPage = jest.fn();
const charts = {
...defaultChartState,
hasFinishedFetching: false,
isFetching: false,
items: [],
} as any;
spyOnUseState = jest
.spyOn(React, "useState")
// @ts-ignore
.mockImplementation((init: any) => {
if (init === false) {
// Mocking the result of hasLoadedFirstPage to simulate that is already loaded
return [true, setState];
}
if (init === 1) {
// Mocking the result of setPage to ensure it's called
return [1, setPage];
}
return [init, setState];
});
// Mock intersection observer
const observe = jest.fn();
const unobserve = jest.fn();
window.IntersectionObserver = jest.fn(callback => {
(callback as (e: any) => void)([{ isIntersecting: true }]);
return { observe, unobserve } as any;
});
mountWrapper(defaultStore, <Catalog {...populatedProps} charts={charts} />);
expect(setPage).toHaveBeenCalledWith(2);
});
Ideally we should check that fetchCharts
is called with the second page, rather than mocking setPage
but if we don't mock setPage
, it enters an infinite loop increasing the page and if we mock it, the related useEffect
is not called.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW, you may need to also mock window.IntersectionObserverEntry
, I commented that part of the code when trying this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FTR:
The condition was: if ("IntersectionObserver" in window && "IntersectionObserverEntry" in window && "isIntersecting" in window.IntersectionObserverEntry.prototype)
Since mocking window.IntersectionObserverEntry.prototype
is causing problems, we decided to get rid of this condition.
In the future, if we decide to implement a fallback mechanism (fetch all pages instead of one-by-one), we can add it back.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the changes, this is almost done :) Accepting it already once you add that test I mention
expect(mockUseEffect).toHaveBeenNthCalledWith(3, [false, false]); // [hasRequestedFirstPage, isFetching]) | ||
expect(fetchCharts).toHaveBeenNthCalledWith(1, "default-cluster", "kubeapps", "", 1, 20, ""); // [hasRequestedFirstPage, isFetching]) | ||
expect(mockUseEffect).toHaveBeenCalledTimes(7); | ||
expect(wrapper.find("CatalogItems>CatalogItem>ChartCatalogItem").length).toBe(0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can just do find(ChartCatalogItem)
, no?
expect(resetRequestCharts).toHaveBeenNthCalledWith(1); | ||
}); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this test is still not there, no?
Don't wait for a +1 from me, unless there's something specific you want input on. I'll trust Andres' review :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, still +1 but I would change the test.
spyOnUseState.mockRestore(); | ||
expect(setPage).toHaveBeenCalledWith(2); // changes page to 2 | ||
expect(resetRequestCharts).toHaveBeenCalled(); // but receives a change in a filter | ||
expect(wrapper.find("CatalogItems").prop("page")).toBe(1); // and page is again 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is not ensuring what you want. You can verify it commenting the useEffect(...)
that reset the page and the charts. If you do that, this test will still pass I believe.
I would do a much simpler test, without the setState
mock. I would just check that after the filter change, resetRequestCharts
is called twice (I belive it's called once the first time the component is rendered). We would not be checking that the page number is reset but since it's in the same piece of logic it's worth it not to add extra complexity in the test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just commented
useEffect(() => { setPage(1); resetRequestCharts();}, ...
and the test is not passing. Besides, when moving to the simpler test, it seems resetRequestCharts
is called just once (but, I agree with you, it should be twice; I will check it)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As per the offline conversation: setPage
is not really changing the page expect(wrapper.find("CatalogItems").prop("page")).toBe(2); ---> false
.
Besides, implementing the test for testing this is not that easy. For instance, trying to set a repo search filter with 'checked=true` didn't work.
The agreed decision has been just to add a //TODO here and go ahead with this PR (so that the release can be out asap).
@@ -366,7 +366,7 @@ describe("pagination and chart fetching", () => { | |||
/>, | |||
); | |||
expect(wrapper.find("CatalogItems").prop("page")).toBe(1); | |||
expect(wrapper.find("CatalogItems>CatalogItem>ChartCatalogItem").length).toBe(0); | |||
expect(wrapper.find("ChartCatalogItem").length).toBe(0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
btw, just fyi in case you don't know, the difference between using wrapper.find(Component)
and wrapper.find("Component")
is that the second verifies that there is a component named "Component" while the first ensures that the exact component (the one you are importing in the test file) is present.
You don't need to change this, it's just that I usually do the former since it's more accurate, in case there are two components that are named the same.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahhh, good to know! Thanks for the tip!
Merging finally!!! 🤞 |
Description of the change
This PR adds endless scrolling in the Catalog view as a result of the analysis and discussions of the design document.
We already implemented the server-side filtering for the most expensive calls: 1) filter by text; 2) filter by repository.
Moreover, the available categories are retrieved from the server-side (instead of getting unique names each time on client-side).
In this PR we support endless pagination, so once the user has reached the bottom of the page, new chunks will be requested.
Benefits
Users won't experience a long delay when rendering the whole set of charts, since the catalog will be retrieved little by little.
Possible drawbacks
Currently, there are issues when triggering many requests at a time. For instance, for replicating this failure scenario, rapidly click on a repository checkbox multiple times and scroll up and down. In the network tab, some requests don't make sense: repeated ones, race conditions. etcCurrently, we don't retry failed requests.
Applicable issues
Additional information
Change initially introduced in #2221 but removed from there to keep it as simple as possible.
Specifically, this PR is being sent after having implemented the necessary code in other places for the pagination to work, namely:
Some implementation details:
First render:
page
=1
hasRequestedFirstPage
=false
hasLoadedFirstPage
=false
isFetching
=false
useEffect(initial)->
fetchCharts
withpage
=1
isFetching
=false
useEffect(
isFetching
changed totrue
) ->hasRequestedFirstPage
=true
useEffect(
hasRequestedFirstPage
changed totrue
) ->hasLoadedFirstPage
=true
Scrolling
user scrolls down and, because
hasLoadedFirstPage
istrue
->page
=page + 1
useEffect(
page
changed to)->fetchCharts
withpage
=2