Skip to content

Commit

Permalink
Extending a Concept Set with a new tab 'Annotations' (#2971)
Browse files Browse the repository at this point in the history
* Show metadata tag to show history conceptset
* Add function to remove metadata conceptset
* Adjust styles action column metadata tab
* [ATL-17] Refactored the annotations feature. Renamed metadata to annotation, added vocabulary version and createdBy/createdDate
* [ATL-58] Added concept set version to annotations
* [ATL-58] Implemented copying of annotations along with ConceptSet
* [ATL-58] Transform search data JSON to a human friendly format in annotations tab
* Added 'Copied From' list of concept set ids for concept set annotations
* Fixed issue with current conceptset ID not propagating correctly into a URL for router to the current active concept set
* Annotations delete button (whole Actions column) to be visible only for Admin (user with conceptset:annotation:*:delete permission)
* Changed owner/permissions check for annotations delete to consider ownership of the concept set for annotations
---------

Co-authored-by: hernaldo.urbina <[email protected]>
Co-authored-by: oleg-odysseus <[email protected]>
Co-authored-by: Chris Knoll <[email protected]>
  • Loading branch information
4 people authored Dec 17, 2024
1 parent 0f4594e commit 5163a9d
Show file tree
Hide file tree
Showing 15 changed files with 440 additions and 4 deletions.
5 changes: 5 additions & 0 deletions js/components/atlas-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,28 @@ define(['knockout', 'lscache', 'services/job/jobDetail', 'assets/ohdsi.util', 'c
state.vocabularyUrl = ko.observable(sessionStorage.vocabularyUrl);
state.evidenceUrl = ko.observable(sessionStorage.evidenceUrl);
state.resultsUrl = ko.observable(sessionStorage.resultsUrl);
state.currentVocabularyVersion = ko.observable(sessionStorage.currentVocabularyVersion);
state.vocabularyUrl.subscribe(value => updateKey('vocabularyUrl', value));
state.evidenceUrl.subscribe(value => updateKey('evidenceUrl', value));
state.resultsUrl.subscribe(value => updateKey('resultsUrl', value));
state.currentVocabularyVersion.subscribe(value => updateKey('currentVocabularyVersion', value));

// This default values are stored during initialization
// and used to reset after session finished
state.defaultVocabularyUrl = ko.observable();
state.defaultEvidenceUrl = ko.observable();
state.defaultResultsUrl = ko.observable();
state.defaultVocabularyVersion = ko.observable();
state.defaultVocabularyUrl.subscribe((value) => state.vocabularyUrl(value));
state.defaultEvidenceUrl.subscribe((value) => state.evidenceUrl(value));
state.defaultResultsUrl.subscribe((value) => state.resultsUrl(value));
state.defaultVocabularyVersion.subscribe((value) => state.currentVocabularyVersion(value));

state.resetCurrentDataSourceScope = function() {
state.vocabularyUrl(state.defaultVocabularyUrl());
state.evidenceUrl(state.defaultEvidenceUrl());
state.resultsUrl(state.defaultResultsUrl());
state.currentVocabularyVersion(state.defaultVocabularyVersion());
}

state.sourceKeyOfVocabUrl = ko.computed(() => {
Expand Down
14 changes: 14 additions & 0 deletions js/components/conceptAddBox/concept-add-box.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ define([

sharedState.activeConceptSet(conceptSet);

const filterSource = localStorage?.getItem('filter-source') || null;
const filterData = JSON.parse(localStorage?.getItem('filter-data') || null);
const datasAdded = JSON.parse(localStorage?.getItem('data-add-selected-concept') || null) || [];
const dataSearch = { filterData, filterSource }
const payloadAdd = this.conceptsToAdd().map(item => {
return {
"searchData": dataSearch,
"vocabularyVersion": sharedState.currentVocabularyVersion(),
"conceptId": item.CONCEPT_ID
}
})

localStorage.setItem('data-add-selected-concept', JSON.stringify([...datasAdded, ...payloadAdd]))

// if concepts were previewed, then they already built and can have individual option flags!
if (this.previewConcepts().length > 0) {
if (!conceptSet.current()) {
Expand Down
1 change: 1 addition & 0 deletions js/components/conceptset/const.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ define([
RECOMMEND: 'recommend',
EXPORT: 'conceptset-export',
IMPORT: 'conceptset-import',
ANNOTATION: 'annotation'
};

const ConceptSetSources = {
Expand Down
45 changes: 45 additions & 0 deletions js/components/faceted-datatable.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,52 @@ define(['knockout', 'text!./faceted-datatable.html', 'crossfilter', 'utils/Commo

self.outsideFilters = (params.outsideFilters || ko.observable()).extend({notify: 'always'});

self.setDataLocalStorage = (data, nameItem) => {
const filterArrayString = localStorage.getItem(nameItem)
let filterArrayObj = filterArrayString? JSON.parse(filterArrayString): []

if(!data?.selected()){
filterArrayObj.push({title:data.facet.caption(), value:`${data.key} (${data.value})`,key:data.key})
}else{
filterArrayObj = filterArrayObj.filter((item)=> item.key !== data.key)
}
localStorage.setItem(nameItem, JSON.stringify(filterArrayObj))
}

self.setDataObjectLocalStorage = (data, nameItem) => {
const filterObjString = localStorage.getItem(nameItem)
let filterObj = filterObjString ? JSON.parse(filterObjString): {}
let newFilterObj = {}

if(!data?.selected()){
const dataPush = { title: data.facet.caption(), value: `${data.key} (${data.value})`, key: data.key };
newFilterObj.filterColumns = filterObj['filterColumns'] ? [...filterObj['filterColumns'], dataPush] : [dataPush]
newFilterObj = { ...filterObj, filterColumns : newFilterObj.filterColumns };
}else{
newFilterObj.filterColumns = filterObj['filterColumns'].filter((item)=> item.key !== data.key);
newFilterObj = { ...filterObj, filterColumns : newFilterObj.filterColumns };
}
localStorage.setItem(nameItem, JSON.stringify(newFilterObj))
}

self.updateFilters = function (data, event) {
const currentPath = window.location?.href;
if (currentPath?.includes('/conceptset/')) {
if (currentPath?.includes('/included-sourcecodes')) {
localStorage.setItem('filter-source', 'Included Source Codes');
} else if (currentPath?.includes('/included')) {
localStorage.setItem('filter-source', 'Included Concepts');
}
self.setDataLocalStorage(data, 'filter-data');
}
const isAddConcept = currentPath?.split('?').reduce((prev, curr) => prev || curr.includes('search'), false) &&
currentPath?.split('?').reduce((prev, curr) => prev || curr.includes('query'), false) ||
currentPath?.includes('/concept/')

if (isAddConcept) {
localStorage.setItem('filter-source', 'Search');
self.setDataObjectLocalStorage(data, 'filter-data')
}
var facet = data.facet;
data.selected(!data.selected());
if (data.selected()) {
Expand Down
15 changes: 15 additions & 0 deletions js/pages/concept-sets/components/tabs/conceptset-annotation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<loading data-bind="visible: isLoading()" params="status: ko.i18n('components.annotation.loading', 'Loading annotation...')"></loading>
<div data-bind="visible: !isLoading()">
<faceted-datatable params="
order: [],
autoWidth: false,
reference: data,
columns: columns,
options: null,
pageLength: pageLength,
lengthMenu: lengthMenu,
rowClick: onRowClick,
language: ko.i18n('datatable.language')
">
</faceted-datatable>
</div>
175 changes: 175 additions & 0 deletions js/pages/concept-sets/components/tabs/conceptset-annotation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
define([
'knockout',
'text!./conceptset-annotation.html',
'components/Component',
'utils/AutoBind',
'utils/CommonUtils',
'services/AuthAPI',
'faceted-datatable',
'less!./conceptset-annotation.less',
], function (
ko,
view,
Component,
AutoBind,
commonUtils,
authApi,
) {
class ConceptsetAnnotation extends AutoBind(Component) {
constructor(params) {
super(params);
this.isLoading = ko.observable(true);
this.data = ko.observable();
this.getList = params.getList;
this.delete = params.delete;
this.canDeleteAnnotations = params.canDeleteAnnotations;

const { pageLength, lengthMenu } = commonUtils.getTableOptions('M');
this.pageLength = params.pageLength || pageLength;
this.lengthMenu = params.lengthMenu || lengthMenu;

this.columns = ko.computed(() => {
let cols = [
{
title: ko.i18n('columns.conceptID', 'Concept Id'),
data: 'conceptId',
},
{
title: ko.i18n('columns.searchData', 'Search Data'),
className: this.classes('tbl-col', 'search-data'),
render: (d, t, r) => {
if (r.searchData === null || r.searchData === undefined || !r.searchData) {
return 'N/A';
} else {
return `<p>${r.searchData}</p>`
}
},
sortable: false
},
{
title: ko.i18n('columns.vocabularyVersion', 'Vocabulary Version'),
data: 'vocabularyVersion',
render: (d, t, r) => {
if (r.vocabularyVersion === null || r.vocabularyVersion === undefined || !r.vocabularyVersion) {
return 'N/A';
} else {
return `<p>${r.vocabularyVersion}</p>`
}
},
sortable: false
},
{
title: ko.i18n('columns.conceptSetVersion', 'Concept Set Version'),
data: 'conceptSetVersion',
render: (d, t, r) => {
if (r.conceptSetVersion === null || r.conceptSetVersion === undefined || !r.conceptSetVersion) {
return 'N/A';
} else {
return `<p>${r.conceptSetVersion}</p>`
}
},
sortable: false
},
{
title: ko.i18n('columns.createdBy', 'Created By'),
data: 'createdBy',
render: (d, t, r) => {
if (r.createdBy === null || r.createdBy === undefined || !r.createdBy) {
return 'N/A';
} else {
return `<p>${r.createdBy}</p>`
}
},
sortable: false
},
{
title: ko.i18n('columns.createdDate', 'Created Date'),
render: (d, t, r) => {
if (r.createdDate === null || r.createdDate === undefined) {
return 'N/A';
} else {
return `<p>${r.createdDate}</p>`
}
},
sortable: false
},
{
title: ko.i18n('columns.originConceptSets', 'Origin Concept Sets'),
render: (d, t, r) => {
if (r.copiedFromConceptSetIds === null || r.copiedFromConceptSetIds === undefined) {
return 'N/A';
} else {
return `<p>${r.copiedFromConceptSetIds}</p>`
}
},
sortable: false
}
];

if (this.canDeleteAnnotations()) {
cols.push({
title: ko.i18n('columns.action', 'Action'),
sortable: false,
render: function () {
return `<i class="deleteIcon fa fa-trash" aria-hidden="true"></i>`;
}
});
}
return cols;
});

this.loadData();
}

objectMap(obj) {
const newObject = {};
const keysNotToParse = ['createdBy', 'createdDate', 'vocabularyVersion', 'conceptSetVersion', 'copiedFromConceptSetIds', 'searchData'];
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === 'string' && !keysNotToParse.includes(key)) {
newObject[key] = JSON.parse(obj[key] || null);
} else {
newObject[key] = obj[key];
}
});
return newObject;
}

async onRowClick(d, e){
try {
const { id } = d;
if(e.target.className === 'deleteIcon fa fa-trash') {
const res = await this.delete(id);
if(res){
this.loadData();
}
}
} catch (ex) {
console.log(ex);
} finally {
this.isLoading(false);
}
}

handleConvertData(arr){
const newDatas = [];
(arr || []).forEach(item => {
newDatas.push(this.objectMap(item))
})
return newDatas;
}

async loadData() {
this.isLoading(true);
try {
const data = await this.getList();
this.data(this.handleConvertData(data.data));
} catch (ex) {
console.log(ex);
} finally {
this.isLoading(false);
}
}

}
return commonUtils.build('conceptset-annotation', ConceptsetAnnotation, view);
});
23 changes: 23 additions & 0 deletions js/pages/concept-sets/components/tabs/conceptset-annotation.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.conceptset-annotation {

&__tbl-col {
&--search-data {
min-width: 40%;
}
&--concept-data{
max-width: 500px;
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}

.deleteIcon {
color: #d9534f;
cursor: pointer;
min-width: 30px;
}
30 changes: 29 additions & 1 deletion js/pages/concept-sets/components/tabs/conceptset-expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ define([
});

this.datatableLanguage = ko.i18n('datatable.language');
this.currentConceptSetId = ko.observable(params.router.routerParams().conceptSetId);

this.data = ko.pureComputed(() => this.conceptSetItems().map((item, idx) => ({ ...item, idx, isSelected: ko.observable() })));

Expand Down Expand Up @@ -113,7 +114,34 @@ define([

removeConceptsFromConceptSet() {
const idxForRemoval = this.data().filter(concept => concept.isSelected()).map(item => item.idx);
this.conceptSetStore.removeItemsByIndex(idxForRemoval);

const removeItems = this.data().filter(concept => concept.isSelected());
const datasAdded = JSON.parse(localStorage.getItem('data-add-selected-concept') || null) || [];
const datasDeleted = JSON.parse(localStorage.getItem('data-remove-selected-concept') || null) || [];

const datasRemove = [];
const payloadRemove = removeItems.map(item => {
if((datasAdded.map(item => item.conceptId)).includes(item.concept.CONCEPT_ID)){
datasRemove.push(item.concept.CONCEPT_ID);
return null;
}
return {
"searchData": "",
"relatedConcepts": "",
"conceptHierarchy": "",
"conceptSetData": { id: this.currentConceptSetId(), name: this.conceptSetStore.current().name()},
"conceptData": item,
"conceptId": item.concept.CONCEPT_ID
}
});

const dataRemoveSelected = [...datasDeleted, ...payloadRemove].filter((item, i, arr) => item && arr.indexOf(item) === i);
localStorage.setItem('data-remove-selected-concept', JSON.stringify(dataRemoveSelected));
if(datasRemove?.length){
const newAddDatas = datasAdded.filter(data => !datasRemove.includes(data.conceptId));
localStorage.setItem('data-add-selected-concept', JSON.stringify(newAddDatas));
}
this.conceptSetStore.removeItemsByIndex(idxForRemoval);
}

async selectAllConceptSetItems(key, areAllSelected) {
Expand Down
Loading

0 comments on commit 5163a9d

Please sign in to comment.