diff --git a/app/client/views/autoform/afFormGroup_tight-horizontal.coffee b/app/client/views/autoform/afFormGroup_tight-horizontal.coffee index c0eadbc..fb1fc57 100644 --- a/app/client/views/autoform/afFormGroup_tight-horizontal.coffee +++ b/app/client/views/autoform/afFormGroup_tight-horizontal.coffee @@ -1,48 +1,50 @@ Template['afFormGroup_tight-horizontal'].helpers afFieldInputAtts: -> atts = _.omit(@afFieldInputAtts or {}, 'input-col-class') # We have a special template for check boxes, but otherwise we # want to use the same as those defined for bootstrap3 template. if AutoForm.getInputType(@afFieldInputAtts) == 'boolean-checkbox' atts.template = 'bootstrap3-horizontal' else atts.template = 'bootstrap3' atts afFieldLabelAtts: -> atts = _.clone(@afFieldLabelAtts or {}) # Add bootstrap class atts = AutoForm.Utility.addClass(atts, 'control-label') atts rightColumnClass: -> atts = @afFieldInputAtts or {} atts['input-col-class'] or '' skipLabel: -> self = this type = AutoForm.getInputType(self.afFieldInputAtts) self.skipLabel or type == 'boolean-checkbox' and !self.afFieldInputAtts.leftLabel reallySkipLabel: -> reallySkipLabel = false - if @name.indexOf('choices') > -1 or @name.indexOf('subquestions') > -1 + if @name.indexOf('choices') > -1 or @name.indexOf('subquestions') > -1 if @name.indexOf('.0.') is -1 reallySkipLabel = true reallySkipLabel myFormGroupClass: -> if @name.indexOf('choices') > -1 if @name.indexOf('label') > -1 - "col-md-9" + "col-md-8" else if @name.indexOf('variable') > -1 "col-md-3" else if @name.indexOf('value') > -1 "col-md-3 choice-value" - else if @name.indexOf('subquestions') > -1 + else if @name.indexOf('conditional') > -1 + "col-md-1" + else if @name.indexOf('subquestions') > -1 if @name.indexOf('code') > -1 "col-md-2 subquestion-code" else if @name.indexOf('label') > -1 "col-md-10" else if @name.indexOf('minLabel') > -1 "col-md-5" else if @name.indexOf('maxLabel') > -1 "col-md-5" else "" diff --git a/app/client/views/autoform/afFormGroup_tight-horizontal.html b/app/client/views/autoform/afFormGroup_tight-horizontal.html index e774d1c..87df75a 100644 --- a/app/client/views/autoform/afFormGroup_tight-horizontal.html +++ b/app/client/views/autoform/afFormGroup_tight-horizontal.html @@ -1,16 +1,16 @@ diff --git a/app/client/views/questionnaires/edit_questionnaire.html b/app/client/views/questionnaires/edit_questionnaire.html index 7adfe1e..6d23ccf 100644 --- a/app/client/views/questionnaires/edit_questionnaire.html +++ b/app/client/views/questionnaires/edit_questionnaire.html @@ -1,119 +1,119 @@ diff --git a/app/client/views/questionnaires/questionnaire_wizzard.coffee b/app/client/views/questionnaires/questionnaire_wizzard.coffee index cc86a82..8bcadc3 100644 --- a/app/client/views/questionnaires/questionnaire_wizzard.coffee +++ b/app/client/views/questionnaires/questionnaire_wizzard.coffee @@ -1,600 +1,633 @@ @__showQuestionnaireWizzard = (data) -> Session.set 'selectedQuestionnaireWizzard', data if !data.readonly #check if this questionnaire was answered before by this patient #we do this on the client only, because we can't check it properly by the #server anyway questionIds = Questions.find( questionnaireId: data.questionnaire._id ).map (q) -> q._id count = Answers.find( visitId: data.visit._id questionId: $in: questionIds ).count() if count > 0 swal { title: 'Attention!' text: "This questionnaire was alredy filled out. To view data, please use the ‘show’ button. Do you want to proceed? Old data will be overwritten. A log entry will be created. Please state a reason." type: 'input' inputPlaceholder: "Please state a reason." showCancelButton: true confirmButtonText: 'Yes' closeOnConfirm: false }, (confirmedWithReason)-> if confirmedWithReason is false #cancel swal.close() else if !confirmedWithReason? or confirmedWithReason.length is 0 swal.showInputError("You need to state a reason!") else Meteor.call "logActivity", "reopen questionnaire (#{data.questionnaire.id}) for editing which was already filled out (patient: #{data.patient.id} visit:#{data.visit.title})", "notice", confirmedWithReason, data swal.close() doShowQuestionnaireWizzard(data) return else doShowQuestionnaireWizzard(data) else doShowQuestionnaireWizzard(data) doShowQuestionnaireWizzard = (data) -> Modal.show('questionnaireWizzard', data, keyboard: false) @__closeQuestionnaireWizzard = -> if isAFormDirty() swal { title: 'Unsaved Changes' text: "Do you want to save the changes on this page?" type: 'warning' showCancelButton: true confirmButtonText: 'Save and exit' cancelButtonText: "Exit without saving" closeOnConfirm: false }, (save) -> if save submitAllForms('close') else swal.close() Modal.hide('questionnaireWizzard') else Modal.hide('questionnaireWizzard') _numQuestions = new ReactiveVar(0) _numPages = new ReactiveVar(0) _questionIdsForPage = new ReactiveVar({}) _pageIndex = new ReactiveVar(0) _numFormsToSubmit = 0 _readonly = new ReactiveVar(false) _questionnaire = new ReactiveVar(null) _nextQuestionnaire = null _preview = new ReactiveVar(false) _lang = new ReactiveVar(null) isAFormDirty = -> if _readonly.get() or _preview.get() return false isDirty = false $("form").each () -> return if isDirty e = $(@)[0] dirty = formIsDirty(e) isDirty = dirty if dirty isDirty _goto = null submitAllForms = (goto) -> if _readonly.get() or _preview.get() throw new Error("Can't submitAllForms because _readonly == true") _goto = goto numFormsToSubmit = 0 missingAnswer = false optionalQuestions = [] questions = Questions.find questionnaireId: _questionnaire.get()._id .map (question) -> if question.optional optionalQuestions.push(question._id) #count forms and check for empty inputs $("form").each -> e = $(@) classes = e.attr('class') if classes? and classes.indexOf('question') > -1 numFormsToSubmit += 1 if optionalQuestions.indexOf(e.attr('id')) < 0 if classes? and classes.indexOf('questionForm') > -1 #check multiple subquestions forms lines = e.find('tbody tr') lines.each -> l = $(@) lineAnswered = l.find('input:checked').length > 0 if !lineAnswered missingAnswer = true l.addClass("missing-answer") else l.removeClass("missing-answer") else #check one question forms updateDoc = AutoForm.getFormValues(e.attr('id')).updateDoc if Object.keys(updateDoc).length is 0 or updateDoc?['$unset']?.value is "" missingAnswer = true e.addClass("missing-answer") else e.removeClass("missing-answer") if missingAnswer swal { title: 'missing answers' text: "You have left some questions unanswered, are you sure you want to continue?" type: 'warning' showCancelButton: true confirmButtonText: 'Yes' cancelButtonText: "Cancel" }, -> doSubmitAllForms(numFormsToSubmit) else swal.close() #possibly open swal "unsaved changes" doSubmitAllForms(numFormsToSubmit) _submittingForms = false doSubmitAllForms = (numFormsToSubmit) -> _submittingForms = true _numFormsToSubmit = numFormsToSubmit $("form").each -> e = $(@) classes = e.attr('class') if classes? and classes.indexOf('question') > -1 + q = Questions.findOne(e.attr('id')) + # Remove the answer to conditional question if it is hidden + if q.conditional? + value = $('#' + q.conditional).val().value + parentQuestion = Questions.findOne(q.conditional) + removeAnswer = true + if parentQuestion.type is 'multipleChoice' + selection = parentQuestion.choices.find((x) -> x.value is value) + if selection? + removeAnswer = !selection.conditional + else if parentQuestion.type is 'boolean' + if (parentQuestion.showCondtionalQuestionsOn? and + value in parentQuestion.showCondtionalQuestionsOn) + removeAnswer = false + if removeAnswer + e.trigger('reset') + e.val().value = 'NA' e.submit() formSubmitted = -> if (_numFormsToSubmit -= 1) <= 0 _submittingForms = false if _goto is 'nextPage' nextPage() else if _goto is 'previousPage' previousPage() else if _goto is 'close' Modal.hide('questionnaireWizzard') else if _goto? and _goto.pageIndex? _pageIndex.set _goto.pageIndex nextPage = -> if _pageIndex.get() is _numPages.get()-1 # deactiavted: go to next questionnaire automatically #if _nextQuestionnaire? # _pageIndex.set 0 # _questionnaire.set _nextQuestionnaire #else Modal.hide('questionnaireWizzard') else $('modal-content').scrollTop(0) _pageIndex.set _pageIndex.get()+1 previousPage = -> index = _pageIndex.get() index -= 1 if index > 0 _pageIndex.set index autoformHooks = onSubmit: (insertDoc, updateDoc, currentDoc) -> if !_submittingForms #ignore enter press @done() return false if _preview.get() or _readonly.get() return insertDoc.visitId = currentDoc.visitId insertDoc.questionId = currentDoc.questionId insertDoc._id = currentDoc._id if currentDoc._id? #console.log "submit questionAutoform" #console.log insertDoc if insertDoc.value? and (!currentDoc.value? or (currentDoc.value? and currentDoc.value isnt insertDoc.value)) Meteor.call "upsertAnswer", insertDoc, (error) -> throwError error if error? formSubmitted() @done() false Template.questionnaireWizzard.created = -> _questionnaire.set @data.questionnaire delete @data.questionnaire if @data.readonly _readonly.set true else _readonly.set false if @data.preview _preview.set true else _preview.set false #close on escape key press $(document).on('keyup.wizzard', (e)-> e.stopPropagation() if e.keyCode is 27 #escape __closeQuestionnaireWizzard() return ) #close if user has been logged out @autorun -> if !Meteor.user() __closeQuestionnaireWizzard() self = @ @autorun -> self.subscribe("questionsForQuestionnaire", _questionnaire.get()._id) #manage nextQuestionnaire @autorun -> return if _preview.get() data = Template.currentData() validatedQuestionnaires = data.visit.validatedQuestionnaires i = 0 index = null while i < validatedQuestionnaires.length-1 && index is null q = validatedQuestionnaires[i] if q._id is _questionnaire.get()._id index = i i += 1 if index? and index < validatedQuestionnaires.length-1 _nextQuestionnaire = validatedQuestionnaires[index+1] else _nextQuestionnaire = null #collect autoformIds, count pages @autorun -> count = 0 page = 0 questionIdsForPage = {} didBreakPage = false autoformIds = [] Questions.find questionnaireId: _questionnaire.get()._id , sort: {index: 1} .forEach (q) -> if q.type isnt "description" and q._id isnt "table" and q._id isnt "table_polar" autoformIds.push q._id count += 1 if questionIdsForPage[page]? questionIdsForPage[page].push q._id else questionIdsForPage[page] = [q._id] didBreakPage = false if q.break page += 1 didBreakPage = true page -= 1 if didBreakPage _questionIdsForPage.set questionIdsForPage _numQuestions.set count _numPages.set page+1 _pageIndex.set 0 AutoForm.addHooks(autoformIds, autoformHooks, true) #determine language @autorun -> data = Template.currentData() patient = data.patient questionnaire = _questionnaire.get() if !patient #preview _lang.set null return if patient.primaryLanguage? if patient.primaryLanguage is questionnaire.primaryLanguage _lang.set null return if questionnaire.translationLanguages? and questionnaire.translationLanguages.length > 0 lang = questionnaire.translationLanguages.find (t) -> t is patient.primaryLanguage if lang? _lang.set lang return if patient.secondaryLanguage? if patient.secondaryLanguage is questionnaire.primaryLanguage _lang.set null return if questionnaire.translationLanguages? and questionnaire.translationLanguages.length > 0 lang = questionnaire.translationLanguages.find (t) -> t is patient.secondaryLanguage if lang? _lang.set lang return _lang.set null return @autorun -> Template.currentData() #must trigger reselection value = _lang.get() if !value? value = _questionnaire.get().primaryLanguage Meteor.setTimeout -> $("#source-lang option[value=#{value}]").attr('selected', true) , 100 adjustHeaderHeight = -> $('.questionnaireWizzard h2.modal-title').css('font-size', '1pt') width = $('.questionnaireWizzard .modal-header').width() height = $('.questionnaireWizzard .modal-header').height() rWidth = $('.questionnaireWizzard .modal-header .regulations').width() $('.questionnaireWizzard .title-wrapper').width(width-rWidth-5) $('.questionnaireWizzard .title-wrapper').height(height) fs = 16 counter = 0 while(true) if fs > 32 return counter += 1 $('.questionnaireWizzard h2.modal-title').css('font-size', fs+'pt') if $('.questionnaireWizzard h2.modal-title').height() > 60 $('.questionnaireWizzard h2.modal-title').css('font-size', fs-1+'pt') return fs += 1 Template.questionnaireWizzard.rendered = -> $(window).resize(adjustHeaderHeight) Meteor.setTimeout(adjustHeaderHeight, 2000) Template.questionnaireWizzard.destroyed = -> $(document).unbind('keyup.wizzard') Session.set('selectedQuestionnaireWizzard', null) $(window).off("resize", adjustHeaderHeight) Template.questionnaireWizzard.helpers patientDescription: -> d = @patient.id if @patient.hrid d += " (#{@patient.hrid})" d userDescription: -> getUserDescription(Meteor.user()) language: -> lang = _lang.get() questionnaire = _questionnaire.get() if !lang and questionnaire.primaryLanguage? lang = questionnaire.primaryLanguage lang hasLangs: -> _questionnaire.get().primaryLanguage? langs: -> questionnaire = _questionnaire.get() tl = questionnaire.translationLanguages or [] pl = questionnaire.primaryLanguage langs = isoLangs.filter (l) -> l.code is pl or tl.indexOf(l.code) > -1 langs = JSON.parse(JSON.stringify(langs)) _.some langs, (l) -> if l.code is pl l.suffix = "- PRIMARY LANGUAGE -" l.code is pl langs templateGestures: 'swipeleft div': (evt, templateInstance) -> nextQuestion() 'swiperight div': (evt, templateInstance) -> previousQuestion() title: -> _questionnaire.get().title questionsForPage: -> questionIdsForPage = _questionIdsForPage.get()[_pageIndex.get()] cursor = Questions.find( questionnaireId: _questionnaire.get()._id _id: {$in: questionIdsForPage} , sort: {index: 1} ) lang = _lang.get() if lang? #we need to translate all questions questions = cursor.map (q) -> q.translateTo lang return questions else #no need to translate return cursor questionnaire: -> _questionnaire.get() answerForQuestion: (visitId, questionId) -> return if _preview.get() Answers.findOne visitId: visitId questionId: questionId displayQuestion: (visitId, question) -> - if !question.conditional? - return '' - else + showQuestion = true + if question.conditional? + showQuestion = false answer_conditional = Answers.findOne visitId: visitId questionId: question.conditional if answer_conditional? - return '' - else - return 'display:none;' + parentQuestion = Questions.findOne(question.conditional) + if parentQuestion.type is 'multipleChoice' + selection = parentQuestion.choices.find( + (x) -> x.value is answer_conditional.value) + if selection? + showQuestion = selection.conditional + else if parentQuestion.type is 'boolean' + if (parentQuestion.showCondtionalQuestionsOn? and + answer_conditional.value in parentQuestion.showCondtionalQuestionsOn) + showQuestion = true + return if showQuestion then '' else 'display:none;' readonly: -> _readonly.get() preview: -> _preview.get() formType: -> if _readonly.get() "disabled" else "normal" answerFormSchema: -> schema = _id: type: String optional: true visitId: type: String optional: true questionId: type: String optional: true value: @question.getSchemaDict() new SimpleSchema(schema) doc: -> return if _preview.get() @answer or visitId: @visit._id questionId: @question._id pages: -> answers = {} questionIds = Questions.find questionnaireId: _questionnaire.get()._id .map (question) -> question._id if !_preview.get() Answers.find visitId: @visit._id questionId: {$in: questionIds} .forEach (answer) -> answers[answer.questionId] = answer activeIndex = _pageIndex.get() questionIdsForPage = _questionIdsForPage.get() pages = [] for i in [0.._numPages.get()-1] css = "" allQuestionsAnsweredInPage = true someQuestionsAnsweredInPage = false Questions.find questionnaireId: _questionnaire.get()._id _id: {$in: questionIdsForPage[i]} .forEach (question) -> return if question.type is "description" answer = answers[question._id] if question.type is "table" or question.type is "table_polar" if !answer? or answer.value.length < question.subquestions.length allQuestionsAnsweredInPage = false if answer? and answer.value.length > 0 someQuestionsAnsweredInPage = true else if !answer? allQuestionsAnsweredInPage = false if allQuestionsAnsweredInPage css = "answered" else if someQuestionsAnsweredInPage css = "answeredPartly" if i is activeIndex css += " active" pages[i] = index: i+1 css: css pages isOnFirstPage: -> _pageIndex.get() is 0 isOnLastPageOfLastQuestionnaire: -> # deactiavted: go to next questionnaire automatically _pageIndex.get() is _numPages.get()-1# and (_preview.get() or _nextQuestionnaire is null) Template.questionnaireWizzard.events "click #next": (evt, tmpl) -> if _readonly.get() or _preview.get() nextPage() else submitAllForms('nextPage') false "click #back": (evt, tmpl) -> if _readonly.get() or _preview.get() previousPage() else submitAllForms('previousPage') false "click .jumpToPage": (evt) -> pageIndex = @index-1 if isAFormDirty() swal { title: 'Unsaved Changes' text: "Do you want to save the changes on this page?" type: 'warning' showCancelButton: true confirmButtonText: 'Save' cancelButtonText: "Don't save" closeOnConfirm: false }, (save) -> if save submitAllForms(pageIndex: pageIndex) else swal.close() _pageIndex.set pageIndex else _pageIndex.set pageIndex false "click #close": (evt) -> __closeQuestionnaireWizzard() false "submit .questionForm": (evt) -> #table and table_polar if _readonly.get() or _preview.get() return evt.preventDefault() evt.stopPropagation() if @question.type is "description" formSubmitted() return answer = visitId: @visit._id questionId: @question._id value: [] _id: @answer._id if @answer? for subquestion in @question.subquestions inputs = $(evt.target).find("input[data-subquestion_code=#{subquestion.code}]:checked") if @question.selectionMode is "multi" values = [] inputs.each -> input = $(@) values.push input.data('choice_value').toString() if values.length > 0 answer.value.push code: subquestion.code value: values else #if @question.selectionMode is "single" if inputs.length > 1 throw new Meteor.Error('error when processing the values: single selection got multiple values.') else if inputs.length is 1 value = inputs.first().data('choice_value').toString() answer.value.push code: subquestion.code value: value if answer.value.length > 0 Meteor.call "upsertAnswer", answer, (error) -> throwError error if error? formSubmitted() else formSubmitted() false "change #source-lang": (evt) -> _lang.set $(evt.target).find(":selected").attr('value') "change form": (evt) -> - value = evt.currentTarget.value - # Check if value is defined and has a true value - if value? and !(value.value in ['false', '0']) + question = Questions.findOne(evt.currentTarget.id) + value = evt.currentTarget.value.value + showQuestion = false + if question.type is 'multipleChoice' + selection = question.choices.find((x) -> x.value is value) + if selection? + showQuestion = selection.conditional + else if question.type is 'boolean' + if question.showCondtionalQuestionsOn? and value in question.showCondtionalQuestionsOn + showQuestion = true + if showQuestion $("form[data-conditional='#{evt.currentTarget.id}']").show() else $("form[data-conditional='#{evt.currentTarget.id}']").hide() diff --git a/app/client/views/questionnaires/questionnaire_wizzard.html b/app/client/views/questionnaires/questionnaire_wizzard.html index 6207dd5..58b2d08 100644 --- a/app/client/views/questionnaires/questionnaire_wizzard.html +++ b/app/client/views/questionnaires/questionnaire_wizzard.html @@ -1,111 +1,109 @@ diff --git a/app/lib/collections/patients.coffee b/app/lib/collections/patients.coffee index 01947d1..2d69e51 100644 --- a/app/lib/collections/patients.coffee +++ b/app/lib/collections/patients.coffee @@ -1,268 +1,268 @@ class @Patient constructor: (doc) -> _.extend this, doc caseManagers: -> return null unless @caseManagerIds? Meteor.users.find(_id: $in: @caseManagerIds).map((x) -> x) study: -> return null unless @studyId? Studies.findOne _id: @studyId studyDesigns: -> return [] unless @studyDesignIds? StudyDesigns.find _id: $in: @studyDesignIds , sort: createdAt: 1 languages: -> langs = "" langs += @primaryLanguage if @primaryLanguage? if @secondaryLanguage langs += ", #{@secondaryLanguage}" langs @Patients = new Meteor.Collection("patients", transform: (doc) -> new Patient(doc) ) if Meteor.isServer Patients._ensureIndex( { id: 1 }, { unique: true } ) Patients.before.insert BeforeInsertTimestampHook Patients.before.update BeforeUpdateTimestampHook schema = 'id': type: String 'hrid': type: String optional: true unique: true index: true 'creatorId': type: String 'studyId': type: String 'studyDesignId': type: String optional: true 'studyDesignIds': type: [String] optional: true defaultValue: [] - 'caseManagerId': + 'caseManagerId': # TODO remove it at some point type: String optional: true 'caseManagerIds': type: [String] optional: true 'primaryLanguage': type: String optional: true 'secondaryLanguage': type: String optional: true 'hasData': type: Boolean defaultValue: false 'hasDataForDesignIds': type: [String] optional: true 'isExcluded': type: Boolean defaultValue: false 'excludesIncludes': type: [Object] optional: true 'excludesIncludes.$.reason': type: String 'excludesIncludes.$.createdAt': type: Number 'updatedAt': type: Number optional: true 'createdAt': type: Number optional: true Patients.attachSchema new SimpleSchema(schema) if Meteor.isServer Meteor.methods "createPatient": (studyId) -> canAddPatient(studyId) check studyId, String study = Studies.findOne studyId throw new Meteor.Error(403, "study not found.") unless study? throw new Meteor.Error(400, "Study is locked. Please unlock it first.") if study.isLocked _id = null tries = 0 loop try _id = Patients.insert id: readableRandom(6) creatorId: Meteor.userId() studyId: studyId hasData: false caseManagerIds: [Meteor.userId()] catch e console.log "Error: createPatient" console.log e finally tries += 1 break if _id or tries >= 10 throw new Meteor.Error(500, "Can't create patient, id space seems to be full.") unless _id? return _id Meteor.methods 'updatePatients': (ids, update) -> check ids, [String] check update, Object ids.forEach((patientId) -> canUpdatePatient(patientId)) allowedKeys = [ "$set.caseManagerIds", "$set.studyDesignIds", "$set.primaryLanguage", "$set.secondaryLanguage", "$unset.primaryLanguage", "$unset.secondaryLanguage", ] if Roles.userIsInRole(Meteor.user(), 'admin') allowedKeys = [ allowedKeys..., "$unset.caseManagerIds", "$unset.studyDesignIds" ] if ids.length > 1 if update['$set']? #don't overwrite with empty values delete update['$unset'] else if ids.length is 1 allowedKeys = _.union allowedKeys, [ "$set.hrid", "$unset.hrid" ] # If no study designs are selected store an empty array if update['$unset']? and update['$set']? and update['$unset']['studyDesignIds']? delete update['$unset']['studyDesignIds'] update['$set']['studyDesignIds'] = [] #pick whitelisted keys update = _.pickDeep update, allowedKeys Patients.find(_id: $in: ids).forEach (p) -> study = Studies.findOne p.studyId throw new Meteor.Error(403, "study not found.") unless study? throw new Meteor.Error(400, "Study is locked. Please unlock it first.") if study.isLocked #check if changing studyDesign for a patient which has data if update['$set']?['studyDesignIds']? or update['$unset']?['studyDesignIds']? patientIds = [] Patients.find(_id: $in: ids).forEach (p) -> # unset is always a string if update['$unset']?['studyDesignIds']? and p.hasData patientIds.push p.id if p.hasDataForDesignIds? if update['$set']?['studyDesignIds']? allFound = true p.hasDataForDesignIds.forEach (sDId) -> if update['$set']['studyDesignIds'].indexOf(sDId) is -1 allFound = false if !allFound patientIds.push p.id if patientIds.length > 0 throw new Meteor.Error(400, "You have removed one or more study designs from patient(s) (#{patientIds.join(', ')}) which have already entered data for one of these designs. This is not allowed and your changes are therefore discarded.") #remove already created visits with no data Patients.find(_id: $in: ids).forEach (p) -> Visits.find( patientId: p._id ).forEach (v) -> if Answers.find( visitId: v._id).count() is 0 Visits.remove _id: v._id ids.forEach (id) -> try Patients.update _id: id, update catch e if e.code is 11000 #MongoError: E11000 duplicate key error throw new Meteor.Error(400, "The HRID you entered exists already, please choose a unique value.") else throw e return "excludePatient": (patientId, reason) -> checkIfAdmin() check patientId, String check reason, String patient = Patients.findOne patientId throw new Meteor.Error(500, "Patient can't be found.") unless patient? throw new Meteor.Error(500, "Patient is already excluded.") if patient.isExcluded study = Studies.findOne patient.studyId throw new Meteor.Error(403, "study not found.") unless study? throw new Meteor.Error(400, "Study is locked. Please unlock it first.") if study.isLocked Patients.update patientId, $push: excludesIncludes: reason: reason createdAt: Date.now() Patients.update patientId, $set: isExcluded: true return "includePatient": (patientId, reason) -> checkIfAdmin() check patientId, String check reason, String patient = Patients.findOne patientId throw new Meteor.Error(500, "Patient can't be found.") unless patient? throw new Meteor.Error(500, "Patient isn't excluded.") if !patient.isExcluded study = Studies.findOne patient.studyId throw new Meteor.Error(403, "study not found.") unless study? throw new Meteor.Error(400, "Study is locked. Please unlock it first.") if study.isLocked Patients.update patientId, $push: excludesIncludes: reason: reason createdAt: Date.now() Patients.update patientId, $set: isExcluded: false return "removePatient": (patientId, forceReason) -> check patientId, String canUpdatePatient(patientId) patient = Patients.findOne patientId throw new Meteor.Error(500, "Patient can't be found.") unless patient? study = Studies.findOne patient.studyId throw new Meteor.Error(403, "study not found.") unless study? throw new Meteor.Error(400, "Study is locked. Please unlock it first.") if study.isLocked if patient.hasData and !forceReason throw new Meteor.Error(500, "patientHasData") if patient.hasData Meteor.call "logActivity", "remove patient (#{patient.id}) which has data", "critical", forceReason, patient else Meteor.call "logActivity", "remove patient (#{patient.id}) which has no data", "notice", null, patient Visits.find(patientId: patientId).forEach (v) -> Answers.remove visitId: v._id Visits.remove v._id Patients.remove _id: patientId return diff --git a/app/lib/collections/questions.coffee b/app/lib/collections/questions.coffee index 89d78f0..5ecd0db 100644 --- a/app/lib/collections/questions.coffee +++ b/app/lib/collections/questions.coffee @@ -1,736 +1,758 @@ class @Question constructor: (doc) -> _.extend this, doc getModifiedPlainObject: (modifier) -> TempCollection = new Mongo.Collection("temp", connection: null) TempCollection.insert(@) selector = { _id: @_id } TempCollection.update(selector, modifier) simulation = TempCollection.findOne(selector) TempCollection.remove(selector) simulation.updatedAt = Date.now() JSON.parse(JSON.stringify(simulation)) copy: -> copy = _.clone @ #http://stackoverflow.com/questions/597588/how-do-you-clone-an-array-of-objects-in-javascript if @choices? copy.choices = JSON.parse(JSON.stringify(@choices)) if @subquestions? copy.subquestions = JSON.parse(JSON.stringify(@subquestions)) copy translateTo: (lang) -> return if !lang try translation = @translations[lang] try @label = translation.label if translation.label? try @choices.forEach (c) -> translatedChoice = translation.choices.find (tc) -> tc.value is c.value if translatedChoice? c.label = translatedChoice.label try @subquestions.forEach (s) -> tSubquestion = translation.subquestions.find (ts) -> ts.code is s.code if tSubquestion? s.label = tSubquestion.label if tSubquestion.label? s.minLabel = tSubquestion.minLabel if tSubquestion.minLabel? s.maxLabel = tSubquestion.maxLabel if tSubquestion.maxLabel? return @ getSchemaDict: (lang) -> s = _.pickDeep @, 'type', 'label', 'optional', 'min', 'max', 'decimal', 'options', 'options.label', 'options.value' switch @type when "text" s.type = String s.autoform = type: "textarea" when "number" s.type = Number when "boolean" s.type = Boolean s.autoform = type: "boolean-radios" when "date" s.type = Date s.autoform = type: "bootstrap-datepicker" when "dateTime" s.type = Date s.autoform = type: "bootstrap-datetimepicker" when "multipleChoice" s.autoform = options: @choices if @selectionMode is "multi" s.type = [String] if @orientation is 'inline' s.autoform.type = "select-checkbox-inline" else #if @orientation is 'vertical' s.autoform.type = "select-checkbox" else #if @selectionMode is "single" s.type = String if @orientation is 'inline' s.autoform.type = "select-radio-inline" else #if @orientation is 'vertical' s.autoform.type = "select-radio" when "description" s.type = String s.label = ' ' s.autoform = type: "description" delete s.options return s getMetaSchemaDict: (finalValidation)-> schema = {} noWhitespaceRegex = /^\S*$/ #don't match if contains whitespace if @type is "table" or @type is "table_polar" _.extend schema, label: label: "Title" type: String optional: true autoform: type: "textarea" else if @type isnt "description" _.extend schema, code: label: "Code" type: String regEx: noWhitespaceRegex defaultValue: new Mongo.ObjectID()._str label: label: "Question" type: String defaultValue: "Insert label here" autoform: type: "textarea" _.extend schema, type: label: "Type" type: String autoform: type: "select" options: -> [ {label: "Text", value: "text"}, {label: "Number", value: "number"}, {label: "Boolean", value: "boolean"}, {label: "Date", value: "date"}, {label: "Date & Time", value: "dateTime"}, {label: "Multiple Choice", value: "multipleChoice"}, {label: "Table Multiple Choice", value: "table"}, {label: "Table Polar", value: "table_polar"}, {label: "Description (no question)", value: "description"}, ] conditional: label: "Conditional" type: String optional: true autoform: type: "select" options: -> Questions.find({'questionnaireId': Session.get('editingQuestionnaireId'), '_id': {'$ne': Session.get('selectedQuestionId')}}).map (x) -> {label: "#{x.index}: #{x.label[0..40]}", value: "#{x._id}"} break: label: "insert pagebreak after this item" type: Boolean defaultValue: false if @type isnt "description" _.extend schema, optional: label: "Optional" type: Boolean defaultValue: true if @type is "multipleChoice" or @type is "table" or @type is "table_polar" _.extend schema, selectionMode: label: "Mode" type: String defaultValue: "single" autoform: type: "select-radio-inline" options: [ label: "single selection (radios)" value: "single" , label: "multiple selections (checkboxes)" value: "multi" ] if @type is "multipleChoice" _.extend schema, orientation: label: "Orientation" type: String defaultValue: "vertical" autoform: type: "select-radio-inline" options: [ label: "inline" value: "inline" , label: "vertical" value: "vertical" ] if @type is "description" _.extend schema, label: label: "Text (markdown)" type: String defaultValue: "Insert text here" autoform: type: "textarea" rows: 10 if @type is "number" _.extend schema, min: type: Number optional: true decimal: true max: type: Number optional: true decimal: true decimal: type: Boolean defaultValue: false + if @type is "boolean" + _.extend schema, + showCondtionalQuestionsOn: + label: "Show conditional questions on:" + type: [String] + optional: true + defaultValue: false + autoform: + type: "select-checkbox-inline" + options: [ + label: "true" + value: "true" + , + label: "false" + value: "false" + ] + if @type is "multipleChoice" or @type is "table" or @type is "table_polar" _.extend schema, choices: type: [Object] label: "Choices" minCount: 1 defaultValue: [{label: "Insert choice here", value: "1"}] 'choices.$.label': type: String optional: true 'choices.$.value': type: String regEx: noWhitespaceRegex + 'choices.$.conditional': + label: '?' + type: Boolean + optional: true + defaultValue: false if @selectionMode is "multi" schema['choices.$.value'].custom = -> #console.log "> #{@value} #{@key} <" #console.log "----" digitRegex = /(\d+)/g matches = digitRegex.exec(@key) if matches.length > 0 index = parseInt(matches[0])-1 while index >= 0 v = @field("choices.#{index}.value").value #console.log v if v? and v.valueOf() is @value.valueOf() return "notUnique" index -= 1 return if @type is "table" _.extend schema, subquestions: type: [Object] label: "Subquestions" minCount: 1 defaultValue: [{label: "Insert question here", code: new Mongo.ObjectID()._str}] 'subquestions.$.label': type: String autoform: type: "textarea" 'subquestions.$.code': type: String regEx: noWhitespaceRegex custom: -> digitRegex = /(\d+)/g matches = digitRegex.exec(@key) if matches.length > 0 index = parseInt(matches[0])-1 while index >= 0 v = @field("subquestions.#{index}.code").value if v? and v.valueOf() is @value.valueOf() return "notUnique" index -= 1 return if @type is "table_polar" _.extend schema, subquestions: type: [Object] label: "Subquestions" minCount: 1 defaultValue: [{code: new Mongo.ObjectID()._str}] 'subquestions.$.minLabel': label: "min label" type: String optional: true 'subquestions.$.maxLabel': label: "max label" type: String optional: true 'subquestions.$.code': type: String regEx: noWhitespaceRegex custom: -> digitRegex = /(\d+)/g matches = digitRegex.exec(@key) if matches.length > 0 index = parseInt(matches[0])-1 while index >= 0 v = @field("subquestions.#{index}.code").value if v? and v.valueOf() is @value.valueOf() return "notUnique" index -= 1 return _.extend schema, translations: type: Object blackbox: true optional: true if finalValidation _.extend schema, _id: type: String optional: true questionnaireId: type: String index: type: Number createdAt: type: Number optional: true updatedAt: type: Number optional: true return schema getTranslationSchemaDict: -> schema = {} noWhitespaceRegex = /^\S*$/ #don't match if contains whitespace if @type is "table" or @type is "table_polar" _.extend schema, label: label: "Title" type: String optional: true autoform: type: "textarea" else if @type isnt "description" _.extend schema, label: label: "Question" type: String autoform: type: "textarea" if @type is "description" _.extend schema, label: label: "Text (markdown)" type: String autoform: type: "textarea" rows: 10 if @type is "multipleChoice" or @type is "table" or @type is "table_polar" _.extend schema, choices: type: [Object] label: "Choices" minCount: @choices.length or 1 maxCount: @choices.length or 1 'choices.$.label': type: String optional: true 'choices.$.value': type: String autoform: readonly: true if @type is "table" _.extend schema, subquestions: type: [Object] label: "Subquestions" minCount: @subquestions.length or 1 maxCount: @subquestions.length or 1 'subquestions.$.label': type: String autoform: type: "textarea" 'subquestions.$.code': type: String autoform: readonly: true if @type is "table_polar" _.extend schema, subquestions: type: [Object] label: "Subquestions" minCount: 0 maxCount: @subquestions.length 'subquestions.$.minLabel': label: "min label" type: String optional: true 'subquestions.$.maxLabel': label: "max label" type: String optional: true 'subquestions.$.code': type: String autoform: readonly: true return schema @Questions = new Meteor.Collection("questions", transform: (doc) -> new Question(doc) ) Questions.before.insert BeforeInsertTimestampHook Questions.before.update BeforeUpdateTimestampHook Meteor.methods insertQuestion: (question) -> checkIfAdmin() check(question.questionnaireId, String) questionnaire = Questionnaires.findOne question.questionnaireId throw new Meteor.Error(400, "questionnaire #{question.questionnaireId}) not found.") unless questionnaire? check(question.type, String) if question.type isnt "text" and question.type isnt "description" throw new Meteor.Error(400, "only text and description questions can be inserted") delete question._id delete question.code numQuestions = Questions.find(questionnaireId: questionnaire._id).count() nextIndex = numQuestions+1 if (question.index? and question.index > nextIndex) or !question.index? question.index = nextIndex q = new Question(question) ss = new SimpleSchema(q.getMetaSchemaDict(true)) ss.clean(question) check(question, ss) Questions.insert question copyQuestion: (questionId) -> checkIfAdmin() check questionId, String question = Questions.findOne questionId throw new Meteor.Error(403, "question (#{questionId}) not found.") unless question? questionnaire = Questionnaires.findOne question.questionnaireId throw new Meteor.Error(400, "questionnaire #{question.questionnaireId}) not found.") unless questionnaire? delete question._id if question.code? question.code += ":#{new Mongo.ObjectID()._str}" if question.subquestions? question.subquestions.forEach (q) -> q.code += ":#{new Mongo.ObjectID()._str}" question.index = Questions.find(questionnaireId: questionnaire._id).count()+1 q = new Question(question) ss = new SimpleSchema(q.getMetaSchemaDict(true)) ss.clean(question) check(question, ss) Questions.insert question updateQuestion: (modifier, docId) -> checkIfAdmin() check(modifier, Object) check(docId, String) question = Questions.findOne docId throw new Meteor.Error(403, "question (#{docId}) not found.") unless question? typeChange = false #check if question.code is unique if (code = modifier['$set'].code) and code isnt question.code count = Questions.find( _id: $ne: question._id questionnaireId: question.questionnaireId $or: [ {code: code}, {'subquestions.code': code} ] ).count() if count > 0 details = EJSON.stringify [ {name: "code", type: "notUnique", value: code} ] throw new Meteor.Error(400, "validationError", details) #check for dangerous changes not allowed for already answered questions #don't check if subquestions.$.code and choices.$.value are unique, we do that in the schema dangerousChange = false if (type=modifier['$set'].type)? and Object.keys(modifier['$set']).length is 1 typeChange = true dangerousChange = true if (choices = modifier['$set'].choices)? if choices.length < question.choices.length dangerousChange = true else i = 0 values = {} while i 0 details = EJSON.stringify [ {name: "subquestions.#{i}.code", type: "notUnique", value: s.code} ] throw new Meteor.Error(400, "validationError", details) i += 1 if (selectionMode=modifier['$set'].selectionMode)? and selectionMode isnt question.selectionMode dangerousChange = true if dangerousChange count = Answers.find( questionId: docId ).count() if count > 0 throw new Meteor.Error(400, "validationErrorQuestionInUse") q = new Question(question).getModifiedPlainObject(modifier) schema = new Question(q).getMetaSchemaDict(true) ss = new SimpleSchema(schema) if typeChange #only the type changed: we need to clean the new object #to ornament it with default values #apply modifier ss.clean(q) check(q, ss) # delete translations entirely on typeChange if question.translations? delete q.translations #replace question entirely #use direct to prevent $set.updatedAt being added Questions.direct.update docId, q else check(q, ss) Questions.update docId, modifier # after save tasks # update translated choices and subquestions question = Questions.findOne question._id checkTranslations = false if dangerousChange and question.translations? Object.keys(question.translations).forEach (lang) -> translation = question.translations[lang] if question.choices? checkTranslations = true translatedChoices = [] question.choices.forEach (c) -> translatedChoice = translation.choices.find (tc) -> tc.value is c.value if !translatedChoice? translatedChoice = c translatedChoices.push translatedChoice Questions.update question._id, $set: "translations.#{lang}.choices": translatedChoices if question.subquestions? checkTranslations = true translatedSubquestions = [] question.subquestions.forEach (s) -> tSubquestion = translation.subquestions.find (ts) -> ts.code is s.code if !tSubquestion? tSubquestion = s translatedSubquestions.push tSubquestion Questions.update question._id, $set: "translations.#{lang}.subquestions": translatedSubquestions if checkTranslations throw new Meteor.Error(400, "validationWarningCheckTranslations") return # changed at: 2016/08/03: didn't work in production. not able to reproduce, reason unknown. # moveQuestion: (questionnaireId, oldIndex, newIndex) -> # checkIfAdmin() # check(questionnaireId, String) # check(oldIndex, Match.Integer) # check(newIndex, Match.Integer) # # questionnaire = Questionnaires.findOne # _id: questionnaireId # # question = Questions.findOne # questionnaireId: questionnaireId # index: oldIndex # throw new Meteor.Error(403, "question with index #{oldIndex} not found.") unless question? # # Questions.update # questionnaireId: questionnaireId # index: { $gt: oldIndex } # , # $inc: { index: -1 } # , # multi: true # Questions.update # questionnaireId: questionnaireId # index: { $gte: newIndex } # , # $inc: { index: 1 } # , # multi: true # Questions.update # _id: question._id # , # $set: { index: newIndex} # return moveQuestion: (questionId, up) -> checkIfAdmin() check(questionId, String) check(up, Boolean) question = Questions.findOne questionId throw new Meteor.Error(403, "question not found.") unless question? questionnaire = Questionnaires.findOne _id: question.questionnaireId throw new Meteor.Error(403, "questionnaire not found.") unless questionnaire? numQuestions = Questions.find( questionnaireId: questionnaire._id ).count() if question.index is 1 and up return if question.index >= numQuestions and !up return addend = -1 addend = 1 if !up Questions.update questionnaireId: question.questionnaireId index: question.index + addend , $inc: { index: -addend } Questions.update _id: questionId , $inc: { index: addend } return removeQuestion: (id) -> checkIfAdmin() check(id, String) question = Questions.findOne id throw new Meteor.Error(403, "question (#{id}) not found.") unless question? questionnaire = Questionnaires.findOne _id: question.questionnaireId throw new Meteor.Error(403, "questionnaire (#{question.questionnaireId}) not found.") unless questionnaire? #check if question is used count = Answers.find( questionId: id ).count() if count > 0 throw new Meteor.Error(400, "validationErrorQuestionInUse") Questions.remove id #update index of remaining questions Questions.update questionnaireId: questionnaire._id index: { $gt: question.index } , $inc: { index: -1 } , multi: true return translateQuestion: (questionId, translation, lang) -> checkIfAdmin() check(questionId, String) check(translation, Object) check(lang, String) question = Questions.findOne questionId throw new Meteor.Error(403, "question (#{questionId}) not found.") unless question? ss = new SimpleSchema(question.getTranslationSchemaDict()) ss.clean(translation) check(translation, ss) Questions.update _id: question._id, $set: "translations.#{lang}": translation Questionnaires.update _id: question.questionnaireId , $addToSet: translationLanguages: lang return