From 0980494ca0869bc87d8f08b659dbc8a1690969b9 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 20 Oct 2015 23:32:29 -0700 Subject: [PATCH] Add search to add course / session learning material Allows the user to find an already created learning material and then add it to a course or session. Any LMs that have already been added are excluded from the results. --- app/components/detail-learning-materials.js | 99 ++++++++++++++----- app/components/learningmaterial-search.js | 78 +++++++++++++++ app/locales/en/translations.js | 2 +- app/locales/es/translations.js | 2 +- app/locales/fr/translations.js | 2 +- app/mirage/config.js | 26 ++++- app/mirage/helpers/get-all.js | 3 + app/styles/components/_detailview.scss | 54 ++++++++++ .../components/detail-learning-materials.hbs | 9 +- .../components/learningmaterial-search.hbs | 24 +++++ .../course/learningmaterials-test.js | 38 +++++++ .../course/session/learningmaterials-test.js | 38 +++++++ .../learningmaterial-search-test.js | 18 ++++ 13 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 app/components/learningmaterial-search.js create mode 100644 app/templates/components/learningmaterial-search.hbs create mode 100644 tests/integration/components/learningmaterial-search-test.js diff --git a/app/components/detail-learning-materials.js b/app/components/detail-learning-materials.js index 1deec70f67..0bc994370d 100644 --- a/app/components/detail-learning-materials.js +++ b/app/components/detail-learning-materials.js @@ -2,19 +2,23 @@ import Ember from 'ember'; import DS from 'ember-data'; import { translationMacro as t } from "ember-i18n"; +const {computed, inject, RSVP} = Ember; +const {notEmpty, or, not} = computed; +const {service} = inject; +const {PromiseArray} = DS; + export default Ember.Component.extend({ - currentUser: Ember.inject.service(), - store: Ember.inject.service(), - i18n: Ember.inject.service(), + currentUser: service(), + store: service(), + i18n: service(), subject: null, isCourse: false, - isManaging: Ember.computed.or('isManagingMaterial', 'isManagingMesh'), - isManagingMaterial: Ember.computed.notEmpty('managingMaterial'), - isManagingMesh: Ember.computed.notEmpty('meshMaterial'), + isManaging: or('isManagingMaterial', 'isManagingMesh'), + isManagingMaterial: notEmpty('managingMaterial'), + isManagingMesh: notEmpty('meshMaterial'), managingMaterial: null, meshMaterial: null, - isSession: Ember.computed.not('isCourse'), - materials: Ember.computed.alias('subject.learningMaterials'), + isSession: not('isCourse'), newLearningMaterials: [], classNames: ['detail-learning-materials'], newButtonTitle: t('general.add'), @@ -22,29 +26,47 @@ export default Ember.Component.extend({ bufferTerms: [], learningMaterialStatuses: function(){ var self = this; - return DS.PromiseArray.create({ + return PromiseArray.create({ promise: self.get('store').findAll('learning-material-status') }); }.property(), - learningMaterialUserRoles: function(){ + learningMaterialUserRoles: computed(function(){ var self = this; - return DS.PromiseArray.create({ + return PromiseArray.create({ promise: self.get('store').findAll('learning-material-user-role') }); - }.property(), - proxyMaterials: Ember.computed('materials.@each', function(){ + }), + proxyMaterials: computed('subject.learningMaterials.[]', function(){ let materialProxy = Ember.ObjectProxy.extend({ sortTerms: ['name'], sortedDescriptors: Ember.computed.sort('content.meshDescriptors', 'sortTerms') }); - return this.get('materials').map(material => { + return this.get('subject.learningMaterials').map(material => { return materialProxy.create({ content: material }); }); }), + parentMaterials: computed('subject.learningMaterials.[]', function(){ + let defer = RSVP.defer(); + this.get('subject.learningMaterials').then(subLms => { + let promises = []; + let learningMaterials = []; + subLms.forEach(lm => { + promises.pushObject(lm.get('learningMaterial').then(learningMaterial => { + learningMaterials.pushObject(learningMaterial); + })); + }); + RSVP.all(promises).then(()=>{ + defer.resolve(learningMaterials); + }); + }); + return PromiseArray.create({ + promise: defer.promise + }); + }), actions: { - manageMaterial: function(learningMaterial){ + manageMaterial(learningMaterial){ var buffer = Ember.Object.create(); buffer.set('publicNotes', learningMaterial.get('publicNotes')); buffer.set('required', learningMaterial.get('required')); @@ -57,13 +79,13 @@ export default Ember.Component.extend({ }); }); }, - manageDescriptors: function(learningMaterial){ + manageDescriptors(learningMaterial){ learningMaterial.get('meshDescriptors').then(descriptors => { this.set('bufferTerms', descriptors.toArray()); this.set('meshMaterial', learningMaterial); }); }, - save: function(){ + save(){ if(this.get('isManagingMaterial')){ let buffer = this.get('bufferMaterial'); let learningMaterial = this.get('managingMaterial'); @@ -116,14 +138,14 @@ export default Ember.Component.extend({ }); } }, - cancel: function(){ + cancel(){ this.set('bufferMaterial', null); this.set('managingMaterial', null); this.set('bufferTerms', []); this.set('meshMaterial', null); }, - addNewLearningMaterial: function(type){ + addNewLearningMaterial(type){ var self = this; if(type === 'file' || type === 'citation' || type === 'link'){ self.get('learningMaterialStatuses').then(function(statuses){ @@ -146,7 +168,7 @@ export default Ember.Component.extend({ }); } }, - saveNewLearningMaterial: function(lm){ + saveNewLearningMaterial(lm){ var self = this; var subjectLm; var lmCollectionType; @@ -176,26 +198,49 @@ export default Ember.Component.extend({ }); }); }, - removeNewLearningMaterial: function(lm){ + removeNewLearningMaterial(lm){ this.get('newLearningMaterials').removeObject(lm); }, - addTermToBuffer: function(term){ + addTermToBuffer(term){ this.get('bufferTerms').addObject(term); }, - removeTermFromBuffer: function(term){ + removeTermFromBuffer(term){ this.get('bufferTerms').removeObject(term); }, - changeStatus: function(newStatus){ + changeStatus(newStatus){ this.get('bufferMaterial').set('status', newStatus); }, - changeRequired: function(value){ + changeRequired(value){ this.get('bufferMaterial').set('required', value); }, - changePublicNotes: function(value){ + changePublicNotes(value){ this.get('bufferMaterial').set('publicNotes', value); }, - changeNotes: function(value){ + changeNotes(value){ this.get('bufferMaterial').set('notes', value); + }, + addLearningMaterial(parentLearningMaterial){ + let newLearningMaterial; + let lmCollectionType; + if(this.get('isCourse')){ + newLearningMaterial = this.get('store').createRecord('course-learning-material', { + course: this.get('subject'), + learningMaterial: parentLearningMaterial + }); + lmCollectionType = 'courseLearningMaterials'; + } + if(this.get('isSession')){ + newLearningMaterial = this.get('store').createRecord('session-learning-material', { + session: this.get('subject'), + learningMaterial: parentLearningMaterial + }); + lmCollectionType = 'sessionLearningMaterials'; + } + newLearningMaterial.save().then(savedLearningMaterial => { + parentLearningMaterial.get(lmCollectionType).then(children => { + children.pushObject(savedLearningMaterial); + }); + }); } }, }); diff --git a/app/components/learningmaterial-search.js b/app/components/learningmaterial-search.js new file mode 100644 index 0000000000..91f05deef6 --- /dev/null +++ b/app/components/learningmaterial-search.js @@ -0,0 +1,78 @@ +import Ember from 'ember'; + +const {inject, run, computed} = Ember; +const {service} = inject; +const {debounce} = run; +const {or, notEmpty} = computed; + +export default Ember.Component.extend({ + store: service(), + i18n: service(), + classNames: ['learningmaterial-search'], + results: [], + currentMaterials: [], + searching: false, + showMoreInputPrompt: false, + showNoResultsMessage: false, + currentlySearchingForTerm: false, + hasResults: notEmpty('results'), + showList: or('searching', 'showMoreInputPrompt', 'showNoResultsMessage', 'hasResults'), + actions: { + clear() { + let input = this.$('input')[0]; + input.value = ''; + this.setProperties({ + searchTerms: '', + showMoreInputPrompt: false, + showNoResultsMessage: false, + searching: false, + results: [], + showClearButton: false, + }); + }, + inputValueChanged(){ + let input = this.$('input')[0]; + let searchTerms = input.value; + if(this.get('currentlySearchingForTerm') === searchTerms){ + return; + } + this.setProperties({ + currentlySearchingForTerm: searchTerms, + showMoreInputPrompt: false, + showNoResultsMessage: false, + searching: false, + }); + let noWhiteSpaceTerm = searchTerms.replace(/ /g,''); + if(noWhiteSpaceTerm.length === 0){ + this.send('clear'); + return; + } else if(noWhiteSpaceTerm.length < 3){ + this.setProperties({ + results: [], + showMoreInputPrompt: true, + }); + return; + } + this.set('searching', true); + debounce(this, function(){ + this.send('search', searchTerms); + }, 1000); + }, + search(searchTerms){ + this.set('searching', true); + this.get('store').query('learningMaterial', {q: searchTerms}).then(learningMaterials => { + let results = learningMaterials.filter(learningMaterial => { + return !this.get('currentMaterials').contains(learningMaterial); + }); + this.set('searching', false); + if(results.get('length') === 0){ + this.set('showNoResultsMessage', true); + } + this.set('results', results.sortBy('title')); + }); + }, + add(lm){ + this.sendAction('add', lm); + }, + } +}); diff --git a/app/locales/en/translations.js b/app/locales/en/translations.js index e143d920c2..5a1853ce93 100644 --- a/app/locales/en/translations.js +++ b/app/locales/en/translations.js @@ -267,7 +267,7 @@ export default { 'copyrightPermission': 'Copyright Permission', 'copyrightAgreement': "The file I am attempting to upload is my own or I have express permission to reproduce and/or distribute this item and does not contain any protected health information. My use of this file is in compliance with Government and University policies on copyright and information security and my educational program's guidelines for professional conduct. This file also adheres to the Terms and Conditions for this application.", 'copyrightRationale':'Copyright Rationale', - + 'searchPlaceholder': 'Find Learning Material', }, 'groupMembers': { 'filterPlaceholder': 'Filter by name or email', diff --git a/app/locales/es/translations.js b/app/locales/es/translations.js index eb0ee83787..d54c5280b5 100644 --- a/app/locales/es/translations.js +++ b/app/locales/es/translations.js @@ -267,7 +267,7 @@ export default { 'copyrightPermission': 'Permisos de Derechos de Autor', 'copyrightAgreement': "El archivo yo estoy tratando a subir es mi pripio o yo la tengo la autorización expresa a reproducir y/o distribuir esta información y que no contiene ninguna información de la salud protegida. Mi uso de este archivo conforme a Políticas del gobierno y a la Universidad según a la seguridad de copyright e información y las reglas de mi program de estudio a cerca de conducto profesional. Este archivo también adhiere a los Terminos y Condiciones para esta aplicación.", 'copyrightRationale':'Justificación para Derechos de Autor', - + 'searchPlaceholder': 'encontrar material de aprendizaje', }, 'groupMembers': { 'filterPlaceholder': 'Aplicar un Filtro por nombre o email', diff --git a/app/locales/fr/translations.js b/app/locales/fr/translations.js index 497365a42a..4edf26eaef 100755 --- a/app/locales/fr/translations.js +++ b/app/locales/fr/translations.js @@ -267,7 +267,7 @@ export default { 'copyrightPermission': "Droit d'auteur", 'copyrightAgreement': "Le fichier que je tente de télécharger est ma propre ou je avoir la permission expresse à reproduire et / ou distribuer cet article et ne contient pas d'informations de santé protégées . Mon utilisation de ce fichier est en conformité avec les politiques gouvernementales et universitaires sur le droit d'auteur et de la sécurité de l'information et les directives de mon programme d'enseignement de conduite professionnelle . Ce fichier respecte également les Termes et Conditions pour cette application .", 'copyrightRationale': "Raison du droit d'auteur", - + 'searchPlaceholder': "Trouver matières d'étude", }, 'groupMembers': { 'filterPlaceholder': "Filtre par nom ou email", diff --git a/app/mirage/config.js b/app/mirage/config.js index a8cf1999d7..2a9f3066d9 100644 --- a/app/mirage/config.js +++ b/app/mirage/config.js @@ -58,7 +58,17 @@ export default function() { this.get('/api/courselearningmaterials/:id', 'courseLearningMaterial'); this.put('/api/courselearningmaterials/:id', 'courseLearningMaterial'); this.delete('/api/courselearningmaterials/:id', 'courseLearningMaterial'); - this.post('/api/courselearningmaterials', 'courseLearningMaterial'); + this.post('/api/courselearningmaterials', function(db, request) { + let attrs = JSON.parse(request.requestBody); + let record = db.courseLearningMaterials.insert(attrs); + let lm = db.learningMaterials.find(record.learningMaterial); + if(lm){ + lm.courseLearningMaterials.pushObject(record); + } + return { + courseLearningMaterial: record + }; + }); this.get('/api/courses', getAll); this.get('/api/courses/:id', 'course'); @@ -232,7 +242,19 @@ export default function() { this.get('/api/sessionlearningmaterials/:id', 'sessionLearningMaterial'); this.put('/api/sessionlearningmaterials/:id', 'sessionLearningMaterial'); this.delete('/api/sessionlearningmaterials/:id', 'sessionLearningMaterial'); - this.post('/api/sessionlearningmaterials', 'sessionLearningMaterial'); + + this.post('/api/sessionlearningmaterials', function(db, request) { + let attrs = JSON.parse(request.requestBody); + let record = db.sessionLearningMaterial.insert(attrs); + let lm = db.learningMaterials.find(record.learningMaterial); + + if(lm){ + lm.sessionLearningMaterials.pushObject(record); + } + return { + sessionLearningMaterial: record + }; + }); this.get('/api/sessiontypes', getAll); this.get('/api/sessiontypes/:id', 'sessionType'); diff --git a/app/mirage/helpers/get-all.js b/app/mirage/helpers/get-all.js index 7d7e366bdb..96fa6a0f1b 100644 --- a/app/mirage/helpers/get-all.js +++ b/app/mirage/helpers/get-all.js @@ -77,6 +77,9 @@ export default function getAll(db, request){ case 'meshDescriptors': comparisonString = (obj.name + obj.annotation).toLowerCase(); break; + case 'learningMaterials': + comparisonString = (obj.title).toLowerCase(); + break; default: console.log('No Q comparison defined for ' + modelName); return false; diff --git a/app/styles/components/_detailview.scss b/app/styles/components/_detailview.scss index 30462fbc36..2a2425f11a 100644 --- a/app/styles/components/_detailview.scss +++ b/app/styles/components/_detailview.scss @@ -347,3 +347,57 @@ $collapse-control-shadow-grey: #e2e2e2; list-style-type: disc; } } + +.learningmaterial-search { + display: inline-block; + float: right; + margin-top: 1em; + position: relative; + + input { + height: 2em; + width: 200px; + } + + .results { + @include transition (all .2s ease-in-out); + background: $white; + border: 1px solid $background-grey; + border-radius: $base-border-radius; + box-shadow: 0 2px 2px transparentize($black, .8); + color: $base-font-color; + cursor: pointer; + left: 0; + max-height: 400px; + overflow-x: visible; + overflow-y: auto; + //this should be absolute, but first we have to move the .global-search + //out of the grid. + position: relative; + top: 0; + width: 300px; + z-index: 100; + + li { + border-bottom: 1px solid $base-border-color; + padding: $base-font-size * .6; + padding-left: 1em; + + &.active:hover { + background-color: $background-grey; + cursor: pointer; + } + + &.inactive { + color: darken($header-grey, 20); + font-style: italic; + } + } + } + + .livesearch-user-email { + color: $medium-grey; + font-style: italic; + padding-right: .2em; + } +} diff --git a/app/templates/components/detail-learning-materials.hbs b/app/templates/components/detail-learning-materials.hbs index f6f1bd1d98..2dca3d5368 100644 --- a/app/templates/components/detail-learning-materials.hbs +++ b/app/templates/components/detail-learning-materials.hbs @@ -1,7 +1,7 @@ -
+
{{#unless isManaging}} - {{t 'general.learningMaterials'}} ({{materials.length}}) + {{t 'general.learningMaterials'}} ({{proxyMaterials.length}}) {{/unless}} {{#if isManagingMaterial}} @@ -14,6 +14,9 @@ {{/if}}
+
+ {{learningmaterial-search add='addLearningMaterial' currentMaterials=parentMaterials}} +
{{#if isManaging}} @@ -59,7 +62,7 @@ remove='removeNewLearningMaterial'}} {{/each}} {{/if}} - {{#if materials.length}} + {{#if proxyMaterials.length}} diff --git a/app/templates/components/learningmaterial-search.hbs b/app/templates/components/learningmaterial-search.hbs new file mode 100644 index 0000000000..bf2fcf1f81 --- /dev/null +++ b/app/templates/components/learningmaterial-search.hbs @@ -0,0 +1,24 @@ + +
    + {{#if searching}} +
  • {{t 'general.currentlySearchingPrompt'}}
  • + {{else if showMoreInputPrompt}} +
  • {{t 'general.moreInputRequiredPrompt'}}
  • + {{else if showNoResultsMessage}} +
  • {{t 'general.noSearchResultsPrompt'}}
  • + {{else}} + {{#each results as |learningMaterial|}} +
  • + {{learningMaterial.title}} +
  • + {{/each}} + {{/if}} +
diff --git a/tests/acceptance/course/learningmaterials-test.js b/tests/acceptance/course/learningmaterials-test.js index 7058cd9536..875dc259f4 100644 --- a/tests/acceptance/course/learningmaterials-test.js +++ b/tests/acceptance/course/learningmaterials-test.js @@ -4,6 +4,8 @@ import { test } from 'qunit'; import startApp from 'ilios/tests/helpers/start-app'; +const {run} = Ember; +const {later} = run; var application; var fixtures = {}; @@ -61,6 +63,15 @@ module('Acceptance: Course - Learning Materials', { status: 1, courseLearningMaterials: [4], })); + fixtures.learningMaterials.pushObject(server.create('learningMaterial',{ + title: 'Letter to Doc Brown', + originalAuthor: 'Marty McFly', + owningUser: 4136, + status: 1, + userRole: 1, + copyrightPermission: true, + courseLearningMaterials: [], + })); fixtures.courseLearningMaterials = []; fixtures.courseLearningMaterials.pushObject(server.create('courseLearningMaterial',{ learningMaterial: 1, @@ -546,3 +557,30 @@ test('cancel term changes', function(assert) { }); }); }); + +test('find and add learning material', function(assert) { + visit(url); + andThen(function() { + assert.equal(currentPath(), 'course.index'); + let container = find('.detail-learning-materials'); + let rows = find('.detail-content tbody tr', container); + assert.equal(rows.length, fixtures.course.learningMaterials.length); + + let searchBoxInput = find('input', container); + fillIn(searchBoxInput, 'doc'); + triggerEvent(searchBoxInput, 'search'); + andThen(function(){ + later(function(){ + let searchResults = find('.results li', container); + assert.equal(searchResults.length, 1); + assert.equal(getElementText($(searchResults[0])), getText('Letter to Doc Brown')); + click(searchResults[0]); + + andThen(function(){ + let rows = find('.detail-content tbody tr', container); + assert.equal(rows.length, fixtures.course.learningMaterials.length + 1); + }); + }, 1000); + }); + }); +}); diff --git a/tests/acceptance/course/session/learningmaterials-test.js b/tests/acceptance/course/session/learningmaterials-test.js index c052024260..205e43956e 100644 --- a/tests/acceptance/course/session/learningmaterials-test.js +++ b/tests/acceptance/course/session/learningmaterials-test.js @@ -4,6 +4,8 @@ import { test } from 'qunit'; import startApp from 'ilios/tests/helpers/start-app'; +const {run} = Ember; +const {later} = run; var application; var fixtures = {}; @@ -68,6 +70,15 @@ module('Acceptance: Session - Learning Materials', { status: 1, sessionLearningMaterials: [4], })); + fixtures.learningMaterials.pushObject(server.create('learningMaterial',{ + title: 'Letter to Doc Brown', + originalAuthor: 'Marty McFly', + owningUser: 4136, + status: 1, + userRole: 1, + copyrightPermission: true, + courseLearningMaterials: [], + })); fixtures.sessionLearningMaterials = []; fixtures.sessionLearningMaterials.pushObject(server.create('sessionLearningMaterial',{ learningMaterial: 1, @@ -552,3 +563,30 @@ test('cancel term changes', function(assert) { }); }); }); + +test('find and add learning material', function(assert) { + visit(url); + andThen(function() { + assert.equal(currentPath(), 'course.session.index'); + let container = find('.detail-learning-materials'); + let rows = find('.detail-content tbody tr', container); + assert.equal(rows.length, fixtures.session.learningMaterials.length); + + let searchBoxInput = find('input', container); + fillIn(searchBoxInput, 'doc'); + triggerEvent(searchBoxInput, 'search'); + andThen(function(){ + later(function(){ + let searchResults = find('.results li', container); + assert.equal(searchResults.length, 1); + assert.equal(getElementText($(searchResults[0])), getText('Letter to Doc Brown')); + click(searchResults[0]); + + andThen(function(){ + let rows = find('.detail-content tbody tr', container); + assert.equal(rows.length, fixtures.session.learningMaterials.length + 1); + }); + }, 1000); + }); + }); +}); diff --git a/tests/integration/components/learningmaterial-search-test.js b/tests/integration/components/learningmaterial-search-test.js new file mode 100644 index 0000000000..4ad57f753b --- /dev/null +++ b/tests/integration/components/learningmaterial-search-test.js @@ -0,0 +1,18 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import tHelper from "ember-i18n/helper"; + +moduleForComponent('learningmaterial-search', 'Integration | Component | learningmaterial search', { + integration: true, + beforeEach: function() { + this.container.lookup('service:i18n').set('locale', 'en'); + this.registry.register('helper:t', tHelper); + } +}); + +test('it renders', function(assert) { + assert.expect(1); + this.render(hbs`{{learningmaterial-search}}`); + + assert.equal(this.$().text().trim(), ''); +});