diff --git a/js/components/authorship.html b/js/components/authorship.html index d881bdcb4..247e4e318 100644 --- a/js/components/authorship.html +++ b/js/components/authorship.html @@ -3,10 +3,20 @@ , + {createdBy: createdBy, createdDate: createdDate})"> + + , + {modifiedBy: modifiedBy, modifiedDate: modifiedDate})"> + + , + + + + + + diff --git a/js/components/authorship.js b/js/components/authorship.js index 38e2c5f61..0ed00123c 100644 --- a/js/components/authorship.js +++ b/js/components/authorship.js @@ -20,6 +20,8 @@ define([ this.createdDate = params.createdDate; this.modifiedBy = params.modifiedBy; this.modifiedDate = params.modifiedDate; + this.lockedBy = params.lockedBy; + this.lockedDate = params.lockedDate; } } diff --git a/js/components/circe/components/ConceptSetBrowser.js b/js/components/circe/components/ConceptSetBrowser.js index 6ac03a981..9f9902e33 100644 --- a/js/components/circe/components/ConceptSetBrowser.js +++ b/js/components/circe/components/ConceptSetBrowser.js @@ -7,10 +7,11 @@ define([ 'services/AuthAPI', 'utils/DatatableUtils', 'utils/CommonUtils', + 'services/ConceptSet', 'components/ac-access-denied', 'databindings', 'css!./style.css' -], function (ko, template, VocabularyProvider, appConfig, ConceptSet, authApi, datatableUtils, commonUtils) { +], function (ko, template, VocabularyProvider, appConfig, ConceptSet, authApi, datatableUtils, commonUtils, conceptSetService) { function CohortConceptSetBrowser(params) { var self = this; @@ -96,17 +97,42 @@ define([ self.loadConceptSetsFromRepository = function (url) { self.loading(true); - + VocabularyProvider.getConceptSetList(url) .done(function (results) { datatableUtils.coalesceField(results, 'modifiedDate', 'createdDate'); datatableUtils.addTagGroupsToFacets(results, self.options.Facets); - datatableUtils.addTagGroupsToColumns(results, self.columns); - self.repositoryConceptSets(results); - self.loading(false); + + // Extract IDs for batch locked status check + const conceptSetIds = results.map(cs => cs.id); + const isLockedBatchCheckRequest = { + conceptSetIds: conceptSetIds + }; + conceptSetService.getLockedStatusesForConceptSets(isLockedBatchCheckRequest).then(response => { + const lockStatusMap = response.data.lockStatus; + // Apply the locking status to each ConceptSet + results.forEach(conceptSet => { + conceptSet.isLocked = lockStatusMap[conceptSet.id] || false; + }); + // Update the observable array with locked statuses + self.repositoryConceptSets(results); + datatableUtils.addTagGroupsToColumns(results, self.columns); + self.loading(false); + }).catch(error => { + console.error('Error while batch-fetching locked statuses for concept sets', error); + // Defaulting isLocked to false in case of error + results.forEach(conceptSet => { + conceptSet.isLocked = false; + }); + self.repositoryConceptSets(results); + datatableUtils.addTagGroupsToColumns(results, self.columns); + self.loading(false); + }); + }) .fail(function (err) { - console.log(err); + console.log('Error fetching concept sets:', err); + self.loading(false); }); } @@ -150,6 +176,15 @@ define([ }; this.columns = ko.observableArray([ + { + title: '', + data: 'isLocked', + sortable: false, + render: function (data, type, row) { + return data ? '' : ''; + }, + width: '20px', + }, { title: ko.i18n('columns.id', 'Id'), data: 'id' diff --git a/js/components/circe/components/style.css b/js/components/circe/components/style.css index 194940659..f0a4ed61d 100644 --- a/js/components/circe/components/style.css +++ b/js/components/circe/components/style.css @@ -11,3 +11,12 @@ .conceptset-browser-panel .new-concept-set { width: 175px; } + +.fa-key { + color: yellow !important; + text-shadow: + -0.5px -0.5px 0 black, + 0.5px -0.5px 0 black, + -0.5px 0.5px 0 black, + 0.5px 0.5px 0 black; +} \ No newline at end of file diff --git a/js/components/conceptset/const.js b/js/components/conceptset/const.js index 7f3c3410c..9e867c81e 100644 --- a/js/components/conceptset/const.js +++ b/js/components/conceptset/const.js @@ -15,7 +15,8 @@ define([ EXPORT: 'conceptset-export', IMPORT: 'conceptset-import', ANNOTATION: 'annotation', - MAPPINGS: 'resolve-mappings' + MAPPINGS: 'resolve-mappings', + LOCK_HISTORY: 'lockHistory', }; const ConceptSetSources = { diff --git a/js/pages/concept-sets/components/modal/snapshot-lock-modal.html b/js/pages/concept-sets/components/modal/snapshot-lock-modal.html new file mode 100644 index 000000000..2c5370b13 --- /dev/null +++ b/js/pages/concept-sets/components/modal/snapshot-lock-modal.html @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/js/pages/concept-sets/components/modal/snapshot-lock-modal.js b/js/pages/concept-sets/components/modal/snapshot-lock-modal.js new file mode 100644 index 000000000..fb52ef5a6 --- /dev/null +++ b/js/pages/concept-sets/components/modal/snapshot-lock-modal.js @@ -0,0 +1,144 @@ +define([ + 'knockout', + 'text!./snapshot-lock-modal.html', + 'components/Component', + 'utils/CommonUtils', + 'utils/AutoBind', + 'services/SourceAPI', + 'services/ConceptSet', + 'atlas-state', + 'less!./snapshot-lock-modal.less', + 'databindings', +], function ( + ko, + view, + Component, + commonUtils, + AutoBind, + sourceApi, + conceptSetService, + sharedState, +) { + class SnapshotLockModal extends AutoBind(Component) { + constructor(params) { + super(params); + this.isModalShown = params.isModalShown; + this.isLocked = params.isLocked; + this.conceptSetStore = params.conceptSetStore; + this.parentLoading = params.parentLoading; + + this.isLocking = ko.observable(false); + this.isUnlocking = ko.observable(false); + this.isSnapshotting = ko.observable(false); + this.actionInProgress = ko.observable(''); + + this.takeSnapshotWhenUnlocking = ko.observable(false); + this.currentConceptSetId = params.currentConceptSetId; + this.currentVocabularyVersion = params.currentVocabularyVersion; + this.currentVocabularySchema = ko.observable(); + this.snapshotDescriptionMessage = ko.observable(''); + this.canExecuteActions = ko.pureComputed(() => { + const hasSnapshotDesc = this.snapshotDescriptionMessage().trim().length > 0; + return hasSnapshotDesc; + }); + this.fetchAndSetVocabularySchema(); + } + + async fetchAndSetVocabularySchema() { + try { + const source = await sourceApi.getSourceInfo(sharedState.sourceKeyOfVocabUrl()); + this.processSourceData(source); + } catch (error) { + console.error(`Error fetching source information: ${error}`); + this.currentVocabularySchema(undefined); + } + } + + processSourceData(source) { + if (!source || !source.daimons) { + this.currentVocabularySchema(undefined); + } else { + const vocabularyDaimon = source.daimons.find(daimon => daimon.daimonType === 'Vocabulary'); + this.currentVocabularySchema(vocabularyDaimon ? vocabularyDaimon.tableQualifier : undefined); + } + } + + + createSnapshotActionRequest(action, takeSnapshot = true) { + return { + sourceKey: sharedState.sourceKeyOfVocabUrl(), + action: action, + message: this.snapshotDescriptionMessage(), + takeSnapshot: takeSnapshot + }; + } + + snapshotAndLock() { + if (this.isLocking()) return; + this.isLocking(true); + this.parentLoading(true); + this.actionInProgress('Locking and creating a snapshot...'); + + const request = this.createSnapshotActionRequest("LOCK"); + conceptSetService.invokeConceptSetSnapshotAction(this.currentConceptSetId(), request) + .then(() => { + this.conceptSetStore.current.notifySubscribers(); + this.snapshotDescriptionMessage(""); + this.isModalShown(false); + console.log("Concept set locked and snapshot created"); + }) + .catch(error => console.error(`Error locking concept set: ${error}`)) + .finally(() => { + this.isLocking(false); + this.parentLoading(false); + this.actionInProgress(''); + }); + } + + snapshotOnly() { + if (this.isSnapshotting()) return; + this.isSnapshotting(true); + this.parentLoading(true); + this.actionInProgress('Creating a snapshot...'); + + + const request = this.createSnapshotActionRequest("SNAPSHOT"); + conceptSetService.invokeConceptSetSnapshotAction(this.currentConceptSetId(), request) + .then(() => { + this.snapshotDescriptionMessage(""); + console.log("Concept set snapshot created"); + this.isModalShown(false); + }) + .catch(error => console.error(`Error creating snapshot: ${error}`)) + .finally(() => { + this.isSnapshotting(false); + this.parentLoading(false); + this.actionInProgress(''); + }); + } + + unlockConceptSet(takeSnapshot) { + if (this.isUnlocking()) return; + this.isUnlocking(true); + this.parentLoading(true); + this.actionInProgress('Unlocking ...'); + + const request = this.createSnapshotActionRequest("UNLOCK", takeSnapshot); + conceptSetService.invokeConceptSetSnapshotAction(this.currentConceptSetId(), request) + .then(() => { + this.conceptSetStore.current.notifySubscribers(); + this.snapshotDescriptionMessage(""); + this.isModalShown(false); + console.log("Concept set unlocked"); + }) + .catch(error => console.error(`Error unlocking concept set: ${error}`)) + .finally(() => { + this.isUnlocking(false); + this.parentLoading(false); + this.actionInProgress(''); + }); + } + } + + return commonUtils.build('snapshot-lock-modal', SnapshotLockModal, view); +}); \ No newline at end of file diff --git a/js/pages/concept-sets/components/modal/snapshot-lock-modal.less b/js/pages/concept-sets/components/modal/snapshot-lock-modal.less new file mode 100644 index 000000000..465a06d93 --- /dev/null +++ b/js/pages/concept-sets/components/modal/snapshot-lock-modal.less @@ -0,0 +1,118 @@ +.snapshot-lock-modal { + + .modal-body { + padding: 20px; + } + + .modal-text { + white-space: pre-wrap; + margin-bottom: 20px; + } + + .center-buttons { + display: flex; + justify-content: space-around; + margin-top: 20px; + } + + .center-buttons>button { + flex-grow: 1; + flex-basis: 0%; + margin: 0 5px; + } + + .large-text-input { + display: block; + width: 80%; + margin: 10px auto; + min-height: 65px; + max-height: 150px; + height: auto; + padding: 10px; + } + + .full-width { + width: 100%; + } + + .bold { + font-weight: bold; + } + + &__tag-groups-label, + &__tag-list-label, + &__new-tag-label { + font-size: 1.4rem; + } + + &__tag-list-label, + &__new-tag-label { + margin-top: 1.3rem; + } + + &__new-tag-label { + color: #0070dd; + cursor: pointer; + } + + &__new-tag-label:hover { + color: #23527c; + } + + .cell-tag-name { + width: 100px; + } + + .cell-group-description { + width: 460px; + } + + .cell-tag-created { + width: 120px; + } + + .cell-tag-author { + width: 100px; + } + + .cell-tag-description { + width: 200px; + } + + .cell-tag-type { + width: 80px; + } + + .cell-tag-name, + .cell-group-description, + .cell-tag-description, + .cell-tag-type, + .cell-tag-created, + .cell-tag-author, + .cell-tag-action { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .groups-dropdown { + padding: 7px; + margin-left: -1px; + width: fit-content; + width: -moz-fit-content; + } + + &__action-link { + cursor: pointer; + } + + .dataTable { + width: 100% !important; + } + + .dataTable th, + .dataTable td { + padding: 6px; + } +} \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/conceptset-lock-history.html b/js/pages/concept-sets/components/tabs/conceptset-lock-history.html new file mode 100644 index 000000000..c0edefe3b --- /dev/null +++ b/js/pages/concept-sets/components/tabs/conceptset-lock-history.html @@ -0,0 +1,77 @@ + +
+
+ +
+ + + + + + + + + + + + + +
+ +
+ + + + +
+ +
+ +
+ +
+ +
+ +
+
+ + +
\ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/conceptset-lock-history.js b/js/pages/concept-sets/components/tabs/conceptset-lock-history.js new file mode 100644 index 000000000..1b7ac3131 --- /dev/null +++ b/js/pages/concept-sets/components/tabs/conceptset-lock-history.js @@ -0,0 +1,392 @@ +define([ + 'knockout', + 'text!./conceptset-lock-history.html', + 'components/Component', + 'utils/AutoBind', + 'utils/CommonUtils', + 'services/ConceptSet', + 'utils/Renderers', + 'services/MomentAPI', + 'colvis', + 'faceted-datatable', + 'less!./conceptset-lock-history.less', +], function ( + ko, + view, + Component, + AutoBind, + commonUtils, + conceptSetService, + renderers, + momentAPI, +) { + class ConceptsetLockHistory extends AutoBind(Component) { + constructor(params) { + super(params); + this.currentConceptSet = params.currentConceptSet(); + + this.isLoading = ko.observable(true); + this.snapshotHistory = ko.observable(); + this.canDeleteSnapshots = ko.observable(false); + this.datatableLanguage = ko.i18n('datatable.language'); + this.commonUtils = commonUtils; + this.tableOptions = commonUtils.getTableOptions('M'); + + this.snapshotHistoryColumns = ko.computed(() => { + let cols = [ + { + title: ko.i18n('columns.action', 'Action'), + data: 'action', + sortable: false, + }, + { + title: ko.i18n('columns.snapshotDate', 'Date'), + data: 'snapshotDate', + render: (d, t, r) => { + if (t === 'sort' || t === 'type') { + return d ? new Date(d).getTime() : 0; // to avoid incorrect sorting by date in string representation + } + if (r.snapshotDate === null || r.snapshotDate === undefined) { + return 'N/A'; + } else { + return `

${momentAPI.formatDateTimeUTC(r.snapshotDate, true)}

` + } + }, + sortable: false + }, + { + title: ko.i18n('columns.snapshotId', 'Snapshot ID'), + data: null, + render: (d, t, r) => { + const snapshotId = r.snapshotId; + const isEmptySnapshot = r.emptySnapshot; + return isEmptySnapshot ? '' : `

${snapshotId}

`; + }, + sortable: false, + }, + { + title: ko.i18n('columns.createdBy', 'Created By'), + data: 'user', + render: (d, t, r) => { + if (r.user === null || r.user === undefined || !r.user) { + return 'N/A'; + } else { + return `

${r.user}

` + } + }, + sortable: false + }, + { + title: ko.i18n('columns.vocabularyBundle', 'Vocabulary Bundle Name [Schema]'), + data: null, + render: (d, t, r) => { + const name = r.vocabularyBundleName ? r.vocabularyBundleName : 'N/A'; + const schema = r.vocabularyBundleSchema ? `[${r.vocabularyBundleSchema}]` : '[N/A]'; + return `

${name} ${schema}

`; + }, + sortable: false + }, + { + title: ko.i18n('columns.vocabularyBundleVersion', 'Vocabulary Bundle Version'), + data: 'vocabularyBundleVersion', + visible: false, + render: (d, t, r) => { + if (r.vocabularyBundleVersion === null || r.vocabularyBundleVersion === undefined || !r.vocabularyBundleVersion) { + return 'N/A'; + } else { + return `

${r.vocabularyBundleVersion}

` + } + }, + sortable: false + }, + { + title: ko.i18n('columns.conceptSetVersion', 'Concept Set Version'), + render: (d, t, r) => { + if (r.conceptSetVersion === null || r.conceptSetVersion === undefined || !r.conceptSetVersion) { + return 'N/A'; + } else { + return `

${r.conceptSetVersion}

` + } + }, + sortable: false + }, + { + title: ko.i18n('columns.message', 'Message'), + data: 'message', + render: (d, t, r) => { + if (r.message === null || r.message === undefined || !r.message) { + return 'N/A'; + } else { + return `

${r.message}

` + } + }, + sortable: false + }, + ]; + + if (this.canDeleteSnapshots()) { + cols.push({ + title: ko.i18n('columns.delete', 'Delete'), + sortable: false, + render: function () { + return ``; + } + }); + } + return cols; + }); + + + this.selectedSnapshotId = ko.observable(); + this.expressionItems = ko.observableArray([]); + this.includedConcepts = ko.observableArray([]); + this.includedSourceCodes = ko.observableArray([]); + + + this.selectedTab = ko.observable(0); + this.tabs = [{ + title: ko.i18n('cs.manager.tabs.conceptSetExpression', 'Concept Set Expression'), + componentParams: { data: this.expressionItems }, + }, + { + title: ko.i18n('cs.manager.tabs.includedConcepts', 'Included Concepts'), + componentParams: { snapshotId: this.selectedSnapshotId }, + }, + { + title: ko.i18n('cs.manager.tabs.includedSourceCodes', 'Source Codes'), + componentParams: { snapshotId: this.selectedSnapshotId }, + }, + ]; + + this.selectedTab.subscribe(() => { + if (this.selectedSnapshotId()) { + this.loadTabData(); + } + }); + + this.tableOptions = params.tableOptions || commonUtils.getTableOptions('M'); + this.expressionItemColumns = [ + { + title: ko.i18n('columns.conceptId', 'Concept Id'), + sortable: true, + data: 'concept.CONCEPT_ID', + }, + { + title: ko.i18n('columns.conceptCode', 'Concept Code'), + sortable: true, + data: 'concept.CONCEPT_CODE', + }, + { + title: ko.i18n('columns.conceptName', 'Concept Name'), + sortable: true, + data: 'concept.CONCEPT_NAME', + }, + { + title: ko.i18n('columns.conceptClassId', 'Class'), + sortable: true, + data: 'concept.CONCEPT_CLASS_ID', + visible: false, + }, + { + title: ko.i18n('columns.domain', 'Domain'), + sortable: true, + data: 'concept.DOMAIN_ID', + }, + { + title: ko.i18n('columns.vocabularyId', 'Vocabulary'), + sortable: true, + data: 'concept.VOCABULARY_ID', + visible: false, + }, + { + title: ko.i18n('columns.standardConceptCode', 'Standard Concept Code'), + sortable: true, + data: 'concept.STANDARD_CONCEPT', + }, + { + title: ko.i18n('columns.standardConceptCaption', 'Standard Concept Caption'), + sortable: true, + data: 'concept.STANDARD_CONCEPT_CAPTION', + }, + { + title: ko.i18n('columns.validStartDate', 'Valid Start Date'), + render: (s, type, d) => type === "sort" ? +d['concept.VALID_START_DATE'] : + momentAPI.formatDateTimeWithFormat(d['concept.VALID_START_DATE'], momentAPI.DATE_FORMAT), + visible: false + }, + { + title: ko.i18n('columns.validEndDate', 'Valid End Date'), + render: (s, type, d) => type === "sort" ? +d['concept.VALID_END_DATE'] : + momentAPI.formatDateTimeWithFormat(d['concept.VALID_END_DATE'], momentAPI.DATE_FORMAT), + visible: false + }, + { + title: ko.i18n('columns.excluded', 'Excluded'), + class: 'text-center', + orderable: false, + render: () => this.renderCheckbox('isExcluded'), + }, + { + title: ko.i18n('columns.descendants', 'Descendants'), + class: 'text-center', + orderable: false, + render: () => this.renderCheckbox('includeDescendants'), + }, + { + title: ko.i18n('columns.mapped', 'Mapped'), + class: 'text-center', + orderable: false, + render: () => this.renderCheckbox('includeMapped'), + }, + ]; + + this.conceptColumns = [ + { + title: ko.i18n('columns.conceptId', 'Concept Id'), + sortable: true, + data: 'concept.CONCEPT_ID', + }, + { + title: ko.i18n('columns.conceptCode', 'Concept Code'), + sortable: true, + data: 'concept.CONCEPT_CODE', + }, + { + title: ko.i18n('columns.conceptName', 'Concept Name'), + sortable: true, + data: 'concept.CONCEPT_NAME', + }, + { + title: ko.i18n('columns.conceptClassId', 'Class'), + sortable: true, + data: 'concept.CONCEPT_CLASS_ID', + }, + { + title: ko.i18n('columns.domain', 'Domain'), + sortable: true, + data: 'concept.DOMAIN_ID', + }, + { + title: ko.i18n('columns.vocabularyId', 'Vocabulary'), + sortable: true, + data: 'concept.VOCABULARY_ID', + }, + { + title: ko.i18n('columns.standardConceptCode', 'Standard Concept Code'), + sortable: true, + data: 'concept.STANDARD_CONCEPT', + visible: false, + }, + { + title: ko.i18n('columns.standardConceptCaption', 'Standard Concept Caption'), + sortable: true, + data: 'concept.STANDARD_CONCEPT_CAPTION', + }, + { + title: ko.i18n('columns.validStartDate', 'Valid Start Date'), + render: (s, type, d) => type === "sort" ? +d.concept['VALID_START_DATE'] : + momentAPI.formatDateTimeWithFormat(d.concept['VALID_START_DATE'], momentAPI.DATE_FORMAT), + visible: false + }, + { + title: ko.i18n('columns.validEndDate', 'Valid End Date'), + render: (s, type, d) => type === "sort" ? +d.concept['VALID_END_DATE'] : + momentAPI.formatDateTimeWithFormat(d.concept['VALID_END_DATE'], momentAPI.DATE_FORMAT), + visible: false + }, + ]; + + this.loadData(); + } + + renderCheckbox(field) { + return renderers.renderConceptSetCheckbox(false, field); + } + + async loadConceptSetExpression() { + if (this.selectedSnapshotId()) { + this.isLoading(true); + try { + const getConceptSetSnapshotItemsRequest = { + snapshotId: [this.selectedSnapshotId()] + }; + let items = await conceptSetService.getConceptSetSnapshotItems(getConceptSetSnapshotItemsRequest); + this.conceptSetItems(items); + } catch (error) { + console.error(`Error loading concept set expression data: ${error.message}`); + } finally { + this.isLoading(false); + } + } + } + + async loadConceptSetSnapshotDetails(tabIndex) { + this.isLoading(true); + try { + let data; + if (tabIndex === 1) { + data = await conceptSetService.fetchSnapshotIncludedConcepts(this.selectedSnapshotId()); + } else if (tabIndex === 2) { + data = await conceptSetService.fetchSnapshotIncludedSourceCodes(this.selectedSnapshotId()); + } + this.tabs[tabIndex].componentParams.data(data); + } catch (error) { + console.error(`Error loading data for tab ${this.tabs[tabIndex].title}: ${error.message}`); + } finally { + this.isLoading(false); + } + } + + async onRowClick(data, event) { + $(event.currentTarget).closest('table').find('tr').removeClass('highlighted'); + $(event.currentTarget).addClass('highlighted'); + this.selectedSnapshotId(data.snapshotId); + this.loadTabData(); + } + + async loadTabData() { + const snapshotId = this.selectedSnapshotId(); + let snapshotItemType; + switch (this.selectedTab()) { + case 0: snapshotItemType = 'EXPRESSION_ITEMS'; break; + case 1: snapshotItemType = 'CONCEPTS'; break; + case 2: snapshotItemType = 'SOURCE_CODES'; break; + } + const getConceptSetSnapshotItemsRequest = { + snapshotId: snapshotId, + snapshotItemType: snapshotItemType + }; + try { + const result = await conceptSetService.getConceptSetSnapshotItems(getConceptSetSnapshotItemsRequest); + const conceptSetItems = result.json?.conceptSetItems || []; + // Load respective tab data + if (this.selectedTab() === 0) { + this.expressionItems(conceptSetItems); + } else if (this.selectedTab() === 1) { + this.includedConcepts(conceptSetItems); + } else { // Tab 2 + this.includedSourceCodes(conceptSetItems); + } + + this.isLoading(false); + } catch (error) { + console.error(`Error loading data for tab: ${error.message}`); + this.isLoading(false); + } + } + + async loadData() { + this.isLoading(true); + try { + const data = await conceptSetService.listConceptSetSnapshots(this.currentConceptSet.id); + this.snapshotHistory(data.data); + } catch (ex) { + console.error(`Error refreshing snapshots: ${ex}`); + } finally { + this.isLoading(false); + } + } + + } + return commonUtils.build('conceptset-lock-history', ConceptsetLockHistory, view); +}); \ No newline at end of file diff --git a/js/pages/concept-sets/components/tabs/conceptset-lock-history.less b/js/pages/concept-sets/components/tabs/conceptset-lock-history.less new file mode 100644 index 000000000..eae2fb0e9 --- /dev/null +++ b/js/pages/concept-sets/components/tabs/conceptset-lock-history.less @@ -0,0 +1,15 @@ +.deleteIcon { + color: #d9534f; + cursor: pointer; + min-width: 30px; +} + +.button-container { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.snapshotHistoryTable tr.highlighted { + background-color: #dbdbdb !important; +} \ No newline at end of file diff --git a/js/pages/concept-sets/conceptset-manager.html b/js/pages/concept-sets/conceptset-manager.html index a1643e25a..e1ea615f5 100644 --- a/js/pages/concept-sets/conceptset-manager.html +++ b/js/pages/concept-sets/conceptset-manager.html @@ -3,7 +3,7 @@ - + @@ -25,6 +25,19 @@ + @@ -202,3 +215,12 @@ checkAssignPermissionFn: $component.checkAssignPermission, checkUnassignPermissionFn: $component.checkUnassignPermission "> + + diff --git a/js/pages/concept-sets/conceptset-manager.js b/js/pages/concept-sets/conceptset-manager.js index d371fcdf6..fd736801a 100644 --- a/js/pages/concept-sets/conceptset-manager.js +++ b/js/pages/concept-sets/conceptset-manager.js @@ -46,7 +46,9 @@ define([ 'components/ac-access-denied', 'components/versions/versions', './components/tabs/conceptset-annotation', - './components/tabs/resolve-mappings' + './components/tabs/resolve-mappings', + './components/tabs/conceptset-lock-history', + './components/modal/snapshot-lock-modal', ], function ( ko, view, @@ -77,31 +79,75 @@ define([ constructor(params) { super(params); this.commonUtils = commonUtils; - this.conceptSetStore = ConceptSetStore.repository(); + this.conceptSetStore = ConceptSetStore.repository(); this.selectedSource = ko.observable(); this.currentConceptSet = ko.pureComputed(() => this.conceptSetStore.current()); this.previewVersion = sharedState.currentConceptSetPreviewVersion; this.currentConceptSetDirtyFlag = sharedState.RepositoryConceptSet.dirtyFlag; this.currentConceptSetMode = sharedState.currentConceptSetMode; this.isOptimizeModalShown = ko.observable(false); + this.isSnapshotLockModalShown = ko.observable(false); + this.isAllowedSnapshotActions = ko.pureComputed(() => { + return (this.conceptSetStore.current() && authApi.isPermittedUpdateConceptset(this.conceptSetStore.current().id) && authApi.isPermitted('conceptset:'+ this.conceptSetStore.current().id + ':snapshot:post')) || (authApi.isPermitted('conceptset:*:snapshot:post')); + }) this.defaultName = ko.unwrap(globalConstants.newEntityNames.conceptSet); this.loading = ko.observable(); this.optimizeLoading = ko.observable(); this.fade = ko.observable(false); + this.isLocked = ko.observable(); + this.lockedDate = ko.observable(); + this.lockedBy = ko.observable(); + + this.currentConceptSetSubscription = this.currentConceptSet.subscribe(currentConceptSet => { + if (currentConceptSet) { + this.loadLastConceptSetSnapshotAction(this.currentConceptSet().id) + .then(data => { + let isLockedValue = ("LOCK" === data.data.action); + if (isLockedValue) { + this.isLocked(true); + this.lockedBy(data.data.user); + this.lockedDate(commonUtils.formatDateForAuthorship(data.data.snapshotDate)); + } else { + this.isLocked(false); + this.lockedBy(undefined); + this.lockedDate(undefined); + } + }).catch(error => { + console.error('Error fetching locked details:', error); + this.isLocked(undefined); + this.lockedBy(undefined); + this.lockedDate(undefined); + }); + } + }) + this.canEdit = ko.pureComputed(() => { if (!authApi.isAuthenticated()) { return false; } - - if (this.currentConceptSet() && (this.currentConceptSet() - .id !== 0)) { - return authApi.isPermittedUpdateConceptset(this.currentConceptSet() - .id) || !config.userAuthenticationEnabled; + if (this.currentConceptSet() && (this.currentConceptSet().id !== 0)) { + if (this.isLocked()) { + return false; + } + return authApi.isPermittedUpdateConceptset(this.currentConceptSet().id) || !config.userAuthenticationEnabled; } else { return authApi.isPermittedCreateConceptset() || !config.userAuthenticationEnabled; } }); + + this.description = ko.pureComputed(() => { + if (this.canEdit()) { + return ''; + } else { + if (this.isLocked()) { + return '(Read only, Locked)'; + } else { + return '(Read only)'; + } + } + }); + this.isNameFilled = ko.computed(() => { return this.currentConceptSet() && this.currentConceptSet().name() && this.currentConceptSet().name().trim(); }); @@ -228,6 +274,17 @@ define([ onDiagnoseCallback: this.diagnose.bind(this), }); + this.lockHistoryParams = ko.observable({ + currentConceptSet: this.currentConceptSet, + changeFlag: ko.pureComputed(() => this.currentConceptSetDirtyFlag().isChanged()), + }); + + this.canLock = ko.observable(true); + this.canUnlock = ko.observable(true); + + this.currentVocabularyVersion = sharedState.currentVocabularyVersion(); + this.currentConceptSetId = ko.pureComputed(() => this.currentConceptSet().id); + this.tabs = [ { title: ko.i18n('cs.manager.tabs.conceptSetExpression', 'Concept Set Expression'), @@ -359,6 +416,12 @@ define([ loading: this.conceptSetStore.loadingIncluded, }, }, + { + title: ko.i18n('cs.manager.tabs.lockHistory', 'Snapshot/Lock History'), + key: ViewMode.LOCK_HISTORY, + componentName: 'conceptset-lock-history', + componentParams: this.lockHistoryParams, + }, ]; this.selectedTab = ko.observable(0); @@ -414,6 +477,11 @@ define([ // initially resolve the concept set this.conceptSetStore.resolveConceptSetExpression().then(() => this.conceptSetStore.refresh(this.tabs[this.selectedTab() || 0].key)); + this.conceptSetStore.current.notifySubscribers(); + } + + lockOrUnlockSnapshot() { + this.isSnapshotLockModalShown(true); } onRouterParamsChanged(params, newParams) { @@ -480,6 +548,13 @@ define([ this.previewVersion(null); conceptSet = await conceptSetService.loadConceptSet(conceptSetId); expression = await conceptSetService.loadConceptSetExpression(conceptSetId); + + const isLockedBatchCheckRequest = { + conceptSetIds: [conceptSetId] + }; + const lockStatusResponse = await conceptSetService.getLockedStatusesForConceptSets(isLockedBatchCheckRequest); + const lockStatusMap = lockStatusResponse.data.lockStatus; + conceptSet.isLocked = ko.observable(lockStatusMap[conceptSetId] || false); } conceptSet.expression = _.isEmpty(expression) ? {items: []} : expression; sharedState.RepositoryConceptSet.current({...conceptSet, ...(new ConceptSet(conceptSet))}); @@ -739,8 +814,20 @@ define([ createdDate: createdDate, modifiedBy: modifiedBy, modifiedDate: modifiedDate, + lockedBy: this.lockedBy, + lockedDate: this.lockedDate, } } + + async loadLastConceptSetSnapshotAction(conceptSetId) { + try { + return await conceptSetService.getLastConceptSetSnapshotAction(conceptSetId); + } catch (ex) { + console.error(`Error fetching last snapshot action: ${ex}`); + return null; + } + } + diagnose() { if (this.currentConceptSet()) { return conceptSetService.runDiagnostics(this.currentConceptSet()); diff --git a/js/pages/concept-sets/conceptset-manager.less b/js/pages/concept-sets/conceptset-manager.less index 5be658ffa..da29eb1f9 100644 --- a/js/pages/concept-sets/conceptset-manager.less +++ b/js/pages/concept-sets/conceptset-manager.less @@ -39,4 +39,38 @@ .optimizer-new-name { margin-bottom: 5px; +} + +.btn-lock-locked { + content: "\f023"; /* fas fa-lock */ +} + +.btn-lock-unlocked { + content: "\f3c1"; /* fas fa-lock-open */ +} + +.fa-stack { + width: 1em; + height: 1em; + line-height: 1em; + position: relative; +} + +.fa-camera { + font-size: 1em; + color: #ffffff; +} + +.camera-lock-icon { + font-size: 0.25em; + color: #363636; + position: absolute; + top: 1%; + left: 1%; + transform: translate(-50%, -50%); +} + +.btn-lock-locked .fa-unlock, +.btn-lock-unlocked .fa-lock { + display: none; } \ No newline at end of file diff --git a/js/pages/concept-sets/const.js b/js/pages/concept-sets/const.js index 93559f03a..28ea36a20 100644 --- a/js/pages/concept-sets/const.js +++ b/js/pages/concept-sets/const.js @@ -12,6 +12,7 @@ define( IMPORT: conceptSetConstants.ViewMode.IMPORT, ANNOTATION: conceptSetConstants.ViewMode.ANNOTATION, MAPPINGS: conceptSetConstants.ViewMode.MAPPINGS, + LOCK_HISTORY: conceptSetConstants.ViewMode.LOCK_HISTORY, EXPLORE: 'explore', COMPARE: 'compare', VERSIONS: 'versions', diff --git a/js/services/ConceptSet.js b/js/services/ConceptSet.js index 77e290942..e25bb8008 100644 --- a/js/services/ConceptSet.js +++ b/js/services/ConceptSet.js @@ -144,6 +144,31 @@ define(function (require) { }).then(({ data }) => data); } + function listConceptSetSnapshots(conceptSetId) { + return httpService.doGet(config.webAPIRoot + 'conceptset/' + (conceptSetId || '-1') + '/snapshots') + .catch(authApi.handleAccessDenied); + } + + function getLastConceptSetSnapshotAction(conceptSetId) { + return httpService.doGet(config.webAPIRoot + 'conceptset/' + (conceptSetId || '-1') + '/snapshot') + .catch(authApi.handleAccessDenied); + } + + function invokeConceptSetSnapshotAction(id, conceptSetSnapshotActionRequest) { + return httpService.doPost(config.api.url + 'conceptset/' + id + '/snapshot', conceptSetSnapshotActionRequest) + .catch(authApi.handleAccessDenied); + } + + function getConceptSetSnapshotItems(getConceptSetSnapshotItemsRequest) { + return httpService.doPost(config.api.url + 'conceptset/snapshot-items', getConceptSetSnapshotItemsRequest) + .catch(authApi.handleAccessDenied); + } + + function getLockedStatusesForConceptSets(isLockedBatchCheckRequest) { + return httpService.doPost(config.webAPIRoot + 'conceptset/locked', isLockedBatchCheckRequest) + .catch(authApi.handleAccessDenied); + } + const api = { loadConceptSet, loadConceptSetExpression, @@ -165,7 +190,12 @@ define(function (require) { copyVersion, saveConceptSetAnnotation, getConceptSetAnnotation, - deleteConceptSetAnnotation + deleteConceptSetAnnotation, + listConceptSetSnapshots, + getLastConceptSetSnapshotAction, + invokeConceptSetSnapshotAction, + getLockedStatusesForConceptSets, + getConceptSetSnapshotItems, }; return api; diff --git a/js/services/SourceAPI.js b/js/services/SourceAPI.js index d52e14a04..0c612fb39 100644 --- a/js/services/SourceAPI.js +++ b/js/services/SourceAPI.js @@ -45,6 +45,15 @@ define(function (require, exports) { } } + function getSourceInfo(sourceKey) { + return ohdsiUtil.cachedAjax({ + method: 'GET', + url: config.api.url + 'source/' + sourceKey, + contentType: 'application/json', + error: authApi.handleAccessDenied, + }); + } + function getSource(sourceKey) { return ohdsiUtil.cachedAjax({ method: 'GET', @@ -217,6 +226,7 @@ define(function (require, exports) { var api = { getSources: getSources, getSource: getSource, + getSourceInfo: getSourceInfo, saveSource: saveSource, getCacheKey: getCacheKey, initSourcesConfig: initSourcesConfig,