diff --git a/.gitignore b/.gitignore index 12084bde7..05ba2f6a8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ js/config-local.js js/version.js package-lock.json +.vscode +.env \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 000000000..fa51da29e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..3d0a5eea7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "eslint.alwaysShowStatus": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "eslint.quiet": true, + "prettier.singleQuote": true, + "editor.tabSize": 2, + "editor.formatOnSave": true +} diff --git a/js/pages/cohort-definitions/cohort-definition-manager.css b/js/pages/cohort-definitions/cohort-definition-manager.css index a4f0e9175..f85554957 100644 --- a/js/pages/cohort-definitions/cohort-definition-manager.css +++ b/js/pages/cohort-definitions/cohort-definition-manager.css @@ -56,4 +56,30 @@ .cohort-conceptset-button-pane .btn-success.disabled { color: #f3f3f3; -} \ No newline at end of file +} + +.sampleCreatingForm label { + font-weight: bold !important; +} + +.myCustomInputError { + border-color: #a94442 +} + +.myCustomInputSuccess { + border-color: #3c763d +} + +.myCustomTextError { + color: #a94442 !important +} + +.myCustomTextSuccess { + color: #3c763d !important +} + +.sample-list.fa-trash { + color: red; + cursor: pointer; +} + \ No newline at end of file diff --git a/js/pages/cohort-definitions/cohort-definition-manager.html b/js/pages/cohort-definitions/cohort-definition-manager.html index c3c8dfe75..42b7b9cd9 100644 --- a/js/pages/cohort-definitions/cohort-definition-manager.html +++ b/js/pages/cohort-definitions/cohort-definition-manager.html @@ -26,7 +26,7 @@ - @@ -44,6 +44,10 @@ Generation +
  • + Samples +
  • +
  • Reporting
  • @@ -354,8 +358,209 @@

    Appendix 1: Concept Set Definitions

    + +
    +
    +
    +
    + Sample Selections +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    + +
    + +
    + +
    +
    +

    +
    +
    + +
    +
    + +
    + +
    + + + +
    +
    +
    + Cohort Not Generated +
    +
    + This cohort has not been generated in the data source you selected. Please return to the generation tab to generate the cohort before accessing samples. +
    +
    +
    +
    +
    + +
    +
    + *Mandatory fields + + + Sample name cannot be empty +
    +
    + + + Number of patients must be a positive integer +
    + +
    + +
    +
    + +
    +
    + + Age must be a non-negative integer +
    +
    + +
    +
    + +
    + First and second age must be non-negative integers and not equal +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    +
    + + + + + + +
    +
    + - + \ No newline at end of file diff --git a/js/pages/cohort-definitions/cohort-definition-manager.js b/js/pages/cohort-definitions/cohort-definition-manager.js index 59ddea809..0cce1edbd 100644 --- a/js/pages/cohort-definitions/cohort-definition-manager.js +++ b/js/pages/cohort-definitions/cohort-definition-manager.js @@ -16,6 +16,7 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', 'atlas-state', 'clipboard', 'd3', + 'services/Sample', 'services/Jobs', 'services/job/jobDetail', 'services/JobDetailsService', @@ -76,6 +77,7 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', sharedState, clipboard, d3, + sampleService, jobService, jobDetail, jobDetailsService, @@ -108,6 +110,81 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', return (textA < textB) ? -1 : (textA > textB) ? 1 : 0; } + function gender(code) { + if(code==8507) return 'Male' + if(code==8532) return 'Female' + else return "Other" + } + + function mapSampleListData(originalData) { + return originalData.map(el => { + let selectionCriteria; + let mode; + if(el.age) { + switch (el.age.mode) { + case 'between': + mode = 'Between' + break; + case 'notBetween': + mode = 'Not between' + break; + case 'lessThan': + mode= 'Less than' + break; + case 'lessThanOrEqual': + mode= 'Less than or equal' + break; + case 'equalTo': + mode= 'Equal' + break; + case 'greaterThan': + mode= 'Greater than' + break; + case 'greaterThanOrEqual': + mode= 'Greater than or equal' + break; + default: + break; + } + } else { + mode = '' + } + if((mode=='Between'||mode=='Not between')&& el.age) { + selectionCriteria = `${mode} ${el.age.min} and ${el.age.max}` + } else if(el.age) { + selectionCriteria = `${mode} ${el.age.value}` + } else { + selectionCriteria = 'Random age' + } + if(el.gender.otherNonBinary&&el.gender.conceptIds.length==2) { + selectionCriteria =`Mix, ${selectionCriteria}` + } else { + if (el.gender.otherNonBinary) { + selectionCriteria = `Other, ${selectionCriteria}` + } + if (el.gender.conceptIds[0]) { + selectionCriteria = `${gender(el.gender.conceptIds[0])}, ${selectionCriteria}` + } + if (el.gender.conceptIds[1]) { + selectionCriteria = `${gender(el.gender.conceptIds[1])}, ${selectionCriteria}` + } + } + const sampleId = el.id; + const sampleName = el.name || '' + const patientCounts = el.size + const createdBy = el.createdBy && el.createdBy.name || '' + const createdOn = new Date(el.createdDate).toLocaleString() + return { + sampleId, + sampleName, + selectionCriteria, + patientCounts, + createdBy, + createdOn, + } + }) + } + class CohortDefinitionManager extends AutoBind(Clipboard(Page)) { constructor(params) { super(params); @@ -123,7 +200,7 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', this.cohortDefinitionSourceInfo = sharedState.CohortDefinition.sourceInfo; this.dirtyFlag = sharedState.CohortDefinition.dirtyFlag; this.conceptSets = ko.computed(() => this.currentCohortDefinition() && this.currentCohortDefinition().expression().ConceptSets); - this.conceptSetStore = ConceptSetStore.getStore(ConceptSetStore.sourceKeys().cohortDefinition); + this.conceptSetStore = ConceptSetStore.getStore(ConceptSetStore.sourceKeys().cohortDefinition); this.sourceAnalysesStatus = {}; this.reportCohortDefinitionId = ko.observable(); this.reportSourceKey = ko.observable(); @@ -136,6 +213,210 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', this.service = cohortDefinitionService; this.defaultName = globalConstants.newEntityNames.cohortDefinition; this.isReportGenerating = ko.observable(false); + + // sample states + this.showSampleCreatingModal = ko.observable(false) + this.sampleSourceKey = ko.observable() + this.isSampleGenerating = ko.observable(false) + this.isLoadingSampleData = ko.observable(false) + this.cohortDefinitionIdOnRoute=ko.observable() + // new sample state + this.newSampleCreatingLoader = ko.observable(false) + this.sampleName=ko.observable('') + this.patientCount=ko.observable() + this.sampleAgeType = ko.observable('') + this.isAgeRange =ko.observable(false) + this.firstAge = ko.observable() + this.secondAge = ko.observable() + this.isMaleSample=ko.observable(false) + this.isFeMaleSample=ko.observable(false) + this.isOtherGenderSample=ko.observable(false) + //error state + this.isAgeRangeError = ko.observable() + this.firstAgeError = ko.observable() + this.sampleNameError=ko.observable() + this.patientCountError=ko.observable() + //reset sample state after closing + this.showSampleCreatingModal.subscribe(val =>{ + if(!val) this.resetSampleForm() + }) + + //sampleSourceKey changes => get list of samples + this.sampleSourceKey.subscribe(val => { + const cohortId = this.currentCohortDefinition()? + this.currentCohortDefinition().id(): + this.cohortDefinitionIdOnRoute() + if(!val) { + history.pushState(null, '', `#/cohortdefinition/${cohortId}/samples`) + return + }; + history.pushState(null, '', `#/cohortdefinition/${cohortId}/samples/${val}`) + this.getSampleList(cohortId) + }) + + //validation input value + this.sampleAgeType.subscribe(val => { + this.isAgeRange(val=='between'||val=='notBetween') + }) + this.isAgeRange.subscribe(val => { + this.firstAgeError(undefined) + this.isAgeRangeError(undefined) + this.firstAge(null) + this.secondAge(null) + }) + this.secondAge.subscribe(val => { + let secondAge; + if(val!=null) { + secondAge = Number(val) + } else { + secondAge == val + } + if(secondAge==null&&this.firstAge()==null) { + this.isAgeRangeError(undefined) + this.firstAgeError(undefined) + return + } + if (this.isAgeRange()) { + if(!Number.isInteger(secondAge)||secondAge<0||!this.firstAge()||!secondAge||secondAge==this.firstAge()) { + this.isAgeRangeError(true) + } else { + this.isAgeRangeError(false) + } + } else { + this.isAgeRangeError(undefined) + } + }) + + this.firstAge.subscribe(val => { + let firstAge; + if(val!=null) { + firstAge = Number(val) + } else { + firstAge == val + } + if(firstAge==null&&this.secondAge()==null) { + this.isAgeRangeError(undefined) + this.firstAgeError(undefined) + return + } + + if(this.isAgeRange()) { + if(!Number.isInteger(firstAge)||!this.secondAge()||!secondAge||firstAge<0||firstAge==this.secondAge()) { + this.isAgeRangeError(true) + } else { + this.isAgeRangeError(false) + } + } + + if(!this.isAgeRange()) { + if(!Number.isInteger(firstAge)||firstAge<0) { + this.firstAgeError(true) + } else { + this.firstAgeError(false) + } + } + }) + + this.sampleName.subscribe(val =>{ + if(!val.trim()) { + this.sampleNameError(true) + } else { + this.sampleNameError(false) + } + }) + + this.patientCount.subscribe(val =>{ + if(!val||!Number.isInteger(Number(val))||Number(val)<=0) { + this.patientCountError(true) + } else { + this.patientCountError(false) + } + }) + //sample list + this.sampleListCols = [ + { + title: 'Sample Id', + data:'sampleId', + visible: false, + }, + { + title: 'Sample name', + render: datatableUtils.getLinkFormatter(d => ({ + label: d['sampleName'], + linkish: true, + })), + }, + { + title: 'Number of patients', + data: 'patientCounts', + }, + { + title: 'Selection criteria', + data: 'selectionCriteria', + }, + { + title: 'Created by', + data: 'createdBy', + }, + { + title: 'Created on', + data: 'createdOn' + }, + { + title: 'Delete', + sortable: false, + render: function() {return ``} + } + ] + this.sampleList = ko.observableArray() + + // Sample data table + this.sampleCols = [ + { + title: '', + sortable: false, + data: 'selected', + render: function(d) { + return `` + } + }, + { + title: 'Person ID', + render: datatableUtils.getLinkFormatter(d => ({ + label: d['personId'], + linkish: true, + })), + }, + { + title: 'Gender', + data: 'gender', + }, + { + title: 'Age at index', + data: 'ageIndex', + }, + { + title: 'Number of events', + data: 'eventCounts', + } + ] + this.selectedSampleId = ko.observable('') + this.selectedSampleName = ko.observable('') + this.sampleDataLoading = ko.observable(false) + this.sampleData =ko.observableArray([]) + + this.isCohortGenerated = ko.computed(() => { + const sourceInfo = this.cohortDefinitionSourceInfo().find(d => d.sourceKey == this.sampleSourceKey()); + if (sourceInfo&&this.getStatusMessage(sourceInfo) == 'COMPLETE') { + return true + } + return false + }) + this.enableViewPatients = ko.computed(() => { + return this.sampleData().filter(el=>el.selected).length>0; + }) + //end of sample states + this.cdmSources = ko.computed(() => { return sharedState.sources().filter((source) => commonUtils.hasCDM(source) && authApi.hasSourceAccess(source.sourceKey)); }); @@ -382,7 +663,7 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', }, ] }; - + this.stopping = ko.pureComputed(() => this.cohortDefinitionSourceInfo().reduce((acc, target) => ({...acc, [target.sourceKey]: ko.observable(false)}), {})); this.isSourceStopping = (source) => this.stopping()[source.sourceKey]; @@ -623,10 +904,18 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', PermissionService.decorateComponent(this, { entityTypeGetter: () => entityType.COHORT_DEFINITION, entityIdGetter: () => this.currentCohortDefinition().id(), - createdByUsernameGetter: () => this.currentCohortDefinition() && this.currentCohortDefinition().createdBy() + createdByUsernameGetter: () => this.currentCohortDefinition() && this.currentCohortDefinition().createdBy() && this.currentCohortDefinition().createdBy().login }); + this.tabMode.subscribe(mode => { + if(mode&&this.currentCohortDefinition()&&mode!=='samples') { + const cohortId = this.currentCohortDefinition().id() + // use push state to prevent the component to re-render + history.pushState(null, '', `#/cohortdefinition/${cohortId}`) + } + }) + this.pollForInfoPeriodically(); this.subscriptions.push( @@ -683,6 +972,9 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', } async save () { + const result = window.confirm('Modify cohort definition will delete all created samples, do you still want to proceed?') + if(!result) return + this.sampleSourceKey(null) this.isSaving(true); let cohortDefinitionName = this.currentCohortDefinition().name(); @@ -887,13 +1179,21 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', } onRouterParamsChanged(params) { - let { cohortDefinitionId, conceptSetId, selectedSourceId, mode = 'definition', sourceKey } = params; + let { cohortDefinitionId, conceptSetId, selectedSourceId, mode = 'definition', sourceKey, sampleId } = params; + this.cohortDefinitionIdOnRoute(cohortDefinitionId) // cohortDefinitionId can be undefined in case of following links fron notifications // when another tab of the same cohort definition is selected if (!cohortDefinitionId && this.currentCohortDefinition()) { cohortDefinitionId = this.currentCohortDefinition().id(); } this.tabMode(mode); + if(sourceKey) { + this.sampleSourceKey(sourceKey) + } + if(sampleId) { + this.selectedSampleId(sampleId) + this.fetchSampleData({sampleId, sourceKey, cohortDefinitionId}) + } if (!this.checkifDataLoaded(cohortDefinitionId, conceptSetId, sourceKey)) { this.prepareCohortDefinition(cohortDefinitionId, conceptSetId, selectedSourceId, sourceKey); } else if (selectedSourceId) { @@ -1048,7 +1348,7 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', this.conceptSetStore.isEditable(this.canEdit()); commonUtils.routeTo(`/cohortdefinition/${this.currentCohortDefinition().id()}/conceptsets/`); } - + reload () { if (this.modifiedJSON.length > 0) { var updatedExpression = JSON.parse(this.modifiedJSON); @@ -1328,6 +1628,224 @@ define(['jquery', 'knockout', 'text!./cohort-definition-manager.html', this.printFriendlyLoading(false); } } + + // samples methods + clickSampleTab() { + this.tabMode('samples') + const cohortId = this.currentCohortDefinition().id() + history.pushState(null, '', `#/cohortdefinition/${cohortId}/samples`) + } + addNewSample() { + this.showSampleCreatingModal(true) + } + + validateSampleForm() { + // if a mandotory field is not yet filled at all, it should be error + if(this.sampleNameError()==undefined) this.sampleNameError(true) + if(this.patientCountError()==undefined) this.patientCountError(true) + if(!this.isAgeRange()) { + // not-madatory field + if(this.firstAgeError()==undefined) this.firstAgeError(false); + if(!this.firstAgeError()&&!this.sampleNameError()&&!this.patientCountError()) { + return true + } + return false + } else { + // not madatory field + if(this.isAgeRangeError()==undefined) this.isAgeRangeError(false) + if(!this.isAgeRangeError()&&!this.sampleNameError()&&!this.patientCountError()) { + return true + } + return false + } + } + + resetSampleForm() { + this.sampleName('') + this.patientCount('') + this.sampleAgeType('lessThan') + this.firstAge(null) + this.secondAge(null) + this.isMaleSample(false) + this.isFeMaleSample(false) + this.isOtherGenderSample(false) + + this.isAgeRangeError(undefined) + this.firstAgeError(undefined) + this.sampleNameError(undefined) + this.patientCountError(undefined) + this.isAgeRange(false) + } + + createNewSample() { + const allValidated = this.validateSampleForm() + if(!allValidated) return + // create Sample + const cohortDefinitionId =this.currentCohortDefinition().id(); + const sourceKey=this.sampleSourceKey() + const name = this.sampleName(); + const size = Number(this.patientCount()); + const ageMode = this.sampleAgeType(); + let conceptIds = []; + let otherNonBinary = false + const selectAllGender = !this.isMaleSample()&&!this.isFeMaleSample()&&!this.isOtherGenderSample() + if(this.isMaleSample()||selectAllGender) { + conceptIds.push(8507) + } + if(this.isFeMaleSample()||selectAllGender) { + conceptIds.push(8532) + } + if(this.isOtherGenderSample()||selectAllGender) { + otherNonBinary = true + } + + const firstAge = Number(this.firstAge()); + const secondAge = Number(this.secondAge()); + let age; + if(this.firstAge()==null&&this.secondAge()==null) { + age = null + } else { + age = { + value: this.isAgeRange()?null:firstAge, + mode: ageMode, + min:this.isAgeRange()? firstAge { + console.log('res', res) + if(res.ok) { + const newData= mapSampleListData([res.data]) + this.sampleList.unshift(...newData) + this.showSampleCreatingModal(false) + } + //close pop-up + }) + .catch((error) => { + console.error(error) + alert('Error when creating sample, please try again later') + }) + .finally(() => { + this.newSampleCreatingLoader(false) + }) + } + + getSampleList(cohortId) { + this.isLoadingSampleData(true) + const cohortDefinitionId= cohortId || this.currentCohortDefinition().id(); + // if (cohortDefinitionId==0) return + const sourceKey=this.sampleSourceKey() + sampleService.getSampleList({cohortDefinitionId, sourceKey}) + .then(res => { + if(res.generationStatus!="COMPLETE") { + this.sampleSourceKey(null) + alert('Cohort should be generated before creating samples') + return + } + const sampleListData = mapSampleListData(res.samples) + this.sampleList(sampleListData) + }) + .catch(error=>{ + console.error(error) + alert('Error when fetching sample list, please try again later') + }) + .finally(() => { + this.isLoadingSampleData(false) + }) + } + + onSampleListRowClick(d, e) { + // find index of click + const {sampleId} = d; + const rowIndex = this.sampleList().findIndex(el=>el.sampleId == sampleId) + + const cohortDefinitionId= this.currentCohortDefinition().id(); + const sourceKey=this.sampleSourceKey(); + if(e.target.className=='sample-list fa fa-trash') { + //TODO: Delete row + sampleService.deleteSample({cohortDefinitionId, sourceKey, sampleId}) + .then(res=>{ + if(res.ok) { + this.sampleList.splice(rowIndex, 1) + } + }) + .catch(() => { + alert('Error when deleting sample, please try again later') + }) + } else { + //TODO: details sample + this.fetchSampleData({sampleId, sourceKey, cohortDefinitionId}) + } + } + + fetchSampleData({sampleId, sourceKey, cohortDefinitionId}) { + this.sampleDataLoading(true) + sampleService.getSample({ cohortDefinitionId, sourceKey, sampleId }) + .then(res=>{ + this.selectedSampleId(sampleId) + this.selectedSampleName(res.name) + this.showSampleDataTable(res.elements) + history.pushState(null, '', `#/cohortdefinition/${cohortDefinitionId}/samples/${sourceKey}/${sampleId}`) + }) + .catch(error => { + console.error(error); + alert('Error when fetching sample data, please try again later') + }) + .finally(() => { + this.sampleDataLoading(false) + }) + } + + onSampleDataClick(d) { + const selectedPatients = this.sampleData().filter(el=>el.selected).map(el=>el.personId); + //change checkbox state + + if (selectedPatients.includes(d.personId)) { + this.sampleData.replace(d, { ...d, selected: !d.selected }) + } else { + if (selectedPatients.length == 2) { + alert('You can only select maximum 2 patients') + return + } + this.sampleData.replace(d, { ...d, selected: !d.selected }) + } + } + + showSampleDataTable(sample) { + const transformedSampleData = sample.map(el => ({ + personId: el.personId, + gender: gender(el.genderConceptId), + ageIndex: el.age, + eventCounts: el.recordCount || '', + selected: false + })) + + this.sampleData(transformedSampleData); + } + + viewSamplePatient() { + const sampleId = this.selectedSampleId() + const cohortDefinitionId= this.currentCohortDefinition().id(); + const sourceKey=this.sampleSourceKey(); + const selectedPatients = this.sampleData().filter(el=>el.selected).map(el=>el.personId); + if(selectedPatients.length==0) { + alert('You must select one or two patients to proceed') + return + } + if(selectedPatients[1]) { + window.open(`#/profiles/${sourceKey}/${selectedPatients[0]}/${cohortDefinitionId}/${sampleId}/${selectedPatients[1]}`) + } + window.open(`#/profiles/${sourceKey}/${selectedPatients[0]}/${cohortDefinitionId}/${sampleId}`) + } } return commonUtils.build('cohort-definition-manager', CohortDefinitionManager, view); diff --git a/js/pages/cohort-definitions/routes.js b/js/pages/cohort-definitions/routes.js index 28bf6fbf2..a3cd8d409 100644 --- a/js/pages/cohort-definitions/routes.js +++ b/js/pages/cohort-definitions/routes.js @@ -14,7 +14,9 @@ define( router.setCurrentView('cohort-definitions'); }); }), - '/cohortdefinition/:cohortDefinitionId/conceptsets/:conceptSetId/:mode': new AuthorizedRoute((cohortDefinitionId, conceptSetId, mode) => { + + '/cohortdefinition/:cohortDefinitionId/samples': new AuthorizedRoute( + cohortDefinitionId => { require([ 'components/conceptset/ConceptSetStore', 'components/cohortbuilder/CohortDefinition', @@ -25,9 +27,79 @@ define( 'conceptset-editor', './components/reporting/cost-utilization/report-manager', 'explore-cohort', - ], function (ConceptSetStore) { - sharedState.CohortDefinition.mode('conceptsets'); - sharedState.activeConceptSet(ConceptSetStore.cohortDefinition()); + ], function() { + // not re-render component if it was rendered already + router.setCurrentView('cohort-definition-manager', { + cohortDefinitionId, + mode: 'samples', + }) + sharedState.ConceptSet.source('cohort') + sharedState.CohortDefinition.mode('samples') + }) + } + ), + + '/cohortdefinition/:cohortDefinitionId/samples/:sourceKey': new AuthorizedRoute( + (cohortDefinitionId, sourceKey) => { + require([ + 'components/cohortbuilder/CohortDefinition', + 'components/atlas.cohort-editor', + './cohort-definitions', + './cohort-definition-manager', + 'components/cohort-definition-browser', + 'conceptset-editor', + './components/reporting/cost-utilization/report-manager', + 'explore-cohort', + ], function() { + router.setCurrentView('cohort-definition-manager', { + cohortDefinitionId, + sourceKey, + mode: 'samples', + }) + sharedState.ConceptSet.source('cohort') + sharedState.CohortDefinition.mode('samples') + }) + } + ), + + '/cohortdefinition/:cohortDefinitionId/samples/:sourceKey/:sampleId': new AuthorizedRoute( + (cohortDefinitionId, sourceKey, sampleId) => { + require([ + 'components/cohortbuilder/CohortDefinition', + 'components/atlas.cohort-editor', + './cohort-definitions', + './cohort-definition-manager', + 'components/cohort-definition-browser', + 'conceptset-editor', + './components/reporting/cost-utilization/report-manager', + 'explore-cohort', + ], function() { + router.setCurrentView('cohort-definition-manager', { + cohortDefinitionId, + sampleId, + sourceKey, + mode: 'samples', + }) + sharedState.CohortDefinition.mode('samples') + }) + } + ), + + '/cohortdefinition/:cohortDefinitionId/conceptsets/:conceptSetId/:mode': new AuthorizedRoute( + (cohortDefinitionId, conceptSetId, mode) => { + require([ + 'components/conceptset/ConceptSetStore', + 'components/cohortbuilder/CohortDefinition', + 'components/atlas.cohort-editor', + './cohort-definitions', + './cohort-definition-manager', + 'components/cohort-definition-browser', + 'conceptset-editor', + './components/reporting/cost-utilization/report-manager', + 'explore-cohort', + ], function(ConceptSetStore) { + sharedState.CohortDefinition.mode('conceptsets') + sharedState.activeConceptSet(ConceptSetStore.cohortDefinition()) router.setCurrentView('cohort-definition-manager', { cohortDefinitionId, mode: 'conceptsets', @@ -46,7 +118,7 @@ define( './components/reporting/cost-utilization/report-manager', 'explore-cohort', 'components/conceptset/concept-modal', - ], function () { + ], function() { // Determine the view to show on the cohort manager screen based on the path path = path.split("/"); let view = 'definition'; diff --git a/js/pages/profiles/const.js b/js/pages/profiles/const.js index 73be315fe..f68b24294 100644 --- a/js/pages/profiles/const.js +++ b/js/pages/profiles/const.js @@ -1,14 +1,22 @@ -define( - (require, exports) => { - const pageTitle = 'Profiles'; - const paths = { - source: sourceKey => `#/profiles/${sourceKey}`, - person: (sourceKey, personId) => `#/profiles/${sourceKey}/${personId}`, - }; +define((require, exports) => { + const pageTitle = 'Profiles' + const paths = { + source: sourceKey => `#/profiles/${sourceKey}`, + person: (sourceKey, personId) => `#/profiles/${sourceKey}/${personId}`, + onePersonSample: ({ sourceKey, personId, cohortDefinitionId, sampleId }) => + `#/profiles/${sourceKey}/${personId}/${cohortDefinitionId}/${sampleId}`, + twoPersonSample: ({ + sourceKey, + personId, + cohortDefinitionId, + sampleId, + secondPersonId, + }) => + `#/profiles/${sourceKey}/${personId}/${cohortDefinitionId}/${sampleId}/${secondPersonId}`, + } - return { - pageTitle, - paths, - }; + return { + pageTitle, + paths, } -); \ No newline at end of file +}) diff --git a/js/pages/profiles/profile-manager.html b/js/pages/profiles/profile-manager.html index f6989070c..948c51732 100644 --- a/js/pages/profiles/profile-manager.html +++ b/js/pages/profiles/profile-manager.html @@ -1,140 +1,296 @@ - - +
    -
    -
    -
    -
    - - -
    - -
    -
    - -
    -
    - -
    -
    -
    - -
    -
    - -
    -   -   |   -   |   - - at index - at start of observation - |  Cohort: -
    -
    - - - - - -
    Can't find - -
    - -
    -
    -
    -
    -
    -
    - +
    +
    +
    + + +
    + + +
    + +
    + +
    +
    + +
    +
    +
    + +
    + +   |   + +   +   |   +   |   + + at index + at start of observation + |  Cohort: + + + |  Sample: + + + + Next patient + + + + + Previous patient + + + + + + + + +
    +
    + + + + + +
    + Can't find + +
    + +
    +
    +
    +
    + +
    + + }" + >
    -
    -
    -
    -
    +
    +
    + +
    + +
    - -
    -
    -
    - + +
    +
    +
    + +
    + + + + + +
    +
    +   |   +   +   |   +   |   + + at index + + |  Cohort: + + + at start of observation + |  Sample: + + + + +
    +
    + +
    + + + +
    + Can't find + +
    + +
    +
    - \ No newline at end of file + + diff --git a/js/pages/profiles/profile-manager.js b/js/pages/profiles/profile-manager.js index bfb99d51b..dcef3de78 100644 --- a/js/pages/profiles/profile-manager.js +++ b/js/pages/profiles/profile-manager.js @@ -1,496 +1,796 @@ -"use strict"; +'use strict' define([ - 'knockout', - 'const', - 'services/PluginRegistry', - 'text!./profile-manager.html', - 'd3', - 'appConfig', - 'services/AuthAPI', - 'services/Profile', - 'atlas-state', - 'components/cohortbuilder/CohortDefinition', - 'services/CohortDefinition', - 'services/Vocabulary', - 'pages/Page', - 'utils/AutoBind', - 'utils/CommonUtils', - 'pages/Router', - 'moment', - './const', - 'lodash', - 'crossfilter', - 'assets/ohdsi.util', - 'd3-tip', - 'databindings', - 'faceted-datatable', - 'extensions/bindings/profileChart', - 'less!./profile-manager.less', - 'components/heading', - 'components/ac-access-denied' - ], - function ( - ko, - globalConstants, - pluginRegistry, - view, - d3, - config, - authApi, - profileService, - sharedState, - CohortDefinition, - cohortDefinitionService, - vocabularyService, - Page, - AutoBind, - commonUtils, - router, - moment, - constants, - _, - crossfilter, - util, - ) { - - var reduceToRecs = [ // crossfilter group reduce functions where group val - // is an array of recs in the group - (p, v, nf) => p.concat(v), - (p, v, nf) => _.without(p, v), - () => [] - ]; - - class ProfileManager extends AutoBind(Page) { - constructor(params) { - super(params); - this.sharedState = sharedState; - this.aspectRatio = ko.observable(); - this.config = config; - this.filterHighlightsText = ko.observable(); - this.loadingStatus = ko.observable('loading'); - - this.sourceKey = ko.observable(router.routerParams().sourceKey); - this.personId = ko.observable(router.routerParams().personId); - this.personRecords = ko.observableArray(); - - this.cohortDefinitionId = ko.observable(router.routerParams().cohortDefinitionId); - this.currentCohortDefinition = ko.observable(null); - this.cohortDefinition = sharedState.CohortDefinition.current; - // if a cohort definition id has been specified, see if it is - // already loaded into the page model. If not, load it from the - // server - if (this.cohortDefinitionId() && - ( - this.cohortDefinition() && - this.cohortDefinition().id() === this.cohortDefinitionId - ) - ) { - // The cohort definition requested is already loaded into the page model - just reference it - this.currentCohortDefinition(this.cohortDefinition()) - } else if (this.cohortDefinitionId()) { - cohortDefinitionService.getCohortDefinition(this.cohortDefinitionId()) - .then((cohortDefinition) => { - this.currentCohortDefinition(new CohortDefinition(cohortDefinition)); - }); - } - this.isAuthenticated = authApi.isAuthenticated; - this.permittedSources = ko.computed(() => sharedState.sources().filter(s => authApi.isPermittedViewProfiles(s.sourceKey))); - this.canViewProfiles = ko.computed(() => { - return (config.userAuthenticationEnabled && this.isAuthenticated() && this.permittedSources().length > 0) || !config.userAuthenticationEnabled; - }); - - this.cohortSource = ko.observable(); - this.person = ko.observable(); - this.loadingPerson = ko.observable(false); - this.cantFindPerson = ko.observable(false); - this.shadedRegions = ko.observable([]); - - this.setSourceKey = (d) => { - this.sourceKey(d.sourceKey); - }; - - this.cohortDefSource = ko.computed(() => { - return { - cohortDef: this.currentCohortDefinition(), - sourceKey: this.sourceKey(), - }; - }); - this.cohortDefSource.subscribe((o) => { - this.loadConceptSets(o); - }); - this.loadConceptSets = (o) => { - if (!o.cohortDef) - return; - var conceptSets = ko.toJS(o.cohortDef.expression().ConceptSets()); - conceptSets.forEach((conceptSet) => { - vocabularyService.resolveConceptSetExpression(conceptSet.expression) - .then(resolvedIds => this.loadedConceptSet(conceptSet, resolvedIds)); - }); - }; - this.conceptSets = ko.observable({}); - this.loadedConceptSet = (conceptSet, ids, status) => { - this.conceptSets(_.extend({}, this.conceptSets(), { - [conceptSet.name]: ids - })); - }; - this.loadConceptSets(this.cohortDefSource()); - - this.sourceKeyCaption = ko.computed(() => { - return this.sourceKey() || "Select a Data Source"; - }); - this.personRequests = {}; - this.personRequest; - this.xfObservable = ko.observable(); - this.xfDimensions = []; - this.crossfilter = ko.observable(); - this.highlightEnabled = ko.observable(false); - this.filteredRecs = ko.observableArray([]); - this.filtersChanged = ko.observable(); - this.facetsObs = ko.observableArray([]); - this.highlightRecs = ko.observableArray([]); - this.getGenderClass = ko.computed(() => { - if (this.person()) { - if (this.person() - .gender === 'FEMALE') { - return "fa fa-female"; - } else if (this.person() - .gender === 'MALE') { - return "fa fa-male"; - } else { - return "fa fa-question"; - } - } - }); - this.dateRange = ko.computed(() => { - if (this.canViewProfileDates() && this.xfObservable && this.xfObservable() && this.xfObservable().isElementFiltered()) { - const filtered = this.xfObservable().allFiltered(); - return filtered.map(v => ({ - startDate: moment(v.startDate).add(v.startDays, 'days').valueOf(), - endDate: moment(v.endDate).subtract(v.endDays, 'days').valueOf(), - })) - .reduce((a, v) => ({ - startDate: a.startDate < v.startDate ? a.startDate : v.startDate, - endDate: a.endDate > v.endDate ? a.endDate : v.endDate, - })); - } - return { - startDate: null, endDate: null, - }; - }); - this.startDate = ko.computed(() => this.dateRange().startDate); - this.endDate = ko.computed(() => this.dateRange().endDate); - - this.dimensions = { - 'Domain': { - caption: 'Domain', - func: d => d.domain, - filter: ko.observable(null), - Members: [], - }, - 'profileChart': { - name: 'profileChart', - func: d => [d.startDay, d.endDay], - filter: ko.observable(null), - }, - 'conceptName': { - name: 'conceptName', - func: d => d.conceptName, - filter: ko.observable(null), - }, - 'concepts': { - name: 'concepts', - isArray: true, - func: d => { - return (_.chain(this.conceptSets()) - .map(function (ids, conceptSetName) { - if (_.includes(ids, d.conceptId)) - return ' ' + conceptSetName; - }) - .compact() - .value() - .concat(d.conceptName) - ); - }, - filter: ko.observable(null), - }, - }; - this.searchHighlight = ko.observable(); - this.highlightData = ko.observableArray(); - this.defaultColor = '#888'; - this.words = ko.computed(() => { - if (!this.xfObservable()) { - return; - } - if (this.xfDimensions.length == 0) { - this.xfDimensions.push(this.xfObservable().dimension(function (d) { - return d; - })); - } - // var recs = this.xfObservable().allFiltered(); - // var conceptSets = this.conceptSets(); - this.dimensionSetup(this.dimensions.concepts, this.xfObservable()); - const stopWords = [ - 'Outpatient Visit', 'No matching concept', - ]; - let words = this.dimensions.concepts.group.all() - .filter(d => { - let filtered = true; - if (this.filterHighlightsText() && this.filterHighlightsText().length > 0) { - if (d.key.toLowerCase().indexOf(this.filterHighlightsText().toLowerCase()) == -1) { - filtered = false; - } - } - return d.value.length && stopWords.indexOf(d.key) === -1 && filtered; - }); - words = words.map(d => { - return { - caption: d.key, - domain: d.value[0].domain, - text: d.key, - recs: d.value, - count: d.value.length, - highlight: ko.observable(this.defaultColor) - } - }); - words = _.sortBy(words, d => -d.recs.length) - // profile chart will render all data in case when no data is captured by filter - if (words.length !== 0) { - this.highlightData(words); - } - }); - this.searchHighlight.subscribe(func => { - if (func) - this.highlight(this.filteredRecs() - .filter(func)); - else - this.highlight([]); - }); - this.cohortDefinitionButtonText = ko.observable('Click Here to Select a Cohort'); - - this.showSection = { - profileChart: ko.observable(true), - datatable: ko.observable(true), - }; - this.highlightDom = '<<"row vertical-align"<"col-xs-6"><"col-xs-6 search"f>><"row vertical-align"<"col-xs-6"i><"col-xs-6"p>>>'; - this.highlightColumns = ['select', { - render: this.swatch, - data: 'highlight()', - sortable: false - }, { - title: 'Concept Name', - data: 'caption' - }, { - title: 'Domain', - data: 'domain' - }, { - title: 'Total Records', - data: 'count' - }]; - - this.columns = [{ - title: 'Concept Id', - data: 'conceptId' - }, - { - title: 'Concept Name', - data: 'conceptName' - }, - { - title: 'Domain', - data: 'domain' - }, - { - title: 'Start Day', - data: 'startDay' - }, - { - title: 'End Day', - data: 'endDay' - } - ]; - // d3.schemePaired - this.palette = ['#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99', '#e31a1c', '#fdbf6f', '#ff7f00', '#cab2d6', '#6a3d9a', '#ff9', '#b15928']; - - - this.sourceKey.subscribe((sourceKey) => { - document.location = constants.paths.source(sourceKey); - }); - this.personId.subscribe((personId) => { - document.location = constants.paths.person(this.sourceKey(), personId); - }); - - $('.highlight-filter').on('click', function (evt) { - return false; - }); - - this.highlightOptions = {}; - this.options = { - Facets: [{ - 'caption': 'Domain', - 'binding': d => d.domain, - }] - }; - - $("#modalHighlights").draggable(); - - if (this.personId()) { - this.loadPerson(); - } - - this.plugins = pluginRegistry.findByType(globalConstants.pluginTypes.PROFILE_WIDGET); - } - - loadPerson() { - this.cantFindPerson(false); - this.loadingPerson(true); - this.xfDimensions = []; - - let url = constants.paths.person(this.sourceKey(), this.personId()); - - this.loadingStatus('loading profile data from database'); - this.personRequest = this.personRequests[url] = profileService.getProfile(this.sourceKey(), this.personId(), this.cohortDefinitionId()) - .then((person) => { - if (this.personRequest !== this.personRequests[url]) { - return; - } - this.loadingStatus('processing profile data'); - person.personId = this.personId(); - this.loadingPerson(false); - let cohort; - let cohortDefinitionId = this.cohortDefinitionId(); - if (cohortDefinitionId) { - cohort = _.find(person.cohorts, function (o) { - return o.cohortDefinitionId == cohortDefinitionId; - }); - } - // In the event that we could not find the matching cohort in the person object or the cohort definition id is not specified default it - if (typeof cohort === "undefined") { - cohort = { - startDate: _.chain(person.records) - .map(d => d.startDate) - .min() - .value() - }; - } - person.records.forEach((rec) => { - // have to get startDate from person.cohorts - // rec.startDay = Math.floor((rec.startDate - cohort.startDate) / (1000 * 60 * 60 * 24)); - // rec.endDay = rec.endDate ? Math.floor((rec.endDate - cohort.startDate) / (1000 * 60 * 60 * 24)) : rec.startDay; - rec.highlight = this.defaultColor; - rec.stroke = this.defaultColor; - }); - this.personRecords(person.records); - person.shadedRegions = - person.observationPeriods.map(op => { - return { - x1: op.x1, - x2: op.x2, - className: 'observation-period', - }; - }); - this.shadedRegions(person.shadedRegions); - this.person(person); - }) - .catch(() => { - this.cantFindPerson(true); - this.loadingPerson(false); - }); - } - - removeHighlight() { - this.highlight([]); - } - - highlight(recs, evt) { - if (recs && recs.length > 0) { - this.highlightEnabled(true); - } else { - this.highlightEnabled(false); - } - this.highlightRecs([{ - 'color': '#f00', - 'recs': recs - }] || []); - } - - dimensionSetup(dim, cf) { - if (!cf) return; - dim.dimension = cf.dimension(dim.func, dim.isArray); - dim.filter(null); - dim.group = dim.dimension.group(); - dim.group.reduce(...reduceToRecs); - dim.groupAll = dim.dimension.groupAll(); - dim.groupAll.reduce(...reduceToRecs); - } - - dispToggle(pm, evt) { - let section = evt.target.value; - this.showSection[section](!this.showSection[section]()); - } - - swatch(d) { - return '
    '; - } - - daysBeforeIndex(d) { - if (d.startDay >= -30 && d.startDay <= 0) { - return '0-30 days'; - } else if (d.startDay >= -60 && d.startDay < -30) { - return '31-60 days'; - } else if (d.startDay >= -90 && d.startDay < -60) { - return '61-90 days'; - } else if (d.startDay < -90) { - return '90+ days'; - } - } - - setHighlights(colorIndex) { - const dt = $('#highlight-table table').DataTable(); - const rows = dt.rows('.selected'); - var selectedData = rows.data(); - for (let i = 0; i < selectedData.length; i++) { - selectedData[i].highlight(this.getHighlightBackground(colorIndex)); // set the swatch color - selectedData[i].recs.forEach(r => { - r.highlight = this.getHighlightBackground(colorIndex); - r.stroke = this.getHighlightColor(colorIndex); - }); // set the record colors - } - rows && rows[0] && rows[0].forEach(r => dt.row(r).invalidate()); - this.highlightRecs.valueHasMutated(); - }; - - getHighlightColor(i) { - return this.palette[i * 2]; - } - - getHighlightBackground(i) { - return this.palette[i * 2 + 1]; - } - - clearHighlights() { - const dt = $('#highlight-table table').DataTable(); - const rows = dt.rows('.selected'); - var selectedData = rows.data(); - for (let i = 0; i < selectedData.length; i++) { - selectedData[i].highlight(this.defaultColor); // set the swatch color - selectedData[i].recs.forEach(r => { - r.highlight = this.defaultColor; // set the record colors - r.stroke = this.defaultColor; // set the record colors - }) - } - rows && rows[0] && rows[0].forEach(r => dt.row(r).invalidate()); - this.highlightRecs.valueHasMutated(); - } - - highlightRowClick(data, evt, row) { - evt.stopPropagation(); - $(row).toggleClass('selected'); - } - - canViewProfileDates() { - return config.viewProfileDates && (!config.userAuthenticationEnabled || (config.userAuthenticationEnabled && authApi.isPermittedViewProfileDates())); - } - } - - return commonUtils.build('profile-manager', ProfileManager, view); - }); \ No newline at end of file + 'require', + 'knockout', + 'const', + 'services/PluginRegistry', + 'text!./profile-manager.html', + 'd3', + 'utils/DatatableUtils', + 'services/Sample', + 'appConfig', + 'services/AuthAPI', + 'services/Profile', + 'atlas-state', + 'components/cohortbuilder/CohortDefinition', + 'services/CohortDefinition', + 'services/Vocabulary', + 'pages/Page', + 'utils/AutoBind', + 'utils/CommonUtils', + 'pages/Router', + 'moment', + './const', + 'lodash', + 'crossfilter', + 'assets/ohdsi.util', + 'd3-tip', + 'databindings', + 'faceted-datatable', + 'extensions/bindings/profileChart', + 'less!./profile-manager.less', + 'components/heading', + 'components/ac-access-denied', + './profileTimeline', +], function( + require, + ko, + globalConstants, + pluginRegistry, + view, + d3, + datatableUtils, + sampleService, + config, + authApi, + profileService, + sharedState, + CohortDefinition, + cohortDefinitionService, + vocabularyService, + Page, + AutoBind, + commonUtils, + router, + moment, + constants, + _, + crossfilter, + util +) { + var reduceToRecs = [ + // crossfilter group reduce functions where group val + // is an array of recs in the group + (p, v, nf) => p.concat(v), + (p, v, nf) => _.without(p, v), + () => [], + ] + const Timeline = require('./profileTimeline') + + function gender(code) { + if (code == 8507) return 'Male' + if (code == 8532) return 'Female' + else return 'Other' + } + + class ProfileManager extends AutoBind(Page) { + constructor(params) { + super(params) + this.showSection = { + profileChart: ko.observable(true), + datatable: ko.observable(true), + } + this.sharedState = sharedState + this.config = config + this.loadingStatus = ko.observable('loading') + + this.sourceKey = ko.observable(router.routerParams().sourceKey) + this.personId = ko.observable(router.routerParams().personId) + this.personRecords = ko.observableArray() + + this.cohortDefinitionId = ko.observable( + router.routerParams().cohortDefinitionId + ) + this.person = ko.observable() + this.loadingPerson = ko.observable(false) + this.cantFindPerson = ko.observable(false) + // sample redirect state + this.isLoadingSampleData = ko.observable(false) + this.patientSelectionData = ko.observableArray([]) + this.selectedPatients = ko.observableArray([]) + + this.sampleName = ko.observable() + this.sampleId = ko.observable(router.routerParams().sampleId) // it shoule equa 'sample' + if (this.sampleId()) { + this.fetchSampleData({ + sampleId: this.sampleId(), + sourceKey: this.sourceKey(), + cohortDefinitionId: this.cohortDefinitionId(), + }) + $('#modalPatientSelection').on('hidden.bs.modal', () => { + // overridde selected patients to original ones if it's data not fetched + const currentPersonId = this.personId() + const currentSecondPersonId = this.secondPersonId() + if ( + this.selectedPatients().includes(currentSecondPersonId) && + this.selectedPatients().includes(currentPersonId) + ) { + // nothing changes => do nothing + return + } + + const currentPersonIndex = this.patientSelectionData().findIndex( + el => el.personId == currentPersonId + ) + const currentSecondPersonIndex = this.patientSelectionData().findIndex( + el => el.personId == currentSecondPersonId + ) + + if (this.selectedPatients().length >= 1) { + const index = this.patientSelectionData().findIndex( + el => el.personId == this.selectedPatients()[0] + ) + this.patientSelectionData.replace( + this.patientSelectionData()[index], + { + ...this.patientSelectionData()[index], + selected: false, + } + ) + } + if (this.selectedPatients().length == 2) { + const index = this.patientSelectionData().findIndex( + el => el.personId == this.selectedPatients()[1] + ) + this.patientSelectionData.replace( + this.patientSelectionData()[index], + { + ...this.patientSelectionData()[index], + selected: false, + } + ) + } + this.selectedPatients.removeAll() + this.selectedPatients.push(currentPersonId) + if (currentSecondPersonId) { + this.selectedPatients.push(currentSecondPersonId) + } + this.patientSelectionData.replace( + this.patientSelectionData()[currentPersonIndex], + { + ...this.patientSelectionData()[currentPersonIndex], + selected: true, + } + ) + + this.patientSelectionData.replace( + this.patientSelectionData()[currentSecondPersonIndex], + { + ...this.patientSelectionData()[currentSecondPersonIndex], + selected: true, + } + ) + }) + } + this.sampleId.subscribe(val => { + this.fetchSampleData({ + sampleId: val, + sourceKey: this.sourceKey(), + cohortDefinitionId: this.cohortDefinitionId(), + }) + }) + // sample second person state + this.showPerson2 = ko.observable(false) + + this.secondPersonId = ko.observable(router.routerParams().secondPersonId) + this.secondPersonRecords = ko.observableArray() + this.cantFindSecondPerson = ko.observable(false) + this.loadingSecondPerson = ko.observable(false) + this.secondPersonGender = ko.observable() + this.xfObservableSecond = ko.observable() + this.secondPersonGenderClass = ko.computed(() => { + if (this.secondPersonGender() === 'FEMALE') { + return 'fa fa-female' + } else if (this.secondPersonGender() === 'MALE') { + return 'fa fa-male' + } else { + return 'fa fa-question' + } + }) + this.secondPersonRecordCount = ko.observable() + this.secondPersonAgeAtIndex = ko.observable() + + this.secondPersonId.subscribe(val => { + if (val && this.sampleId()) { + this.loadComparingPerson(val) + } + }) + if (this.sampleId() && this.personId()) { + this.selectedPatients.push(this.personId()) + this.loadComparingPerson() + } + if (this.sampleId && this.secondPersonId()) { + this.selectedPatients.push(this.secondPersonId()) + this.loadComparingPerson(this.secondPersonId()) + } + this.combinedPersonIds = ko.computed(() => { + if (!this.secondPersonId()) { + return this.personId() + } else { + return `${this.personId()}; ${this.secondPersonId()}` + } + }) + + this.currentCohortDefinition = ko.observable(null) + this.cohortDefinition = sharedState.CohortDefinition.current + // if a cohort definition id has been specified, see if it is + // already loaded into the page model. If not, load it from the + // server + if ( + this.cohortDefinitionId() && + (this.cohortDefinition() && + this.cohortDefinition().id() === this.cohortDefinitionId) + ) { + // The cohort definition requested is already loaded into the page model - just reference it + this.currentCohortDefinition(this.cohortDefinition()) + } else if (this.cohortDefinitionId()) { + cohortDefinitionService + .getCohortDefinition(this.cohortDefinitionId()) + .then(cohortDefinition => { + this.currentCohortDefinition(new CohortDefinition(cohortDefinition)) + }) + } + this.isAuthenticated = authApi.isAuthenticated + this.permittedSources = ko.computed(() => + sharedState + .sources() + .filter(s => authApi.isPermittedViewProfiles(s.sourceKey)) + ) + this.canViewProfiles = ko.computed(() => { + return ( + (config.userAuthenticationEnabled && + this.isAuthenticated() && + this.permittedSources().length > 0) || + !config.userAuthenticationEnabled + ) + }) + + this.cohortSource = ko.observable() + this.shadedRegions = ko.observable([]) + + this.setSourceKey = d => { + this.sourceKey(d.sourceKey) + } + + this.cohortDefSource = ko.computed(() => { + return { + cohortDef: this.currentCohortDefinition(), + sourceKey: this.sourceKey(), + } + }) + this.cohortDefSource.subscribe(o => { + this.loadConceptSets(o) + }) + this.loadConceptSets = o => { + if (!o.cohortDef) return + var conceptSets = ko.toJS(o.cohortDef.expression().ConceptSets()) + conceptSets.forEach((conceptSet) => { + vocabularyService.resolveConceptSetExpression(conceptSet.expression) + .then(resolvedIds => this.loadedConceptSet(conceptSet, resolvedIds)); + }) + } + this.conceptSets = ko.observable({}) + this.loadedConceptSet = (conceptSet, ids, status) => { + this.conceptSets( + _.extend({}, this.conceptSets(), { + [conceptSet.name]: ids, + }) + ) + } + this.loadConceptSets(this.cohortDefSource()) + + this.sourceKeyCaption = ko.computed(() => { + return this.sourceKey() || 'Select a Data Source' + }) + this.personRequests = {} + this.personRequest + this.xfObservable = ko.observable() + this.crossfilter = ko.observable() + this.filteredRecs = ko.observableArray([]) + this.filtersChanged = ko.observable() + this.facetsObs = ko.observableArray([]) + this.getGenderClass = ko.computed(() => { + if (this.person()) { + if (this.person().gender === 'FEMALE') { + return 'fa fa-female' + } else if (this.person().gender === 'MALE') { + return 'fa fa-male' + } else { + return 'fa fa-question' + } + } + }) + this.dateRange = ko.computed(() => { + if ( + this.canViewProfileDates() && + this.xfObservable && + this.xfObservable() && + this.xfObservable().isElementFiltered() + ) { + const filtered = this.xfObservable().allFiltered() + return filtered + .map(v => ({ + startDate: moment(v.startDate) + .add(v.startDays, 'days') + .valueOf(), + endDate: moment(v.endDate) + .subtract(v.endDays, 'days') + .valueOf(), + })) + .reduce((a, v) => ({ + startDate: a.startDate < v.startDate ? a.startDate : v.startDate, + endDate: a.endDate > v.endDate ? a.endDate : v.endDate, + })) + } + return { + startDate: null, + endDate: null, + } + }) + this.startDate = ko.computed(() => this.dateRange().startDate) + this.endDate = ko.computed(() => this.dateRange().endDate) + + this.dimensions = { + Domain: { + caption: 'Domain', + func: d => d.domain, + filter: ko.observable(null), + Members: [], + }, + profileChart: { + name: 'profileChart', + func: d => [d.startDay, d.endDay], + filter: ko.observable(null), + }, + conceptName: { + name: 'conceptName', + func: d => d.conceptName, + filter: ko.observable(null), + }, + concepts: { + name: 'concepts', + isArray: true, + func: d => { + return _.chain(this.conceptSets()) + .map(function(ids, conceptSetName) { + if (_.includes(ids, d.conceptId)) + return ' ' + conceptSetName + }) + .compact() + .value() + .concat(d.conceptName) + }, + filter: ko.observable(null), + }, + } + this.cohortDefinitionButtonText = ko.observable( + 'Click Here to Select a Cohort' + ) + + this.patientSelectionColumn = [ + { + title: '', + sortable: false, + data: 'selected', + render: function(d) { + return `` + }, + }, + { + title: 'Person ID', + render: datatableUtils.getLinkFormatter(d => ({ + label: d['personId'], + linkish: true, + })), + }, + { + title: 'Gender', + data: 'gender', + }, + { + title: 'Age at index', + data: 'ageIndex', + }, + { + title: 'Number of events', + data: 'eventCounts', + }, + ] + + this.columns = [ + { + title: 'Concept Id', + data: 'conceptId', + }, + { + title: 'Concept Name', + data: 'conceptName', + }, + { + title: 'Domain', + data: 'domain', + }, + { + title: 'Start Day', + data: 'startDay', + }, + { + title: 'End Day', + data: 'endDay', + }, + ] + // d3.schemePaired + this.palette = [ + '#a6cee3', + '#1f78b4', + '#b2df8a', + '#33a02c', + '#fb9a99', + '#e31a1c', + '#fdbf6f', + '#ff7f00', + '#cab2d6', + '#6a3d9a', + '#ff9', + '#b15928', + ] + + this.sourceKey.subscribe(sourceKey => { + document.location = constants.paths.source(sourceKey) + }) + this.personId.subscribe(personId => { + if (!this.sampleId()) { + document.location = constants.paths.person(this.sourceKey(), personId) + this.loadPerson() + } + if (this.sampleId() && personId) { + this.loadComparingPerson() + } + }) + + this.options = { + Facets: [ + { + caption: 'Domain', + binding: d => d.domain, + }, + ], + } + + $('#modalPatientSelection').draggable() + + if (this.personId() && !this.sampleId()) { + this.loadPerson() + } + + this.plugins = pluginRegistry.findByType( + globalConstants.pluginTypes.PROFILE_WIDGET + ) + } + + loadPerson() { + this.cantFindPerson(false) + this.loadingPerson(true) + let url = constants.paths.person(this.sourceKey(), this.personId()) + this.loadingStatus('loading profile data from database') + this.personRequest = this.personRequests[url] = profileService + .getProfile( + this.sourceKey(), + this.personId(), + this.cohortDefinitionId() + ) + .then(person => { + if (this.personRequest !== this.personRequests[url]) { + return + } + this.loadingStatus('processing profile data') + person.personId = this.personId() + this.loadingPerson(false) + let cohort + let cohortDefinitionId = this.cohortDefinitionId() + if (cohortDefinitionId) { + cohort = _.find(person.cohorts, function(o) { + return o.cohortDefinitionId == cohortDefinitionId + }) + } + // In the event that we could not find the matching cohort in the person object or the cohort definition id is not specified default it + if (typeof cohort === 'undefined') { + cohort = { + startDate: _.chain(person.records) + .map(d => d.startDate) + .min() + .value(), + } + } + + this.personRecords(person.records) + this.person(person) + if (!this.timeline1) { + this.timeline1 = new Timeline('profileTimeline1') + this.timeline1.updateData(person.records) + } else { + // get new timeline + this.timeline1.removeInput() + this.timeline1.updateData(person.records) + } + }) + .catch(err => { + // remove if error + if (this.timeline1) { + this.timeline1.remove() + this.timeline1 = null + } + this.cantFindPerson(true) + this.loadingPerson(false) + }) + } + + dimensionSetup(dim, cf) { + if (!cf) return + dim.dimension = cf.dimension(dim.func, dim.isArray) + dim.filter(null) + dim.group = dim.dimension.group() + dim.group.reduce(...reduceToRecs) + dim.groupAll = dim.dimension.groupAll() + dim.groupAll.reduce(...reduceToRecs) + } + + dispToggle(pm, evt) { + let section = evt.target.value + this.showSection[section](!this.showSection[section]()) + } + + swatch(d) { + return '
    ' + } + + canViewProfileDates() { + return ( + config.viewProfileDates && + (!config.userAuthenticationEnabled || + (config.userAuthenticationEnabled && + authApi.isPermittedViewProfileDates())) + ) + } + + //sample related methods + fetchSampleData({ sampleId, sourceKey, cohortDefinitionId }) { + this.isLoadingSampleData(true) + sampleService + .getSample({ cohortDefinitionId, sourceKey, sampleId }) + .then(res => { + const transformedSampleData = res.elements.map(el => { + let selected + if ( + el.personId == this.personId() || + el.personId == this.secondPersonId() + ) { + selected = true + } else { + selected = false + } + return { + personId: el.personId, + gender: gender(el.genderConceptId), + ageIndex: el.age, + eventCounts: el.recordCount || '', + selected, + } + }) + this.patientSelectionData(transformedSampleData) + this.sampleName(res.name) + }) + .catch(error => { + console.error(error) + }) + .finally(() => { + this.isLoadingSampleData(false) + }) + } + onSampleDataClick(d) { + //change checkbox state + //if user does not fetch new data, these state will be overridden + // on listening to close button (see this event handler in the constructors ) + if (this.selectedPatients().includes(d.personId)) { + this.selectedPatients.remove(d.personId) + this.patientSelectionData.replace(d, { ...d, selected: !d.selected }) + } else { + if (this.selectedPatients().length == 2) { + alert('You can only select maximum 2 patients') + return + } + this.selectedPatients.push(d.personId) + this.patientSelectionData.replace(d, { ...d, selected: !d.selected }) + } + } + + comparePatient() { + if (this.selectedPatients() == 0) { + alert('Please select one or two patients') + return + } + const sourceKey = this.sourceKey() + const sampleId = this.sampleId() + const cohortDefinitionId = this.cohortDefinitionId() + const [person1, person2] = this.selectedPatients() + if (person1 && person2) { + const url = constants.paths.twoPersonSample({ + sourceKey, + personId: person1, + cohortDefinitionId, + sampleId, + secondPersonId: person2, + }) + history.pushState(null, '', url) + this.personId(person1) + this.secondPersonId(person2) + this.showPerson2(true) + this.showSection['datatable'](false) + } else if (!person2) { + if (this.timeline2) { + this.timeline2.remove() + this.timeline2 = null + } + const url = constants.paths.onePersonSample({ + sourceKey, + personId: person1, + cohortDefinitionId, + sampleId, + }) + history.pushState(null, '', url) + this.personId(person1) + this.secondPersonId(null) + this.showPerson2(false) + } + $('.selectionPatient.close').trigger('click') + } + + loadComparingPerson(secondPerson) { + if (!secondPerson) { + this.cantFindPerson(false) + this.loadingPerson(true) + } else { + this.cantFindSecondPerson(false) + this.loadingSecondPerson(true) + this.showPerson2(true) + this.showSection['datatable'](false) + } + profileService + .getProfile( + this.sourceKey(), + secondPerson || this.personId(), + this.cohortDefinitionId() + ) + .then(person => { + // const records = person.records.filter(el => el.conceptId) + const records = person.records + if (!secondPerson) { + this.loadingPerson(false) + this.personRecords(records) + this.person(person) + if (!this.timeline1) { + this.timeline1 = new Timeline('profileTimeline1') + this.timeline1.updateData(records) + } else { + // get new timeline + this.timeline1.removeInput() + this.timeline1.updateData(records) + } + } else { + this.loadingSecondPerson(false) + this.secondPersonRecords(records) + this.secondPersonGender(person.gender) + this.secondPersonRecordCount(person.recordCount) + this.secondPersonAgeAtIndex(person.ageAtIndex) + if (!this.timeline2) { + this.timeline2 = new Timeline('profileTimeline2') + this.timeline2.updateData(records) + } else { + this.timeline2.removeInput() + this.timeline2.updateData(records) + } + } + }) + .catch(err => { + console.error(err) + // remove if error + if (this.timeline1 && !secondPerson) { + this.timeline1.remove() + this.timeline1 = null + this.cantFindPerson(true) + this.loadingPerson(false) + } + if (this.timeline2 && secondPerson) { + this.timeline2.remove() + this.timeline2 = null + this.cantFindSecondPerson(true) + this.loadingSecondPerson(false) + } + }) + } + + loadNextPerson() { + const currentPersonIndex = this.patientSelectionData().findIndex( + el => el.personId == this.personId() + ) + let nextIndex + if (currentPersonIndex == this.patientSelectionData().length - 1) { + nextIndex = 0 + } else { + nextIndex = currentPersonIndex + 1 + } + const nextPersonId = this.patientSelectionData()[nextIndex].personId + this.selectedPatients().splice(0, 1, nextPersonId) + this.personId(nextPersonId) + this.patientSelectionData.replace( + this.patientSelectionData()[currentPersonIndex], + { ...this.patientSelectionData()[currentPersonIndex], selected: false } + ) + this.patientSelectionData.replace( + this.patientSelectionData()[nextIndex], + { ...this.patientSelectionData()[nextIndex], selected: true } + ) + } + + loadPreviousPerson() { + const currentPersonIndex = this.patientSelectionData().findIndex( + el => el.personId == this.personId() + ) + let previousIndex + if (currentPersonIndex == 0) { + previousIndex = this.patientSelectionData().length - 1 + } else { + previousIndex = currentPersonIndex - 1 + } + const perviousPeronId = this.patientSelectionData()[previousIndex] + .personId + this.selectedPatients().splice(0, 1, perviousPeronId) + this.personId(perviousPeronId) + this.patientSelectionData.replace( + this.patientSelectionData()[currentPersonIndex], + { ...this.patientSelectionData()[currentPersonIndex], selected: false } + ) + this.patientSelectionData.replace( + this.patientSelectionData()[previousIndex], + { ...this.patientSelectionData()[previousIndex], selected: true } + ) + } + + removePerson() { + const [person1, person2] = this.selectedPatients() + this.selectedPatients(person2) + this.comparePatient() + } + + removePerson2() { + const [person1, person2] = this.selectedPatients() + this.selectedPatients.remove(person2) + this.secondPersonId(null) + this.timeline2.remove() + this.timeline2 = null + this.showPerson2(false) + + const secondPersonIndex = this.patientSelectionData().findIndex( + el => el.personId == person2 + ) + + this.patientSelectionData.replace( + this.patientSelectionData()[secondPersonIndex], + { ...this.patientSelectionData()[secondPersonIndex], selected: false } + ) + } + } + + return commonUtils.build('profile-manager', ProfileManager, view) +}) diff --git a/js/pages/profiles/profile-manager.less b/js/pages/profiles/profile-manager.less index b944339b3..cd69b5a16 100644 --- a/js/pages/profiles/profile-manager.less +++ b/js/pages/profiles/profile-manager.less @@ -1,206 +1,118 @@ .profile-manager { - &__chart { - display: inline-block; - width: 100%; - vertical-align: top; - overflow: hidden; - text-align: center; - shape-rendering: crispEdges; - border-bottom: solid 1px #ccc; - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently */ - - & .brush .extent { - stroke: #f19119; - fill: #f19119; - fill-opacity: 0.15; - shape-rendering: crispEdges; - } - - & .observation-period { - fill: none; - stroke: #b7cbdc; - opacity: 0.7; - stroke-width: 2; - } - - & svg { - display: inline-block; - left: 0; - background-color: #eee; - } - & .axis text { - font-size: 10px; - } - - & .axis path, .axis line { - stroke: #666 !important; - stroke-width: 1px !important; - } - - & path { - opacity: 0.3; - &.drug { - fill:#337AB7; - } - &.condition { - fill:rgb(166,206,227); - } - &.observation { - fill:rgb(31,120,180); - } - &.visit { - fill:rgb(178,223,138); - } - &.conditionera { - fill:rgb(51,160,44); - } - &.drugera { - fill:rgb(251,154,153); - } - &.doseera { - fill:rgb(227,26,28); - } - &.procedure { - fill:rgb(253,191,111); - } - &.device { - fill:rgb(255,127,0); - } - &.measurement { - fill:rgb(202,178,214); - } - &.specimen { - fill:rgb(106,61,154); - } - &.death { - fill:rgb(255,255,153); - } - } - - & rect { - &.point { - opacity: 0.8; - fill: #337AB7; - stroke-width:1px; - } - - &.dim { - opacity: 0.2 !important; - } - - &.category { - fill: #222; - stroke:#eee; - stroke-width:1px; - } - } - - & .link { - stroke: #444; - } - - & .highlighted { - & path, line, rect { - stroke-width: 1; - opacity:0.7; - } - } - & text.unhighlighted { - opacity: 0.3; - } - & g { - &.inset { - & rect { - &.inset-point { - fill: black; - } - &.background { - fill: #fff; - stroke: gray; - stroke-width: 2; - } - &.highlighted { - width: 3px; - height: 3px; - } - &.insetZoom { - opacity: 1; - cursor: -webkit-grab; - fill: transparent; - stroke:#f19119; - stroke-width: 2; - } - &.resizeLeft, - &.resizeRight { - opacity: 0; - cursor: ew-resize; - } - &.filteredout { - fill: lightgray; - } - } - } - &.unhighlighted { - & rect { - opacity: 0.5; - fill: #ccc; - } - } - } - & text { - &.category { - fill: #ddd; - font-weight:normal; - } - &.label { - fill: #ccc; - &.highlighted { - font-size:1.1em; - stroke-width:0.3 - } - } - } - & #cohortPeople { - display: inline-block; - } - } - - & #wordcloud { - overflow: auto; - height: 100%; - } - - & .wordcloud { - & span { - margin: 2px; - padding: 3px; - display: block; - cursor: pointer; - border: solid 1px #ccc; - } - &-help { - text-align:center; - color:#aaa; - font-size:10px; - } - } - - &__profile-control { - & .feFilter .facetScrollBox { - max-height: none; - } - } - - &__profile-manager-table { - padding-right: 10px; - } - - & .highlight-pane { - text-align: center; - } + &__profile-manager-table { + padding-right: 10px; + } + + & .highlight-pane { + text-align: center; + } + #profileTimeline1, + #profileTimeline2 { + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); + transition: 0.3s; + margin: 20px 20px 20px 20px; + position: relative; + } + + .profileTimeline { + .tooltip { + position: absolute !important; + text-align: left; + width: auto; + height: auto; + padding: 4px; + font-size: 12px; + background: black; + border: 0px; + border-radius: 8px; + pointer-events: none; + color: white; + } + + .colorPicker { + position: absolute !important; + } + + .labelContainers { + cursor: pointer; + } + + .label { + font-weight: normal !important; + font-family: Verdana, Arial, sans-serif; + } + + .timelineFilter { + display: flex; + justify-content: space-between; + } + .timelineFilter:first-child { + text-align: left; + } + + .hidden-axis { + visibility: hidden; + } + + //switcher + .switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; + } + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: 0.4s; + transition: 0.4s; + } + + .slider:before { + position: absolute; + content: ''; + height: 12px; + width: 12px; + left: 4px; + bottom: 4px; + background-color: white; + -webkit-transition: 0.4s; + transition: 0.4s; + } + + input:checked + .slider { + background-color: #2196f3; + } + + input:focus + .slider { + box-shadow: 0 0 1px #2196f3; + } + + input:checked + .slider:before { + -webkit-transform: translateX(20px); + -ms-transform: translateX(20px); + transform: translateX(20px); + } + + /* Rounded sliders */ + .slider.round { + border-radius: 34px; + } + + .slider.round:before { + border-radius: 50%; + } + } } diff --git a/js/pages/profiles/profileTimeline.js b/js/pages/profiles/profileTimeline.js new file mode 100644 index 000000000..865a86713 --- /dev/null +++ b/js/pages/profiles/profileTimeline.js @@ -0,0 +1,995 @@ +define(function(require, exports) { + const moment = require('moment') + const _ = require('lodash') + const d3 = require('d3') + const { schemeCategory10 } = require('d3-scale-chromatic') + const icons = { + open: + 'M11.9994 1.72559L1.16058 13.1063C0.894011 13.3862 0.466495 13.3862 0.199926 13.1063C-0.0666428 12.8264 -0.0666428 12.3775 0.199926 12.0976L11.5216 0.209923C11.7881 -0.0699738 12.2156 -0.0699738 12.4822 0.209924L23.7988 12.0976C23.9296 12.2349 24 12.4198 24 12.5993C24 12.7789 23.9346 12.9637 23.7988 13.101C23.5322 13.3809 23.1047 13.3809 22.8382 13.101L11.9994 1.72559Z', + close: + 'M12.0006 11.5906L22.8394 0.20984C23.106 -0.0700579 23.5335 -0.0700579 23.8001 0.20984C24.0666 0.489738 24.0666 0.938628 23.8001 1.21852L12.4784 13.1062C12.2119 13.3861 11.7844 13.3861 11.5178 13.1062L0.201183 1.21852C0.0704137 1.08122 1.48104e-07 0.896379 1.50246e-07 0.716822C1.52387e-07 0.537265 0.0653841 0.352427 0.201183 0.215119C0.467753 -0.0647777 0.895268 -0.0647776 1.16184 0.215119L12.0006 11.5906Z', + pinned: + 'M52.963,21.297c-0.068-0.329-0.297-0.603-0.609-0.727c-2.752-1.097-5.67-1.653-8.673-1.653 c-4.681,0-8.293,1.338-9.688,1.942L19.114,8.2c0.52-4.568-1.944-7.692-2.054-7.828C16.881,0.151,16.618,0.016,16.335,0 c-0.282-0.006-0.561,0.091-0.761,0.292L0.32,15.546c-0.202,0.201-0.308,0.479-0.291,0.765c0.016,0.284,0.153,0.549,0.376,0.726 c2.181,1.73,4.843,2.094,6.691,2.094c0.412,0,0.764-0.019,1.033-0.04l12.722,14.954c-0.868,2.23-3.52,10.27-0.307,18.337 c0.124,0.313,0.397,0.541,0.727,0.609c0.067,0.014,0.135,0.021,0.202,0.021c0.263,0,0.518-0.104,0.707-0.293l14.57-14.57 l13.57,13.57c0.195,0.195,0.451,0.293,0.707,0.293s0.512-0.098,0.707-0.293c0.391-0.391,0.391-1.023,0-1.414l-13.57-13.57 l14.527-14.528C52.929,21.969,53.031,21.627,52.963,21.297z', + } + class Timeline { + constructor(chartContainer) { + // chart variables + this.chartContainer = chartContainer + + this.svg + + this.brush + + this.brushed + + this.xDayScale + + this.r = 5 + + this.fontSize = 12 + + this.domainFontSize = 16 + + this.ySpace = 35 + + // label space + this.lineStrokeWidth = 3 + + this.truncateLength = 20 + + this.margin = { + top: 30, + right: 60, + bottom: 30, + left: 200, + } + this.width = + document.getElementById(this.chartContainer).offsetWidth - + this.margin.left - + this.margin.right + + this.height = 1000 + + this.lineStroke = '#CDCDCD' + + this.circleFill = '#CDCDCD' + + this.expandAllColor = '#467AB2' + + this.textFill = 'black' + + this.unPinnednedFill = '#8D8D8D' + + this.pinnedFill = '#467AB2' + + this.filteredData = [] + this.filterText = '' + this.axisType = '' + this.allData = [] + this.originalData = [] + // + this.updateData = this.updateData.bind(this) + this.init() + } + + init() { + d3.select(`#${this.chartContainer}`) + .append('div') + .attr('class', 'profileTimeline') + + this.implementColorScheme() + this.implementFilter() + this.implementAxisSelection() + this.implementExpandingAll() + this.implementTooltip() + this.implementColorPicker() + + this.svg = d3 + .select(`#${this.chartContainer} .profileTimeline`) + .append('svg') + .append('g') + .attr('transform', `translate(${this.margin.left},${this.margin.top})`) + .attr('class', 'timelineChart') + + this.svg + .append('defs') + .append('svg:clipPath') + .attr('id', 'clip') + .append('svg:rect') + .attr('width', 10) + .attr('height', 10) + .attr('x', -this.r) + .attr('y', -this.r) + // create day xAxis + this.svg.append('g').attr('class', 'dayAxis') + // create date xAxis + this.svg.append('g').attr('class', 'dateAxis hidden-axis') + + // dayAxis Scale + this.xDayScale = d3.scaleLinear().range([0, this.width]) + // dateAxis Scale + this.xDateScale = d3.scaleTime().range([0, this.width]) + // create brush + this.brush = this.svg.append('g').attr('class', 'brush') + // hide color picker if click outsite + + d3.select(`#${this.chartContainer} .profileTimeline`).on('click', () => { + const tagName = d3.event.target.tagName.toLowerCase() + const clickOnCircle = typeof tagName == 'string' && tagName == 'circle' + const colorPicker = d3 + .select(`#${this.chartContainer}`) + .select(`.colorPicker`) + + if (!clickOnCircle) { + colorPicker + .transition() + .duration(400) + .style('opacity', 0) + .style('z-index', -1) + } + }) + + window.addEventListener( + 'resize', + _.throttle(() => { + this.width = + document.getElementById(this.chartContainer).offsetWidth - + this.margin.left - + this.margin.right + this.updateScale() + this.drawTimeline(this.originalData) + }, 200) + ) + } + + updateData(rawData) { + this.allData.splice( + 0, + this.allData.length, + ...this.transformedData(rawData) + ) + const newOriginalData = this.transformedData(rawData).filter( + timeline => !timeline.belongTo + ) + this.originalData.splice(0, this.originalData.length, ...newOriginalData) + this.maxMoment = d3.max( + this.allData + .map(el => el.observationData) + .flat() + .map(el => el.endDay) + ) + + this.minMoment = d3.min( + this.allData + .map(el => el.observationData) + .flat() + .map(el => el.startDay) + ) + + this.maxDate = d3.max( + this.allData + .map(el => el.observationData) + .flat() + .map(el => el.endDate) + ) + + this.minDate = d3.min( + this.allData + .map(el => el.observationData) + .flat() + .map(el => el.startDate) + ) + this.updateScale() + this.drawTimeline(this.originalData) + } + + updateScale() { + // dayAxis Scale + this.xDayScale = d3.scaleLinear().range([0, this.width]) + // dateAxis Scale + this.xDateScale = d3.scaleTime().range([0, this.width]) + this.xDayScale.domain([this.minMoment, this.maxMoment]) + // dateAxis Scale + this.xDateScale.domain([this.minDate, this.maxDate]) + + this.svg + .select(`#${this.chartContainer} .dayAxis`) + .call(d3.axisBottom(this.xDayScale)) + this.svg + .select(`#${this.chartContainer} .dateAxis`) + .call(d3.axisBottom(this.xDateScale)) + } + + implementColorScheme() { + this.colorScheme = d3 + .scaleOrdinal() + .domain(this.allData.filter(el => !el.belongTo).map(el => el.id)) + .range(schemeCategory10) + } + + implementFilter() { + const input = d3 + .select(`#${this.chartContainer} .profileTimeline`) + .append('div') + .style('text-align', 'left') + .attr('class', 'timelineFilter') + .append('div') + .style('width', '168px') + .append('input') + .attr('placeholder', 'Filter timeline') + .attr('class', 'form-control input-sm') + d3.select(`#${this.chartContainer} .timelineFilter`).style( + 'padding', + `${this.margin.top / 2}px 0 0 ${this.margin.left}px` + ) + input.on('input', _.debounce(() => this.handleFilter(), 200)) + } + + handleFilter() { + this.filteredData.splice(0, this.filteredData.length) + const inputVal = d3 + .select(`#${this.chartContainer} .timelineFilter`) + .select('input') + .node() + .value.trim() + + this.filterText = inputVal + + if (inputVal === '') { + this.handleCollapseAll() + } else { + const filteredData = this.allData.filter( + el => + el.belongTo && + el.label.toLowerCase().includes(inputVal.toLowerCase()) + ) + if (filteredData.length === 0) { + this.filterText = '' + this.handleCollapseAll() + return + } + this.handleExpandingAll() + } + } + + implementAxisSelection() { + const container = d3 + .select(`#${this.chartContainer} .timelineFilter`) + .append('div') + .style('text-align', 'right') + .style('margin-right', `${this.margin.right / 2}px`) + + container + .append('text') + .text('Effective date: ') + .attr( + 'title', + 'Turn effective date on to see the events’ effective date instead of the index day.' + ) + const switcher = container.append('label').attr('class', 'switch') + const checkbox = switcher.append('input').attr('type', 'checkbox') + switcher.append('span').attr('class', 'slider round') + + checkbox.on('change', () => this.changeAxisView()) + } + + changeAxisView() { + const selected = d3.select(`#${this.chartContainer} .switch>input`).node() + .checked + this.axisType = selected ? 'Date' : 'Day' + this.svg.select('.dayAxis').classed('hidden-axis', selected) + this.svg.select('.dateAxis').classed('hidden-axis', !selected) + } + + implementExpandingAll() { + const div = d3 + .select(`#${this.chartContainer} .profileTimeline`) + .append('div') + .attr('class', 'expandingButton') + .style('text-align', 'left') + .style('color', this.expandAllColor) + .style('padding', `${this.margin.top / 3}px 0 0 ${this.margin.left}px`) + + const expandText = div + .append('button') + .attr('class', 'btn btn-primary btn-sm') + .text('Expand all') + .style('font-size', `${this.fontSize}px`) + .style('margin-right', `10px`) + + const collapseAll = div + .append('button') + .attr('class', 'btn btn-default btn-sm') + .text('Collapse all') + .style('font-size', `${this.fontSize}px`) + + expandText.on('click', () => this.handleExpandingAll()) + collapseAll.on('click', () => this.handleCollapseAll()) + } + + handleExpandingAll() { + let expandedData + if (this.filterText) { + expandedData = this.allData + .filter( + el => + !el.belongTo || + el.isPinned || + (el.belongTo && + el.label.toLowerCase().includes(this.filterText.toLowerCase())) + ) + .map(el => { + if (!el.belongTo && !el.expanded) return { ...el, expanded: true } + return el + }) + } else { + expandedData = this.allData.map(el => { + if (!el.belongTo) return { ...el, expanded: true } + return el + }) + } + this.originalData.splice(0, this.originalData.length, ...expandedData) + + this.drawTimeline(this.originalData) + this.allExpanded = true + } + + handleCollapseAll() { + _.remove(this.originalData, el => el.belongTo && !el.isPinned) + // change domain expanded state + for (let i = 0; i < this.originalData.length; i += 1) { + if (!this.originalData[i].belongTo) { + this.originalData[i].expanded = false + } + } + this.drawTimeline(this.originalData) + } + + implementTooltip() { + d3.select(`#${this.chartContainer} .profileTimeline`) + .append('div') + .attr('class', 'tooltip') + .style('opacity', 0) + } + + implementColorPicker() { + const container = d3 + .select(`#${this.chartContainer} .profileTimeline`) + .append('div') + .attr('class', 'colorPicker selected-color') + .style('opacity', 0) + .style('z-index', -1) + + const palette = [ + '#1f78b4', + '#33a02c', + '#e31a1c', + '#ff7f00', + '#6a3d9a', + '#b15928', + ] + + const iconColors = [ + '#a6cee3', + '#b2df8a', + '#fb9a99', + '#fdbf6f', + '#cab2d6', + '#ff9', + ] + const colorButtons = container + .selectAll('button') + .data(palette) + .enter() + .append('button') + + colorButtons + .attr('title', 'Set selected events color') + .attr('class', 'btn selected-color') + .attr( + 'style', + (d, i) => `background: ${d}; borderColor: ${iconColors[i]}` + ) + + colorButtons + .append('span') + .attr('class', 'fa fa-paint-brush selected-color') + .attr('style', (d, i) => `color: ${iconColors[i]}`) + + this.colorButtons = colorButtons + } + + expandDomain(domain) { + let expandedData + if (this.filterText) { + expandedData = this.allData + .filter(el => el.belongTo === domain.label) + .filter( + el => + el.isPinned || + (el.belongTo && + el.label.toLowerCase().includes(this.filterText.toLowerCase())) + ) + } else { + expandedData = this.allData.filter(el => el.belongTo === domain.label) + } + const domainIndex = this.originalData.findIndex( + el => el.label === domain.label + ) + this.originalData[domainIndex].expanded = true + // calculate length of current expanding concepts + const domainConceptsLength = this.originalData.filter( + el => el.belongTo === domain.label + ).length + this.originalData.splice( + domainIndex + 1, + domainConceptsLength, + ...expandedData + ) + this.drawTimeline(this.originalData) + } + + closeDomain(domain) { + const domainIndex = this.originalData.findIndex( + el => el.label === domain.label + ) + this.originalData[domainIndex].expanded = false + const closingLength = this.originalData.filter( + el => el.belongTo === domain.label + ).length + const existingElement = this.originalData.filter( + el => el.belongTo === domain.label && el.isPinned + ) + // _.remove(this.originalData, el => el.belongTo === domain.label && !el.isPinned); + this.originalData.splice( + domainIndex + 1, + closingLength, + ...existingElement + ) + this.drawTimeline(this.originalData) + } + + resetBrush() { + this.svg.select('.brush').call(this.brushed.move, null) + this.xDayScale.domain([this.minMoment, this.maxMoment]) + this.xDateScale.domain([this.minDate, this.maxDate]) + + this.svg + .select(`#${this.chartContainer} .dayAxis`) + .call(d3.axisBottom(this.xDayScale)) + this.svg + .select(`#${this.chartContainer} .dateAxis`) + .call(d3.axisBottom(this.xDateScale)) + // update circles + this.svg.selectAll('circle').attr('cx', d => this.xDayScale(d.startDay)) + + // update lines + this.svg + .selectAll('.observationLine') + .attr('x1', d => this.xDayScale(d.startDay)) + .attr('x2', d => this.xDayScale(d.endDay)) + } + + makeBrush() { + // to handle reset brush + let idleTimeout + function idled() { + idleTimeout = null + } + // (re)apply brush + this.brushed = d3 + .brushX() + .extent([[0, -this.r], [this.width, this.height]]) + .on('end', () => { + const extent = d3.event.selection + if (!extent) { + if (!idleTimeout) { + // This allows to wait a little bit + idleTimeout = setTimeout(idled, 350) + return + } + this.xDayScale.domain([this.minMoment, this.maxMoment]) + this.xDateScale.domain([this.minDate, this.maxDate]) + } else { + const extentDay0 = this.xDayScale.invert(extent[0]) + const extentDay1 = this.xDayScale.invert(extent[1]) + + const extentDate0 = this.xDateScale.invert(extent[0]) + const extentDate1 = this.xDateScale.invert(extent[1]) + + this.xDayScale.domain([extentDay0, extentDay1]) + this.xDateScale.domain([extentDate0, extentDate1]) + this.svg.select('.brush').call(this.brushed.move, null) + } + // update axis + this.svg.select('.dayAxis').call(d3.axisBottom(this.xDayScale)) + this.svg.select('.dateAxis').call(d3.axisBottom(this.xDateScale)) + // update circles + this.svg + .selectAll('circle') + .attr('cx', d => this.xDayScale(d.startDay)) + + // update lines + this.svg + .selectAll('.observationLine') + .attr('x1', d => this.xDayScale(d.startDay)) + .attr('x2', d => this.xDayScale(d.endDay)) + }) + + this.brush.call(this.brushed) + } + + drawTimeline(chartData) { + // reset brushed if exists + if (this.brushed) { + this.resetBrush() + } + // (re)calculate height and width + this.height = chartData.length * this.ySpace + + // re-assign height and width + d3.select(`#${this.chartContainer} svg`) + .attr('width', this.width + this.margin.left + this.margin.right) + .attr('height', this.height + this.margin.top + this.margin.bottom) + + // (re)calculate axis + + this.svg + .select('.dayAxis') + .attr('transform', `translate(0,${this.height})`) + this.svg + .select('.dateAxis') + .attr('transform', `translate(0,${this.height})`) + + // (re)calculate clip path + this.svg + .select(`#${this.chartContainer} rect`) + .attr('width', this.width + this.r * 2) + .attr('height', this.height) + + this.makeBrush() + + let timelineParent = this.svg + .selectAll('.timelineParent') + .data(chartData, d => + d.belongTo ? d.label + d.belongTo + d.isPinned : d.label + d.expanded + ) + + // remove a timeline + // if not remove timelineChildren, they are still in memory even after being deleted + timelineParent + .exit() + .select('.labelContainers') + .remove() + + timelineParent + .exit() + .select('.timelineChildren') + .remove() + + timelineParent.exit().remove() + + const timelineParentEnter = timelineParent + .enter() + .append('g') + .attr('class', 'timelineParent') + .attr('transform', (d, i) => `translate(${0},${i * 2})`) + + const labelContainers = timelineParentEnter + .append('g') + .attr('class', 'labelContainers') + // append timelineChildren to newly added timelinesParent + timelineParentEnter.append('g').attr('class', 'timelineChildren') + + labelContainers + .attr( + 'transform', + (d, i) => + `translate(${ + d.belongTo + ? -this.margin.left + this.truncateLength + 20 + : -this.margin.left + this.truncateLength + },${this.fontSize / 3})` + ) + .on('click', (d, i) => { + if (d.belongTo) { + this.pinLabel({ ...d, isPinned: !d.isPinned }) + } else if (!d.belongTo && !d.expanded) { + this.expandDomain(d) + } else if (!d.belongTo && d.expanded) { + this.closeDomain(d) + } + }) + + labelContainers + .append('title') + .text(d => (d.belongTo ? 'Pin important events in the timeline' : '')) + + labelContainers.append('path').attr('class', 'icon') + + labelContainers + .append('text') + .attr('class', 'label') + .attr( + 'style', + d => `font-size: ${!d.belongTo ? this.domainFontSize : this.fontSize}` + ) + .attr('fill', this.textFill) + .style('text-anchor', 'start') + .attr('x', 10) + + // merge back to the timelineParent + timelineParent = timelineParentEnter.merge(timelineParent) + // update other timeline + timelineParent + .attr('transform', (d, i) => `translate(${0},${i * this.ySpace})`) + .style('alignment-baseline', 'middle') + + timelineParent + .select('g.labelContainers') + .select('text.label') + .text(d => { + const label = _.truncate(d.label, { length: this.truncateLength }) + return label + }) + .attr('fill', this.textFill) + + const iconPath = timelineParent + .select('g.labelContainers') + .select('path.icon') + iconPath + .attr('d', d => { + if (d.belongTo) { + return icons.pinned + } + return d.expanded ? icons.open : icons.close + }) + .attr('fill', d => { + if (d.belongTo) { + return d.isPinned ? this.pinnedFill : 'white' + } else { + return this.textFill + } + }) + .attr('stroke', d => { + if (d.belongTo) { + return this.unPinnednedFill + } + }) + .attr('transform', d => { + if (d.belongTo) { + return 'scale(0.25) rotate(90) translate(-45,-40)' + } else { + return 'scale(0.5) translate(-10,-15)' + } + }) + + // draw circles and lines + const timelineChildren = timelineParent.select('.timelineChildren') + timelineChildren.attr('clip-path', 'url(#clip)') + + // draw lines + const lines = timelineChildren.selectAll('line').data( + d => { + const observationData = d.observationData.filter( + el => el.endDay !== el.startDay + ) + return observationData.map(el => ({ + ...el, + selectedColor: d.selectedColor, + })) + }, + d => d.startDay + d.endDay + d.conceptId + d.selectedColor + ) + + lines.exit().remove() + + lines + .enter() + .append('line') + .attr('class', 'observationLine') + .attr('x1', d => this.xDayScale(d.startDay)) + .attr('x2', d => this.xDayScale(d.endDay)) + .attr('stroke', d => { + if (d.inDomainLine) { + return ` ${this.circleFill}` + } else { + if (d.selectedColor) { + return `${d.selectedColor}` + } else { + return `${this.colorScheme(d.conceptId)}` + } + } + }) + .attr('stroke-width', this.lineStrokeWidth) + + // cicles + const circles = timelineChildren.selectAll('circle').data( + d => { + const observationData = d.observationData + return observationData.map(el => ({ + ...el, + selectedColor: d.selectedColor, + })) + }, + d => d.startDay + d.endDay + d.conceptId + d.selectedColor + ) + circles.exit().remove() + + circles + .enter() + .append('circle') + .attr('cx', d => { + return this.xDayScale(d.startDay) + }) + .attr('style', d => { + if (d.inDomainLine) { + return `fill: ${this.circleFill}` + } else { + if (d.selectedColor) { + return `fill: ${d.selectedColor}` + } else { + return `fill: ${this.colorScheme(d.conceptId)}` + } + } + }) + .attr('r', this.r) + .attr('width', 100) + .attr('height', 100) + .on('mouseover', d => { + // display tooltip + const singleTimelineData = chartData.filter(el => + d.inDomainLine + ? el.label === d.domain + : el.label === d.conceptName && d.domain === el.belongTo + )[0] + this.showTooltip(singleTimelineData, d) + }) + .on('mouseout', () => { + this.hideTooltip() + }) + .on('click', d => { + if (d.inDomainLine) return + // return click event if any + this.colorButtons.on('click', null) + this.selectColor(d) + }) + } + + pinLabel(d) { + const originalDataIndex = this.originalData.findIndex( + el => el.id === d.id && d.belongTo === el.belongTo + ) + const allDataIndex = this.allData.findIndex( + el => el.id === d.id && el.belongTo === d.belongTo + ) + this.allData[allDataIndex].isPinned = d.isPinned + this.originalData[originalDataIndex].isPinned = d.isPinned + this.drawTimeline(this.originalData) + } + + showTooltip(timeLineData, d, content) { + const tooltip = d3.select(`#${this.chartContainer} .tooltip`) + let tooltipContent + if (!content) { + tooltipContent = this.getTooltipContent(timeLineData.observationData, d) + } else { + tooltipContent = `
    ${content}
    ` + } + tooltip + .transition() + .duration(100) + .style('opacity', 1) + + tooltip.html(tooltipContent) + + const tooltipSize = tooltip.node().getBoundingClientRect() + var coordinates = d3.mouse(this.svg.node()) + tooltip.style( + 'left', + coordinates[0] + this.margin.left - tooltipSize.width + 'px' + ) + tooltip.style('top', coordinates[1] + this.margin.top + 'px') + } + + hideTooltip() { + const tooltip = d3.select(`#${this.chartContainer} .tooltip`) + tooltip + .transition() + .duration(0) + .style('opacity', 0) + } + + getTooltipContent(timelineObservationData, dataPoint) { + const tooltipContentList = [] + timelineObservationData + .filter(point => point.startDay === dataPoint.startDay) + .forEach(point => { + const pointIndex = tooltipContentList.findIndex( + p => + p.startDay === point.startDay && + p.endDay === point.endDay && + p.conceptId === point.conceptId + ) + if (pointIndex > -1) { + tooltipContentList[pointIndex].frequency += 1 + } else { + tooltipContentList.push({ ...point, frequency: 1 }) + } + }) + let tooltipContent = '' + tooltipContentList.forEach(content => { + const startTime = + this.axisType === 'Date' + ? moment(content.startDate).format('MM-DD-YYYY') + : content.startDay + const endTime = + this.axisType === 'Date' + ? moment(content.endDate).format('MM-DD-YYYY') + : content.endDay + const startEndDifferent = content.startDay !== content.endDay + tooltipContent += `
    + ${content.conceptId}
    + ${content.conceptName}
    + Start: ${startTime} ${ + startEndDifferent ? `- End: ${endTime}` : '' + } + , + Frequency: ${content.frequency} +
    ` + }) + + return tooltipContent + } + + transformedData(data) { + const sortedData = data.sort((a, b) => { + if (a.domain < b.domain) { + return -1 + } + if (a.domain > b.domain) { + return 1 + } + return 0 + }) + const tData = _.transform( + sortedData, + // eslint-disable-next-line no-unused-vars + (accumulator, item, index, originalArr) => { + const { conceptId, conceptName, domain } = item + const { endDay, startDay } = item + const startDate = + item.startDate || new Date(moment(new Date()).add(startDay, 'days')) + const endDate = + item.startDate || new Date(moment(new Date()).add(endDay, 'days')) + const observationData = { + endDay, + startDay, + endDate, + startDate, + conceptId, + conceptName, + domain, + selectedColor: null, + } + + const timeLineDomain = { + label: domain, + id: domain, + observationData: [{ ...observationData, inDomainLine: true }], + belongTo: null, + expanded: false, + hidden: false, + isPinned: false, + } + + const timeLine = { + label: conceptName, + id: conceptId, + isPinned: false, + hidden: false, + expanded: false, + observationData: [{ ...observationData }], + belongTo: domain, + selectedColor: null, + } + + // push timeline for domain + const timeLineDomainIndex = accumulator.findIndex( + el => el.id === domain + ) + if (timeLineDomainIndex > -1) { + accumulator[timeLineDomainIndex].observationData.push({ + ...observationData, + inDomainLine: true, + }) + } else { + accumulator.push(timeLineDomain) + } + + // push timeline for concept + const timeLineIndex = accumulator.findIndex( + el => el.id === conceptId && el.belongTo === domain + ) + + if (timeLineIndex > -1) { + accumulator[timeLineIndex].observationData.push(observationData) + } else { + accumulator.push(timeLine) + } + }, + [] + ) + return tData + } + + selectColor(d) { + const colorPicker = d3.select(`#${this.chartContainer} .colorPicker`) + const colorPickerSize = colorPicker.node().getBoundingClientRect() + var coordinates = d3.mouse(this.svg.node()) + + colorPicker.style( + 'left', + coordinates[0] + this.margin.left - colorPickerSize.width / 2 + 'px' + ) + colorPicker.style( + 'top', + coordinates[1] + this.margin.top + colorPickerSize.height * 1.5 + 'px' + ) + + colorPicker + .transition() + .duration(400) + .style('opacity', 1) + .style('z-index', 10) + + this.colorButtons.on('click', color => { + colorPicker + .transition() + .duration(400) + .style('opacity', 0) + .style('z-index', -1) + this.fillColor(d, color) + //remove event listener after click + //if not removed, each time click on circle, this will be invoked 1 more time -> not good + this.colorButtons.on('click', null) + }) + } + + fillColor(d, selectedColor) { + const originalDataIndex = this.originalData.findIndex( + el => el.belongTo === d.domain && d.conceptId === el.id + ) + const allDataIndex = this.allData.findIndex( + el => el.belongTo === d.domain && d.conceptId === el.id + ) + this.allData[allDataIndex].selectedColor = selectedColor + this.originalData[originalDataIndex].selectedColor = selectedColor + this.drawTimeline(this.originalData) + } + + remove() { + this.originalData = null + this.allData = null + this.filterText = '' + this.svg.selectAll('*').remove() + d3.select(`#${this.chartContainer}`) + .selectAll('*') + .remove() + } + removeInput() { + document.querySelector( + `#${this.chartContainer} .switch>input` + ).checked = false + this.changeAxisView() + + document.querySelector( + `#${this.chartContainer} .timelineFilter input` + ).value = '' + this.filteredData.splice(0, this.filteredData.length) + this.filterText = null + } + } + + return Timeline +}) diff --git a/js/pages/profiles/routes.js b/js/pages/profiles/routes.js index 688807b09..85d9d66c0 100644 --- a/js/pages/profiles/routes.js +++ b/js/pages/profiles/routes.js @@ -1,22 +1,28 @@ -define( - (require, factory) => { - const { AuthorizedRoute } = require('pages/Route'); - function routes(router) { - return { - '/profiles/?((\w|.)*)': new AuthorizedRoute((path) => { - require(['./profile-manager', 'components/cohort-definition-browser'], function () { - path = path.split("/"); - const params = {}; - params.sourceKey = (path[0] || null); - params.personId = (path[1] || null); - params.cohortDefinitionId = (path[2] || null); +define((require, factory) => { + const { AuthorizedRoute } = require('pages/Route') + function routes(router) { + return { + '/profiles/?((w|.)*)': new AuthorizedRoute(path => { + require([ + './profile-manager', + 'components/cohort-definition-browser', + ], function() { + path = path.split('/') + const params = {} + params.sourceKey = path[0] || null + params.personId = path[1] || null + params.cohortDefinitionId = path[2] || null + params.sampleId = path[3] || null + params.secondPersonId = path[4] || null - router.setCurrentView('profile-manager', params); - }); - }), - }; + if (params.secondPersonId) + params.secondPersonId = Number(params.secondPersonId) + if (params.personId) params.personId = Number(params.personId) + router.setCurrentView('profile-manager', params) + }) + }), } - - return routes; } -); \ No newline at end of file + + return routes +}) diff --git a/js/services/Sample.js b/js/services/Sample.js new file mode 100644 index 000000000..40eef8fa4 --- /dev/null +++ b/js/services/Sample.js @@ -0,0 +1,43 @@ +define(['services/http', 'appConfig'], function(httpService, config) { + function createSample(payload, { cohortDefinitionId, sourceKey }) { + return httpService + .doPost( + `${config.webAPIRoot}cohortsample/${cohortDefinitionId}/${sourceKey}`, + { + ...payload, + } + ) + .catch(error => { + console.log(error) + }) + } + + function getSampleList({ cohortDefinitionId, sourceKey }) { + return httpService + .doGet( + `${config.webAPIRoot}cohortsample/${cohortDefinitionId}/${sourceKey}` + ) + .then(res => res.data) + } + + function getSample({ cohortDefinitionId, sourceKey, sampleId }) { + return httpService + .doGet( + `${config.webAPIRoot}cohortsample/${cohortDefinitionId}/${sourceKey}/${sampleId}` + ) + .then(res => res.data) + } + + function deleteSample({ cohortDefinitionId, sourceKey, sampleId }) { + return httpService.doDelete( + `${config.webAPIRoot}cohortsample/${cohortDefinitionId}/${sourceKey}/${sampleId}` + ) + } + + return { + createSample, + getSampleList, + getSample, + deleteSample, + } +}) diff --git a/js/services/SourceAPI.js b/js/services/SourceAPI.js index 8aa5dbe4c..6f106a6c0 100644 --- a/js/services/SourceAPI.js +++ b/js/services/SourceAPI.js @@ -221,3 +221,4 @@ define(function (require, exports) { return api; }); + diff --git a/package.json b/package.json index 05d494bfc..d46112cdf 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,12 @@ "@babel/plugin-proposal-object-rest-spread": "^7.0.0", "@babel/polyfill": "^7.0.0", "@babel/preset-env": "^7.1.5", + "eslint": "^6.4.0", + "eslint-config-prettier": "^6.3.0", + "eslint-plugin-prettier": "^3.1.0", "esprima": "^4.0.1", "html-document": "^0.8.1", + "prettier": "1.18.2", "genversion": "^2.2.0", "requirejs": "^2.3.6", "rimraf": "^2.6.2", @@ -86,6 +90,7 @@ "prismjs": "^1.15.0", "qs": "^6.5.2", "querystring": "^0.2.0", + "timelines-chart": "^2.8.2", "urijs": "^1.19.1", "xss": "^1.0.3" } diff --git a/prettierignore b/prettierignore new file mode 100644 index 000000000..71b154685 --- /dev/null +++ b/prettierignore @@ -0,0 +1,7 @@ +# ignore everything except directory js/pages/profiles +/* +!/js +/js/* +!/js/pages +/js/pages/* +!/js/pages/profiles \ No newline at end of file