Skip to content

Commit 66eb211

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 4f17c80 commit 66eb211

File tree

9 files changed

+251
-18
lines changed

9 files changed

+251
-18
lines changed

src/components/entity/table-view.vue

Lines changed: 14 additions & 4 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"
@@ -33,6 +33,7 @@ import { inject, reactive, useTemplateRef, watch } from 'vue';
3333
import EntityTable from './table.vue';
3434
import OdataLoadingMessage from '../odata-loading-message.vue';
3535
import Pagination from '../pagination.vue';
36+
import usePaginationQueryRef from '../../composables/pagination-query-ref';
3637
3738
import { apiPaths } from '../../util/request';
3839
import { noop, reemit } from '../../util/util';
@@ -59,7 +60,8 @@ const datasetName = inject('datasetName');
5960
const { dataset, odataEntities, deletedEntityCount } = useRequestData();
6061
6162
const pageSizeOptions = [250, 500, 1000];
62-
const pagination = reactive({ page: 0, size: pageSizeOptions[0], count: 0 });
63+
const { pageNumber, pageSize } = usePaginationQueryRef(pageSizeOptions);
64+
const pagination = reactive({ page: pageNumber, size: pageSize, count: 0 });
6365
6466
// For more information about how the snapshot filter works, see
6567
// SubmissionTableView.
@@ -78,11 +80,13 @@ const setSnapshotFilter = () => {
7880
// `clear` indicates whether odataEntities should be cleared before sending the
7981
// request. `refresh` indicates whether the request is a background refresh
8082
// (whether the refresh button was pressed).
81-
const fetchChunk = (clear, refresh = false) => {
83+
const fetchChunk = (clear, refresh = false, skipSettingSnapshotFilter = false) => {
8284
// Are we fetching the first chunk of entities or the next chunk?
8385
const first = clear || refresh;
84-
if (first) {
86+
if (first && !skipSettingSnapshotFilter) {
8587
setSnapshotFilter();
88+
}
89+
if (refresh) {
8690
pagination.page = 0;
8791
}
8892
@@ -115,6 +119,11 @@ const fetchChunk = (clear, refresh = false) => {
115119
})
116120
.then(() => {
117121
pagination.count = odataEntities.count;
122+
const lastPage = Math.max(0, Math.ceil(pagination.count / pagination.size) - 1);
123+
if (pagination.page > lastPage) {
124+
pagination.page = lastPage;
125+
fetchChunk(true, false, true);
126+
}
118127
119128
if (props.deleted) {
120129
deletedEntityCount.cancelRequest();
@@ -128,6 +137,7 @@ const fetchChunk = (clear, refresh = false) => {
128137
};
129138
fetchChunk(true);
130139
watch([() => props.deleted, () => props.filter, () => props.searchTerm], () => {
140+
pagination.page = 0;
131141
fetchChunk(true);
132142
});
133143
const handlePageChange = () => {

src/components/submission/table-view.vue

Lines changed: 18 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="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"
@@ -33,6 +33,7 @@ import { computed, reactive, useTemplateRef, watch } from 'vue';
3333
import OdataLoadingMessage from '../odata-loading-message.vue';
3434
import Pagination from '../pagination.vue';
3535
import SubmissionTable from './table.vue';
36+
import usePaginationQueryRef from '../../composables/pagination-query-ref';
3637
3738
import { apiPaths } from '../../util/request';
3839
import { noop, reemit } from '../../util/util';
@@ -72,7 +73,8 @@ const emit = defineEmits(['review', 'delete', 'restore']);
7273
const { odata, deletedSubmissionCount } = useRequestData();
7374
7475
const pageSizeOptions = [250, 500, 1000];
75-
const pagination = reactive({ page: 0, size: pageSizeOptions[0], count: 0 });
76+
const { pageNumber, pageSize } = usePaginationQueryRef(pageSizeOptions);
77+
const pagination = reactive({ page: pageNumber, size: pageSize, count: 0 });
7678
7779
let snapshotFilter;
7880
const setSnapshotFilter = () => {
@@ -99,13 +101,15 @@ const odataSelect = computed(() => {
99101
});
100102
101103
// `clear` indicates whether this.odata should be cleared before sending the
102-
// request. `refresh` indicates whether the request is a background refresh
104+
// request. `refresh` indicates whether the request is a background refresh.
103105
// (whether the refresh button was pressed).
104-
const fetchChunk = (clear, refresh = false) => {
106+
const fetchChunk = (clear, refresh = false, skipSettingSnapshotFilter = false) => {
105107
// Are we fetching the first chunk of submissions or the next chunk?
106108
const first = clear || refresh;
107-
if (first) {
109+
if (first && !skipSettingSnapshotFilter) {
108110
setSnapshotFilter();
111+
}
112+
if (refresh) {
109113
pagination.page = 0;
110114
}
111115
@@ -136,6 +140,11 @@ const fetchChunk = (clear, refresh = false) => {
136140
})
137141
.then(() => {
138142
pagination.count = odata.count;
143+
const lastPage = Math.max(0, Math.ceil(pagination.count / pagination.size) - 1);
144+
if (pagination.page > lastPage) {
145+
pagination.page = lastPage;
146+
fetchChunk(true, false, true);
147+
}
139148
140149
if (props.deleted) {
141150
deletedSubmissionCount.cancelRequest();
@@ -148,7 +157,10 @@ const fetchChunk = (clear, refresh = false) => {
148157
.catch(noop);
149158
};
150159
fetchChunk(true);
151-
watch([() => props.filter, () => props.deleted], () => { fetchChunk(true); });
160+
watch([() => props.filter, () => props.deleted], () => {
161+
pagination.page = 0;
162+
fetchChunk(true);
163+
});
152164
watch(() => props.fields, (_, oldFields) => {
153165
if (oldFields != null) fetchChunk(true);
154166
});

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 < 0) 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,72 @@ describe('EntityList', () => {
919919
component.find('.pagination select').element.value.should.be.eql('2');
920920
});
921921
});
922+
923+
it('adds page-size query parameter when page size is changed', () => {
924+
createEntities(251);
925+
return load('/projects/1/entity-lists/trees/entities')
926+
.complete()
927+
.request(component => {
928+
const sizeDropdown = component.find('.pagination select:has(option[value="500"])');
929+
return sizeDropdown.setValue(500);
930+
})
931+
.respondWithData(() => testData.entityOData(500))
932+
.afterResponse(component => {
933+
component.vm.$route.query['page-size'].should.equal('500');
934+
});
935+
});
936+
937+
it('adds page-number query parameter when next page is clicked', () => {
938+
createEntities(251);
939+
return load('/projects/1/entity-lists/trees/entities')
940+
.complete()
941+
.request(component =>
942+
component.find('button[aria-label="Next page"]').trigger('click'))
943+
.respondWithData(() => testData.entityOData(250, 250))
944+
.afterResponse(component => {
945+
component.vm.$route.query['page-number'].should.equal('2');
946+
});
947+
});
948+
949+
it('displays the correct page when page-number is provided in URL', () => {
950+
createEntities(501);
951+
return load('/projects/1/entity-lists/trees/entities?page-number=2', { root: false })
952+
.afterResponse(component => {
953+
component.find('.pagination select').element.value.should.be.eql('1');
954+
});
955+
});
956+
957+
it('displays correct number of rows when page-size is provided in URL', () => {
958+
createEntities(600);
959+
return load('/projects/1/entity-lists/trees/entities?page-size=500', { root: false })
960+
.afterResponse(component => {
961+
component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('500');
962+
});
963+
});
964+
965+
it('selects first page when page-number is less than 1 in URL', () => {
966+
createEntities(251);
967+
return load('/projects/1/entity-lists/trees/entities?page-number=0', { root: false })
968+
.afterResponse(component => {
969+
component.find('.pagination select').element.value.should.be.eql('0');
970+
});
971+
});
972+
973+
it('selects last page when page-number is greater than last page in URL', () => {
974+
createEntities(501);
975+
return load('/projects/1/entity-lists/trees/entities?page-number=999', { root: false })
976+
.afterResponse(component => {
977+
component.find('.pagination select').element.value.should.be.eql('2');
978+
});
979+
});
980+
981+
it('floors page-size to nearest valid value when invalid page-size is provided in URL', () => {
982+
createEntities(251);
983+
return load('/projects/1/entity-lists/trees/entities?page-size=350', { root: false })
984+
.afterResponse(component => {
985+
component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('250');
986+
});
987+
});
922988
});
923989

924990
describe('deleted entities', () => {
@@ -949,7 +1015,7 @@ describe('EntityList', () => {
9491015

9501016
it('updates the deleted count', () => {
9511017
testData.extendedEntities.createPast(1, { deletedAt: new Date().toISOString() });
952-
return load('/projects/1/entity-lists/truee/entities', { root: false, container: { router: testRouter() } })
1018+
return load('/projects/1/entity-lists/tree/entities', { root: false, container: { router: testRouter() } })
9531019
.complete()
9541020
.request(component =>
9551021
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
@@ -942,5 +942,97 @@ describe('SubmissionList', () => {
942942
component.find('.pagination select').element.value.should.be.eql('2');
943943
});
944944
});
945+
946+
it('adds page-size query parameter when page size is changed', () => {
947+
createSubmissions(251);
948+
return load('/projects/1/forms/f/submissions')
949+
.complete()
950+
.request(component => {
951+
const sizeDropdown = component.find('.pagination select:has(option[value="500"])');
952+
return sizeDropdown.setValue(500);
953+
})
954+
.respondWithData(() => testData.submissionOData(500))
955+
.afterResponse(component => {
956+
component.vm.$route.query['page-size'].should.equal('500');
957+
});
958+
});
959+
960+
it('adds page-number query parameter when next page is clicked', () => {
961+
createSubmissions(251);
962+
return load('/projects/1/forms/f/submissions')
963+
.complete()
964+
.request(component =>
965+
component.find('button[aria-label="Next page"]').trigger('click'))
966+
.respondWithData(() => testData.submissionOData(250, 250))
967+
.afterResponse(component => {
968+
component.vm.$route.query['page-number'].should.equal('2');
969+
});
970+
});
971+
972+
it('displays the correct page when page-number is provided in URL', () => {
973+
createSubmissions(501);
974+
return load('/projects/1/forms/f/submissions?page-number=2', { root: false })
975+
.afterResponse(component => {
976+
component.find('.pagination select').element.value.should.be.eql('1');
977+
});
978+
});
979+
980+
it('displays correct number of rows when page-size is provided in URL', () => {
981+
createSubmissions(600);
982+
return load('/projects/1/forms/f/submissions?page-size=500', { root: false })
983+
.afterResponse(component => {
984+
component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('500');
985+
});
986+
});
987+
988+
it('selects first page when page-number is less than 1 in URL', () => {
989+
createSubmissions(251);
990+
return load('/projects/1/forms/f/submissions?page-number=0', { root: false })
991+
.afterResponse(component => {
992+
component.find('.pagination select').element.value.should.be.eql('0');
993+
});
994+
});
995+
996+
it('selects last page when page-number is greater than last page in URL', () => {
997+
createSubmissions(501);
998+
return load('/projects/1/forms/f/submissions?page-number=999', { root: false })
999+
.afterResponse(component => {
1000+
component.find('.pagination select').element.value.should.be.eql('2');
1001+
});
1002+
});
1003+
1004+
it('floors page-size to nearest valid value when invalid page-size is provided in URL', () => {
1005+
createSubmissions(251);
1006+
return load('/projects/1/forms/f/submissions?page-size=350', { root: false })
1007+
.afterResponse(component => {
1008+
component.find('.pagination select:has(option[value="500"])').element.value.should.be.eql('250');
1009+
});
1010+
});
1011+
1012+
it('removes page-number from query parameter when switching to map view', () => {
1013+
const { geopoint } = testData.fields;
1014+
const fields = [geopoint('/location')];
1015+
testData.extendedForms.createPast(1, { fields });
1016+
testData.extendedSubmissions.createPast(251);
1017+
1018+
return load('/projects/1/forms/f/submissions', { container: { router: testRouter() } })
1019+
.complete()
1020+
.request(component =>
1021+
component.find('button[aria-label="Next page"]').trigger('click'))
1022+
.respondWithData(() => testData.submissionOData(250, 250))
1023+
.afterResponse(component => {
1024+
// Verify we're on page 2
1025+
component.vm.$route.query['page-number'].should.equal('2');
1026+
})
1027+
.request(component => {
1028+
const radioField = component.getComponent('.radio-field');
1029+
const mapOption = radioField.findAll('input[type="radio"]')[1];
1030+
return mapOption.trigger('change');
1031+
})
1032+
.respondWithData(testData.submissionGeojson)
1033+
.afterResponse(component => {
1034+
component.vm.$route.query.should.not.have.property('page-number');
1035+
});
1036+
});
9451037
});
9461038
});

0 commit comments

Comments
 (0)