From 5ff5c866688b5dbdeecf168364fd276d28e69da5 Mon Sep 17 00:00:00 2001 From: Alain Mazy Date: Fri, 13 Dec 2024 10:27:06 +0100 Subject: [PATCH] infinite scroll --- TODO | 2 - WebApplication/package-lock.json | 49 ++++--- WebApplication/package.json | 3 +- WebApplication/src/components/StudyItem.vue | 3 + WebApplication/src/components/StudyList.vue | 18 ++- WebApplication/src/main.js | 2 + WebApplication/src/orthancApi.js | 6 +- WebApplication/src/store/modules/studies.js | 134 ++++++++++++-------- release-notes.md | 10 +- 9 files changed, 144 insertions(+), 83 deletions(-) diff --git a/TODO b/TODO index c672168..19f7949 100644 --- a/TODO +++ b/TODO @@ -57,8 +57,6 @@ UI improvements: } - configure other viewers url (ex: radiant://?n=pstv&v=0020000D&v=%22StudyInstanceUID%22 or osirix or horos ...) -- make table sortable - - orthanc-share should generate QR code with publication links - Q&R on multiple modalities at a same time (select the modalities you want to Q&R and display the modality in the study list) diff --git a/WebApplication/package-lock.json b/WebApplication/package-lock.json index 10ffa7d..fcda460 100644 --- a/WebApplication/package-lock.json +++ b/WebApplication/package-lock.json @@ -23,6 +23,7 @@ "vue": "^3.4.21", "vue-i18n": "^9.10.2", "vue-router": "^4.3.0", + "vue3-observe-visibility": "^1.0.2", "vuex": "^4.1.0" }, "devDependencies": { @@ -450,12 +451,12 @@ } }, "node_modules/@intlify/core-base": { - "version": "9.14.1", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.1.tgz", - "integrity": "sha512-rG5/hlNW6Qfve41go37szEf0mVLcfhYuOu83JcY0jZKasnwsrcZYYWDzebCcuO5I/6Sy1JFWo9p+nvkQS1Dy+w==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.2.tgz", + "integrity": "sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==", "dependencies": { - "@intlify/message-compiler": "9.14.1", - "@intlify/shared": "9.14.1" + "@intlify/message-compiler": "9.14.2", + "@intlify/shared": "9.14.2" }, "engines": { "node": ">= 16" @@ -465,11 +466,11 @@ } }, "node_modules/@intlify/message-compiler": { - "version": "9.14.1", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.1.tgz", - "integrity": "sha512-MY8hwukJBnXvGAncVKlHsqKDQ5ZcQx4peqEmI8wBUTXn4pezrtTGYXNoz81cLyEEHB+L/zlKWVBSh5TiX4gYoQ==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.2.tgz", + "integrity": "sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==", "dependencies": { - "@intlify/shared": "9.14.1", + "@intlify/shared": "9.14.2", "source-map-js": "^1.0.2" }, "engines": { @@ -480,9 +481,9 @@ } }, "node_modules/@intlify/shared": { - "version": "9.14.1", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.1.tgz", - "integrity": "sha512-XjHu6PEQup9MnP1x0W9y0nXXfq9jFftAYSfV11hryjtH4XqXP8HrzMvXI+ZVifF+jZLszaTzIhvukllplxTQTg==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.2.tgz", + "integrity": "sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==", "engines": { "node": ">= 16" }, @@ -1084,9 +1085,9 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -1285,12 +1286,12 @@ } }, "node_modules/vue-i18n": { - "version": "9.14.1", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.1.tgz", - "integrity": "sha512-xjxV0LYc1xQ8TbAVfIyZiOSS8qoU1R0YwV7V5I8I6Fd64+zvsTsdPgtylPsie3Vdt9wekeYhr+smKDeaK6RBuA==", + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.2.tgz", + "integrity": "sha512-JK9Pm80OqssGJU2Y6F7DcM8RFHqVG4WkuCqOZTVsXkEzZME7ABejAUqUdA931zEBedc4thBgSUWxeQh4uocJAQ==", "dependencies": { - "@intlify/core-base": "9.14.1", - "@intlify/shared": "9.14.1", + "@intlify/core-base": "9.14.2", + "@intlify/shared": "9.14.2", "@vue/devtools-api": "^6.5.0" }, "engines": { @@ -1317,6 +1318,14 @@ "vue": "^3.2.0" } }, + "node_modules/vue3-observe-visibility": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/vue3-observe-visibility/-/vue3-observe-visibility-1.0.2.tgz", + "integrity": "sha512-PmNmvLezogMAgwix6VPnkkrx7sj834GblvqCCDI2iySpohTQvCm1j2q89aTmyS0XzG4sWBK2Izypejf4IpNFig==", + "peerDependencies": { + "vue": "^3.x.x" + } + }, "node_modules/vuex": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuex/-/vuex-4.1.0.tgz", diff --git a/WebApplication/package.json b/WebApplication/package.json index 506ad52..f1516d0 100644 --- a/WebApplication/package.json +++ b/WebApplication/package.json @@ -23,7 +23,8 @@ "vue": "^3.4.21", "vue-i18n": "^9.10.2", "vue-router": "^4.3.0", - "vuex": "^4.1.0" + "vuex": "^4.1.0", + "vue3-observe-visibility": "^1.0.2" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", diff --git a/WebApplication/src/components/StudyItem.vue b/WebApplication/src/components/StudyItem.vue index 47285de..7872c5a 100644 --- a/WebApplication/src/components/StudyItem.vue +++ b/WebApplication/src/components/StudyItem.vue @@ -87,7 +87,10 @@ export default { this.selected = false; }, async clickedSelect() { + // console.log(this.studyId, this.selected); await this.$store.dispatch('studies/selectStudy', { studyId: this.studyId, isSelected: !this.selected }); // this.selected is the value before the click + this.selected = !this.selected; + // console.log(this.studyId, this.selected); } }, computed: { diff --git a/WebApplication/src/components/StudyList.vue b/WebApplication/src/components/StudyList.vue index 9d349b3..5a273bc 100644 --- a/WebApplication/src/components/StudyList.vue +++ b/WebApplication/src/components/StudyList.vue @@ -11,6 +11,7 @@ import { endOfMonth, endOfYear, startOfMonth, startOfYear, subMonths, subDays, s import api from "../orthancApi"; import { ref } from 'vue'; import SourceType from "../helpers/source-type"; +import { ObserveVisibility as vObserveVisibility } from 'vue3-observe-visibility' document._allowedFilters = ["StudyDate", "StudyTime", "AccessionNumber", "PatientID", "PatientName", "PatientBirthDate", "StudyInstanceUID", "StudyID", "StudyDescription", "ModalitiesInStudy", "labels"] @@ -737,6 +738,11 @@ export default { await this.$router.replace(newUrl); }, + async extendStudyList() { + if (this.sourceType == SourceType.LOCAL_ORTHANC && this['configuration/hasExtendedFind']) { + await this.$store.dispatch('studies/extendFilteredStudies'); + } + }, async reloadStudyList() { if (this.sourceType == SourceType.LOCAL_ORTHANC && this['configuration/hasExtendedFind']) { await this.$store.dispatch('studies/clearStudies'); @@ -842,6 +848,16 @@ export default { onDeletedStudy(studyId) { this.$store.dispatch('studies/deleteStudy', { studyId: studyId }); }, + visibilityChanged(isVisible, entry) { + if (isVisible) { + let studyId = entry.target.id; + if (studyId == this.studiesIds[this.studiesIds.length - 1]) { + // console.log("Last element shown -> should load more studies"); + this.extendStudyList(); + } + } + + } }, components: { StudyItem, ResourceButtonGroup } } @@ -983,7 +999,7 @@ export default { - diff --git a/WebApplication/src/main.js b/WebApplication/src/main.js index 3b7bee0..396f409 100644 --- a/WebApplication/src/main.js +++ b/WebApplication/src/main.js @@ -15,6 +15,7 @@ import axios from 'axios' import Datepicker from '@vuepic/vue-datepicker'; import '@vuepic/vue-datepicker/dist/main.css'; import mitt from "mitt" +import VueObserveVisibility from 'vue3-observe-visibility' // Names of the params that can contain an authorization token // If one of these params contain a token, it will be passed as a header @@ -30,6 +31,7 @@ axios.get('../api/pre-login-configuration').then((config) => { app.use(router) app.use(store) app.use(i18n) + app.use(VueObserveVisibility) app.component('Datepicker', Datepicker); app.config.globalProperties.messageBus = messageBus; diff --git a/WebApplication/src/orthancApi.js b/WebApplication/src/orthancApi.js index 495fc47..cd7dd03 100644 --- a/WebApplication/src/orthancApi.js +++ b/WebApplication/src/orthancApi.js @@ -74,7 +74,7 @@ export default { window.axiosFindStudiesAbortController = null; } }, - async findStudies(filterQuery, labels, LabelsConstraint, orderBy) { + async findStudies(filterQuery, labels, LabelsConstraint, orderBy, since) { await this.cancelFindStudies(); window.axiosFindStudiesAbortController = new AbortController(); @@ -95,6 +95,10 @@ export default { payload["OrderBy"] = orderBy; } + if (since) { + payload["Since"] = since; + } + return (await axios.post(orthancApiUrl + "tools/find", payload, { signal: window.axiosFindStudiesAbortController.signal diff --git a/WebApplication/src/store/modules/studies.js b/WebApplication/src/store/modules/studies.js index e93d3ce..e5165f7 100644 --- a/WebApplication/src/store/modules/studies.js +++ b/WebApplication/src/store/modules/studies.js @@ -37,6 +37,76 @@ function insert_wildcards(initialValue) { return finalValue.replaceAll('**', ''); } +async function get_studies_shared(context, append) { + const commit = context.commit; + const state = context.state; + const getters = context.getters; + + if (!append) { + commit('setStudiesIds', { studiesIds: [] }); + commit('setStudies', { studies: [] }); + } + + try { + commit('setIsSearching', { isSearching: true}); + let studies = []; + + if (state.sourceType == SourceType.LOCAL_ORTHANC) { + let orderBy = [...state.orderByFilters]; + if (state.orderByFilters.length == 0) { + orderBy.push({'Type': 'Metadata', 'Key': 'LastUpdate', 'Direction': 'DESC'}) + } + let since = (append ? state.studiesIds.length : null); + studies = (await api.findStudies(getters.filterQuery, state.labelFilters, "All", orderBy, since)); + } else if (state.sourceType == SourceType.REMOTE_DICOM || state.sourceType == SourceType.REMOTE_DICOM_WEB) { + // make sure to fill all columns of the StudyList + let filters = { + "PatientBirthDate": "", + "PatientID": "", + "AccessionNumber": "", + "PatientBirthDate": "", + "StudyDescription": "", + "StudyDate": "" + }; + + // request values for e.g ModalitiesInStudy, NumberOfStudyRelatedSeries + for (let t of store.state.configuration.requestedTagsForStudyList) { + filters[t] = ""; + } + + // overwrite with the filtered values + for (const [k, v] of Object.entries(getters.filterQuery)) { + filters[k] = v; + } + + let remoteStudies; + if (state.sourceType == SourceType.REMOTE_DICOM) { + remoteStudies = (await api.remoteDicomFind("Study", state.remoteSource, filters, true /* isUnique */)); + } else if (state.sourceType == SourceType.REMOTE_DICOM_WEB) { + remoteStudies = (await api.qidoRs("Study", state.remoteSource, filters, true /* isUnique */)); + } + + // copy the tags in MainDicomTags, ... to have a common study structure between local and remote studies + studies = remoteStudies.map(s => { return {"MainDicomTags": s, "PatientMainDicomTags": s, "RequestedTags": s, "ID": s["StudyInstanceUID"]} }); + } + + studies = studies.map(s => {return {...s, "sourceType": state.sourceType} }); + let studiesIds = studies.map(s => s['ID']); + + if (!append) { + commit('setStudiesIds', { studiesIds: studiesIds }); + commit('setStudies', { studies: studies }); + } else { + commit('extendStudiesIds', { studiesIds: studiesIds }); + commit('extendStudies', { studies: studies }); + } + } catch (err) { + console.log("Find studies cancelled", err); + } finally { + commit('setIsSearching', { isSearching: false}); + } +} + ///////////////////////////// GETTERS const getters = { filterQuery: (state) => { @@ -85,6 +155,12 @@ const mutations = { setStudies(state, { studies }) { state.studies = studies; }, + extendStudiesIds(state, { studiesIds }) { + state.studiesIds.push(...studiesIds); + }, + extendStudies(state, { studies }) { + state.studies.push(...studies); + }, addStudy(state, { studyId, study }) { if (!state.studiesIds.includes(studyId)) { state.studiesIds.push(studyId); @@ -221,61 +297,11 @@ const actions = { commit('setStudiesIds', { studiesIds: [] }); commit('setStudies', { studies: [] }); }, + async extendFilteredStudies({ commit, getters, state }) { + get_studies_shared({ commit, getters, state }, true); + }, async reloadFilteredStudies({ commit, getters, state }) { - commit('setStudiesIds', { studiesIds: [] }); - commit('setStudies', { studies: [] }); - - try { - commit('setIsSearching', { isSearching: true}); - let studies = []; - - if (state.sourceType == SourceType.LOCAL_ORTHANC) { - let orderBy = [...state.orderByFilters]; - if (state.orderByFilters.length == 0) { - orderBy.push({'Type': 'Metadata', 'Key': 'LastUpdate', 'Direction': 'DESC'}) - } - studies = (await api.findStudies(getters.filterQuery, state.labelFilters, "All", orderBy)); - } else if (state.sourceType == SourceType.REMOTE_DICOM || state.sourceType == SourceType.REMOTE_DICOM_WEB) { - // make sure to fill all columns of the StudyList - let filters = { - "PatientBirthDate": "", - "PatientID": "", - "AccessionNumber": "", - "PatientBirthDate": "", - "StudyDescription": "", - "StudyDate": "" - }; - - // request values for e.g ModalitiesInStudy, NumberOfStudyRelatedSeries - for (let t of store.state.configuration.requestedTagsForStudyList) { - filters[t] = ""; - } - - // overwrite with the filtered values - for (const [k, v] of Object.entries(getters.filterQuery)) { - filters[k] = v; - } - - let remoteStudies; - if (state.sourceType == SourceType.REMOTE_DICOM) { - remoteStudies = (await api.remoteDicomFind("Study", state.remoteSource, filters, true /* isUnique */)); - } else if (state.sourceType == SourceType.REMOTE_DICOM_WEB) { - remoteStudies = (await api.qidoRs("Study", state.remoteSource, filters, true /* isUnique */)); - } - - // copy the tags in MainDicomTags, ... to have a common study structure between local and remote studies - studies = remoteStudies.map(s => { return {"MainDicomTags": s, "PatientMainDicomTags": s, "RequestedTags": s, "ID": s["StudyInstanceUID"]} }); - } - - studies = studies.map(s => {return {...s, "sourceType": state.sourceType} }); - let studiesIds = studies.map(s => s['ID']); - commit('setStudiesIds', { studiesIds: studiesIds }); - commit('setStudies', { studies: studies }); - } catch (err) { - console.log("Find studies cancelled", err); - } finally { - commit('setIsSearching', { isSearching: false}); - } + get_studies_shared({ commit, getters, state }, false); }, async cancelSearch() { await api.cancelFindStudies(); diff --git a/release-notes.md b/release-notes.md index 8156926..7f6d472 100644 --- a/release-notes.md +++ b/release-notes.md @@ -2,11 +2,13 @@ Pending changes in the mainline =============================== Changes: - - Allow sorting by columns when the Orthanc DB supports "ExtendedFind" - - Optimized loading of "most-recent" studies when the Orthanc DB supports "ExtendedFind" + - When Orthanc DB supports "ExtendedFind" (SQLite in 1.12.5+ and PosgreSQL 7.0+): + - new features in the local studies list: + - Allow sorting by columns + - Optimized loading of "most-recent" studies + - Load the following studies when scrolling to the bottom of the current list. + - New configuration "EnableLabelsCount" to enable/disable the display of the number of studies with each label. - Disable some UI components on ReadOnly systems. - - New configuration "EnableLabelsCount" to enable/disable the display of the number of - studies with each label. - The study list header is now sticking on top of the screen. Fixes: