Skip to content

Commit

Permalink
8694rrpje: a ConceptFilter component, to search across a configured p…
Browse files Browse the repository at this point in the history
…roject filter. Also swapped out all the loading-overlay with bootstrap-vue versions of the same component
  • Loading branch information
tomolopolis committed Jul 12, 2024
1 parent 39d3c8f commit f14b89b
Show file tree
Hide file tree
Showing 13 changed files with 334 additions and 147 deletions.
18 changes: 18 additions & 0 deletions webapp/api/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,24 @@ def generate_concept_filter(request):
return HttpResponseBadRequest('Missing either cuis or cdb_id param. Cannot generate filter.')


@api_view(http_method_names=['POST'])
def cuis_to_concepts(request):
cuis = request.data.get('cuis')
cdb_id = request.data.get('cdb_id')
if cdb_id is not None:
if cuis is not None:
cdb = get_cached_cdb(cdb_id, CDB_MAP)
concept_list = [{'cui': cui, 'name': cdb.cui2preferred_name[cui]} for cui in cuis]
resp = {'concept_list': concept_list}
return Response(resp)
else:
cdb = get_cached_cdb(cdb_id, CDB_MAP)
concept_list = [{'cui': cui, 'name': cdb.cui2preferred_name[cui]} for cui in cdb.cui2preferred_name.keys()]
resp = {'concept_list': concept_list}
return Response(resp)
return HttpResponseBadRequest('Missing either cuis or cdb_id param. Cannot produce concept list.')


