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 @@
+
+
+
+
+
You are about to Lock and/or create a snapshot for this concept set.
+
+
+
Locking a concept set means that the Concept Set
+ Expression tab cannot be edited anymore. However, vocabulary updates can still add new codes
+ that are mapped to the existing codes in the Concept Set Expression.
+ A snapshot saves a static version of the Concept Set
+ Expression, Included Concepts, and Included Source Codes tabs at the time it is created. If the
+ Concept Set is later modified, the snapshot remains unchanged. Additionally, if the Concept Set
+ is affected by a vocabulary update, the snapshot will not be altered.
+
+
+ There are two options: Snapshot or a Snapshot + Lock .
+ Plain snapshot:
+
+
+ Snapshot saves a static version of the concept set but does not prevent modification to the Concept
+ Set Expression tab.
+ The Snapshot saves the Concept set expression, Included Concepts and Included Source Codes.
+ Snapshot saves current vocabulary version and schema will be saved
+ Snapshots will not be impacted by future vocabulary updates
+ Current SYSTEM vocabulary version:
+ Current SYSTEM vocabulary schema:
+
+
Snapshot with Lock (along with the above actions):
+
+ Concept Set will be switched to read-only mode and marked locked.
+
+
+
This action might take some time and would continue even if this window is closed
+
+
Snapshot description message* :
+
+
+
+
+ Snapshot
+ + Lock
+ Snapshot
+ Only
+ Cancel
+
+
+
+
+
+
You are about to UNLOCK this concept set.
+
+ The following will happen:
+
+ Concept Set read-only mode will be disabled
+ Optional: Take a snapshot after unlock
+
+
+
+
+ Take snapshot
+
+
+
This action might take some time and would continue even if this window is closed
+
+
Unlock confirmation message* :
+
+
+
+ Confirm
+ Cancel
+
+
+
+
\ 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 @@
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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,