-
Notifications
You must be signed in to change notification settings - Fork 76
Add pagination info in the query parameter #1417
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,7 @@ except according to the terms contained in the LICENSE file. | |
| type="submission" | ||
| :top="pagination.size" | ||
| :filter="!!filter" | ||
| :total-count="totalCount"/> | ||
| :total-count="pagination.page ? 0 : totalCount"/> | ||
| <!-- @update:page is emitted on size change as well --> | ||
| <Pagination v-if="pagination.count > 0" | ||
| v-model:page="pagination.page" v-model:size="pagination.size" | ||
|
|
@@ -34,6 +34,7 @@ import { computed, reactive, useTemplateRef, watch } from 'vue'; | |
| import OdataLoadingMessage from '../odata-loading-message.vue'; | ||
| import Pagination from '../pagination.vue'; | ||
| import SubmissionTable from './table.vue'; | ||
| import usePaginationQueryRef from '../../composables/pagination-query-ref'; | ||
|
|
||
| import { apiPaths } from '../../util/request'; | ||
| import { noop, reemit, reexpose } from '../../util/util'; | ||
|
|
@@ -73,9 +74,10 @@ const emit = defineEmits(['review', 'delete', 'restore']); | |
| const { odata, deletedSubmissionCount } = useRequestData(); | ||
|
|
||
| const pageSizeOptions = [250, 500, 1000]; | ||
| const { pageNumber, pageSize } = usePaginationQueryRef(pageSizeOptions); | ||
| const pagination = reactive({ | ||
| page: 0, | ||
| size: pageSizeOptions[0], | ||
| page: pageNumber, | ||
| size: pageSize, | ||
| count: computed(() => (odata.dataExists ? odata.count : 0)), | ||
| removed: computed(() => (odata.dataExists ? odata.removedSubmissions : 0)) | ||
| }); | ||
|
|
@@ -88,12 +90,10 @@ const odataSelect = computed(() => { | |
| }); | ||
|
|
||
| // `clear` indicates whether this.odata should be cleared before sending the | ||
| // request. `refresh` indicates whether the request is a background refresh | ||
| // request. `refresh` indicates whether the request is a background refresh. | ||
| // (whether the refresh button was pressed). | ||
| const fetchChunk = (clear, refresh = false) => { | ||
| // Are we fetching the first chunk of submissions or the next chunk? | ||
| const first = clear || refresh; | ||
| if (first) { | ||
| if (refresh) { | ||
| pagination.page = 0; | ||
| } | ||
|
|
||
|
|
@@ -115,6 +115,11 @@ const fetchChunk = (clear, refresh = false) => { | |
| clear | ||
| }) | ||
| .then(() => { | ||
| const lastPage = Math.max(0, Math.ceil(odata.count / pagination.size) - 1); | ||
| if (pagination.page > lastPage) { | ||
| pagination.page = lastPage; | ||
| fetchChunk(true); | ||
| } | ||
| if (props.deleted) { | ||
| deletedSubmissionCount.cancelRequest(); | ||
| if (!deletedSubmissionCount.dataExists) { | ||
|
|
@@ -126,7 +131,10 @@ const fetchChunk = (clear, refresh = false) => { | |
| .catch(noop); | ||
| }; | ||
| fetchChunk(true); | ||
| watch([() => props.filter, () => props.deleted], () => { fetchChunk(true); }); | ||
| watch([() => props.filter, () => props.deleted], () => { | ||
| pagination.page = 0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| fetchChunk(true); | ||
| }); | ||
| watch(() => props.fields, (_, oldFields) => { | ||
| // SubmissionList resets column selector when delete button is pressed, in | ||
| // that case we don't want to send request from here. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| /* | ||
| 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. | ||
| */ | ||
|
Comment on lines
+1
to
+11
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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]) => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure we need a default value here. It's hard for me to think of a case where |
||
| const pageNumber = useQueryRef({ | ||
| fromQuery: (query) => { | ||
| const num = Number.parseInt(query['page-number'], 10); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I using
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I definitely prefer |
||
| if (!num || num < 1) return 0; | ||
| return num - 1; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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).
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.
"Out of valid range" just refers to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess page number starting from 1 is more acceptable than 0. |
||
| }, | ||
| toQuery: (value) => ({ 'page-number': value === 0 ? null : value + 1 }) | ||
| }); | ||
|
|
||
| const pageSize = useQueryRef({ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expecting absolute value for page sizes like 250, 500, 1000 instead of index of options here. |
||
| fromQuery: (query) => { | ||
| const size = Number.parseInt(query['page-size'], 10); | ||
|
|
||
| if (!size || size < pageSizeOptions[0]) return pageSizeOptions[0]; | ||
| if (size >= pageSizeOptions[2]) return pageSizeOptions[2]; | ||
| if (size >= pageSizeOptions[1]) return pageSizeOptions[1]; | ||
| return pageSizeOptions[0]; | ||
| }, | ||
| toQuery: (value) => ({ 'page-size': value }) | ||
| }); | ||
|
|
||
| return { | ||
| pageNumber, | ||
| pageSize | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -929,9 +929,74 @@ describe('EntityList', () => { | |
| }) | ||
| .respondWithSuccess(); | ||
| const text = component.get('.pagination .form-group').text(); | ||
|
|
||
| text.should.equal('Rows 1–249 of 259'); | ||
| }); | ||
|
|
||
| it('adds page-size query parameter when page size is changed', () => { | ||
| createEntities(251); | ||
| return load('/projects/1/entity-lists/trees/entities') | ||
| .complete() | ||
| .request(component => { | ||
| const sizeDropdown = component.find('.pagination select:has(option[value="500"])'); | ||
| return sizeDropdown.setValue(500); | ||
| }) | ||
| .respondWithData(() => testData.entityOData(500)) | ||
| .afterResponse(component => { | ||
| component.vm.$route.query['page-size'].should.equal('500'); | ||
| }); | ||
| }); | ||
|
|
||
| it('adds page-number query parameter when next page is clicked', () => { | ||
| createEntities(251); | ||
| return load('/projects/1/entity-lists/trees/entities') | ||
| .complete() | ||
| .request(component => | ||
| component.find('button[aria-label="Next page"]').trigger('click')) | ||
| .respondWithData(() => testData.entityOData(250, 250)) | ||
| .afterResponse(component => { | ||
| component.vm.$route.query['page-number'].should.equal('2'); | ||
| }); | ||
| }); | ||
|
|
||
| it('displays the correct page when page-number is provided in URL', () => { | ||
| createEntities(501); | ||
| return load('/projects/1/entity-lists/trees/entities?page-number=2', { root: false }) | ||
| .afterResponse(component => { | ||
| component.find('.pagination .form-group').text().should.be.equal('Rows 251–500 of 501'); | ||
| }); | ||
| }); | ||
|
|
||
| it('displays correct number of rows when page-size is provided in URL', () => { | ||
| createEntities(600); | ||
| return load('/projects/1/entity-lists/trees/entities?page-size=500', { root: false }) | ||
| .afterResponse(component => { | ||
| component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('500'); | ||
| }); | ||
| }); | ||
|
|
||
| it('selects first page when page-number is less than 1 in URL', () => { | ||
| createEntities(251); | ||
| return load('/projects/1/entity-lists/trees/entities?page-number=0', { root: false }) | ||
| .afterResponse(component => { | ||
| component.find('.pagination .form-group').text().should.be.eql('Rows 1–250 of 251'); | ||
| }); | ||
| }); | ||
|
|
||
| it('selects last page when page-number is greater than last page in URL', () => { | ||
| createEntities(501); | ||
| return load('/projects/1/entity-lists/trees/entities?page-number=999', { root: false }) | ||
| .afterResponse(component => { | ||
| component.find('.pagination .form-group').text().should.be.eql('Row 501 of 501'); | ||
| }); | ||
| }); | ||
|
|
||
| it('floors page-size to nearest valid value when invalid page-size is provided in URL', () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For some of these edge cases, I feel like they'd be better tested in unit tests of the
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 |
||
| createEntities(251); | ||
| return load('/projects/1/entity-lists/trees/entities?page-size=350', { root: false }) | ||
| .afterResponse(component => { | ||
| component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('250'); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('deleted entities', () => { | ||
|
|
@@ -962,7 +1027,7 @@ describe('EntityList', () => { | |
|
|
||
| it('updates the deleted count', () => { | ||
| testData.extendedEntities.createPast(1, { deletedAt: new Date().toISOString() }); | ||
| return load('/projects/1/entity-lists/truee/entities', { root: false, container: { router: testRouter() } }) | ||
| return load('/projects/1/entity-lists/tree/entities', { root: false, container: { router: testRouter() } }) | ||
| .complete() | ||
| .request(component => | ||
| component.get('.toggle-deleted-entities').trigger('click')) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import testData from '../../data'; | |
| import { loadSubmissionList } from '../../util/submission'; | ||
| import { mergeMountOptions, mount } from '../../util/lifecycle'; | ||
| import { testRequestData } from '../../util/request-data'; | ||
| import { mockLogin } from '../../util/session'; | ||
|
|
||
| const mountComponent = (options) => | ||
| mount(SubmissionFieldDropdown, mergeMountOptions(options, { | ||
|
|
@@ -25,6 +26,10 @@ const strings = (min, max) => { | |
| }; | ||
|
|
||
| describe('SubmissionFieldDropdown', () => { | ||
| beforeEach(() => { | ||
| mockLogin(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. because I changed the router from mock to test in Another thing I can do is to add
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'm open to this. It sure would be nice for there to be fewer cases in which Part of my idea for replace: (location) => {
currentRoute.value = router.resolve(location);
},In other words, it's not actually doing a navigation (it's not actually calling Some other things would have to change in 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 |
||
| }); | ||
|
|
||
| it('passes an option for each selectable field', () => { | ||
| const component = mountComponent({ | ||
| container: { | ||
|
|
||
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 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.pageshould be set to0only 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.