@api_view(http_method_names=['GET'])
def project_progress(request):
projects = [int(p) for p in request.GET.get('projects', []).split(',')]
Expand Down
9 changes: 5 additions & 4 deletions webapp/api/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@
path('api/concept-path/', api.views.cdb_concept_path),
path('api/generate-concept-filter-json/', api.views.generate_concept_filter_flat_json),
path('api/generate-concept-filter/', api.views.generate_concept_filter),
path('reset_password/', api.views.ResetPasswordView.as_view(), name ='reset_password'),
path('reset_password_sent/', pw_views.PasswordResetDoneView.as_view(), name ='password_reset_done'),
path('reset/<uidb64>/<token>', pw_views.PasswordResetConfirmView.as_view(), name ='password_reset_confirm'),
path('reset_password_complete/', pw_views.PasswordResetCompleteView.as_view(), name ='password_reset_complete'),
path('api/cuis-to-concepts/', api.views.cuis_to_concepts),
path('reset_password/', api.views.ResetPasswordView.as_view(), name='reset_password'),
path('reset_password_sent/', pw_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/<uidb64>/<token>', pw_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset_password_complete/', pw_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
re_path('^.*$', api.views.index, name='index'), # Match everything else to home
]
11 changes: 6 additions & 5 deletions webapp/frontend/src/components/common/ClinicalText.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<template>
<div class="note-container">
<loading-overlay :loading="loading !== null">
<div slot="message">{{loading}}</div>
</loading-overlay>
<b-overlay :show="loading !== null" no-wrap opacity="0.2">
<template #overlay>
<b-spinner :variant="'primary'"></b-spinner>
<span class="overlay-message">{{loading}}</span>
</template>
</b-overlay>
<div v-if="!loading" class="clinical-note">
<v-runtime-template ref="clinicalText" :template="formattedText"></v-runtime-template>
</div>
Expand All @@ -18,13 +21,11 @@
<script>
import VRuntimeTemplate from 'v-runtime-template'
import VueSimpleContextMenu from 'vue-simple-context-menu'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import _ from 'lodash'
export default {
name: 'ClinicalText',
components: {
LoadingOverlay,
VRuntimeTemplate,
VueSimpleContextMenu
},
Expand Down
189 changes: 189 additions & 0 deletions webapp/frontend/src/components/common/ConceptFilter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<template>
<div>
<b-overlay :show="loading" no-wrap opacity="0.5">
<template #overlay>
<b-spinner :variant="'primary'"></b-spinner>
</template>
</b-overlay>
<div>
<div class="search-bar">
<b-input v-model="filter" type="search" placeholder="Search for concepts..."></b-input>
<span v-if="filter === ''">Project Concept Filter: {{(allItems || []).length}}</span>
<span v-if="filter !== ''">Found {{allFilteredItems.length}} - showing first {{items.length}}</span>
</div>

<b-table ref="concept-table" id="table-id"
:items="items"
:fields="fields"
sticky-header
show-empty
small>
<template #cell(name)="data">
<span v-html="data.item.name"></span>
</template>
</b-table>
</div>
</div>
</template>

<script>
import _ from 'lodash'
const ROW_LIMIT = 100
export default {
name: "ConceptFilter",
props: {
cuis: String,
cdb_id: Number
},
data () {
return {
rowLimit: ROW_LIMIT,
currentPage: 1,
loading: false,
items: null,
allItems: null,
allFilteredItems: null,
fields: [
{ key: 'cui', label: 'CUI', sortable: true },
{ key: 'name', label: 'Name', sortable: true }
],
filter: '',
}
},
created () {
this.loading = true
let payload = {
cuis: this.cuis !== '' ? this.cuis.split(',') : null,
cdb_id: this.cdb_id
}
this.$http.post('/api/cuis-to-concepts/', payload).then(resp => {
const items = resp.data.concept_list.sort((a,b) => a.name.localeCompare(b.name))
if (items.length > ROW_LIMIT) {
this.items = items.slice(0, ROW_LIMIT)
this.allItems = items
} else {
this.items = items
this.allItems = this.items
}
this.loading = false
})
},
mounted () {
const tableScrollBody = this.$refs["concept-table"].$el;
/* Consider debouncing the event call */
tableScrollBody.addEventListener("scroll", this.onScroll);
},
beforeDestroy() {
/* Clean up just to be sure */
const tableScrollBody = this.$refs["concept-table"].$el;
tableScrollBody.removeEventListener("scroll", this.onScroll);
},
methods: {
onScroll (event) {
if (
event.target.scrollTop + event.target.clientHeight >=
event.target.scrollHeight
) {
// fetch more items
if (this.allFilteredItems !== null && this.items.length !== this.allFilteredItems.length) {
window.setTimeout(() => {
if (this.filter.length !== 0) {
if (this.items.length < this.allFilteredItems.length) {
this.items = this.allFilteredItems.slice(0, ROW_LIMIT * this.currentPage)
this.currentPage ++
this.loading = false
}
} else if (this.items.length !== this.allItems.length) {
this.items = this.allItems.slice(0, ROW_LIMIT * this.currentPage)
this.currentPage ++
this.loading = false
} else {
this.loading = false
}
}, 200)
this.loading = true
}
}
},
filterItems: _.debounce(function(filterStr) {
filterStr = filterStr.toLowerCase()
function highlightCUINames(concepts, query) {
const lowercaseQuery = query.toLowerCase();
return concepts.map(concept => {
const name = concept.name
let lowercaseStr = name.toLowerCase();
let result = '';
let lastIndex = 0;
while (true) {
const index = lowercaseStr.indexOf(lowercaseQuery, lastIndex);
if (index === -1) break;
result += name.slice(lastIndex, index);
result += `<span class="highlight">${name.slice(index, index + query.length)}</span>`;
lastIndex = index + query.length;
}
result += name.slice(lastIndex);
return {...concept, ...{ name: result }}
});
}
let filteredItems = this.allItems.filter((v) => v.name.toLowerCase().includes(filterStr))
filteredItems = filteredItems.sort((a,b) => {
const aStartsWith = a.name.toLowerCase().startsWith(filterStr)
const bStartsWith = b.name.toLowerCase().startsWith(filterStr)
if (aStartsWith !== bStartsWith) {
return aStartsWith ? -1 : 1
}
return a.name.length - b.name.length
})
filteredItems = highlightCUINames(filteredItems, filterStr)
if (filteredItems.length > ROW_LIMIT) {
this.allFilteredItems = filteredItems
this.items = this.allFilteredItems.slice(0, ROW_LIMIT)
} else {
this.items = filteredItems
this.allFilteredItems = []
}
this.currentPage = 0
this.loading = false
}, 300)
},
watch: {
filter (newVal, oldVal) {
if (newVal.length > 0) {
this.filterItems(newVal)
} else {
this.items = this.allItems.slice(0, ROW_LIMIT)
this.allFilteredItems = null
this.currentPage = 0
this.loading = false
}
}
}
}
</script>

<style lang="scss">
.search-bar {
margin: 10px 0;
width: calc(100% - 300px);
input {
display: inline-block;
width: calc(100% - 300px);
}
span {
float: right;
}
}
.highlight {
font-weight: bold;
}
</style>
54 changes: 0 additions & 54 deletions webapp/frontend/src/components/common/LoadingOverlay.vue

This file was deleted.

4 changes: 3 additions & 1 deletion webapp/frontend/src/components/common/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,13 @@ export default {
.modal-header h3 {
margin-top: 0;
text-align: center;
text-align: left;
margin-left: 0;
}
.modal-header h4 {
display: inline-block;
margin-left: 0;
}
.modal-header .close {
Expand Down
13 changes: 7 additions & 6 deletions webapp/frontend/src/components/common/ProjectList.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<template>
<div class="full-height project-table">
<div class="table-container">
<loading-overlay :loading="loadingProjects">
<p slot="message">Loading Projects...</p>
</loading-overlay>
<b-overlay :show="loadingProjects">
<template #overlay>
<b-spinner :variant="'primary'"></b-spinner>
<span class="overlay-message">Loading Projects...</span>
</template>
</b-overlay>
<b-table id="projectTable" hover small :items="projectItems"
:fields="isAdmin ? projects.fields : projects.fields.filter(f => projects.adminOnlyFields.indexOf(f.key) === -1)"
:select-mode="'single'"
Expand Down Expand Up @@ -169,12 +172,10 @@

<script>
import Modal from "@/components/common/Modal.vue"
import LoadingOverlay from "@/components/common/LoadingOverlay.vue"
import _ from "lodash"
export default {
name: "ProjectList",
components: {LoadingOverlay, Modal},
components: { Modal},
props: {
projectItems: Array,
isAdmin: Boolean,
Expand Down
12 changes: 7 additions & 5 deletions webapp/frontend/src/components/models/ConceptDatabaseViz.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
<button class="btn btn-default" @click="zoomOut"><font-awesome-icon icon="minus"></font-awesome-icon></button>
<button class="btn btn-default" @click="resetZoom">1:1</button>
</div>
<loading-overlay :loading="loading">
<div slot="message">Retrieving Concept Tree...</div>
</loading-overlay>
<b-overlay :show="loading">
<template #overlay>
<b-spinner :variant="'primary'"></b-spinner>
<span class="overlay-message">Retrieving Concept Tree...</span>
</template>
</b-overlay>
<div class="mc-tree-view">
<vue-tree
style="width: 1500px; height: 1000px;"
Expand All @@ -30,13 +33,12 @@
</template>

<script>
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VueTree from '@/components/models/VueTree.vue'
import _ from 'lodash'
export default {
name: 'ConceptDatabaseViz',
components: { LoadingOverlay, VueTree },
components: { VueTree },
props: {
cdb: Object,
selectedCui: String
Expand Down
5 changes: 5 additions & 0 deletions webapp/frontend/src/styles/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,8 @@ $blur-radius: 10px;
opacity: 0;
}

.overlay-message {
padding-left: 10px;
opacity: 0.5;
}

Loading

0 comments on commit f14b89b

Please sign in to comment.