diff --git a/.env-travis b/.env-travis index 298cb95..6af63f1 100644 --- a/.env-travis +++ b/.env-travis @@ -1,5 +1,5 @@ OSF_CLIENT_ID= -OSF_SCOPE=osf.users.all_read +OSF_SCOPE=osf.users.profile_read OSF_URL=https://staging.osf.io OSF_AUTH_URL=https://staging-accounts.osf.io JAMDB_NAMESPACE=experimenter diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 0000000..1559fca --- /dev/null +++ b/.jshintignore @@ -0,0 +1 @@ +tmp/** diff --git a/.template-lintrc.js b/.template-lintrc.js new file mode 100644 index 0000000..a0abb68 --- /dev/null +++ b/.template-lintrc.js @@ -0,0 +1,11 @@ +/* jshint node:true */ +'use strict'; + +module.exports = { + extends: 'recommended', + + rules: { + 'bare-strings': false, + 'block-indentation': 4 + } +}; diff --git a/.travis.yml b/.travis.yml index 4b4d2b8..9c4542d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,4 +21,4 @@ install: - bower install script: - - npm test + - npm run check-style && npm test diff --git a/README.md b/README.md index 805ae6d..99a5e9e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ You will need the following things properly installed on your computer. * [Ember CLI](http://www.ember-cli.com/) * [PhantomJS](http://phantomjs.org/) +## Prepare JamDB instance +Experimenter is designed to talk to a [JamDB](https://github.com/CenterForOpenScience/jamdb) server for all +data storage. In most cases you will be provided a remote staging server for development purposes, but for advanced +development, [these setup scripts](https://github.com/samchrisinger/jam-setup) can help define a basic skeleton for +your project. + ## Installation * `git clone ` this repository @@ -52,9 +58,11 @@ To login via OSF: * in .env file include: ```bash OSF_CLIENT_ID="\" -OSF_SCOPE="osf.users.all_read" +OSF_SCOPE="osf.users.profile_read" OSF_URL="https://staging-accounts.osf.io" SENTRY_DSN="" +WOWZA_PHP='{}' +WOWZA_ASP='{}' ``` First: diff --git a/app/authorizers/osf-jwt.js b/app/authorizers/osf-jwt.js index 8665ea0..df6764b 100644 --- a/app/authorizers/osf-jwt.js +++ b/app/authorizers/osf-jwt.js @@ -1,7 +1,7 @@ import Base from 'ember-simple-auth/authorizers/base'; export default Base.extend({ - authorize(sessionData, setHeader) { - setHeader('Authorization', sessionData.token); - } + authorize(sessionData, setHeader) { + setHeader('Authorization', sessionData.token); + } }); diff --git a/app/components/.gitkeep b/app/components/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/components/ace-editor/template.hbs b/app/components/ace-editor/template.hbs index 7d5f2a5..af1ec1e 100644 --- a/app/components/ace-editor/template.hbs +++ b/app/components/ace-editor/template.hbs @@ -1,2 +1,3 @@ +{{! template-lint-disable style-concatenation }}
{{yield ctx}} diff --git a/app/components/bread-crumbs/template.hbs b/app/components/bread-crumbs/template.hbs new file mode 100644 index 0000000..33f4166 --- /dev/null +++ b/app/components/bread-crumbs/template.hbs @@ -0,0 +1,26 @@ +{{! template-lint-disable block-indentation }}{{!-- linter seems to be having trouble parsing nested blocks --}} + diff --git a/app/components/experiment-detail.js b/app/components/experiment-detail/component.js similarity index 70% rename from app/components/experiment-detail.js rename to app/components/experiment-detail/component.js index 5e186fe..3c7f1a0 100644 --- a/app/components/experiment-detail.js +++ b/app/components/experiment-detail/component.js @@ -10,7 +10,7 @@ export default Ember.Component.extend({ deleting: false, showDeleteWarning: false, actions: { - toggleEditing: function() { + toggleEditing() { this.toggleProperty('editing'); if (!this.get('editing') && this.get('experiment.hasDirtyAttributes')) { this.get('experiment').save().then(() => { @@ -18,42 +18,45 @@ export default Ember.Component.extend({ }); } }, - stop: function() { + stop() { var exp = this.get('experiment'); exp.set('state', exp.ARCHIVED); - exp.save().then(() => { - return this.get('store').findRecord('collection', exp.get('sessionCollectionId')).then((collection) => { + exp.save().then(() => this.get('store').findRecord('collection', exp.get('sessionCollectionId')) + .then((collection) => { collection.set('permissions', {}); return collection.save(); - }).then(() => this.get('toast.info')('Experiment stopped successfully.')); - }); + }) + .then(() => this.get('toast.info')('Experiment stopped successfully.')) + ); }, - start: function() { + start() { var exp = this.get('experiment'); exp.set('state', exp.ACTIVE); - exp.save().then(() => { - return this.get('store').findRecord('collection', exp.get('sessionCollectionId')).then((collection) => { + exp.save().then(() => this.get('store').findRecord('collection', exp.get('sessionCollectionId')) + .then((collection) => { collection.set('permissions', { - [`jam-${this.get('namespaceConfig').get('namespace')}:accounts-*`] : 'CREATE' - }); + [`jam-${this.get('namespaceConfig').get('namespace')}:accounts-*`]: 'CREATE' + }); return collection.save(); - }).then(() => this.get('toast.info')('Experiment started successfully.')); - }); + }) + .then(() => this.get('toast.info')('Experiment started successfully.')) + ); }, - delete: function() { + delete() { this.toggleProperty('showDeleteWarning'); this.set('deleting', true); var exp = this.get('experiment'); exp.set('state', exp.DELETED); - exp.save().then(() => { - return this.get('store').findRecord('collection', exp.get('sessionCollectionId')).then((collection) => { + exp.save().then(() => this.get('store').findRecord('collection', exp.get('sessionCollectionId')) + .then((collection) => { collection.set('permissions', {}); return collection.save(); - }).then(() => this.sendAction('onDelete', exp)); - }); + }) + .then(() => this.sendAction('onDelete', exp)) + ); }, - clone: function() { + clone() { var exp = this.get('experiment'); var expData = exp.toJSON(); expData.title = `Copy of ${expData.title}`; @@ -76,12 +79,11 @@ export default Ember.Component.extend({ expData.thumbnailId = thumbnail.get('id'); finish(); }); - } - else { + } else { finish(); } }, - onSetImage: function(thumbnail) { + onSetImage(thumbnail) { var exp = this.get('experiment'); exp.set('thumbnail', thumbnail); exp.save().then(() => { diff --git a/app/components/experiment-detail/template.hbs b/app/components/experiment-detail/template.hbs new file mode 100644 index 0000000..5bc4d0c --- /dev/null +++ b/app/components/experiment-detail/template.hbs @@ -0,0 +1,165 @@ +
+ {{#if (not editing) }} +
+

{{experiment.title}}

+ +
+
+
+ {{img-selector thumbnail=experiment.thumbnail.raw edit=false}} +
+
+
+

{{if experiment.description experiment.description 'No description'}}

+
+
+ +

{{if experiment.purpose experiment.purpose 'None specified'}}

+
+
+ {{if experiment.duration experiment.duration 'Not specified'}} +
+
+ {{if experiment.exitUrl experiment.exitUrl 'Not specified'}} +
+
+ {{experiment.eligibilityString}} +
+
+ {{experiment.eligibilityMinAge}} +
+
+ {{experiment.eligibilityMaxAge}} +
+ {{/if}} + {{#if editing }} +
+
+
+ {{input class="increase-4 form-control" value=experiment.title}} +
+
+ +
+
+
+
+ {{img-selector thumbnail=experiment.thumbnail.raw edit=true onSetImage=(action 'onSetImage')}} +
+
+
+

+ {{textarea value=experiment.description class="experiment-textarea form-control" + placeholder="Give your experiment a description here..."}} +

+
+
+ +

+ {{textarea value=experiment.purpose class="experiment-textarea form-control" + placeholder="Explain the purpose of your experiment here..."}} +

+
+ +
+ {{input value=experiment.duration + class="experiment-detail-wide form-control"}} +
+
+ {{input value=experiment.exitUrl class="experiment-detail-wide form-control"}} +
+
+ {{input value=experiment.eligibilityString + class="experiment-detail-wide form-control"}} +
+
+ {{input value=experiment.eligibilityMinAge + class="experiment-detail-wide form-control" + placeholder="9 months"}} +
+
+ {{input value=experiment.eligibilityMaxAge + class="experiment-detail-wide form-control" placeholder="2 years"}} +
+
+ {{/if}} +
+ {{moment-format experiment.lastEdited 'MMMM D, YYYY'}}      +
+
+
+
+ {{experiment.state}}      + {{#if (eq experiment.state 'Active')}} + + {{/if}} + {{#if (eq experiment.state 'Draft') }} + + {{/if}} + {{#if (eq experiment.state 'Archived') }} + + {{/if}} +
+
+
+
+
+ {{#link-to 'experiments.info.edit' tagName='div' class="btn col-md-4"}} +
+
+

+

Build Experiment

+

Add/Modify experiment components

+
+
+ {{/link-to}} + {{#link-to 'experiments.info.results' tagName='div' class="btn col-md-4"}} +
+
+

+

View {{sessions.length}} Responses

+

Inspect responses from studies

+
+
+ {{/link-to}} +
+
+
+

+

Clone Experiment

+

Copy experiment structure and details

+
+
+
+
+
+
+ + {{#if experiment.isActive }}  You can't delete active experiments {{/if}} +
+
+
+ +{{#bs-modal open=showDeleteWarning title="Are you sure?" footer=false}} + {{#bs-modal-body}} + Deleting this experiment will remove it from your list of experiments. This action is irreversible. + {{/bs-modal-body}} + {{#bs-modal-footer as |footer|}} +
+
+ {{#bs-button action=(action "close" target=footer) type="default"}}Cancel{{/bs-button}} +
+
+ {{#bs-button action=(action "delete") type="danger"}}Delete now{{/bs-button}} +
+
+ {{/bs-modal-footer}} +{{/bs-modal}} + +{{#bs-modal open=deleting footer=false closeButton=false title="Please wait"}}Deleting experiment...{{/bs-modal}} diff --git a/app/components/experiment-summary.js b/app/components/experiment-summary.js deleted file mode 100644 index 926b613..0000000 --- a/app/components/experiment-summary.js +++ /dev/null @@ -1,4 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Component.extend({ -}); diff --git a/app/components/experiment-display.js b/app/components/experiment-summary/component.js similarity index 100% rename from app/components/experiment-display.js rename to app/components/experiment-summary/component.js diff --git a/app/components/experiment-summary/template.hbs b/app/components/experiment-summary/template.hbs new file mode 100644 index 0000000..1ee82d4 --- /dev/null +++ b/app/components/experiment-summary/template.hbs @@ -0,0 +1,37 @@ +
+
+
+ {{experiment.title}} +
+
+ {{#if experiment.beginDate}} + {{moment-format experiment.beginDate 'MMMM D, YYYY'}} + {{else}} + n/a + {{/if}} +
+
+ {{#if experiment.endDate}} + {{moment-format experiment.endDate 'MMMM D, YYYY'}} + {{else}} + n/a + {{/if}} +
+
+
+
+ {{experiment.description}} +
+
+
+
+ Status: {{experiment.state}} +
+
+ Last Edited: {{moment-format experiment.modifiedOn 'M/D/YYYY'}} +
+
+ {{#link-to "experiments.info" experiment.id class="btn btn-default" }}Details{{/link-to}} +
+
+
diff --git a/app/components/export-tool.js b/app/components/export-tool.js deleted file mode 100644 index a90618c..0000000 --- a/app/components/export-tool.js +++ /dev/null @@ -1,141 +0,0 @@ -import Ember from 'ember'; - -/** - * @module experimenter - * @submodule components - */ - -// Make sure that ember-data objects are serialized to a JS object -function serializeItem(obj) { - if (obj.serialize) { - var serialized = obj.serialize(); - obj = serialized.data.attributes; - } - return obj; -} - -// Flatten a nested object into a single level, with dotted paths for keys -function squash(obj, prefix) { - var ret = {}; - Object.keys(obj).forEach((key) => { - var value = Ember.get(obj, key); - if (value && value.toJSON) { - value = value.toJSON(); - } - - if (Ember.$.isPlainObject(value)) { - ret = Ember.$.extend({}, ret, squash(value, prefix ? `${prefix}.${key}` : key)); - } else { - ret[prefix ? `${prefix}.${key}` : key] = value; - } - }); - return ret; -} - - -/** - * Export tool component: serializes records into one of a number of possible output formats - * @class export-tool - */ -export default Ember.Component.extend({ - /** - * @property data The data to be serialized - */ - data: null, - - /** - * Mapping function to transform a given (squashed) record. Should accept a single argument, - * a (possibly nested) JS object of fields - * @property {function} mappingFunction - * @default Return the item unchanged - */ - mappingFunction(item) { - return item; - }, - - dataFormat: 'JSON', - // Recognized data formats. Hash of form {displayValue: Extension} items - dataFormats: { - JSON: 'JSON', - TSV: 'TSV', - 'TSV (for ISP)': 'TSV', - }, - - processedData: Ember.computed('data', 'dataFormat', function() { - var data = this.get('data') || []; - if (data.toArray) { - data = data.toArray(); - } - let dataArray = data.map(serializeItem); - - var dataFormat = this.get('dataFormat'); - var mappingFunction = this.get('mappingFunction'); - - if (Ember.isPresent(dataArray)) { - let mapped = dataArray.map(mappingFunction); - return this.convertToFormat(mapped, dataFormat); - } else { - return null; - } - }), - - _convertToJSON(dataArray) { - return JSON.stringify(dataArray, null, 4); - }, - _convertToTSV(dataArray) { - // Flatten the dictionary keys for readable column headers - let squashed = dataArray.map((item => squash(item))); - - var fields = Object.keys(squashed[0]); - var tsv = [fields.join('\t')]; - - squashed.forEach((item) => { - var line = []; - fields.forEach(function(field) { - line.push(JSON.stringify(item[field])); - }); - tsv.push(line.join('\t')); - }); - tsv = tsv.join('\r\n'); - return tsv; - }, - _convertToISP(dataArray) { - // ISP-specific TSV file format - - // First custom field mapping... - - // Then serialize to TSV - dataArray = dataArray.map((record) => { - let newRecord = {}; - for (let frameId of Object.keys(record.expData)) { - newRecord[frameId] = record.expData[frameId].responses || {}; - } - return newRecord; - }); - return this._convertToTSV(dataArray); - }, - convertToFormat(dataArray, format) { - if (format === 'JSON') { - return this._convertToJSON(dataArray); - } else if (format === 'TSV') { - return this._convertToTSV(dataArray); - } else if (format === 'TSV (for ISP)') { - return this._convertToISP(dataArray); - } else { - throw 'Unrecognized file format specified'; - } - }, - actions: { - downloadFile() { - let blob = new window.Blob([this.get('processedData')], { - type: 'text/plain;charset=utf-8' - }); - let format = this.get('dataFormat'); - let extension = this.get('dataFormats')[format].toLowerCase(); - window.saveAs(blob, `data.${extension}`); - }, - selectDataFormat(dataFormat) { - this.set('dataFormat', dataFormat); - } - } -}); diff --git a/app/components/export-tool/component.js b/app/components/export-tool/component.js new file mode 100644 index 0000000..484e4b6 --- /dev/null +++ b/app/components/export-tool/component.js @@ -0,0 +1,194 @@ +import Ember from 'ember'; +import { writeCSV, squash, uniqueFields } from 'exp-models/utils/csv-writer'; +import ispFields from './isp-fields'; + +/** + * @module experimenter + * @submodule components + */ + +/** + * Make sure that ember-data objects are serialized to a JS object + * + * @param obj + * @returns {*} + */ +function serializeItem(obj) { + if (obj.serialize) { + obj = obj.serialize().data.attributes; + } + return obj; +} + +/** + * Export tool component: serializes records into one of a number of possible output formats + * @class export-tool + */ +export default Ember.Component.extend({ + /** + * @property {null|Object[]} data The data to be serialized + */ + data: null, + + /** + * Mapping function to transform a given (squashed) record. Should accept a single argument, + * a (possibly nested) JS object of fields + * + * @property {function|null} mappingFunction + * @default null + */ + mappingFunction: null, + + /** @property {('JSON'|'CSV')} the data format */ + dataFormat: 'JSON', + + /** + * Recognized data formats. Hash of form `{displayValue: Extension}` items + * + * @property {Object.String} + */ + dataFormats: { + JSON: 'JSON', + CSV: 'CSV', + 'CSV (for ISP)': 'CSV' + }, + + /** + * @property {null|string} The processed data + */ + processedData: Ember.computed('data', 'dataFormat', 'mappingFunction', function() { + let data = this.get('data') || []; + + if (data.toArray) { + data = data.toArray(); + } + + const dataArray = data.map(serializeItem); + const dataFormat = this.get('dataFormat'); + const mappingFunction = this.get('mappingFunction'); + + if (Ember.isPresent(dataArray)) { + const mapped = mappingFunction ? dataArray.map(mappingFunction) : dataArray; + return this.convertToFormat(mapped, dataFormat); + } + + return null; + }), + + /** + * Converts an array to a JSON string + * + * @param {Object[]} dataArray An array of objects + * @private + * @returns {string} + */ + _convertToJSON(dataArray) { + return JSON.stringify(dataArray, null, 4); + }, + + /** + * Converts an array to a standard CSV file + * + * @param {Object[]} dataArray The rows of the CSV + * @private + * @returns {string} + */ + _convertToCSV(dataArray) { + const data = dataArray.map(squash); + const fields = uniqueFields(data); + return writeCSV(data, fields); + }, + + /** + * Converts an array to a standard CSV file + * + * @param {Object[]} dataArray The rows of the CSV + * @private + * @returns {string} + */ + _convertToISP(dataArray) { + // ISP-specific CSV file format + const normalizedArray = dataArray + .map(record => { + // Rename a few fields to match the spec'ed output + const newRecord = { + PID: record.profileId, + SID: record.extra.studyId, + locale: record.extra.locale + }; + + for (const frameId of Object.keys(record.expData)) { + let responses = record.expData[frameId].responses || {}; + + for (let question of Object.keys(responses)) { + if (frameId === '3-3-rating-form') { + for (let item of Object.keys(responses[question])) { + newRecord[item] = responses[question][item]; + } + } else { + newRecord[question] = responses[question]; + } + } + } + + const squashedRecord = squash(newRecord); + const keys = Object.keys(squashedRecord); + + for (const key of keys) { + if (key.includes('.')) { + squashedRecord[key.replace('.', '_')] = squashedRecord[key]; + delete squashedRecord[key]; + } + } + + return squashedRecord; + }); + + return writeCSV(normalizedArray, ispFields); + }, + + /** + * Converts an array to the specified format + * + * @param {Object[]} dataArray An array of objects + * @param {dataFormats} format The conversion output format + * @returns {string} + */ + convertToFormat(dataArray, format) { + switch (format) { + case 'JSON': + return this._convertToJSON(dataArray); + case 'CSV': + return this._convertToCSV(dataArray); + case 'CSV (for ISP)': + return this._convertToISP(dataArray); + default: + throw new Error('Unrecognized file format specified'); + } + }, + + actions: { + /** + * Creates a file for the user to download + */ + downloadFile() { + const blob = new window.Blob([this.get('processedData')], { + type: 'text/plain;charset=utf-8' + }); + + const format = this.get('dataFormat'); + const extension = this.get('dataFormats')[format].toLowerCase(); + + window.saveAs(blob, `data.${extension}`); + }, + + /** + * Sets the current data format + * + * @param {dataFormats} dataFormat + */ + selectDataFormat(dataFormat) { + this.set('dataFormat', dataFormat); + } + } +}); diff --git a/app/components/export-tool/isp-fields.js b/app/components/export-tool/isp-fields.js new file mode 100644 index 0000000..4273615 --- /dev/null +++ b/app/components/export-tool/isp-fields.js @@ -0,0 +1,51 @@ +function generateNumericFields(prefix, total) { + const arr = []; + + for (let i = 1; i <= total; i++) { + arr.push(`${prefix}${i}`); + } + + return arr; +} + +// The order from the ISP Codebook +export default [ + 'PID', + 'SID', + 'locale', + 'Age', + 'Gender', + 'Ethnicity', + 'Language', + 'SocialStatus', + 'BirthCity', + 'BirthCountry', + 'Residence', + 'Religion1to10', + 'ReligionYesNo', + 'ReligionFollow', + 'EventTime', + 'WhatResponse', + 'WhereResponse', + 'WhoResponse', + ...generateNumericFields('ThreeCat_rsq', 90), + ...generateNumericFields('NineCat_rsq', 90), + 'PosNeg', + 'SitSimilarity', + ...generateNumericFields('BBI', 16), + 'Risk', + ...generateNumericFields('BFI', 60), + ...generateNumericFields('SWB', 4), + ...generateNumericFields('IntHapp', 9), + ...generateNumericFields('Constru', 13), + ...generateNumericFields('Tight', 6), + 'ChangeYesNo', + 'ChangeDescribe', + 'ChangeSuccess', + ...generateNumericFields('Trust', 5), + ...generateNumericFields('LOT', 6), + ...generateNumericFields('Honest', 10), + ...generateNumericFields('Micro', 6), + ...generateNumericFields('Narq', 6), + ...generateNumericFields('ReligionScale', 17) +]; diff --git a/app/components/export-tool/template.hbs b/app/components/export-tool/template.hbs new file mode 100644 index 0000000..5fbca8a --- /dev/null +++ b/app/components/export-tool/template.hbs @@ -0,0 +1,17 @@ +
+
+
+ +
+
+
+ {{textarea value=processedData class="export-tool-textarea"}} +
+
+ +
+
diff --git a/app/components/img-selector.js b/app/components/img-selector/component.js similarity index 62% rename from app/components/img-selector.js rename to app/components/img-selector/component.js index 5a473dc..a29c202 100644 --- a/app/components/img-selector.js +++ b/app/components/img-selector/component.js @@ -4,20 +4,20 @@ export default Ember.Component.extend({ thumbnail: null, edit: true, actions: { - uploadImage: function(e) { - var self = this; + uploadImage(e) { + var _this = this; var reader = new window.FileReader(); - reader.onload = function(event){ - self.set('thumbnail', event.target.result); - var onSetImage = self.get('onSetImage'); + reader.onload = function (event) { + _this.set('thumbnail', event.target.result); + var onSetImage = _this.get('onSetImage'); if (onSetImage) { onSetImage(event.target.result); } }; reader.readAsDataURL(e.target.files[0]); }, - clickInput: function() { + clickInput: function () { this.$().find('.img-selector-input').click(); } } diff --git a/app/components/img-selector/template.hbs b/app/components/img-selector/template.hbs new file mode 100644 index 0000000..a964243 --- /dev/null +++ b/app/components/img-selector/template.hbs @@ -0,0 +1,13 @@ +{{#if thumbnail }} + Thumbnail +{{ else }} +
+ +
+{{/if}} +{{#if edit}} +
+
+ + {{input type="file" change=(action 'uploadImage') class="img-selector-input"}} +{{/if}} diff --git a/app/components/multi-toggle.js b/app/components/multi-toggle/component.js similarity index 100% rename from app/components/multi-toggle.js rename to app/components/multi-toggle/component.js diff --git a/app/components/multi-toggle/template.hbs b/app/components/multi-toggle/template.hbs new file mode 100644 index 0000000..ec234f6 --- /dev/null +++ b/app/components/multi-toggle/template.hbs @@ -0,0 +1,9 @@ +
+ +
\ No newline at end of file diff --git a/app/components/participant-creator/component.js b/app/components/participant-creator/component.js index a619ef8..1995d13 100644 --- a/app/components/participant-creator/component.js +++ b/app/components/participant-creator/component.js @@ -1,103 +1,200 @@ import Ember from 'ember'; +import { validator, buildValidations } from 'ember-cp-validations'; + +/** + * @module experimenter + * @submodule components + */ // h/t: http://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript function makeId(len) { - var text = ''; - var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ''; + const possible = '0123456789'; - for (var i = 0; i < len; i++) { + for (let i = 0; i < len; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } return text; } -export default Ember.Component.extend({ +const Validations = buildValidations({ + batchSize: validator('number', { + gt: 0, + lte: 100, + integer: true, + allowString: true + }), + studyId: validator('presence', { + presence: true, + ignoreBlank: true + }) +}); + +/** + * Participant creator page in component form. + * + * Sample usage: + * ```handlebars + * {{participant-creator}} + * ``` + * @class participant-creator + */ +export default Ember.Component.extend(Validations, { store: Ember.inject.service(), + toast: Ember.inject.service(), batchSize: 1, tag: null, studyId: null, extra: null, nextExtra: '', - invalidStudyId: false, invalidFieldName: false, creating: false, - createdAccounts: [], - _creatingPromise: null, + createdAccounts: null, + showErrors: false, init() { this._super(...arguments); this.set('extra', Ember.A()); + this.set('createdAccounts', []); }, - + /** + * Generate an array of ID strings + * @param batchSize + * @param tag + * @returns {String[]} + * @private + */ _generate(batchSize, tag) { var ret = []; for (let i = 0; i < batchSize; i++) { - ret.push(`${makeId(5)}${tag ? `-${tag}` : ''}`); + ret.push(`${makeId(10)}${tag ? `-${tag}` : ''}`); } return ret; }, - exampleId: Ember.computed('tag', function() { - var tag = this.get('tag'); - return `12345${tag ? `-${tag}` : ''}`; - }), - createdAccountsCSV: Ember.computed('createdAccounts', function() { + /** + * @method _sendBulkRequest Send a single (bulk) ajax request and return a promise + * @param {String} modelName The name of the record type to create (eg + * @param {Object[]} attributes An array of attributes objects, as would be passed to `createRecord` for the corresponding model name + * @returns {Promise} A promise that resolves to the array of new, quasi-unsaved ember model objects + * @private + */ + _sendBulkRequest(modelName, attributes) { + // Serialize the attributes as though creating a new record + const records = attributes.map(obj => this.get('store').createRecord(modelName, obj)); + const payload = records.map(rec => rec.serialize({includeId: true}).data); + const adapter = this.get('store').adapterFor(modelName); + const url = adapter.buildURL(modelName, null, null, 'createRecord'); // url templates bypass urlfor methods + return adapter.ajax(url, 'POST', { + data: { data: payload }, + isBulk: true + }).then((res) => { + // JamDB bulk responses can include placeholder null values in the data array, if the corresponding + // request array item failed. Filter these error placeholders out to get just records created, and map + // them from long Jam IDs to the short ones used here + const createdIDs = new Set((res.data || []).filter(item => !!item).map(item => item.id.split('.').pop())); + // Return the records that actually got created on the server, and clean up the remainder that errored + // This is an ugly side effect of the various ways that we are bypassing the ember data store + const createdRecords = []; + const erroredRecords = []; + records.forEach(item => { + if (createdIDs.has(item.id)) { + createdRecords.push(item); + } else { + erroredRecords.push(item); + } + }); + this._clearAccounts(erroredRecords, false); + return createdRecords; + }); + }, + + /** + * Clear temporary and quasi-unsaved account objects from the store + * @method _clearAccounts + * @parameter A list of records to unload + * @parameter {Boolean} clear Whether to clear the entire list of created accounts stored locally + * @private + */ + _clearAccounts(accounts, clear=true) { + accounts = accounts || this.get('createdAccounts'); + if (accounts) { + accounts.forEach(item => { + this.get('store').deleteRecord(item); + }); + } + if (clear) { + this.set('createdAccounts', []); + } + }, + + accountsToCSV() { var keys = ['id', 'extra.studyId'].concat(this.get('extra').map(e => `extra.${e.key}`)); return keys.join(',') + '\n' + this.get('createdAccounts').map((a) => { var props = a.getProperties(keys); return keys.map(k => props[k]).join(','); }).join('\n'); + }, + + exampleId: Ember.computed('tag', function() { + var tag = this.get('tag'); + return `12345${tag ? `-${tag}` : ''}`; }), + actions: { - createParticipants() { - var studyId = this.get('studyId'); - if (!studyId || !studyId.trim()) { - this.set('invalidStudyId', true); - this.set('creating', false); + createParticipants(batchSize) { + // Only show messages after first attempt to submit form + this.set('showErrors', true); + if (!this.get('validations.isValid')) { return; } - Ember.run(() => { - console.log('creating...'); - this.set('creating', true); - this.set('createdAccounts', []); - }); + // Each new batch of contributors creates a new CSV file with no records from the previous batch + this._clearAccounts(); + + var studyId = this.get('studyId'); var tag = this.get('tag'); - var batchSize = parseInt(this.get('batchSize')) || 0; - var accounts = this._generate(batchSize, tag); - var store = this.get('store'); + batchSize = parseInt(batchSize) || 0; + var accountIDs = this._generate(batchSize, tag); var extra = {}; - extra['studyId'] = studyId; + extra.studyId = studyId; this.get('extra').forEach(item => { extra[item.key] = item.value; }); + const accounts = accountIDs.map(id => ({id, password: studyId, extra})); + // TODO: Use the server response errors field to identify any IDs that might already be in use: + // as written, we don't retry to create those. If 3 of 100 requested items fail, we just create 97 items and call it a day. + this.set('creating', true); - Ember.run.later(this, () => { - this.set('_creatingPromise', Ember.RSVP.allSettled( - accounts.map((aId) => { - var attrs = { - id: aId, - password: studyId, - extra: extra - }; - var acc = store.createRecord('account', attrs); - console.log(`Saving ${acc.get('id')}`); - return acc.save().then(() => { - this.get('createdAccounts').pushObject(acc); - console.log(`Saved ${acc.get('id')}`); - }); + // This step is very slow because each password has to be bcrypted- on the front end (jamdb implementation detail). + // Do that in a separate run loop so that UI status indicator can render while we wait; otherwise + // rerender blocks until after the server request has been sent. + this.rerender(); + Ember.run.next(() => { + this._sendBulkRequest('account', accounts) + .then((res) => { + if (res.length > 0) { + // Store all the records that were successfully created, + // adding them to all records from previous requests while on this page. + // Eg, a combined CSV could be generated with 200 records. + this.get('createdAccounts').push(...res); + // This may sometimes be smaller than batchSize, in the rare event that a single record appears + // in res.errors instead, eg because ID was already in use + this.toast.info(`Successfully created ${res.length} accounts!`); + this.send('downloadCSV'); + } else { + // Likely, every ID in this request failed to create for some horrible reason (data.length=0 + // and errors.length=batchSize after filtering out spurious null entries) + this.get('toast').error('Could not create new account(s). If this error persists, please contact support.'); + } }) - ).then(() => { - Ember.run.later(this, () => { - this.set('creating', false); - this.send('downloadCSV'); - }, 100); - })); - }, 50); + .catch(() => this.get('toast').error('Could not create new accounts. Please try again later.')) + .finally(() => this.set('creating', false)); + }); }, addExtraField() { var next = this.get('nextExtra'); @@ -126,17 +223,18 @@ export default Ember.Component.extend({ var extra = this.get('extra'); this.set('extra', extra.filter((item) => item.key !== field)); }, - downloadCSV: function() { - var blob = new window.Blob([this.get('createdAccountsCSV')], { + downloadCSV() { + const content = this.accountsToCSV(); + var blob = new window.Blob([content], { type: 'text/plain;charset=utf-8' }); window.saveAs(blob, 'participants.csv'); }, - toggleInvalidStudyId: function() { - this.toggleProperty('invalidStudyId'); - }, toggleInvalidFieldName: function() { this.toggleProperty('invalidFieldName'); } + }, + willDestroy() { + this._clearAccounts(); } }); diff --git a/app/components/participant-creator/template.hbs b/app/components/participant-creator/template.hbs index 5738ece..722e433 100644 --- a/app/components/participant-creator/template.hbs +++ b/app/components/participant-creator/template.hbs @@ -1,19 +1,22 @@
- Created {{createdAccounts.length}} out of {{batchSize}} + Creating new records. This may take several minutes- please wait.
-
+
- {{input value=batchSize type="number" class="form-control"}} + {{input value=batchSize type="number" min=0 max=100 class="form-control"}} + + {{v-get this 'batchSize' 'message'}} +
-
+
{{input value=studyId type="text" class="form-control"}} + + {{v-get this 'studyId' 'message'}} +
-{{#if invalidStudyId}} - {{#bs-modal title="Study ID Required" closedAction=(action 'toggleInvalidStudyId')}} - {{#bs-modal-body}}Please enter a Study ID for this batch of participants.{{/bs-modal-body}} - {{/bs-modal}} -{{/if}} - {{#if invalidFieldName}} - {{#bs-modal title="Invalid Field Name" closedAction=(action 'toggleInvalidFieldName')}} - {{#bs-modal-body}}A field with this name already exists.{{/bs-modal-body}} - {{/bs-modal}} + {{#bs-modal title="Invalid Field Name" closedAction=(action 'toggleInvalidFieldName')}} + {{#bs-modal-body}}A field with this name already exists.{{/bs-modal-body}} + {{/bs-modal}} {{/if}} diff --git a/app/components/participant-data.js b/app/components/participant-data/component.js similarity index 59% rename from app/components/participant-data.js rename to app/components/participant-data/component.js index af6aaed..cef5914 100644 --- a/app/components/participant-data.js +++ b/app/components/participant-data/component.js @@ -6,20 +6,7 @@ export default Ember.Component.extend({ sortBy: 'profileId', reverse: false, mappingFunction: null, - sortedSessions: Ember.computed('sortBy', 'reverse', { - get() { - var reverse = this.get('reverse'); - var sortBy = this.get('sortBy'); - if (reverse) { - return this.get('sessions').sortBy(sortBy).reverse(); - } - return this.get('sessions').sortBy(sortBy); - }, - set(_, value) { - this.set('sortedSessions', value); - return value; - } - }), + actions: { updateData: function(session) { this.set('participantSession', [session]); @@ -34,6 +21,9 @@ export default Ember.Component.extend({ this.set('reverse', false); } this.set('sortBy', sortBy); + + const rawField = sortBy === 'modifiedOn' ? 'modified_on' : sortBy; + this.sendAction('changeSort', rawField, this.get('reverse')); } } }); diff --git a/app/components/participant-data/template.hbs b/app/components/participant-data/template.hbs new file mode 100644 index 0000000..46d08b3 --- /dev/null +++ b/app/components/participant-data/template.hbs @@ -0,0 +1,21 @@ +
+ + + + + + + + + {{#each sessions as |session|}} + + + + + {{/each}} + +
Participant IDDate
{{session.anonProfileId}}{{moment-format session.modifiedOn 'MM/DD/YYYY'}}
+
+
+ {{export-tool data=participantSession mappingFunction=mappingFunction}} +
diff --git a/app/components/participant-info.js b/app/components/participant-info.js deleted file mode 100644 index 1a0fbd6..0000000 --- a/app/components/participant-info.js +++ /dev/null @@ -1,13 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Component.extend({ - isEditing: false, - actions: { - edit() { - this.set('isEditing', true); - }, - stopEditing() { - this.set('isEditing', false); - } - } -}); diff --git a/app/components/permissions-editor.js b/app/components/permissions-editor.js deleted file mode 100644 index bedde0f..0000000 --- a/app/components/permissions-editor.js +++ /dev/null @@ -1,74 +0,0 @@ -import Ember from 'ember'; -import {adminPattern} from '../utils/patterns'; - -// FIXME: Known bug in original- if the server save request fails, the value will appear to have been added until page reloaded. -// (need to catch and handle errors) -let PermissionsEditor = Ember.Component.extend({ - session: Ember.inject.service(), - tagName: 'table', - classNames: ['table'], - - warn: false, - removeTarget: null, - - newPermissionLevel: 'ADMIN', - newPermissionSelector: '', - - usersList: Ember.computed('permissions', function() { - var permissions = this.get('permissions'); - - // Assumption: all properties passed into this page will match admin pattern - return Object.getOwnPropertyNames(permissions).map(function(key){ - return adminPattern.exec(key)[1]; - }); - }), - - actions: { - addPermission() { - var userId = this.get('newUserId'); - var permissions = Ember.copy(this.get('permissions')); - permissions[`user-osf-${userId}`] = this.get('newPermissionLevel'); - this.set('newUserId', ''); - this.sendAction('onchange', permissions); - this.set('permissions', permissions); - this.rerender(); - }, - - removePermission(userId) { - var currentUserId = this.get('session.data.authenticated.id'); - if (userId === currentUserId) { - this.set('warn', true); - this.set('removeTarget', userId); - } - else { - this.send('_removePermission', userId); - } - }, - _removePermission(userId) { - userId = userId || this.get('removeTarget'); - - var selector = `user-osf-${userId}`; - var permissions = Ember.copy(this.get('permissions')); - - delete permissions[selector]; - this.sendAction('onchange', permissions); - this.set('permissions', permissions); - - var currentUserId = this.get('session.data.authenticated.id'); - if (userId === currentUserId) { - this.get('session').invalidate(); - window.location.reload(); - } - else { - this.rerender(); - } - } - } -}); - -PermissionsEditor.reopenClass({ - positionalParams: ['permissions'] -}); - - -export default PermissionsEditor; diff --git a/app/components/permissions-editor/component.js b/app/components/permissions-editor/component.js new file mode 100644 index 0000000..c90648d --- /dev/null +++ b/app/components/permissions-editor/component.js @@ -0,0 +1,96 @@ +import Ember from 'ember'; +import {adminPattern, makeUserPattern} from '../../utils/patterns'; + +// FIXME: Known bug in original- if the server save request fails, the value will appear to have been added until page reloaded. +// (need to catch and handle errors) + +/** + * Display all users that match a provided user type, and allow adding/removing of new user IDs + * + * Sample usage: + * ```handlebars + * {{permissions-editor + * userPermissions + * displayFilterPattern=accountPattern + * newPermissionLevel=permissionsLevel + * changePermissions=(action 'changePermissions')}} + * ``` + * + * @class permissions-editor + */ +let PermissionsEditor = Ember.Component.extend({ + session: Ember.inject.service(), + tagName: 'table', + classNames: ['table'], + + warn: false, + removeTarget: null, + + newPermissionLevel: 'ADMIN', + + /** + * @property {String} displayFilterPattern Filter the list of known permissions to only those that match the + * specified pattern. Can be used to restrict to OSF users, Jam users associated with a collection, etc. + * This pattern is also used to *add* new users of the specified type. + * + */ + displayFilterPattern: adminPattern, + + // Filter permissions to those that match the provided pattern, and extract usernames for display + usersList: Ember.computed('displayFilterPattern', 'permissions', function() { + const permissions = this.get('permissions'); + const pattern = makeUserPattern(this.get('displayFilterPattern')); + return Object.keys(permissions).map((key) => { + const match = pattern.exec(key); + return match ? match[1] : null; + }).filter(match => !!match + ).sort(); + }), + + actions: { + addPermission() { + const userId = this.get('newUserId'); + let permissions = Ember.copy(this.get('permissions'), true); + permissions[`${this.get('displayFilterPattern')}-${userId}`] = this.get('newPermissionLevel'); + this.set('newUserId', ''); + + this.sendAction('changePermissions', permissions); + this.set('permissions', permissions); + this.rerender(); + }, + + removePermission(userId) { + const currentUserId = this.get('session.data.authenticated.id'); + if (userId === currentUserId) { + this.set('warn', true); + this.set('removeTarget', userId); + } else { + this.send('_removePermission', userId); + } + }, + _removePermission(userId) { + userId = userId || this.get('removeTarget'); + + const selector = `${this.get('displayFilterPattern')}-${userId}`; + const permissions = Ember.copy(this.get('permissions')); + + delete permissions[selector]; + this.sendAction('changePermissions', permissions); + this.set('permissions', permissions); + const currentUserId = this.get('session.data.authenticated.id'); + if (userId === currentUserId) { + this.get('session').invalidate(); + window.location.reload(); + } else { + // TODO: Is rerender necessary? + this.rerender(); + } + } + } +}); + +PermissionsEditor.reopenClass({ + positionalParams: ['permissions'] +}); + +export default PermissionsEditor; diff --git a/app/components/permissions-editor/template.hbs b/app/components/permissions-editor/template.hbs new file mode 100644 index 0000000..0516e97 --- /dev/null +++ b/app/components/permissions-editor/template.hbs @@ -0,0 +1,43 @@ + + + User Selector + Add / remove + + + + {{#each usersList as |userId|}} + + {{userId}} + + + + + {{/each}} + + + {{input value=newUserId type="text" class="form-control"}} + + + + + + +{{#bs-modal open=warn title='Are you sure?' footer=false}} + {{#bs-modal-body}} + You are about to remove your own user id from the whitelist. Doing so will immediately log you out and revoke your access to this site. This action is irreversible. + {{/bs-modal-body}} + {{#bs-modal-footer as |footer|}} +
+ {{#bs-button type="default" action=(action 'close' target=footer) class="permissions-editor-btn"}} + Cancel + {{/bs-button}} + {{#bs-button type="danger" action=(action '_removePermission') class="permissions-editor-btn"}} + Confirm + {{/bs-button}} +
+ {{/bs-modal-footer}} +{{/bs-modal}} \ No newline at end of file diff --git a/app/const.js b/app/const.js index ffc0616..5e62db7 100644 --- a/app/const.js +++ b/app/const.js @@ -3,61 +3,61 @@ var JAM_ID_PATTERN = '\\w+'; var PROFILE_ID_PATTERN = '\\w+\\.\\w+'; export const SESSIONSCHEMA = { - 'type': 'jsonschema', - 'schema': { - 'type': 'object', - 'properties': { - 'completed': { - 'type': 'boolean' + type: 'jsonschema', + schema: { + type: 'object', + properties: { + completed: { + type: 'boolean' }, - 'profileId': { - 'type': 'string', - 'pattern': PROFILE_ID_PATTERN + profileId: { + type: 'string', + pattern: PROFILE_ID_PATTERN }, - 'experimentId': { - 'type': 'string', - 'pattern': JAM_ID_PATTERN + experimentId: { + type: 'string', + pattern: JAM_ID_PATTERN }, - 'experimentVersion': { - 'type': 'string' + experimentVersion: { + type: 'string' }, - 'sequence': { - 'type': 'array', - 'items': { - 'type': 'string' + sequence: { + type: 'array', + items: { + type: 'string' } }, - 'expData': { - 'type': 'object' + expData: { + type: 'object' }, - 'feedback': { - '$oneOf': [{ - 'type': 'string' + feedback: { + $oneOf: [{ + type: 'string' }, null] }, - 'hasReadFeedback': { - '$oneOf': [{ - 'type': 'boolean' + hasReadFeedback: { + $oneOf: [{ + type: 'boolean' }, null] }, - 'earlyExit': { - '$oneOf': [{ - 'type': 'object' + earlyExit: { + $oneOf: [{ + type: 'object' }, null], - 'properties': { - 'reason': { - '$oneOf': [{ - 'type': 'string' + properties: { + reason: { + $oneOf: [{ + type: 'string' }, null] }, - 'privacy': { - 'type': 'string' + privacy: { + type: 'string' } } } }, - 'required': [ + required: [ 'profileId', 'experimentId', 'experimentVersion', diff --git a/app/controllers/application.js b/app/controllers/application.js index eac2af9..4fadfc9 100644 --- a/app/controllers/application.js +++ b/app/controllers/application.js @@ -3,24 +3,24 @@ import Ember from 'ember'; export default Ember.Controller.extend({ session: Ember.inject.service(), isExpanded: true, - isNotLogin: Ember.computed('currentPath', function() { + isNotLogin: Ember.computed('currentPath', function () { return this.get('currentPath') !== 'login'; }), - sizeContainer: function() { + sizeContainer: function () { var winWidth = Ember.$(window).width(); if (winWidth < 992 && this.isExpanded) { this.send('toggleMenu'); } }, - attachResizeListener : function () { + attachResizeListener: function () { Ember.$(window).on('resize', Ember.run.bind(this, this.sizeContainer)); }.on('init'), actions: { - toggleMenu: function() { + toggleMenu: function () { this.toggleProperty('isExpanded'); }, - invalidateSession: function() { + invalidateSession: function () { return this.get('session').invalidate().then(() => { window.location.reload(true); }); diff --git a/app/controllers/experiments/info/edit.js b/app/controllers/experiments/info/edit.js index e34fcf6..b0bd409 100644 --- a/app/controllers/experiments/info/edit.js +++ b/app/controllers/experiments/info/edit.js @@ -27,7 +27,7 @@ function createSchema(container, sequence, frames) { }); } else { setProperty(schema, frameId, null, { - '$oneOf': [ + $oneOf: [ 'object', 'string', 'number', @@ -43,7 +43,6 @@ function createSchema(container, sequence, frames) { return schema; } - export default Ember.Controller.extend({ breadCrumb: 'Edit', toast: Ember.inject.service('toast'), diff --git a/app/controllers/experiments/info/results.js b/app/controllers/experiments/info/results.js index 8b4c6cc..a83c1e0 100644 --- a/app/controllers/experiments/info/results.js +++ b/app/controllers/experiments/info/results.js @@ -2,9 +2,7 @@ import Ember from 'ember'; export default Ember.Controller.extend({ breadCrumb: 'Responses', - experiment: null, - sessions: null, - sanitizeProfileId: function(session) { + sanitizeProfileId(session) { session.profileId = session.profileId.split('.').slice(-1)[0]; return session; } diff --git a/app/controllers/experiments/info/results/index.js b/app/controllers/experiments/info/results/index.js new file mode 100644 index 0000000..667e194 --- /dev/null +++ b/app/controllers/experiments/info/results/index.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +import PaginatedControllerMixin from '../../../../mixins/paginated-controller'; + +export default Ember.Controller.extend(PaginatedControllerMixin, { + page_size: 10, + + queryParams: ['sort'], + sort: '', + + totalPages: Ember.computed('model', function() { + return Math.ceil(this.get('model.meta.total') / this.get('page_size')); + }), + + actions: { + /** + * Change the sort direction (and adapt field names from ember-data to raw queries) + * + * @param {String} field name + * @param reverse + */ + changeSort(field, reverse) { + const direction = reverse ? '-' : '+'; + this.set('sort', direction + field); + } + } +}); diff --git a/app/controllers/experiments/list.js b/app/controllers/experiments/list.js index 576bd64..6c9a542 100644 --- a/app/controllers/experiments/list.js +++ b/app/controllers/experiments/list.js @@ -15,18 +15,16 @@ export default Ember.Controller.extend({ var sort = this.get('sort'); if (sort) { return sort.replace(DESC, ''); - } - else { + } else { return null; } }, - set (_, value) { + set(_, value) { var sort = this.get('sort'); if (sort) { var sign = sort.indexOf(DESC) === 0 ? DESC : ASC; this.set('sort', `${sign}${value}`); - } - else { + } else { this.set('sort', `${ASC}${value}`); } return value; @@ -37,24 +35,22 @@ export default Ember.Controller.extend({ var sort = this.get('sort'); if (sort) { return sort.indexOf(DESC) === 0 ? DESC : ASC; - } - else { + } else { return null; } }, - set (_, value) { + set(_, value) { var sort = this.get('sort'); if (sort) { var prop = sort.replace(DESC, ''); this.set('sort', `${value}${prop}`); - } - else { + } else { this.set('sort', `${value}title`); } return value; } }), - toggleOrder: function(order) { + toggleOrder: function (order) { if (order === ASC) { this.set('sortOrder', DESC); } else { @@ -63,12 +59,12 @@ export default Ember.Controller.extend({ }, activeButtons: ['Active', 'Draft', 'Archived', 'All'], actions: { - selectStatusFilter: function(status) { + selectStatusFilter(status) { this.set('state', status); this.set('sortProperty', 'title'); this.set('sortOrder', ASC); }, - sortingMethod: function(sortProperty) { + sortingMethod(sortProperty) { if (Ember.isEqual(this.get('sortProperty'), sortProperty)) { this.toggleOrder(this.get('sortOrder')); } else { @@ -76,21 +72,21 @@ export default Ember.Controller.extend({ } this.set('sortProperty', sortProperty); }, - resetParams: function() { + resetParams() { this.set('state', null); this.set('match', null); this.set('sortProperty', 'title'); this.set('sortOrder', ASC); }, - updateSearch: function(value) { + updateSearch(value) { this.set('match', `${value}`); this.set('sortProperty', null); }, - toggleModal: function() { + toggleModal() { this.set('newTitle', ''); this.toggleProperty('isShowingModal'); }, - createExperiment: function() { + createExperiment() { var newExperiment = this.store.createRecord('experiment', { // should work after split bug is fixed and schema validation handles null values // for structure, beginDate, endDate, and eligibilityCriteria @@ -112,8 +108,7 @@ export default Ember.Controller.extend({ newExperiment.on('didCreate', () => { if (newExperiment.get('_sessionCollection.isNew')) { newExperiment.get('_sessionCollection').on('didCreate', onCreateSessionCollection); - } - else { + } else { onCreateSessionCollection(); } }); diff --git a/app/controllers/participants/profile.js b/app/controllers/participants/profile.js index 0046a9b..7ed5e66 100644 --- a/app/controllers/participants/profile.js +++ b/app/controllers/participants/profile.js @@ -3,7 +3,7 @@ import Ember from 'ember'; export default Ember.Controller.extend({ // Manually specify column format options for model table columns: [ - {propertyName: 'experimentId', title:'Experiment ID'}, // TODO: Would prefer experiment name or ID here? + {propertyName: 'experimentId', title: 'Experiment ID'}, // TODO: Would prefer experiment name or ID here? {propertyName: 'experimentVersion'}, {propertyName: 'modifiedOn', title: 'Last active'}, // TODO: Is this the correct field to use here? ], diff --git a/app/controllers/project-settings.js b/app/controllers/project-settings.js index 64ccfb8..bc9f7fe 100644 --- a/app/controllers/project-settings.js +++ b/app/controllers/project-settings.js @@ -4,41 +4,47 @@ import {adminPattern} from '../utils/patterns'; import config from 'ember-get-config'; export default Ember.Controller.extend({ + + queryParams: ['collection'], + collection: '', + + namespaceConfig: Ember.inject.service(), + namespaceId: Ember.computed.alias('namespaceConfig.namespace'), // Namespace is apparently a reserved word. + breadCrumb: 'Project configuration', osfURL: config.OSF.url, - filteredPermissions: Ember.computed('model', function() { - var permissions = this.get('model.permissions'); - var users = {}; - var system = {}; - for (let k of Object.getOwnPropertyNames(permissions)) { - var dest = adminPattern.test(k) ? users : system; - dest[k] = permissions[k]; + // List of collection names for which we will allow the user to add read-only Jam-authenticated users + availableCollections: ['accounts'], + + // Define the username format, eg Jam-authenticated users vs OSF provider + userPattern: Ember.computed('namespaceId', 'collection', function () { + const collection = this.get('collection'); + if (collection) { + return `jam-${this.get('namespaceId')}:accounts`; } - return [users, system]; + return adminPattern; }), - /* Return permissions strings that correspond to admin users (those who log in via OSF) */ - _userPermissions: Ember.computed('filteredPermissions', function() { - var filtered = this.get('filteredPermissions'); - return filtered[0]; + // For now, we will only let users add ADMINS to the namespace (must be OSF users), and only grant READ-ONLY access + // to collections (where the new users will be Jam authenticated users) + permissionsLevel: Ember.computed('collection', function () { + if (this.get('collection')) { + return 'READ'; + } + return 'ADMIN'; }), - userPermissions: Ember.computed.readOnly('_userPermissions'), // TODO: is this necessary? - /* Return permissions strings that do not match OSF users */ - systemPermissions: Ember.computed('filteredPermissions', function() { - var filtered = this.get('filteredPermissions'); - return filtered[1]; - }), + permissions: Ember.computed.readOnly('model.permissions'), actions: { - permissionsUpdated(permissions) { + changePermissions(permissions) { // Save updated permissions, and avoid overwriting system-level permissions not displayed to the user - - var payload = Object.assign({}, permissions, this.get('systemPermissions')); - this.set('model.permissions', payload); - this.get('model').save(); + let model = this.get('model'); + let payload = Ember.copy(permissions, true); + model.set('permissions', payload); + model.save(); } } }); diff --git a/app/helpers/increment.js b/app/helpers/increment.js index 486c9c6..7c45c66 100644 --- a/app/helpers/increment.js +++ b/app/helpers/increment.js @@ -1,7 +1,7 @@ import Ember from 'ember'; -export function routeMatches([ val, inc ] /*, hash*/) { - return parseInt(val) + parseInt(inc); +export function routeMatches([val, inc] /*, hash*/) { + return parseInt(val) + parseInt(inc); } export default Ember.Helper.helper(routeMatches); diff --git a/app/helpers/route-matches.js b/app/helpers/route-matches.js index 3308fe4..e1f6731 100644 --- a/app/helpers/route-matches.js +++ b/app/helpers/route-matches.js @@ -1,12 +1,8 @@ import Ember from 'ember'; -const { - isPresent -} = Ember; - -export function routeMatches([ currentRouteName, target ] /*, hash*/) { - let result = currentRouteName.match(new RegExp(`^${target}`)); - return isPresent(result); +export function routeMatches([currentRouteName, target] /*, hash*/) { + let result = currentRouteName.match(new RegExp(`^${target}`)); + return Ember.isPresent(result); } export default Ember.Helper.helper(routeMatches); diff --git a/app/helpers/trim.js b/app/helpers/trim.js index cf6d30d..a7102e0 100644 --- a/app/helpers/trim.js +++ b/app/helpers/trim.js @@ -1,6 +1,6 @@ import Ember from 'ember'; -export function trim(params /*, hash*/ ) { +export function trim(params /*, hash*/) { return (params[0] || '').toString().trim(); } diff --git a/app/mixins/paginated-controller.js b/app/mixins/paginated-controller.js new file mode 100644 index 0000000..2f53a6a --- /dev/null +++ b/app/mixins/paginated-controller.js @@ -0,0 +1,41 @@ +import Ember from 'ember'; + +/** + * @module experimenter + * @submodule mixins + */ + +/** + * Controller mixin to support fetching paginated results. (borrowed from ember-osf) + * + * Because this uses query parameters, it should be used in tandem with PaginatedRouteMixin + * + * @class PaginatedControllerMixin + * @extends Ember.Mixin + */ +export default Ember.Mixin.create({ + queryParams: ['page', 'page_size'], + page: 1, // Current page + page_size: null, // Number of results per page. Param may be omitted. + + totalResults: Ember.computed('model', function() { + return this.get('model.meta.pagination.total'); + }), + totalPages: Ember.computed('model', 'totalResults', function() { + let results = this.get('totalResults'); + let pageSize = this.get('model.meta.pagination.per_page'); + return Math.ceil(results / pageSize); + }), + + actions: { + previous() { + this.decrementProperty('page', 1); + }, + next() { + this.incrementProperty('page', 1); + }, + goToPage(pageNumber) { + this.set('page', pageNumber); + } + } +}); diff --git a/app/mixins/paginated-route.js b/app/mixins/paginated-route.js new file mode 100644 index 0000000..01f8409 --- /dev/null +++ b/app/mixins/paginated-route.js @@ -0,0 +1,78 @@ +import Ember from 'ember'; + +/** + * @module experimenter + * @submodule mixins + */ + +/** + * Route mixin to support fetching paginated results. (borrowed from ember-osf) + * + * Because this uses query parameters, it should be used in tandem with PaginatedControllerMixin + * + * @class PaginatedRouteMixin + * @extends Ember.Mixin + */ +export default Ember.Mixin.create({ + // When page numbers are updated, fetch the new results from the server + queryParams: { + page: { + refreshModel: true + }, + page_size: { + refreshModel: true + } + }, + + /** + * Allow configuration of the backend URL parameter used for page # + * @property pageParam + * @type String + * @default "page" + */ + pageParam: 'page', + + /** + * Allow configuration of the backend URL parameter for number of results per page + * @property perPageParam + * @type String + * @default "page[size]" + */ + perPageParam: 'page[size]', + + /** + * Fetch a route-specified page of results from an external API + * + * To use this argument, pass the params from the model hook as the first argument. + * ```javascript + * model(routeParams) { + * return this.queryForPage('user', routeParams); + * } + * ``` + * + * @method queryForPage + * @param modelName The name of the model to query in the store + * @param routeParams Parameters dictionary available to the model hook; must be passed in manually + * @param userParams Additional user-specified query parameters to further customize the query + * @return {Promise} + */ + queryForPage(modelName, routeParams, userParams) { + userParams = userParams || {}; + let params = Object.assign({}, userParams || {}, routeParams); + + // TODO: Are routeParams necessary? + // Rename the ember-route URL params to what the backend API expects, and remove the old param if necessary + const page = params.page; + delete params.page; + + const pageSize = params.page_size; + delete params.page_size; + if (page) { + params[this.get('pageParam')] = page; + } + if (pageSize) { + params[this.get('perPageParam')] = pageSize; + } + return this.store.query(modelName, params); + } +}); diff --git a/app/router.js b/app/router.js index b3a4f64..fb6812b 100644 --- a/app/router.js +++ b/app/router.js @@ -2,45 +2,37 @@ import Ember from 'ember'; import config from './config/environment'; const Router = Ember.Router.extend({ - location: config.locationType, - rootURL: config.rootURL + location: config.locationType, + rootURL: config.rootURL }); -Router.map(function() { +Router.map(function () { this.route('index', { path: '/' }); this.route('login'); - this.route('errors', function() { + this.route('errors', function () { this.route('generic'); }); - this.route('experiments', function() { + this.route('experiments', function () { this.route('list', { path: '/' }); this.route('info', { path: '/:experiment_id' - }, function() { - this.route('index', { - path: '/' + }, function () { + this.route('edit'); + this.route('results', function () { + this.route('all'); }); - this.route('edit', { - path: '/edit/' - }); - this.route('results', { - path: '/results/' - }); - this.route('preview', { - path: '/preview/' - }); - + this.route('preview'); }); }); - this.route('participants', function() { + this.route('participants', function () { this.route('profile', { path: ':profile_id/' }); diff --git a/app/routes/application.js b/app/routes/application.js index 42713c5..e831d6f 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -2,17 +2,17 @@ import Em from 'ember'; import ENV from 'experimenter/config/environment'; export default Em.Route.extend({ - toast: Em.inject.service(), + toast: Em.inject.service(), - actions: { - error (err) { - if (ENV.environment !== 'development') { - this.get('toast').error(err.message, err.name); - this.transitionTo('errors.generic'); - return false; - } else { - return true; + actions: { + error(err) { + if (ENV.environment !== 'development') { + this.get('toast').error(err.message, err.name); + this.transitionTo('errors.generic'); + return false; + } else { + return true; + } } } - } }); diff --git a/app/routes/creator.js b/app/routes/creator.js deleted file mode 100644 index 30e4e30..0000000 --- a/app/routes/creator.js +++ /dev/null @@ -1,5 +0,0 @@ -import Ember from 'ember'; -import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; - -export default Ember.Route.extend(AuthenticatedRouteMixin, { -}); diff --git a/app/routes/experiments/info.js b/app/routes/experiments/info.js index 7835564..b452f8c 100644 --- a/app/routes/experiments/info.js +++ b/app/routes/experiments/info.js @@ -1,7 +1,6 @@ import Ember from 'ember'; import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; - export default Ember.Route.extend(AuthenticatedRouteMixin, { model(params) { return this.store.find('experiment', params.experiment_id); diff --git a/app/routes/experiments/info/edit.js b/app/routes/experiments/info/edit.js index 8da292d..0d0535d 100644 --- a/app/routes/experiments/info/edit.js +++ b/app/routes/experiments/info/edit.js @@ -1,7 +1,6 @@ import Ember from 'ember'; import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; - export default Ember.Route.extend(AuthenticatedRouteMixin, { model() { var baseParams = this.paramsFor('experiments.info'); diff --git a/app/routes/experiments/info/preview.js b/app/routes/experiments/info/preview.js index f111187..22800f9 100644 --- a/app/routes/experiments/info/preview.js +++ b/app/routes/experiments/info/preview.js @@ -14,7 +14,7 @@ export default Ember.Route.extend(AuthenticatedRouteMixin, WarnOnExitRouteMixin, }, _getSession(params, experiment) { return this._super(params, experiment).then((session) => { - var route = this; + var _this = this; session.setProperties({ id: 'PREVIEW_DATA_DISREGARD' @@ -29,11 +29,10 @@ export default Ember.Route.extend(AuthenticatedRouteMixin, WarnOnExitRouteMixin, controller.showPreviewData(this).then(() => { // Override the WarnOnExitMixin's behavior controller.set('forceExit', true); - return route.transitionTo('experiments.info'); + return _this.transitionTo('experiments.info'); }); return Ember.RSVP.reject(); - } - else { + } else { return Ember.RSVP.resolve(this); } } diff --git a/app/routes/experiments/info/results.js b/app/routes/experiments/info/results.js index b75ca8f..8874704 100644 --- a/app/routes/experiments/info/results.js +++ b/app/routes/experiments/info/results.js @@ -1,14 +1,4 @@ import Ember from 'ember'; import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; - -export default Ember.Route.extend(AuthenticatedRouteMixin, { - model() { - let experiment = this.modelFor('experiments/info'); - return this.store.query(experiment.get('sessionCollectionId'), - { - 'filter[completed]': 1, - 'page[size]':100 - }); - } -}); +export default Ember.Route.extend(AuthenticatedRouteMixin, {}); diff --git a/app/routes/experiments/info/results/all.js b/app/routes/experiments/info/results/all.js new file mode 100644 index 0000000..7edb90f --- /dev/null +++ b/app/routes/experiments/info/results/all.js @@ -0,0 +1,43 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + /** + * + * @param {String} collectionName Name of the collection to query + * @param {Array} dest An array to be used for storing the combined results of all requests + * @param {Number} page The current page number to fetch + * @returns {Promise} A (chained) promise that will resolve to dest when all records have been fetched + * @private + */ + _fetchResults(collectionName, dest, page) { + const options = { + 'filter[completed]': 1, + 'page[size]': 100, + page: page + }; + return this.store.query(collectionName, options).then(res => { + const theseResults = res.toArray(); + dest.push(...theseResults); + // TODO: This is an imperfect means of identifying the last page, but JamDB doesn't tell us directly + if (theseResults.length !== 0 && dest.length < res.get('meta.total')) { + return this._fetchResults(collectionName, dest, page + 1); + } else { + return dest; + } + }); + }, + + model() { + const collectionName = this.modelFor('experiments.info').get('sessionCollectionId'); + const results = Ember.A(); + return this._fetchResults(collectionName, results, 1); + }, + + setupController(controller) { + // Small hack to reuse code + const sanitizeProfileId = this.controllerFor('experiments.info.results').get('sanitizeProfileId'); + controller.set('sanitizeProfileId', sanitizeProfileId); + + return this._super(...arguments); + } +}); diff --git a/app/routes/experiments/info/results/index.js b/app/routes/experiments/info/results/index.js new file mode 100644 index 0000000..58b4d83 --- /dev/null +++ b/app/routes/experiments/info/results/index.js @@ -0,0 +1,25 @@ +import Ember from 'ember'; +import PaginatedRouteMixin from '../../../../mixins/paginated-route'; + +export default Ember.Route.extend(PaginatedRouteMixin, { + queryParams: { + sort: { + refreshModel: true + } + }, + + model(params) { + const experiment = this.modelFor('experiments.info'); + return this.queryForPage(experiment.get('sessionCollectionId'), params, { + 'filter[completed]': 1, + }); + }, + + setupController(controller) { + // Small hack to reuse code + const sanitizeProfileId = this.controllerFor('experiments.info.results').get('sanitizeProfileId'); + controller.set('sanitizeProfileId', sanitizeProfileId); + + return this._super(...arguments); + } +}); diff --git a/app/routes/participants/index.js b/app/routes/participants/index.js index 29a2ed5..8874704 100644 --- a/app/routes/participants/index.js +++ b/app/routes/participants/index.js @@ -1,8 +1,4 @@ import Ember from 'ember'; import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; -export default Ember.Route.extend(AuthenticatedRouteMixin, { - model() { - return this.store.findAll('account'); - } -}); +export default Ember.Route.extend(AuthenticatedRouteMixin, {}); diff --git a/app/routes/participants/profile.js b/app/routes/participants/profile.js index 31344d0..82b48bb 100644 --- a/app/routes/participants/profile.js +++ b/app/routes/participants/profile.js @@ -5,16 +5,18 @@ export default Ember.Route.extend(AuthenticatedRouteMixin, { model(params) { return Ember.RSVP.hash( { - 'account': this.store.query('account', {filter: {'profiles.profileId': params.profile_id}}).then(function(items) { + account: this.store.query('account', { + filter: { 'profiles.profileId': params.profile_id } + }).then(function (items) { // Turn query into a single result return items.toArray()[0]; }), // TODO: Finding profile requires globally unique profile IDs- format .profileId - 'sessions': this.store.query('session', // TODO: Move this page under experiment so that it can query the correct session-bucket for a given experiment - {filter: {profileId: params.profile_id}}), - }).then(function(modelHash) { - // Extract profile from account and add to hash - modelHash.profile = modelHash.account.profileById(params.profile_id); - return modelHash; - }); + sessions: this.store.query('session', // TODO: Move this page under experiment so that it can query the correct session-bucket for a given experiment + { filter: { profileId: params.profile_id } }), + }).then(function (modelHash) { + // Extract profile from account and add to hash + modelHash.profile = modelHash.account.profileById(params.profile_id); + return modelHash; + }); } }); diff --git a/app/routes/project-settings.js b/app/routes/project-settings.js index 0bff4f8..26a7f91 100644 --- a/app/routes/project-settings.js +++ b/app/routes/project-settings.js @@ -3,7 +3,23 @@ import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-rout export default Ember.Route.extend(AuthenticatedRouteMixin, { namespaceConfig: Ember.inject.service(), - model() { - return this.store.find('namespace', this.get('namespaceConfig').get('namespace')); + + queryParams: { + collection: { + refreshModel: true + } + }, + + model(params) { + if (params.collection) { + return this.store.findRecord('collection', params.collection); + } + return this.store.findRecord('namespace', this.get('namespaceConfig').get('namespace')); + }, + + resetController(controller, isExiting) { + if (isExiting) { + controller.set('collection', ''); + } } }); diff --git a/app/routes/settings.js b/app/routes/settings.js deleted file mode 100644 index 7e43d80..0000000 --- a/app/routes/settings.js +++ /dev/null @@ -1,16 +0,0 @@ -import Ember from 'ember'; -import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; - -export default Ember.Route.extend(AuthenticatedRouteMixin, { - model() { - return [ - { - 'firstName': 'Sam', - 'username': 'sam@cos.io', - 'experiments': ['test'], - 'lastName': 'Chrisinger', - 'password': '$2b$12$iujjM4DtPMWVL1B2roWjBeHzjzxaNEP8HbXxdZwRha/j5Pc8E1n2G' - }, - ]; - } -}); diff --git a/app/styles/app.scss b/app/styles/app.scss index 9197436..f5a4d1d 100644 --- a/app/styles/app.scss +++ b/app/styles/app.scss @@ -3,13 +3,13 @@ $small-width: 400px; $background-dark: #606060; $background-light: rgba(96, 96, 96, 0.15); +@import "components/ace-editor"; @import "components/experiment-detail"; @import "components/export-tool"; -@import "components/multi-toggle"; @import "components/img-selector"; -@import "components/ace-editor"; -@import "components/permissions-editor"; +@import "components/multi-toggle"; @import "components/participant-creator"; +@import "components/permissions-editor"; @import "routes/experiments"; @import "routes/login"; diff --git a/app/templates/application.hbs b/app/templates/application.hbs index 4244e24..527f25f 100644 --- a/app/templates/application.hbs +++ b/app/templates/application.hbs @@ -3,23 +3,34 @@
- {{multi-toggle buttons=activeButtons activeButton=state onSelect=(action 'selectStatusFilter')}} + {{multi-toggle buttons=activeButtons activeButton=state onSelect=(action 'selectStatusFilter')}}
-
- Title -
-
- Begin Date +
+ Title
-
- End Date +
+ Begin Date +
+
+ End Date

- {{#unless model.isPending}} - {{#each model as |experiment|}} - {{experiment-summary experiment=experiment}} - {{/each}} - {{else}} - LOADING - {{/unless}} + {{#unless model.isPending}} + {{#each model as |experiment|}} + {{experiment-summary experiment=experiment}} + {{/each}} + {{else}} + LOADING + {{/unless}}
diff --git a/app/templates/login.hbs b/app/templates/login.hbs index 1e0b18b..13fa115 100644 --- a/app/templates/login.hbs +++ b/app/templates/login.hbs @@ -1,32 +1,32 @@
{{#if (not session.isAuthenticated)}} -
+
-
+
-
+ Login via OSF to Begin + +
-
+
{{else}} -
-
-
-
- - -
-
- -
-
-
-
+
+
+
+
+ + +
+
+ +
+
+
+
{{/if}}
diff --git a/app/templates/participants/profile.hbs b/app/templates/participants/profile.hbs index 4ada288..75e6755 100644 --- a/app/templates/participants/profile.hbs +++ b/app/templates/participants/profile.hbs @@ -1,4 +1,4 @@ - +{{!-- TODO: Add breadcrumb to parent --}}

{{model.profile.firstName}} {{model.profile.lastName}}

diff --git a/app/templates/project-settings.hbs b/app/templates/project-settings.hbs index 8e66231..8cf6bdf 100644 --- a/app/templates/project-settings.hbs +++ b/app/templates/project-settings.hbs @@ -1,23 +1,54 @@

Project Settings

- +{{!--TODO: Add permissions widget that shows permission and optionally updates entire list --}} -

Add administrator to project

+ +

Permissions / access controls

+

+ Add user to: + +

-
-

- This interface allows you to specify which OSF accounts are allowed to access this site. To get the id for an OSF user, visit the OSF search page, search for the user you are looking for by name, and click the link that corresponds with that person. From that page, look in the URL and grab the 5-6 character id from that url; e.g. for {{concat osfURL '/ab34rt/'}} the user id is 'ab34rt'. -
-
- Alternatively, ask that user to log into the OSF and visit his or her profile page and to copy the user id from the 'Public Profile' link. -

-
+
+

+ + {{#if collection}} + This interface allows you to allow {{permissionsLevel}}-level access to the {{collection}} collection + of documents, for a specified user. Unlike admin-level access, these permissions do not require an + admin account- they can be granted to a specific website user, such as a study participant. If + you log in with username "officecat", that is the value you should enter under user selector. +
+ Grant these powers sparingly, as the specified user will be able to see all records of the specified type. + {{else}} + This interface allows you to specify which OSF accounts are allowed to access this site. To get the + id for an OSF user, visit the OSF search page, + search for the user you are looking for by name, and click the link that corresponds with that person. + From that page, look in the URL and grab the 5-6 character id from that url; e.g. for + {{concat osfURL '/ab34rt/'}} the user id is 'ab34rt'. +
+
+ Alternatively, ask that user to log into the OSF and visit his or her + profile page + and to copy the user id from the 'Public Profile' link. + {{/if}} + +

+

-
-

Enter the permissions string for the desired user, one per line.

- {{permissions-editor userPermissions onchange=(action 'permissionsUpdated')}} -
+
+

Enter the user id in the format specified above, one per line.

+ {{permissions-editor + permissions + displayFilterPattern=userPattern + newPermissionLevel=permissionsLevel + changePermissions=(action 'changePermissions')}} +
{{outlet}} diff --git a/app/templates/settings.hbs b/app/templates/settings.hbs deleted file mode 100644 index 2a769e2..0000000 --- a/app/templates/settings.hbs +++ /dev/null @@ -1,16 +0,0 @@ -

Profile Settings edit

- -
-
- {{#each model as |info|}} -
{{info.firstName}} {{info.lastName}}
- - Experiments: -
    - {{#each info.experiments as |experiment|}} -
  • {{experiment}}
  • - {{/each}} -
- {{/each}} -
-
diff --git a/app/templates/table-custom/profile-link.hbs b/app/templates/table-custom/profile-link.hbs deleted file mode 100644 index a2083cf..0000000 --- a/app/templates/table-custom/profile-link.hbs +++ /dev/null @@ -1,2 +0,0 @@ -{{#link-to "participants.profile" record.profileId}}View profile{{/link-to}} - \ No newline at end of file diff --git a/app/utils/patterns.js b/app/utils/patterns.js index c2d3408..fff6378 100644 --- a/app/utils/patterns.js +++ b/app/utils/patterns.js @@ -1,10 +1,19 @@ /* Regexes and string templates reused throughout the application -*/ - + */ // Admin users are those authenticated via OSF -var adminPattern = new RegExp(/^user-osf-(\w+|\*)$/); +const adminPattern = 'user-osf'; + +/** + * Create a regex object for matching users based on the specified external auth or Jam collection prefix. + * + * @param {String} prefix Eg 'user-osf' + * @return {RegExp} A new regex object that matches `prefix-abc`, `prefix.*`, or similar. + */ +function makeUserPattern(prefix) { + return new RegExp(`^${prefix}-([\\d\\w\-]{3,64}|\\*)$`); +} -export {adminPattern}; +export {adminPattern, makeUserPattern}; diff --git a/config/environment.js b/config/environment.js index c4a3233..6216251 100644 --- a/config/environment.js +++ b/config/environment.js @@ -31,19 +31,7 @@ module.exports = function (environment) { APP: {} }; - if (environment === 'development') { - ENV.JAMDB = { - url: process.env.JAMDB_URL || 'http://localhost:1212', - namespace: process.env.JAMDB_NAMESPACE, - authorizer: 'osf-jwt' - }; - } else if (environment === 'staging' || environment === 'production') { - ENV.JAMDB = { - url: process.env.JAMDB_URL, - namespace: process.env.JAMDB_NAMESPACE, - authorizer: 'osf-jwt' - }; - } else if (environment === 'test') { + if (environment === 'test') { ENV.JAMDB = { url: '', namespace: 'test', @@ -58,10 +46,13 @@ module.exports = function (environment) { ENV.APP.LOG_VIEW_LOOKUPS = false; ENV.APP.rootElement = '#ember-testing'; - } - - if (environment === 'production') { - + } else { + // Get environment-specific settings from .env file + ENV.JAMDB = { + url: process.env.JAMDB_URL, + namespace: process.env.JAMDB_NAMESPACE, + authorizer: 'osf-jwt' + }; } return ENV; diff --git a/ember-cli-build.js b/ember-cli-build.js index a6d11d7..13e2bac 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -28,7 +28,11 @@ module.exports = function(defaults) { }, 'ember-bootstrap': { importBootstrapFont: false - } + }, + babel: { + optional: ['es6.spec.symbols'], + includePolyfill: true + }, }); app.import('bower_components/ace-builds/src/ace.js'); @@ -71,8 +75,5 @@ module.exports = function(defaults) { // please specify an object with the list of modules as keys // along with the exports of each module as its value. - if (app.env === 'production') { - return require('broccoli-strip-debug')(app.toTree()); - } return app.toTree(); }; diff --git a/lib b/lib index 4b31235..ee2727b 160000 --- a/lib +++ b/lib @@ -1 +1 @@ -Subproject commit 4b312351b03d65c11cfddb3b734b27fb1f61f540 +Subproject commit ee2727b2d2d37e44d1a4e5a18b61e0f10344b57e diff --git a/package.json b/package.json index 3853345..675e8a4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "deploy": "node ./scripts/deploy.js", "build": "ember build", "start": "ember server", - "test": "ember test" + "test": "ember test", + "check-style": "./node_modules/jscs/bin/jscs ." }, "repository": "", "engines": { @@ -26,7 +27,6 @@ "broccoli-funnel": "^1.0.1", "broccoli-merge-trees": "^1.1.1", "broccoli-sass": "^0.7.0", - "broccoli-strip-debug": "^1.1.0", "ember-ajax": "^2.0.1", "ember-bootstrap": "^0.11.1", "ember-bootstrap-datetimepicker": "1.0.4", @@ -40,11 +40,13 @@ "ember-cli-htmlbars-inline-precompile": "^0.3.1", "ember-cli-inject-live-reload": "^1.4.0", "ember-cli-jshint": "^1.0.0", + "ember-cli-json-module": "0.0.3", "ember-cli-moment-shim": "1.3.0", "ember-cli-qunit": "^2.1.0", "ember-cli-release": "^0.2.9", "ember-cli-sentry": "2.4.2", "ember-cli-sri": "^2.1.0", + "ember-cli-template-lint": "0.5.0", "ember-cli-test-loader": "^1.1.0", "ember-cli-uglify": "^1.2.0", "ember-cp-validations": "2.9.7", @@ -68,10 +70,12 @@ "ember-welcome-page": "^1.0.1", "eonasdan-bootstrap-datetimepicker": "4.15.35", "eonasdan-bootstrap-datetimepicker-npm": "4.17.37", + "jscs": "^3.0.7", "jshint": "^2.9.1", "loader.js": "^4.0.1", "moment": "2.11.2", "moment-timezone": "0.5.0", + "pagination-pager": "2.4.2", "request": "^2.69.0", "request-promise": "^2.0.0", "rimraf": "^2.5.2", @@ -96,5 +100,37 @@ "dependencies": { "dotenv": "^2.0.0", "ember-radio-buttons": "^4.0.1" + }, + "jscsConfig": { + "preset": "airbnb", + "excludeFiles": [ + "package.json", + "bower.json", + "testem.js", + "ember-cli-build.js", + "config/environment.js", + "bower_components", + "node_modules", + "lib", + "dist", + "docs", + "examples", + "tmp", + "vendor", + "app/locales", + "tests/unit", + "tests/integration", + "tests/helpers" + ], + "requireSpacesInsideObjectBrackets": false, + "requireSpacesInsideImportedObjectBraces": false, + "requireSpacesInAnonymousFunctionExpression": false, + "requireTrailingComma": false, + "disallowTrailingComma": false, + "requirePaddingNewLinesAfterBlocks": false, + "validateIndentation": 4, + "requirePaddingNewLinesBeforeLineComments": false, + "maximumLineLength": false, + "disallowSpaceBeforeComma": false } } diff --git a/schemas/.gitkeep b/schemas/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index c196a5c..0000000 --- a/scripts/README.md +++ /dev/null @@ -1 +0,0 @@ -The scripts that used to live here are outdated. Please use https://github.com/samchrisinger/jam-setup instead. diff --git a/tests/fixtures/isp-survey.csv b/tests/fixtures/isp-survey.csv new file mode 100644 index 0000000..130b61f --- /dev/null +++ b/tests/fixtures/isp-survey.csv @@ -0,0 +1,2 @@ +PID,SID,locale,Age,Gender,Ethnicity,Language,SocialStatus,BirthCity,BirthCountry,Residence,Religion1to10,ReligionYesNo,ReligionFollow,EventTime,WhatResponse,WhereResponse,WhoResponse,ThreeCat_rsq1,ThreeCat_rsq2,ThreeCat_rsq3,ThreeCat_rsq4,ThreeCat_rsq5,ThreeCat_rsq6,ThreeCat_rsq7,ThreeCat_rsq8,ThreeCat_rsq9,ThreeCat_rsq10,ThreeCat_rsq11,ThreeCat_rsq12,ThreeCat_rsq13,ThreeCat_rsq14,ThreeCat_rsq15,ThreeCat_rsq16,ThreeCat_rsq17,ThreeCat_rsq18,ThreeCat_rsq19,ThreeCat_rsq20,ThreeCat_rsq21,ThreeCat_rsq22,ThreeCat_rsq23,ThreeCat_rsq24,ThreeCat_rsq25,ThreeCat_rsq26,ThreeCat_rsq27,ThreeCat_rsq28,ThreeCat_rsq29,ThreeCat_rsq30,ThreeCat_rsq31,ThreeCat_rsq32,ThreeCat_rsq33,ThreeCat_rsq34,ThreeCat_rsq35,ThreeCat_rsq36,ThreeCat_rsq37,ThreeCat_rsq38,ThreeCat_rsq39,ThreeCat_rsq40,ThreeCat_rsq41,ThreeCat_rsq42,ThreeCat_rsq43,ThreeCat_rsq44,ThreeCat_rsq45,ThreeCat_rsq46,ThreeCat_rsq47,ThreeCat_rsq48,ThreeCat_rsq49,ThreeCat_rsq50,ThreeCat_rsq51,ThreeCat_rsq52,ThreeCat_rsq53,ThreeCat_rsq54,ThreeCat_rsq55,ThreeCat_rsq56,ThreeCat_rsq57,ThreeCat_rsq58,ThreeCat_rsq59,ThreeCat_rsq60,ThreeCat_rsq61,ThreeCat_rsq62,ThreeCat_rsq63,ThreeCat_rsq64,ThreeCat_rsq65,ThreeCat_rsq66,ThreeCat_rsq67,ThreeCat_rsq68,ThreeCat_rsq69,ThreeCat_rsq70,ThreeCat_rsq71,ThreeCat_rsq72,ThreeCat_rsq73,ThreeCat_rsq74,ThreeCat_rsq75,ThreeCat_rsq76,ThreeCat_rsq77,ThreeCat_rsq78,ThreeCat_rsq79,ThreeCat_rsq80,ThreeCat_rsq81,ThreeCat_rsq82,ThreeCat_rsq83,ThreeCat_rsq84,ThreeCat_rsq85,ThreeCat_rsq86,ThreeCat_rsq87,ThreeCat_rsq88,ThreeCat_rsq89,ThreeCat_rsq90,NineCat_rsq1,NineCat_rsq2,NineCat_rsq3,NineCat_rsq4,NineCat_rsq5,NineCat_rsq6,NineCat_rsq7,NineCat_rsq8,NineCat_rsq9,NineCat_rsq10,NineCat_rsq11,NineCat_rsq12,NineCat_rsq13,NineCat_rsq14,NineCat_rsq15,NineCat_rsq16,NineCat_rsq17,NineCat_rsq18,NineCat_rsq19,NineCat_rsq20,NineCat_rsq21,NineCat_rsq22,NineCat_rsq23,NineCat_rsq24,NineCat_rsq25,NineCat_rsq26,NineCat_rsq27,NineCat_rsq28,NineCat_rsq29,NineCat_rsq30,NineCat_rsq31,NineCat_rsq32,NineCat_rsq33,NineCat_rsq34,NineCat_rsq35,NineCat_rsq36,NineCat_rsq37,NineCat_rsq38,NineCat_rsq39,NineCat_rsq40,NineCat_rsq41,NineCat_rsq42,NineCat_rsq43,NineCat_rsq44,NineCat_rsq45,NineCat_rsq46,NineCat_rsq47,NineCat_rsq48,NineCat_rsq49,NineCat_rsq50,NineCat_rsq51,NineCat_rsq52,NineCat_rsq53,NineCat_rsq54,NineCat_rsq55,NineCat_rsq56,NineCat_rsq57,NineCat_rsq58,NineCat_rsq59,NineCat_rsq60,NineCat_rsq61,NineCat_rsq62,NineCat_rsq63,NineCat_rsq64,NineCat_rsq65,NineCat_rsq66,NineCat_rsq67,NineCat_rsq68,NineCat_rsq69,NineCat_rsq70,NineCat_rsq71,NineCat_rsq72,NineCat_rsq73,NineCat_rsq74,NineCat_rsq75,NineCat_rsq76,NineCat_rsq77,NineCat_rsq78,NineCat_rsq79,NineCat_rsq80,NineCat_rsq81,NineCat_rsq82,NineCat_rsq83,NineCat_rsq84,NineCat_rsq85,NineCat_rsq86,NineCat_rsq87,NineCat_rsq88,NineCat_rsq89,NineCat_rsq90,PosNeg,SitSimilarity,BBI1,BBI2,BBI3,BBI4,BBI5,BBI6,BBI7,BBI8,BBI9,BBI10,BBI11,BBI12,BBI13,BBI14,BBI15,BBI16,Risk,BFI1,BFI2,BFI3,BFI4,BFI5,BFI6,BFI7,BFI8,BFI9,BFI10,BFI11,BFI12,BFI13,BFI14,BFI15,BFI16,BFI17,BFI18,BFI19,BFI20,BFI21,BFI22,BFI23,BFI24,BFI25,BFI26,BFI27,BFI28,BFI29,BFI30,BFI31,BFI32,BFI33,BFI34,BFI35,BFI36,BFI37,BFI38,BFI39,BFI40,BFI41,BFI42,BFI43,BFI44,BFI45,BFI46,BFI47,BFI48,BFI49,BFI50,BFI51,BFI52,BFI53,BFI54,BFI55,BFI56,BFI57,BFI58,BFI59,BFI60,SWB1,SWB2,SWB3,SWB4,IntHapp1,IntHapp2,IntHapp3,IntHapp4,IntHapp5,IntHapp6,IntHapp7,IntHapp8,IntHapp9,Constru1,Constru2,Constru3,Constru4,Constru5,Constru6,Constru7,Constru8,Constru9,Constru10,Constru11,Constru12,Constru13,Tight1,Tight2,Tight3,Tight4,Tight5,Tight6,ChangeYesNo,ChangeDescribe,ChangeSuccess,Trust1,Trust2,Trust3,Trust4,Trust5,LOT1,LOT2,LOT3,LOT4,LOT5,LOT6,Honest1,Honest2,Honest3,Honest4,Honest5,Honest6,Honest7,Honest8,Honest9,Honest10,Micro1,Micro2,Micro3,Micro4,Micro5,Micro6,Narq1,Narq2,Narq3,Narq4,Narq5,Narq6,ReligionScale1,ReligionScale2,ReligionScale3,ReligionScale4,ReligionScale5,ReligionScale6,ReligionScale7,ReligionScale8,ReligionScale9,ReligionScale10,ReligionScale11,ReligionScale12,ReligionScale13,ReligionScale14,ReligionScale15,ReligionScale16,ReligionScale17 +"5514232571-something","someString","en-US",32,1,"Special","Tagalog",6,"Big Crater","The Moon",1,1,2,,"15:00","Eating something","Daydreaming","alone",1,2,2,1,2,3,3,1,2,1,2,2,3,2,3,3,2,1,2,3,1,2,1,2,1,2,3,3,2,2,3,2,3,3,2,2,2,2,1,2,1,2,2,1,3,2,2,3,3,2,1,2,1,2,2,2,3,3,2,2,2,1,2,2,3,2,2,1,2,2,3,2,2,2,1,1,2,1,2,3,3,3,1,2,2,2,2,2,1,2,2,6,5,1,4,7,8,6,5,6,6,4,8,3,8,7,4,2,3,7,7,4,4,5,1,6,8,8,4,3,6,6,7,7,3,5,5,5,4,5,6,6,5,2,7,4,4,6,7,5,1,6,2,5,5,4,9,9,4,6,5,3,6,5,7,5,3,2,4,5,8,5,4,5,3,3,4,3,5,9,7,7,3,6,3,4,6,5,2,5,6,4,4,7,7,4,5,3,7,7,3,7,4,4,6,7,4,8,0,2,3,4,2,5,2,5,2,4,1,5,2,4,2,5,1,5,2,4,1,5,1,5,4,3,2,5,5,5,1,4,1,5,1,1,1,2,4,5,1,5,5,1,5,1,5,1,1,5,4,4,2,1,5,1,1,5,5,5,4,7,7,7,6,4,4,1,1,1,1,1,1,1,1,1,2,2,8,2,2,3,2,2,3,2,3,4,3,3,3,2,4,2,,,5,4,3,3,1,3,3,3,4,2,4,1,3,3,2,5,2,4,2,4,1,4,1,3,2,4,,2,4,2,3,3,3,1,2,2,2,2,2,3,4,3,2,4,2,3,3,4,3,3 diff --git a/tests/fixtures/isp-survey.json b/tests/fixtures/isp-survey.json new file mode 100644 index 0000000..6e49bdd --- /dev/null +++ b/tests/fixtures/isp-survey.json @@ -0,0 +1,466 @@ +[ + { + "sequence": [ + "0-0-overview", + "1-1-free-response", + "2-2-card-sort", + "2-2-card-sort", + "3-3-rating-form", + "3-3-rating-form", + "3-3-rating-form", + "3-3-rating-form", + "3-3-rating-form", + "3-3-rating-form", + "3-3-rating-form" + ], + "conditions": {}, + "expData": { + "0-0-overview": { + "responses": { + "Age": 32, + "Ethnicity": "Special", + "SocialStatus": 6, + "Religion1to10": 1, + "Gender": 1, + "BirthCountry": "The Moon", + "ReligionFollow": null, + "BirthCity": "Big Crater", + "Residence": 1, + "Language": "Tagalog", + "ReligionYesNo": 2 + }, + "eventTimings": [ + { + "eventType": "nextFrame", + "timestamp": "2016-12-09T14:35:04.578Z" + } + ] + }, + "1-1-free-response": { + "responses": { + "WhereResponse": "Daydreaming", + "WhatResponse": "Eating something", + "WhoResponse": "alone", + "EventTime": "15:00" + }, + "eventTimings": [ + { + "eventType": "nextFrame", + "timestamp": "2016-12-09T14:35:39.663Z" + } + ] + }, + "3-3-rating-form": { + "responses": { + "0": { + "PosNeg": 6 + }, + "1": { + "SitSimilarity": 4 + }, + "2": { + "BBI16": 8, + "BBI6": 3, + "BBI3": 7, + "BBI1": 4, + "BBI10": 7, + "BBI2": 7, + "BBI8": 7, + "BBI9": 3, + "BBI4": 4, + "BBI5": 5, + "BBI15": 4, + "BBI7": 7, + "BBI13": 6, + "BBI11": 4, + "BBI14": 7, + "BBI12": 4 + }, + "3": { + "Risk": 0 + }, + "4": { + "BFI56": 1, + "BFI17": 5, + "BFI35": 1, + "BFI60": 4, + "BFI40": 1, + "BFI10": 1, + "BFI22": 1, + "BFI20": 1, + "BFI25": 3, + "BFI50": 4, + "BFI37": 2, + "BFI39": 5, + "BFI13": 4, + "BFI34": 1, + "BFI51": 4, + "BFI38": 4, + "BFI41": 5, + "BFI11": 5, + "BFI4": 2, + "BFI21": 5, + "BFI27": 5, + "BFI6": 2, + "BFI8": 2, + "BFI43": 1, + "BFI58": 5, + "BFI42": 5, + "BFI57": 5, + "BFI53": 1, + "BFI47": 1, + "BFI52": 2, + "BFI15": 5, + "BFI12": 2, + "BFI30": 1, + "BFI32": 1, + "BFI26": 2, + "BFI54": 5, + "BFI14": 2, + "BFI45": 1, + "BFI9": 4, + "BFI5": 5, + "BFI24": 4, + "BFI16": 1, + "BFI28": 5, + "BFI49": 5, + "BFI46": 5, + "BFI31": 4, + "BFI1": 2, + "BFI44": 5, + "BFI48": 1, + "BFI18": 2, + "BFI23": 5, + "BFI3": 4, + "BFI7": 5, + "BFI59": 5, + "BFI33": 5, + "BFI19": 4, + "BFI2": 3, + "BFI55": 1, + "BFI36": 1, + "BFI29": 5 + }, + "5": { + "SWB4": 6, + "SWB3": 7, + "SWB1": 7, + "SWB2": 7 + }, + "6": { + "IntHapp3": 1, + "IntHapp8": 1, + "IntHapp6": 1, + "IntHapp2": 4, + "IntHapp1": 4, + "IntHapp4": 1, + "IntHapp7": 1, + "IntHapp9": 1, + "IntHapp5": 1 + }, + "7": { + "Constru4": 2, + "Constru7": 2, + "Constru6": 2, + "Constru2": 1, + "Constru13": 3, + "Constru12": 2, + "Constru10": 2, + "Constru11": 3, + "Constru5": 8, + "Constru1": 1, + "Constru3": 2, + "Constru8": 3, + "Constru9": 2 + }, + "8": { + "Tight4": 3, + "Tight5": 2, + "Tight1": 4, + "Tight3": 3, + "Tight2": 3, + "Tight6": 4 + }, + "9": { + "ChangeYesNo": 2, + "ChangeDescribe": null, + "ChangeSuccess": null + }, + "10": { + "Trust1": 5, + "Trust4": 3, + "Trust2": 4, + "Trust3": 3, + "Trust5": 1 + }, + "11": { + "LOT5": 2, + "LOT4": 4, + "LOT2": 3, + "LOT1": 3, + "LOT3": 3, + "LOT6": 4 + }, + "12": { + "Honest5": 5, + "Honest1": 1, + "Honest8": 2, + "Honest3": 3, + "Honest7": 4, + "Honest10": 1, + "Honest9": 4, + "Honest4": 2, + "Honest2": 3, + "Honest6": 2 + }, + "13": { + "Micro3": 3, + "Micro5": 4, + "Micro4": 2, + "Micro1": 4, + "Micro2": 1 + }, + "14": { + "Narq1": 2, + "Narq5": 3, + "Narq3": 2, + "Narq2": 4, + "Narq4": 3, + "Narq6": 3 + }, + "15": { + "ReligionScale9": 3, + "ReligionScale12": 2, + "ReligionScale4": 2, + "ReligionScale16": 3, + "ReligionScale3": 2, + "ReligionScale8": 4, + "ReligionScale2": 2, + "ReligionScale10": 2, + "ReligionScale15": 4, + "ReligionScale13": 3, + "ReligionScale6": 2, + "ReligionScale5": 2, + "ReligionScale14": 3, + "ReligionScale11": 4, + "ReligionScale17": 3, + "ReligionScale1": 1, + "ReligionScale7": 3 + } + }, + "eventTimings": [ + { + "eventType": "nextFrame", + "timestamp": "2016-12-09T14:50:39.156Z" + } + ] + }, + "2-2-card-sort": { + "responses": { + "NineCat": { + "rsq31": 6, + "rsq23": 4, + "rsq50": 5, + "rsq4": 1, + "rsq63": 6, + "rsq11": 6, + "rsq30": 3, + "rsq33": 7, + "rsq9": 5, + "rsq67": 3, + "rsq82": 7, + "rsq60": 6, + "rsq22": 4, + "rsq86": 4, + "rsq88": 5, + "rsq85": 3, + "rsq54": 5, + "rsq77": 4, + "rsq35": 3, + "rsq55": 5, + "rsq3": 5, + "rsq59": 4, + "rsq17": 4, + "rsq14": 3, + "rsq64": 5, + "rsq83": 3, + "rsq70": 5, + "rsq73": 4, + "rsq36": 5, + "rsq21": 7, + "rsq52": 6, + "rsq18": 2, + "rsq43": 5, + "rsq37": 5, + "rsq49": 7, + "rsq62": 3, + "rsq84": 6, + "rsq58": 9, + "rsq16": 7, + "rsq45": 7, + "rsq42": 6, + "rsq72": 5, + "rsq41": 6, + "rsq61": 5, + "rsq15": 8, + "rsq71": 8, + "rsq51": 1, + "rsq46": 4, + "rsq48": 6, + "rsq75": 3, + "rsq28": 8, + "rsq65": 7, + "rsq38": 5, + "rsq10": 6, + "rsq13": 8, + "rsq47": 4, + "rsq6": 7, + "rsq90": 5, + "rsq89": 2, + "rsq74": 5, + "rsq24": 5, + "rsq57": 9, + "rsq25": 1, + "rsq79": 5, + "rsq1": 2, + "rsq68": 2, + "rsq40": 5, + "rsq27": 8, + "rsq44": 2, + "rsq56": 4, + "rsq39": 4, + "rsq34": 7, + "rsq87": 6, + "rsq32": 6, + "rsq53": 2, + "rsq81": 7, + "rsq78": 3, + "rsq8": 6, + "rsq29": 4, + "rsq66": 5, + "rsq2": 6, + "rsq19": 3, + "rsq80": 9, + "rsq69": 4, + "rsq76": 3, + "rsq20": 7, + "rsq12": 4, + "rsq26": 6, + "rsq7": 8, + "rsq5": 4 + }, + "ThreeCat": { + "rsq31": 3, + "rsq23": 1, + "rsq50": 2, + "rsq4": 1, + "rsq63": 2, + "rsq11": 2, + "rsq37": 2, + "rsq33": 3, + "rsq9": 2, + "rsq21": 1, + "rsq67": 2, + "rsq82": 3, + "rsq60": 2, + "rsq29": 2, + "rsq86": 2, + "rsq88": 2, + "rsq85": 2, + "rsq54": 2, + "rsq77": 2, + "rsq35": 2, + "rsq55": 2, + "rsq3": 2, + "rsq59": 2, + "rsq17": 2, + "rsq14": 2, + "rsq64": 2, + "rsq83": 1, + "rsq70": 2, + "rsq73": 2, + "rsq36": 2, + "rsq22": 2, + "rsq52": 2, + "rsq18": 1, + "rsq43": 2, + "rsq30": 2, + "rsq58": 3, + "rsq62": 1, + "rsq84": 2, + "rsq74": 2, + "rsq16": 3, + "rsq45": 3, + "rsq42": 2, + "rsq72": 2, + "rsq49": 3, + "rsq41": 1, + "rsq61": 2, + "rsq15": 3, + "rsq19": 2, + "rsq51": 1, + "rsq46": 2, + "rsq48": 3, + "rsq75": 1, + "rsq28": 3, + "rsq65": 3, + "rsq38": 2, + "rsq10": 1, + "rsq13": 3, + "rsq56": 2, + "rsq6": 3, + "rsq90": 2, + "rsq89": 1, + "rsq24": 2, + "rsq57": 3, + "rsq25": 1, + "rsq79": 2, + "rsq1": 1, + "rsq68": 1, + "rsq40": 2, + "rsq27": 3, + "rsq44": 1, + "rsq47": 2, + "rsq39": 1, + "rsq34": 3, + "rsq87": 2, + "rsq32": 2, + "rsq53": 1, + "rsq81": 3, + "rsq78": 1, + "rsq8": 1, + "rsq66": 2, + "rsq2": 2, + "rsq71": 3, + "rsq80": 3, + "rsq69": 2, + "rsq76": 1, + "rsq20": 3, + "rsq12": 2, + "rsq26": 2, + "rsq7": 3, + "rsq5": 2 + } + }, + "eventTimings": [ + { + "eventType": "nextFrame", + "timestamp": "2016-12-09T14:40:49.737Z" + } + ] + } + }, + "globalEventTimings": [], + "profileId": "5514232571-something", + "experimentId": "581202be3de08a003a2aca34", + "experimentVersion": "", + "completed": true, + "feedback": "", + "hasReadFeedback": false, + "permissions": "ADMIN", + "extra": { + "studyId": "someString", + "locale": "en-US" + } + } +] diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/integration/components/experiment-detail-test.js b/tests/integration/components/experiment-detail-test.js deleted file mode 100644 index d146857..0000000 --- a/tests/integration/components/experiment-detail-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleForComponent, test } from 'ember-qunit'; -// import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('experiment-detail', 'Integration | Component | experiment detail', { - integration: true -}); - -test('it renders', function(assert) { - // TODO figure out how to test with ember-wormhole - assert.ok(true); -}); diff --git a/tests/integration/components/experiment-summary-test.js b/tests/integration/components/experiment-summary-test.js deleted file mode 100644 index ab7ee01..0000000 --- a/tests/integration/components/experiment-summary-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleForComponent, test } from 'ember-qunit'; -//import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('experiment-summary', 'Integration | Component | experiment summary', { - integration: true -}); - -test('it renders', function(assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... });" - assert.ok(true); -}); diff --git a/tests/integration/components/export-tool-test.js b/tests/integration/components/export-tool-test.js index 917e2b3..01aed73 100644 --- a/tests/integration/components/export-tool-test.js +++ b/tests/integration/components/export-tool-test.js @@ -1,13 +1,131 @@ -import { - moduleForComponent, test -} -from 'ember-qunit'; -// import hbs from 'htmlbars-inline-precompile'; +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import ispSurvey from '../../fixtures/isp-survey'; moduleForComponent('export-tool', 'Integration | Component | export tool', { integration: true }); -test('it renders', function(assert) { - assert.ok(true); +/** + * Adds double quotes around a string + * + * @param value {*} - The value to quote + * @returns {string} + */ +function quote(value) { + if (typeof value !== 'string') { + return value; + } + + return `"${value}"`; +} + +/** + * A legible way to define a *SV file in js + * + * @param {Array<(Array|String)>} arr An array of rows + * @param {String} columnSeparator The column separator (defaults to ',') + * @returns string + */ +function svJoin(arr, columnSeparator) { + return arr + .map((item, i) => + Array.isArray(item) ? + (i > 0 ? item.map(quote) : item).join(columnSeparator) : + item + ) + .join('\n'); +} + +/** + * Runs the export tool component for a CSV and compares the output + * + * @param {Array} data An array of objects to serialize into the CSV File + * @param {Array<(Array|String)>|String} csv The CSV data + * @returns {Function} + */ +function runExportToolCSV(data, csv) { + return function(assert) { + this.set('sanitizeProfileId', sanitizeProfileId); + this.set('data', data); + this.render(hbs`{{export-tool data=data mappingFunction=sanitizeProfileId}}`); + this.$('.export-tool-select').val('CSV'); + this.$('.export-tool-select').change(); + + assert.strictEqual( + this.$('.export-tool-textarea').val(), + typeof csv === 'string' ? csv : svJoin(csv) + ); + }; +} + +const DATA = [{ + age: 35, + profileId: 'test.test' +}]; + +const sanitizeProfileId = function(session) { + if ('profileId' in session) { + session.profileId = session.profileId.split('.').slice(-1)[0]; + } + + return session; +}; + +test('JSON format', function(assert) { + this.set('sanitizeProfileId', sanitizeProfileId); + this.set('data', DATA); + this.render(hbs`{{export-tool data=data mappingFunction=sanitizeProfileId}}`); + + assert.strictEqual(this.$('.export-tool-textarea').val(), JSON.stringify(DATA, null, 4)); +}); + +test('CSV format', runExportToolCSV( + DATA, + 'age,profileId\n35,"test"' +)); + +test('CSV format handles non-english characters', runExportToolCSV( + [ + { + spanish: 'áéíñóúü¿¡', + french: 'àâæçèëïîôœùûüÿ€', + russian: 'дфяшйж', + hindi: 'हिंदी', + arabic: 'العربية', + profileId: 'test.test' + } + ], + [ + ['spanish', 'french', 'russian', 'hindi', 'arabic', 'profileId'], + ['áéíñóúü¿¡', 'àâæçèëïîôœùûüÿ€', 'дфяшйж', 'हिंदी', 'العربية', 'test'] + ] +)); + +test('CSV format for The Pile of Poo Test™', runExportToolCSV( + [ + { + pooTest: 'Iñtërnâtiônàlizætiøn☃💩' + } + ], + [ + 'pooTest', + '"Iñtërnâtiônàlizætiøn☃💩"' + ] +)); + +test('CSV for ISP - Full Example', function(assert) { + this.set('sanitizeProfileId', sanitizeProfileId); + this.set('data', ispSurvey); + this.render(hbs`{{export-tool data=data mappingFunction=sanitizeProfileId}}`); + this.$('.export-tool-select').val('CSV (for ISP)'); + this.$('.export-tool-select').change(); + + assert.strictEqual( + this.$('.export-tool-textarea').val(), + svJoin([ + 'PID,SID,locale,Age,Gender,Ethnicity,Language,SocialStatus,BirthCity,BirthCountry,Residence,Religion1to10,ReligionYesNo,ReligionFollow,EventTime,WhatResponse,WhereResponse,WhoResponse,ThreeCat_rsq1,ThreeCat_rsq2,ThreeCat_rsq3,ThreeCat_rsq4,ThreeCat_rsq5,ThreeCat_rsq6,ThreeCat_rsq7,ThreeCat_rsq8,ThreeCat_rsq9,ThreeCat_rsq10,ThreeCat_rsq11,ThreeCat_rsq12,ThreeCat_rsq13,ThreeCat_rsq14,ThreeCat_rsq15,ThreeCat_rsq16,ThreeCat_rsq17,ThreeCat_rsq18,ThreeCat_rsq19,ThreeCat_rsq20,ThreeCat_rsq21,ThreeCat_rsq22,ThreeCat_rsq23,ThreeCat_rsq24,ThreeCat_rsq25,ThreeCat_rsq26,ThreeCat_rsq27,ThreeCat_rsq28,ThreeCat_rsq29,ThreeCat_rsq30,ThreeCat_rsq31,ThreeCat_rsq32,ThreeCat_rsq33,ThreeCat_rsq34,ThreeCat_rsq35,ThreeCat_rsq36,ThreeCat_rsq37,ThreeCat_rsq38,ThreeCat_rsq39,ThreeCat_rsq40,ThreeCat_rsq41,ThreeCat_rsq42,ThreeCat_rsq43,ThreeCat_rsq44,ThreeCat_rsq45,ThreeCat_rsq46,ThreeCat_rsq47,ThreeCat_rsq48,ThreeCat_rsq49,ThreeCat_rsq50,ThreeCat_rsq51,ThreeCat_rsq52,ThreeCat_rsq53,ThreeCat_rsq54,ThreeCat_rsq55,ThreeCat_rsq56,ThreeCat_rsq57,ThreeCat_rsq58,ThreeCat_rsq59,ThreeCat_rsq60,ThreeCat_rsq61,ThreeCat_rsq62,ThreeCat_rsq63,ThreeCat_rsq64,ThreeCat_rsq65,ThreeCat_rsq66,ThreeCat_rsq67,ThreeCat_rsq68,ThreeCat_rsq69,ThreeCat_rsq70,ThreeCat_rsq71,ThreeCat_rsq72,ThreeCat_rsq73,ThreeCat_rsq74,ThreeCat_rsq75,ThreeCat_rsq76,ThreeCat_rsq77,ThreeCat_rsq78,ThreeCat_rsq79,ThreeCat_rsq80,ThreeCat_rsq81,ThreeCat_rsq82,ThreeCat_rsq83,ThreeCat_rsq84,ThreeCat_rsq85,ThreeCat_rsq86,ThreeCat_rsq87,ThreeCat_rsq88,ThreeCat_rsq89,ThreeCat_rsq90,NineCat_rsq1,NineCat_rsq2,NineCat_rsq3,NineCat_rsq4,NineCat_rsq5,NineCat_rsq6,NineCat_rsq7,NineCat_rsq8,NineCat_rsq9,NineCat_rsq10,NineCat_rsq11,NineCat_rsq12,NineCat_rsq13,NineCat_rsq14,NineCat_rsq15,NineCat_rsq16,NineCat_rsq17,NineCat_rsq18,NineCat_rsq19,NineCat_rsq20,NineCat_rsq21,NineCat_rsq22,NineCat_rsq23,NineCat_rsq24,NineCat_rsq25,NineCat_rsq26,NineCat_rsq27,NineCat_rsq28,NineCat_rsq29,NineCat_rsq30,NineCat_rsq31,NineCat_rsq32,NineCat_rsq33,NineCat_rsq34,NineCat_rsq35,NineCat_rsq36,NineCat_rsq37,NineCat_rsq38,NineCat_rsq39,NineCat_rsq40,NineCat_rsq41,NineCat_rsq42,NineCat_rsq43,NineCat_rsq44,NineCat_rsq45,NineCat_rsq46,NineCat_rsq47,NineCat_rsq48,NineCat_rsq49,NineCat_rsq50,NineCat_rsq51,NineCat_rsq52,NineCat_rsq53,NineCat_rsq54,NineCat_rsq55,NineCat_rsq56,NineCat_rsq57,NineCat_rsq58,NineCat_rsq59,NineCat_rsq60,NineCat_rsq61,NineCat_rsq62,NineCat_rsq63,NineCat_rsq64,NineCat_rsq65,NineCat_rsq66,NineCat_rsq67,NineCat_rsq68,NineCat_rsq69,NineCat_rsq70,NineCat_rsq71,NineCat_rsq72,NineCat_rsq73,NineCat_rsq74,NineCat_rsq75,NineCat_rsq76,NineCat_rsq77,NineCat_rsq78,NineCat_rsq79,NineCat_rsq80,NineCat_rsq81,NineCat_rsq82,NineCat_rsq83,NineCat_rsq84,NineCat_rsq85,NineCat_rsq86,NineCat_rsq87,NineCat_rsq88,NineCat_rsq89,NineCat_rsq90,PosNeg,SitSimilarity,BBI1,BBI2,BBI3,BBI4,BBI5,BBI6,BBI7,BBI8,BBI9,BBI10,BBI11,BBI12,BBI13,BBI14,BBI15,BBI16,Risk,BFI1,BFI2,BFI3,BFI4,BFI5,BFI6,BFI7,BFI8,BFI9,BFI10,BFI11,BFI12,BFI13,BFI14,BFI15,BFI16,BFI17,BFI18,BFI19,BFI20,BFI21,BFI22,BFI23,BFI24,BFI25,BFI26,BFI27,BFI28,BFI29,BFI30,BFI31,BFI32,BFI33,BFI34,BFI35,BFI36,BFI37,BFI38,BFI39,BFI40,BFI41,BFI42,BFI43,BFI44,BFI45,BFI46,BFI47,BFI48,BFI49,BFI50,BFI51,BFI52,BFI53,BFI54,BFI55,BFI56,BFI57,BFI58,BFI59,BFI60,SWB1,SWB2,SWB3,SWB4,IntHapp1,IntHapp2,IntHapp3,IntHapp4,IntHapp5,IntHapp6,IntHapp7,IntHapp8,IntHapp9,Constru1,Constru2,Constru3,Constru4,Constru5,Constru6,Constru7,Constru8,Constru9,Constru10,Constru11,Constru12,Constru13,Tight1,Tight2,Tight3,Tight4,Tight5,Tight6,ChangeYesNo,ChangeDescribe,ChangeSuccess,Trust1,Trust2,Trust3,Trust4,Trust5,LOT1,LOT2,LOT3,LOT4,LOT5,LOT6,Honest1,Honest2,Honest3,Honest4,Honest5,Honest6,Honest7,Honest8,Honest9,Honest10,Micro1,Micro2,Micro3,Micro4,Micro5,Micro6,Narq1,Narq2,Narq3,Narq4,Narq5,Narq6,ReligionScale1,ReligionScale2,ReligionScale3,ReligionScale4,ReligionScale5,ReligionScale6,ReligionScale7,ReligionScale8,ReligionScale9,ReligionScale10,ReligionScale11,ReligionScale12,ReligionScale13,ReligionScale14,ReligionScale15,ReligionScale16,ReligionScale17', + '"5514232571-something","someString","en-US",32,1,"Special","Tagalog",6,"Big Crater","The Moon",1,1,2,,"15:00","Eating something","Daydreaming","alone",1,2,2,1,2,3,3,1,2,1,2,2,3,2,3,3,2,1,2,3,1,2,1,2,1,2,3,3,2,2,3,2,3,3,2,2,2,2,1,2,1,2,2,1,3,2,2,3,3,2,1,2,1,2,2,2,3,3,2,2,2,1,2,2,3,2,2,1,2,2,3,2,2,2,1,1,2,1,2,3,3,3,1,2,2,2,2,2,1,2,2,6,5,1,4,7,8,6,5,6,6,4,8,3,8,7,4,2,3,7,7,4,4,5,1,6,8,8,4,3,6,6,7,7,3,5,5,5,4,5,6,6,5,2,7,4,4,6,7,5,1,6,2,5,5,4,9,9,4,6,5,3,6,5,7,5,3,2,4,5,8,5,4,5,3,3,4,3,5,9,7,7,3,6,3,4,6,5,2,5,6,4,4,7,7,4,5,3,7,7,3,7,4,4,6,7,4,8,0,2,3,4,2,5,2,5,2,4,1,5,2,4,2,5,1,5,2,4,1,5,1,5,4,3,2,5,5,5,1,4,1,5,1,1,1,2,4,5,1,5,5,1,5,1,5,1,1,5,4,4,2,1,5,1,1,5,5,5,4,7,7,7,6,4,4,1,1,1,1,1,1,1,1,1,2,2,8,2,2,3,2,2,3,2,3,4,3,3,3,2,4,2,,,5,4,3,3,1,3,3,3,4,2,4,1,3,3,2,5,2,4,2,4,1,4,1,3,2,4,,2,4,2,3,3,3,1,2,2,2,2,2,3,4,3,2,4,2,3,3,4,3,3' + ]) + ); }); diff --git a/tests/integration/components/img-selector-test.js b/tests/integration/components/img-selector-test.js deleted file mode 100644 index 3febf19..0000000 --- a/tests/integration/components/img-selector-test.js +++ /dev/null @@ -1,10 +0,0 @@ -import { moduleForComponent, test } from 'ember-qunit'; -//import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('img-selector', 'Integration | Component | img selector', { - integration: true -}); - -test('it renders', function(assert) { - assert.ok(true); -}); diff --git a/tests/integration/components/multi-toggle-test.js b/tests/integration/components/multi-toggle-test.js deleted file mode 100644 index 75fd129..0000000 --- a/tests/integration/components/multi-toggle-test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - moduleForComponent, test -} -from 'ember-qunit'; -//import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('multi-toggle', 'Integration | Component | multi toggle', { - integration: true -}); - -test('it renders', function(assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... });" - assert.ok(true); -}); diff --git a/tests/integration/components/participant-creator/component-test.js b/tests/integration/components/participant-creator/component-test.js deleted file mode 100644 index ce3b5d0..0000000 --- a/tests/integration/components/participant-creator/component-test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { - moduleForComponent, - test -} from 'ember-qunit'; -import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('/participant-creator', 'Integration | Component | participant creator', { - integration: true -}); - -test('it renders', function(assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... });" - - this.render(hbs `{{participant-creator}}`); - - assert.ok(this.$().text()); - - // Template block usage:" - this.render(hbs ` - {{#participant-creator}} - template block text - {{/participant-creator}} - `); - - assert.ok(this.$().text()); -}); diff --git a/tests/integration/components/participant-data-test.js b/tests/integration/components/participant-data-test.js deleted file mode 100644 index c410402..0000000 --- a/tests/integration/components/participant-data-test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - moduleForComponent, test -} -from 'ember-qunit'; -//import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('participant-data', 'Integration | Component | participant data', { - integration: true -}); - -test('it renders', function(assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... });" - assert.ok(true); -}); diff --git a/tests/integration/components/participant-info-test.js b/tests/integration/components/participant-info-test.js deleted file mode 100644 index 5a6e1a0..0000000 --- a/tests/integration/components/participant-info-test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - moduleForComponent, test -} -from 'ember-qunit'; -//import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('participant-info', 'Integration | Component | participant info', { - integration: true -}); - -test('it renders', function(assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... });" - assert.ok(true); -}); diff --git a/tests/integration/components/permissions-editor-test.js b/tests/integration/components/permissions-editor-test.js deleted file mode 100644 index 55bda9a..0000000 --- a/tests/integration/components/permissions-editor-test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { - moduleForComponent, test -} -from 'ember-qunit'; -// import hbs from 'htmlbars-inline-precompile'; - -moduleForComponent('permissions-editor', 'Integration | Component | permissions editor', { - integration: true -}); - -test('it renders', function(assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... });" - assert.ok(true); -}); diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/controllers/application-test.js b/tests/unit/controllers/application-test.js deleted file mode 100644 index b71b4a5..0000000 --- a/tests/unit/controllers/application-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:application', 'Unit | Controller | application', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/controllers/experiments-test.js b/tests/unit/controllers/experiments-test.js deleted file mode 100644 index c271f21..0000000 --- a/tests/unit/controllers/experiments-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:experiments', 'Unit | Controller | experiments', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/controllers/login-test.js b/tests/unit/controllers/login-test.js deleted file mode 100644 index b68f797..0000000 --- a/tests/unit/controllers/login-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:login', 'Unit | Controller | login', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/controllers/participants-test.js b/tests/unit/controllers/participants-test.js deleted file mode 100644 index 3dccb84..0000000 --- a/tests/unit/controllers/participants-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:participants', 'Unit | Controller | participants', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/controllers/participants/index-test.js b/tests/unit/controllers/participants/index-test.js deleted file mode 100644 index cafed13..0000000 --- a/tests/unit/controllers/participants/index-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:participants/index', 'Unit | Controller | participants/index', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/controllers/participants/profile-test.js b/tests/unit/controllers/participants/profile-test.js deleted file mode 100644 index 52a2a65..0000000 --- a/tests/unit/controllers/participants/profile-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:participants/profile', 'Unit | Controller | participants/profile', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/controllers/project-settings-test.js b/tests/unit/controllers/project-settings-test.js deleted file mode 100644 index 81f96dd..0000000 --- a/tests/unit/controllers/project-settings-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('controller:project-settings', 'Unit | Controller | project settings', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -// Replace this with your real tests. -test('it exists', function(assert) { - let controller = this.subject(); - assert.ok(controller); -}); diff --git a/tests/unit/helpers/trim-test.js b/tests/unit/helpers/trim-test.js deleted file mode 100644 index cf7f785..0000000 --- a/tests/unit/helpers/trim-test.js +++ /dev/null @@ -1,10 +0,0 @@ -import { trim } from 'experimenter/helpers/trim'; -import { module, test } from 'qunit'; - -module('Unit | Helper | trim'); - -// Replace this with your real tests. -test('it works', function(assert) { - let result = trim([42]); - assert.ok(result); -}); diff --git a/tests/unit/routes/experiments/info/preview-test.js b/tests/unit/routes/experiments/info/preview-test.js deleted file mode 100644 index aaf0d5c..0000000 --- a/tests/unit/routes/experiments/info/preview-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:experiments/info/preview', 'Unit | Route | experiments/info/preview', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/experiments/info/results-test.js b/tests/unit/routes/experiments/info/results-test.js deleted file mode 100644 index 5be9d7e..0000000 --- a/tests/unit/routes/experiments/info/results-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:experiments/info/results', 'Unit | Route | experiments/info/results', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/experiments/info/edit-test.js b/tests/unit/routes/experiments/info/results/all-test.js similarity index 70% rename from tests/unit/routes/experiments/info/edit-test.js rename to tests/unit/routes/experiments/info/results/all-test.js index 2076a8b..d164357 100644 --- a/tests/unit/routes/experiments/info/edit-test.js +++ b/tests/unit/routes/experiments/info/results/all-test.js @@ -1,6 +1,6 @@ import { moduleFor, test } from 'ember-qunit'; -moduleFor('route:experiments/info/edit', 'Unit | Route | experiments/info/edit', { +moduleFor('route:experiments/info/results/all', 'Unit | Route | experiments/info/results/all', { // Specify the other units that are required for this test. // needs: ['controller:foo'] }); diff --git a/tests/unit/routes/index-test.js b/tests/unit/routes/experiments/info/results/index-test.js similarity index 70% rename from tests/unit/routes/index-test.js rename to tests/unit/routes/experiments/info/results/index-test.js index 5d0f50d..d1ccaed 100644 --- a/tests/unit/routes/index-test.js +++ b/tests/unit/routes/experiments/info/results/index-test.js @@ -1,6 +1,6 @@ import { moduleFor, test } from 'ember-qunit'; -moduleFor('route:index', 'Unit | Route | index', { +moduleFor('route:experiments/info/results/index', 'Unit | Route | experiments/info/results/index', { // Specify the other units that are required for this test. // needs: ['controller:foo'] }); diff --git a/tests/unit/routes/login-test.js b/tests/unit/routes/login-test.js deleted file mode 100644 index e78ebad..0000000 --- a/tests/unit/routes/login-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:login', 'Unit | Route | login', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/participants-test.js b/tests/unit/routes/participants-test.js deleted file mode 100644 index 87196f5..0000000 --- a/tests/unit/routes/participants-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:participants', 'Unit | Route | participants', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/participants/index-test.js b/tests/unit/routes/participants/index-test.js deleted file mode 100644 index 0116411..0000000 --- a/tests/unit/routes/participants/index-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:participants/index', 'Unit | Route | participants/index', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/participants/profile-test.js b/tests/unit/routes/participants/profile-test.js deleted file mode 100644 index ed8c39b..0000000 --- a/tests/unit/routes/participants/profile-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:participants/profile', 'Unit | Route | participants/profile', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/project-settings-test.js b/tests/unit/routes/project-settings-test.js deleted file mode 100644 index 071bb77..0000000 --- a/tests/unit/routes/project-settings-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:project-settings', 'Unit | Route | project settings', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/routes/settings-test.js b/tests/unit/routes/settings-test.js deleted file mode 100644 index 76d333a..0000000 --- a/tests/unit/routes/settings-test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { moduleFor, test } from 'ember-qunit'; - -moduleFor('route:settings', 'Unit | Route | settings', { - // Specify the other units that are required for this test. - // needs: ['controller:foo'] -}); - -test('it exists', function(assert) { - let route = this.subject(); - assert.ok(route); -}); diff --git a/tests/unit/utils/patterns-test.js b/tests/unit/utils/patterns-test.js index cfe6b59..7f2700c 100644 --- a/tests/unit/utils/patterns-test.js +++ b/tests/unit/utils/patterns-test.js @@ -1,9 +1,31 @@ -import {adminPattern} from 'experimenter/utils/patterns'; +import {makeUserPattern} from 'experimenter/utils/patterns'; import { module, test } from 'qunit'; module('Unit | Utility | patterns'); // Replace this with your real tests. -test('it exists', function(assert) { - assert.ok(adminPattern); +test('Given prefix, returns a valid regex', function(assert) { + assert.expect(7); + let pattern = makeUserPattern('some-prefix'); + + assert.ok(pattern.test('some-prefix-bob'), + 'Finds a specific suffix'); + + assert.ok(pattern.test('some-prefix-123-bob'), + 'Finds a username with hyphens'); + + assert.notOk(pattern.test('some-prefix-123-notallowed!'), + 'Username characters must be alphanumeric, underscores, or hyphens'); + + assert.ok(pattern.test('some-prefix-*'), + 'Finds a wildcard suffix'); + + assert.notOk(pattern.test('other-prefix-bob'), + 'Different prefixes do not match'); + + assert.equal(pattern.exec('some-prefix-bob')[1], 'bob', + 'Username is captured'); + + assert.equal(pattern.exec('some-prefix-bob-123')[1], 'bob-123', + 'Correctly extracts a username with hyphens'); });