Skip to content

Commit 91183bc

Browse files
committed
Feature getodk/central#1348: Add pagination info in the query parameter
PR Feedback + Created paginationQueryRef composable + Removed pagination query parameter when navigating to the map view + Included skipSettingSnapshotFilter to the fetchChunk function
1 parent 338e4c1 commit 91183bc

File tree

9 files changed

+247
-23
lines changed

9 files changed

+247
-23
lines changed

src/components/entity/table-view.vue

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ except according to the terms contained in the LICENSE file.
1818
type="entity"
1919
:top="pagination.size"
2020
:filter="filter != null || !!searchTerm"
21-
:total-count="dataset.dataExists ? dataset.entities : 0"/>
21+
:total-count="dataset.dataExists && !pagination.page ? dataset.entities : 0"/>
2222
<!-- @update:page is emitted on size change as well -->
2323
<Pagination v-if="pagination.count > 0"
2424
v-model:page="pagination.page" v-model:size="pagination.size"
@@ -34,6 +34,7 @@ import { computed, inject, reactive, useTemplateRef, watch } from 'vue';
3434
import EntityTable from './table.vue';
3535
import OdataLoadingMessage from '../odata-loading-message.vue';
3636
import Pagination from '../pagination.vue';
37+
import usePaginationQueryRef from '../../composables/pagination-query-ref';
3738
3839
import { apiPaths } from '../../util/request';
3940
import { noop, reemit, reexpose } from '../../util/util';
@@ -60,9 +61,10 @@ const datasetName = inject('datasetName');
6061
const { dataset, odataEntities, deletedEntityCount } = useRequestData();
6162
6263
const pageSizeOptions = [250, 500, 1000];
64+
const { pageNumber, pageSize } = usePaginationQueryRef(pageSizeOptions);
6365
const pagination = reactive({
64-
page: 0,
65-
size: pageSizeOptions[0],
66+
page: pageNumber,
67+
size: pageSize,
6668
count: computed(() => (odataEntities.dataExists ? odataEntities.count : 0)),
6769
removed: computed(() => (odataEntities.dataExists ? odataEntities.removedEntities : 0))
6870
});
@@ -71,9 +73,7 @@ const pagination = reactive({
7173
// request. `refresh` indicates whether the request is a background refresh
7274
// (whether the refresh button was pressed).
7375
const fetchChunk = (clear, refresh = false) => {
74-
// Are we fetching the first chunk of entities or the next chunk?
75-
const first = clear || refresh;
76-
if (first) {
76+
if (refresh) {
7777
pagination.page = 0;
7878
}
7979
@@ -97,6 +97,11 @@ const fetchChunk = (clear, refresh = false) => {
9797
clear
9898
})
9999
.then(() => {
100+
const lastPage = Math.max(0, Math.ceil(odataEntities.count / pagination.size) - 1);
101+
if (pagination.page > lastPage) {
102+
pagination.page = lastPage;
103+
fetchChunk(true, false);
104+
}
100105
if (props.deleted) {
101106
deletedEntityCount.cancelRequest();
102107
if (!deletedEntityCount.dataExists) {
@@ -109,6 +114,7 @@ const fetchChunk = (clear, refresh = false) => {
109114
};
110115
fetchChunk(true);
111116
watch([() => props.deleted, () => props.filter, () => props.searchTerm], () => {
117+
pagination.page = 0;
112118
fetchChunk(true);
113119
});
114120
const handlePageChange = () => {

src/components/submission/table-view.vue

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ except according to the terms contained in the LICENSE file.
1818
type="submission"
1919
:top="pagination.size"
2020
:filter="!!filter"
21-
:total-count="totalCount"/>
21+
:total-count="pagination.page ? 0 : totalCount"/>
2222
<!-- @update:page is emitted on size change as well -->
2323
<Pagination v-if="pagination.count > 0"
2424
v-model:page="pagination.page" v-model:size="pagination.size"
@@ -34,6 +34,7 @@ import { computed, reactive, useTemplateRef, watch } from 'vue';
3434
import OdataLoadingMessage from '../odata-loading-message.vue';
3535
import Pagination from '../pagination.vue';
3636
import SubmissionTable from './table.vue';
37+
import usePaginationQueryRef from '../../composables/pagination-query-ref';
3738
3839
import { apiPaths } from '../../util/request';
3940
import { noop, reemit, reexpose } from '../../util/util';
@@ -73,9 +74,10 @@ const emit = defineEmits(['review', 'delete', 'restore']);
7374
const { odata, deletedSubmissionCount } = useRequestData();
7475
7576
const pageSizeOptions = [250, 500, 1000];
77+
const { pageNumber, pageSize } = usePaginationQueryRef(pageSizeOptions);
7678
const pagination = reactive({
77-
page: 0,
78-
size: pageSizeOptions[0],
79+
page: pageNumber,
80+
size: pageSize,
7981
count: computed(() => (odata.dataExists ? odata.count : 0)),
8082
removed: computed(() => (odata.dataExists ? odata.removedSubmissions : 0))
8183
});
@@ -88,12 +90,10 @@ const odataSelect = computed(() => {
8890
});
8991
9092
// `clear` indicates whether this.odata should be cleared before sending the
91-
// request. `refresh` indicates whether the request is a background refresh
93+
// request. `refresh` indicates whether the request is a background refresh.
9294
// (whether the refresh button was pressed).
9395
const fetchChunk = (clear, refresh = false) => {
94-
// Are we fetching the first chunk of submissions or the next chunk?
95-
const first = clear || refresh;
96-
if (first) {
96+
if (refresh) {
9797
pagination.page = 0;
9898
}
9999
@@ -115,6 +115,11 @@ const fetchChunk = (clear, refresh = false) => {
115115
clear
116116
})
117117
.then(() => {
118+
const lastPage = Math.max(0, Math.ceil(odata.count / pagination.size) - 1);
119+
if (pagination.page > lastPage) {
120+
pagination.page = lastPage;
121+
fetchChunk(true, false);
122+
}
118123
if (props.deleted) {
119124
deletedSubmissionCount.cancelRequest();
120125
if (!deletedSubmissionCount.dataExists) {
@@ -126,7 +131,10 @@ const fetchChunk = (clear, refresh = false) => {
126131
.catch(noop);
127132
};
128133
fetchChunk(true);
129-
watch([() => props.filter, () => props.deleted], () => { fetchChunk(true); });
134+
watch([() => props.filter, () => props.deleted], () => {
135+
pagination.page = 0;
136+
fetchChunk(true);
137+
});
130138
watch(() => props.fields, (_, oldFields) => {
131139
// SubmissionList resets column selector when delete button is pressed, in
132140
// that case we don't want to send request from here.

src/composables/data-view.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ export default () => {
1818

1919
const dataView = useQueryRef({
2020
fromQuery: (query) => (!query.deleted && query.map === 'true' ? 'map' : 'table'),
21-
toQuery: (value) => ({ map: value === 'map' ? 'true' : null })
21+
toQuery: (value) => {
22+
if (value === 'map') {
23+
return {
24+
map: true,
25+
'page-size': null,
26+
'page-number': null
27+
};
28+
}
29+
return { map: null };
30+
}
2231
});
2332

2433
const options = computed(() => [
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
Copyright 2025 ODK Central Developers
3+
See the NOTICE file at the top-level directory of this distribution and at
4+
https://github.com/getodk/central-frontend/blob/master/NOTICE.
5+
6+
This file is part of ODK Central. It is subject to the license terms in
7+
the LICENSE file found in the top-level directory of this distribution and at
8+
https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
9+
including this file, may be copied, modified, propagated, or distributed
10+
except according to the terms contained in the LICENSE file.
11+
*/
12+
13+
import useQueryRef from './query-ref';
14+
15+
export default (pageSizeOptions = [250, 500, 1000]) => {
16+
const pageNumber = useQueryRef({
17+
fromQuery: (query) => {
18+
const num = Number.parseInt(query['page-number'], 10);
19+
if (!num || num < 1) return 0;
20+
return num - 1;
21+
},
22+
toQuery: (value) => ({ 'page-number': value === 0 ? null : value + 1 })
23+
});
24+
25+
const pageSize = useQueryRef({
26+
fromQuery: (query) => {
27+
const size = Number.parseInt(query['page-size'], 10);
28+
29+
if (!size || size < pageSizeOptions[0]) return pageSizeOptions[0];
30+
if (size >= pageSizeOptions[2]) return pageSizeOptions[2];
31+
if (size >= pageSizeOptions[1]) return pageSizeOptions[1];
32+
return pageSizeOptions[0];
33+
},
34+
toQuery: (value) => ({ 'page-size': value })
35+
});
36+
37+
return {
38+
pageNumber,
39+
pageSize
40+
};
41+
};

test/components/entity/list.spec.js

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -929,9 +929,74 @@ describe('EntityList', () => {
929929
})
930930
.respondWithSuccess();
931931
const text = component.get('.pagination .form-group').text();
932-
933932
text.should.equal('Rows 1–249 of 259');
934933
});
934+
935+
it('adds page-size query parameter when page size is changed', () => {
936+
createEntities(251);
937+
return load('/projects/1/entity-lists/trees/entities')
938+
.complete()
939+
.request(component => {
940+
const sizeDropdown = component.find('.pagination select:has(option[value="500"])');
941+
return sizeDropdown.setValue(500);
942+
})
943+
.respondWithData(() => testData.entityOData(500))
944+
.afterResponse(component => {
945+
component.vm.$route.query['page-size'].should.equal('500');
946+
});
947+
});
948+
949+
it('adds page-number query parameter when next page is clicked', () => {
950+
createEntities(251);
951+
return load('/projects/1/entity-lists/trees/entities')
952+
.complete()
953+
.request(component =>
954+
component.find('button[aria-label="Next page"]').trigger('click'))
955+
.respondWithData(() => testData.entityOData(250, 250))
956+
.afterResponse(component => {
957+
component.vm.$route.query['page-number'].should.equal('2');
958+
});
959+
});
960+
961+
it('displays the correct page when page-number is provided in URL', () => {
962+
createEntities(501);
963+
return load('/projects/1/entity-lists/trees/entities?page-number=2', { root: false })
964+
.afterResponse(component => {
965+
component.find('.pagination .form-group').text().should.be.equal('Rows 251–500 of 501');
966+
});
967+
});
968+
969+
it('displays correct number of rows when page-size is provided in URL', () => {
970+
createEntities(600);
971+
return load('/projects/1/entity-lists/trees/entities?page-size=500', { root: false })
972+
.afterResponse(component => {
973+
component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('500');
974+
});
975+
});
976+
977+
it('selects first page when page-number is less than 1 in URL', () => {
978+
createEntities(251);
979+
return load('/projects/1/entity-lists/trees/entities?page-number=0', { root: false })
980+
.afterResponse(component => {
981+
component.find('.pagination .form-group').text().should.be.eql('Rows 1–250 of 251');
982+
});
983+
});
984+
985+
it('selects last page when page-number is greater than last page in URL', () => {
986+
createEntities(501);
987+
return load('/projects/1/entity-lists/trees/entities?page-number=999', { root: false })
988+
.afterResponse(component => {
989+
component.find('.pagination .form-group').text().should.be.eql('Row 501 of 501');
990+
});
991+
});
992+
993+
it('floors page-size to nearest valid value when invalid page-size is provided in URL', () => {
994+
createEntities(251);
995+
return load('/projects/1/entity-lists/trees/entities?page-size=350', { root: false })
996+
.afterResponse(component => {
997+
component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('250');
998+
});
999+
});
9351000
});
9361001

9371002
describe('deleted entities', () => {
@@ -962,7 +1027,7 @@ describe('EntityList', () => {
9621027

9631028
it('updates the deleted count', () => {
9641029
testData.extendedEntities.createPast(1, { deletedAt: new Date().toISOString() });
965-
return load('/projects/1/entity-lists/truee/entities', { root: false, container: { router: testRouter() } })
1030+
return load('/projects/1/entity-lists/tree/entities', { root: false, container: { router: testRouter() } })
9661031
.complete()
9671032
.request(component =>
9681033
component.get('.toggle-deleted-entities').trigger('click'))

test/components/submission/field-dropdown.spec.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import testData from '../../data';
99
import { loadSubmissionList } from '../../util/submission';
1010
import { mergeMountOptions, mount } from '../../util/lifecycle';
1111
import { testRequestData } from '../../util/request-data';
12+
import { mockLogin } from '../../util/session';
1213

1314
const mountComponent = (options) =>
1415
mount(SubmissionFieldDropdown, mergeMountOptions(options, {
@@ -25,6 +26,10 @@ const strings = (min, max) => {
2526
};
2627

2728
describe('SubmissionFieldDropdown', () => {
29+
beforeEach(() => {
30+
mockLogin();
31+
});
32+
2833
it('passes an option for each selectable field', () => {
2934
const component = mountComponent({
3035
container: {

test/components/submission/list.spec.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,5 +979,97 @@ describe('SubmissionList', () => {
979979
]);
980980
});
981981
});
982+
983+
it('adds page-size query parameter when page size is changed', () => {
984+
createSubmissions(251);
985+
return load('/projects/1/forms/f/submissions')
986+
.complete()
987+
.request(component => {
988+
const sizeDropdown = component.find('.pagination select:has(option[value="500"])');
989+
return sizeDropdown.setValue(500);
990+
})
991+
.respondWithData(() => testData.submissionOData(500))
992+
.afterResponse(component => {
993+
component.vm.$route.query['page-size'].should.equal('500');
994+
});
995+
});
996+
997+
it('adds page-number query parameter when next page is clicked', () => {
998+
createSubmissions(251);
999+
return load('/projects/1/forms/f/submissions')
1000+
.complete()
1001+
.request(component =>
1002+
component.find('button[aria-label="Next page"]').trigger('click'))
1003+
.respondWithData(() => testData.submissionOData(250, 250))
1004+
.afterResponse(component => {
1005+
component.vm.$route.query['page-number'].should.equal('2');
1006+
});
1007+
});
1008+
1009+
it('displays the correct page when page-number is provided in URL', () => {
1010+
createSubmissions(501);
1011+
return load('/projects/1/forms/f/submissions?page-number=2', { root: false })
1012+
.afterResponse(component => {
1013+
component.find('.pagination .form-group').text().should.be.equal('Rows 251–500 of 501');
1014+
});
1015+
});
1016+
1017+
it('displays correct number of rows when page-size is provided in URL', () => {
1018+
createSubmissions(600);
1019+
return load('/projects/1/forms/f/submissions?page-size=500', { root: false })
1020+
.afterResponse(component => {
1021+
component.find('.pagination select').element.value.should.be.eql('500');
1022+
});
1023+
});
1024+
1025+
it('selects first page when page-number is less than 1 in URL', () => {
1026+
createSubmissions(251);
1027+
return load('/projects/1/forms/f/submissions?page-number=0', { root: false })
1028+
.afterResponse(component => {
1029+
component.find('.pagination .form-group').text().should.be.eql('Rows 1–250 of 251');
1030+
});
1031+
});
1032+
1033+
it('selects last page when page-number is greater than last page in URL', () => {
1034+
createSubmissions(501);
1035+
return load('/projects/1/forms/f/submissions?page-number=999', { root: false })
1036+
.afterResponse(component => {
1037+
component.find('.pagination .form-group').text().should.be.eql('Row 501 of 501');
1038+
});
1039+
});
1040+
1041+
it('floors page-size to nearest valid value when invalid page-size is provided in URL', () => {
1042+
createSubmissions(251);
1043+
return load('/projects/1/forms/f/submissions?page-size=350', { root: false })
1044+
.afterResponse(component => {
1045+
component.find('.pagination select').element.value.should.be.eql('250');
1046+
});
1047+
});
1048+
1049+
it('removes page-number from query parameter when switching to map view', () => {
1050+
const { geopoint } = testData.fields;
1051+
const fields = [geopoint('/location')];
1052+
testData.extendedForms.createPast(1, { fields });
1053+
testData.extendedSubmissions.createPast(251);
1054+
1055+
return load('/projects/1/forms/f/submissions', { container: { router: testRouter() } })
1056+
.complete()
1057+
.request(component =>
1058+
component.find('button[aria-label="Next page"]').trigger('click'))
1059+
.respondWithData(() => testData.submissionOData(250, 250))
1060+
.afterResponse(component => {
1061+
// Verify we're on page 2
1062+
component.vm.$route.query['page-number'].should.equal('2');
1063+
})
1064+
.request(component => {
1065+
const radioField = component.getComponent('.radio-field');
1066+
const mapOption = radioField.findAll('input[type="radio"]')[1];
1067+
return mapOption.trigger('change');
1068+
})
1069+
.respondWithData(testData.submissionGeojson)
1070+
.afterResponse(component => {
1071+
component.vm.$route.query.should.not.have.property('page-number');
1072+
});
1073+
});
9821074
});
9831075
});

0 commit comments

Comments
 (0)