Add pagination info in the query parameter#1417
Conversation
|
A few quick thoughts from me!
I think this PR will conflict with getodk/central#1434, just to the extent that I'll be adding an
If there are pagination-related query parameters, do those currently persist when toggling to map view? For example:
How about we add a composable for the pagination-related query parameters? I'm thinking of something similar to |
| const lastPage = Math.max(0, Math.ceil(pagination.count / pagination.size) - 1); | ||
| if (pagination.page > lastPage) { | ||
| pagination.page = lastPage; | ||
| fetchChunk(false); |
There was a problem hiding this comment.
Are you specifying false so that the snapshot filter isn't reset?
One thing I'm thinking about is that it'd be good to show a loading message during this second fetchChunk(). Otherwise, if that request takes a while, the user might get confused and think that lastPage is empty. Maybe we could show the loading message by setting odata.data to null here before calling fetchChunk(). Or maybe that logic belongs in fetchChunk(), and fetchChunk() just needs different options than clear and refresh. 🤔
There was a problem hiding this comment.
introduced skipSettingSnapshotFilter in fetchChunk
There was a problem hiding this comment.
removed skipSettingSnapshotFilter as we are no longer using snapshot filters.
and set fetchChunk(true) so that loading message is shown
| if (!num || num < 0) return 0; | ||
| return num - 1; | ||
| }, | ||
| toQuery: (value) => ({ 'page-number': value + 1 }) |
There was a problem hiding this comment.
What do you think about removing the query parameter for the first page? Since the first page is the default — one less query parameter in the URL.
| toQuery: (value) => ({ 'page-number': value + 1 }) | |
| toQuery: (value) => ({ 'page-number': value === 0 ? null : value + 1 }) |
|
|
||
| describe('SubmissionFieldDropdown', () => { | ||
| beforeEach(() => { | ||
| mockLogin(); |
There was a problem hiding this comment.
Why is mockLogin() needed here out of curiosity? Something to do with the router?
There was a problem hiding this comment.
because I changed the router from mock to test in test/util/submission.js, with that change request to sessions is made causing the tests to fail.
Another thing I can do is to add replace method to the list of supported method for mockRouter.
There was a problem hiding this comment.
Another thing I can do is to add
replacemethod to the list of supported method for mockRouter.
I'm open to this. It sure would be nice for there to be fewer cases in which testRouter() is needed. As-is, the difference between mockRouter() and testRouter() can feel sort of complicated.
Part of my idea for mockRouter() was that it isolates the individual component being tested from all the logic in the router (the beforeEach guards, etc.). To keep with that theme, maybe replace() could look something like this in mockRouter():
replace: (location) => {
currentRoute.value = router.resolve(location);
},In other words, it's not actually doing a navigation (it's not actually calling router.replace()). Instead, it's just setting currentRoute as if the navigation took place. mockRouter() would no longer be read-only, but it would still be different from testRouter() in that it would never run navigation hooks.
Some other things would have to change in mockRouter() though, like the way that $route is provided on app install. It's just a constant right now.
This path sounds promising, but also no need to go down it right now if it doesn't feel like the right time. I'm happy with us using mockLogin() here.
8081b98 to
0bdd1e6
Compare
| :top="pagination.size" | ||
| :filter="odataFilter != null || !!searchTerm" | ||
| :total-count="dataset.dataExists ? dataset.entities : 0"/> | ||
| :total-count="dataset.dataExists && !pagination.page ? dataset.entities : 0"/> |
There was a problem hiding this comment.
I don't want to show "Loading first N entities...", with this change it will always display "Loading entiities..." when there is a pagination information in the query parameter on the first loading. Note that on change pages using pagination control doesn't show any loading message; we show spinner in the pagination control.
There was a problem hiding this comment.
Could you say more about why you don't want to show "Loading first N entities…"? I'm OK with that change, just wondering whether Nicole suggested it or if there was another reason.
Note that on change pages using pagination control doesn't show any loading message
If that's the case, I sort of think we should simplify OdataLoadingMessage. It has so many cases, many of which aren't about the first page. If some of its i18n messages aren't useful anymore, it'd be nice to remove them so that translators don't have to translate them.
One option is that we make that change in a follow-up PR. In that case, maybe you could file a refactor issue about it?
There was a problem hiding this comment.
It'd be great if we could resolve this comment before merging. If this PR will make several i18n messages irrelevant, I think it'd be best to remove them. That could be done in a follow-up PR, but it'd be nice to track the need to do so in a new issue.
| fromQuery: (query) => { | ||
| const num = Number.parseInt(query['page-number'], 10); | ||
| if (!num || num < 0) return 0; | ||
| return num - 1; |
There was a problem hiding this comment.
should I update the query parameter here? if the given value is out of valid range? we don't do that for invalid values of filters, right?
There was a problem hiding this comment.
we don't do that for invalid values of filters, right?
That's right. If filter query parameters are supplied invalid values, those values are generally ignored. The URL isn't changed/corrected, but nothing explodes either. The invalid values fall back to something reasonable.
In some cases, we can't know right away whether a value is valid. For example, specifying a submitter ID that doesn't exist: we can't know that it's wrong until we receive the response for the list of submitters (async).
should I update the query parameter here?
I'm open to it, but it feels more complex to me. It seems simpler not to change the route if we don't need to. It should be pretty rare that someone specifies an invalid value.
if the given value is out of valid range?
"Out of valid range" just refers to if (!num || num < 0) return 0; right? That logic sounds great to me.
|
|
||
| describe('SubmissionFieldDropdown', () => { | ||
| beforeEach(() => { | ||
| mockLogin(); |
There was a problem hiding this comment.
because I changed the router from mock to test in test/util/submission.js, with that change request to sessions is made causing the tests to fail.
Another thing I can do is to add replace method to the list of supported method for mockRouter.
I am okay with either way, I can hold this PR till maps for Entities are done or at list
Fixed it
Created |
| export default (pageSizeOptions = [250, 500, 1000]) => { | ||
| const pageNumber = useQueryRef({ | ||
| fromQuery: (query) => { | ||
| const num = Number.parseInt(query['page-number'], 10); |
There was a problem hiding this comment.
I using page-number for the readability sake, we can also go with just page.
There was a problem hiding this comment.
I definitely prefer page over page-number. It's much shorter, and I see it on many sites. page + page-size sounds like a nice combo to me. I don't have the strongest feelings about it though, we could run with page-number for now and see if anyone else on the team voices an opinion.
| fromQuery: (query) => { | ||
| const num = Number.parseInt(query['page-number'], 10); | ||
| if (!num || num < 0) return 0; | ||
| return num - 1; |
There was a problem hiding this comment.
I guess page number starting from 1 is more acceptable than 0.
| toQuery: (value) => ({ 'page-number': value === 0 ? null : value + 1 }) | ||
| }); | ||
|
|
||
| const pageSize = useQueryRef({ |
There was a problem hiding this comment.
Expecting absolute value for page sizes like 250, 500, 1000 instead of index of options here.
0bdd1e6 to
66eb211
Compare
91183bc to
116a34d
Compare
|
@matthew-white this is ready for re-review. |
matthew-white
left a comment
There was a problem hiding this comment.
It's looking good!
Probably the main question I still have is where/when pagination.page is set to 0.
In some cases, I made non-essential suggestions. I know we want to get this PR merged soon, so I'm happy with us skipping some of those suggestions or filing follow-up issues about them.
| const first = clear || refresh; | ||
| if (first) { | ||
| if (refresh) { | ||
| pagination.page = 0; |
There was a problem hiding this comment.
I'm trying to think through the ramifications of pagination.page = 0; for a background refresh. Wouldn't that cause the pagination control to immediately change to the first page? But the actual table wouldn't change until the request completes. If that takes a while, I think it'd be confusing for the pagination control to say that it's on the first page. Or even worse, if the request results in an error, the pagination control would incorrectly think that it's on the first page.
If it's a background refresh, I feel like pagination.page should be set to 0 only once the response is received.
It kind of feels like that would be changing the current behavior of background refresh though. Right now, does it refresh the current page rather than returning to the first page? If so, I feel like changing that behavior should maybe go in its own PR. Then this PR could just be focused on query parameters.
There was a problem hiding this comment.
I have tested on test.getodk.cloud and on pressing of refresh button it goes back to the first page.
What I have done now is that $skip would send 0 if refresh or clear is true otherwise use pagination options. And pagination.page=0 happens only in the success response.
| :count="pagination.count" :size-options="pageSizeOptions" | ||
| :spinner="odata.awaitingResponse" | ||
| :removed="pagination.removed" | ||
| @update:page="handlePageChange"/> |
There was a problem hiding this comment.
Instead of calling handlePageChange this way by listening for an event, I'm wondering whether we should add a watcher on pagination.page. Now there are multiple pathways that can change pagination.page. If the user changes the page number in the URL, that will change pagination.page, and I'd expect that to have an effect on the page itself. If that's hard to do, it's probably not a big deal, but that's how filters work right now. Hopefully just making it a watcher would work out.
There was a problem hiding this comment.
when I change the page number in the URL, I see whole page gets reloaded both in chrome and firefox, which sets the correct pagination options before request is made hence page itself shows the correct data. I hope I understood your comment correctly.
There was a problem hiding this comment.
Ah right, I forgot that's how it works. This might have been a concern under the old hash-based routing, but not anymore.
| fetchChunk(true); | ||
| watch([() => props.filter, () => props.deleted], () => { fetchChunk(true); }); | ||
| watch([() => props.filter, () => props.deleted], () => { | ||
| pagination.page = 0; |
There was a problem hiding this comment.
Right now, if you change the selection of columns / form fields, you're sent back to the first page. But I'm only seeing this line pagination.page = 0; for filter changes, not a change to the column selection. I could be convinced that the current behavior isn't important and is fine to change, but I'm wondering if there's a different way to approach this. I kind of like how fetchChunk() used to be responsible for setting pagination.page in more cases. What if fetchChunk() took a page number argument that defaulted to 0? That would also work for the case where the user navigates past the last page. Like I said, I'm open to various approaches here, I just wanted to throw out these thoughts.
There was a problem hiding this comment.
Since I have move pagination.page=0 in the then of the request, it will retain the existing behavior of changing columns.
| /* | ||
| Copyright 2025 ODK Central Developers | ||
| See the NOTICE file at the top-level directory of this distribution and at | ||
| https://github.com/getodk/central-frontend/blob/master/NOTICE. | ||
|
|
||
| This file is part of ODK Central. It is subject to the license terms in | ||
| the LICENSE file found in the top-level directory of this distribution and at | ||
| https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, | ||
| including this file, may be copied, modified, propagated, or distributed | ||
| except according to the terms contained in the LICENSE file. | ||
| */ |
There was a problem hiding this comment.
We don't need these file headers for new files anymore. 🥳 So this can be removed.
|
|
||
| import useQueryRef from './query-ref'; | ||
|
|
||
| export default (pageSizeOptions = [250, 500, 1000]) => { |
There was a problem hiding this comment.
I'm not sure we need a default value here. It's hard for me to think of a case where pageSizeOptions wouldn't be provided. sizeOptions is a required prop in the Pagination component, so it should always exist. If we were to add the concept of a default set of size options, that should probably go in multiple places (e.g., Pagination as well).
| }); | ||
| }); | ||
|
|
||
| it('floors page-size to nearest valid value when invalid page-size is provided in URL', () => { |
There was a problem hiding this comment.
For some of these edge cases, I feel like they'd be better tested in unit tests of the usePaginationQueryRef() composable rather than in every component that uses the composable. There are already so many tests of the EntityList component.
withSetup() provides a way to unit-test a composable. We have unit tests of useQueryRef() that you might find interesting to look at.
Given that these tests are already written, I'm happy to run with them as-is. Mostly I just wanted to share the thought that I think there can be benefit to unit-testing composables and that withSetup() provides a way to do so.
| router: mockRouter(form.publishedAt != null | ||
| ? `/projects/${project.id}/forms/${encodeURIComponent(form.xmlFormId)}/submissions` | ||
| : `/projects/${project.id}/forms/${encodeURIComponent(form.xmlFormId)}/draft`) |
There was a problem hiding this comment.
That's interesting that it's OK for these paths to go away. I see above that loadEntityList() has never specified a path to mockRouter(). There would probably be options to specify a path if we needed to. Maybe by calling .route(), similar to what's done in test/components/submission/filters.spec.js. There, loadSubmissionList() is called with testRouter(), then .route() is called so that the initial route is set correctly. If it's working now though, I guess nothing needs to change 🤷 — no path is needed here. But maybe let's at least get rid of testRouter() in submission/filters.spec.js if testRouter() is going to be the default going forward.
116a34d to
300df5e
Compare
300df5e to
d43c995
Compare
matthew-white
left a comment
There was a problem hiding this comment.
I haven't taken a look at all the changes yet, but I thought I'd leave some initial comments about mockRouter() and replace(). Related comment: #1417 (comment)
There was a problem hiding this comment.
I think these comments are out-of-date now:
- The mock router can now navigate.
- It's no longer read-only.
| push: sinon.fake(), | ||
| replace: (loc) => { | ||
| currentRoute.value = router.resolve(loc); | ||
| }, |
There was a problem hiding this comment.
I don't feel like it totally works to make this change on its own. For example, $route is currently set to a constant at install time under the assumption that it will never change. But that assumption won't be true anymore.
I'm thinking we could create a Proxy object that delegates to currentRoute.value. Something like:
const routeProxy = new Proxy(currentRoute, {
get: (route, prop) => route.value[prop]
});
app.config.globalProperties.$route = routeProxy;
app.provide(routeLocationKey, routeProxy);| app.component('RouterView', RouterViewStub); | ||
| }, | ||
| push: sinon.fake() | ||
| push: sinon.fake(), |
There was a problem hiding this comment.
What do you think about making push() the same as replace()? It feels a little unexpected that replace() will change the route, while push() won't. push() could remain a Sinon fake. So something like:
push: sinon.fake(loc => {
currentRoute.value = router.resolve(loc);
}),I'm not sure how tests currently use push(), so maybe this would cause problems, but hopefully that'd work.
| @@ -129,6 +129,25 @@ describe('SubmissionList', () => { | |||
| }); | |||
| }); | |||
| }); | |||
|
|
|||
| it('makes the correct request after refresh button is pressed on second page', () => { | |||
There was a problem hiding this comment.
Could you add a similar test for EntityList? It looks to me like SubmissionList and EntityList work slightly differently in this area.
| // For defineExpose() | ||
| const exposedFetch = { | ||
| fetchData: (clear = true) => fetchChunk(clear, !clear), | ||
| fetchData: (clear = true) => fetchChunk(clear, clear ? 0 : pagination.page), |
There was a problem hiding this comment.
| fetchData: (clear = true) => fetchChunk(clear, clear ? 0 : pagination.page), | |
| fetchData: (clear = true) => fetchChunk(clear), |
I'm thinking that the second argument here should always be 0 (or should be omitted). At #1417 (comment), you wrote:
I have tested on test.getodk.cloud and on pressing of refresh button it goes back to the first page.
But to me, that doesn't seem to be what this line is doing. If clear is false (if the refresh button has been pressed), then the user will remain on their current pagination.page, not go back to the first page.
|
I think this comment thread still needs to be resolved: #1417 (comment). I glanced over all the discussion above, and with the exception of that thread and my review today, I didn't see any other open threads. |
matthew-white
left a comment
There was a problem hiding this comment.
Looks great! 💯 I know we've been discussing this one for a while, but I think this PR has ended up in a really nice place. I think it says something that it now only modifies 8 files. I really like the unit tests of the usePaginationQueryRef() composable — they feel like they're at the right level.
There are still a few comments left to resolve. Nothing big though, so I'll go ahead and approve. @sadiqkhoja, feel free to merge once you've resolved those comments!
PR Feedback + Created paginationQueryRef composable + Removed pagination query parameter when navigating to the map view + Included skipSettingSnapshotFilter to the fetchChunk function
introduced `page` arg in fetchChunk() set page=0 in success response of the request removed copyright header removed default pageSizeOptions from pagination-query-ref various changes to the tests
5e35277 to
9510b70
Compare
9510b70 to
2d7d63f
Compare
Closes getodk/central#1348
What has been done to verify that this works as intended?
Manual verification + added few tests.
Why is this the best possible solution? Were any other approaches considered?
Please see inline comments.
How does this change affect users? Describe intentional changes to behavior and behavior that could have accidentally been affected by code changes. In other words, what are the regression risks?
No
Does this change require updates to user documentation? If so, please file an issue here and include the link below.
No
Before submitting this PR, please make sure you have:
npm run testandnpm run lintand confirmed all checks still pass OR confirm CircleCI build passes