diff --git a/.travis.yml b/.travis.yml index ea1b929..de8dc13 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,17 @@ branches: only: - master node_js: - - '6' -sudo: false + - "9" +sudo: required +dist: trusty +addons: + chrome: stable cache: directories: - "$HOME/.npm" +env: + global: + - JOBS=1 before_install: - npm config set spin false - npm install -g phantomjs-prebuilt diff --git a/README.md b/README.md index a69437b..4a9360d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ You will need the following things properly installed on your computer. * [Git](https://git-scm.com/) * [Node.js](https://nodejs.org/) (with NPM) * [Ember CLI](https://ember-cli.com/) -* [PhantomJS](http://phantomjs.org/) +* [Google Chrome](https://google.com/chrome/) ## Installation diff --git a/app/adapters/application.js b/app/adapters/application.js index 65c8c47..a9c6c0c 100644 --- a/app/adapters/application.js +++ b/app/adapters/application.js @@ -9,4 +9,11 @@ export default JSONAPIAdapter.extend(DataAdapterMixin, { namespace: API_NAMESPACE, host: API_HOST, authorizer: 'authorizer:application', + // DRF-JSON-API returns 400 by default + handleResponse(status, headers, payload) { + if (status === 400 && payload.errors) { + return new DS.InvalidError(payload.errors); + } + return this._super(...arguments); + } }); diff --git a/app/adapters/datasheet-attachment.js b/app/adapters/datasheet-attachment.js new file mode 100644 index 0000000..a89c329 --- /dev/null +++ b/app/adapters/datasheet-attachment.js @@ -0,0 +1,19 @@ +import ApplicationAdapter from './application'; +import FileUploadAdapter from 'ccdb-web/mixins/file-upload'; + +export default ApplicationAdapter.extend(FileUploadAdapter, { + getFormFields(data) { + return { + 'datasheet': data['data']['attributes']['datasheet'], + 'collection': JSON.stringify(data['data']['relationships']['collection']['data']), + } + }, + + getFormKey(key) { + return key; + }, + + getFormValue(key, value) { + return value; + }, +}); diff --git a/app/app.js b/app/app.js index f796e79..b3b2bd6 100644 --- a/app/app.js +++ b/app/app.js @@ -1,11 +1,9 @@ -import Ember from 'ember'; +import Application from '@ember/application'; import Resolver from './resolver'; import loadInitializers from 'ember-load-initializers'; import config from './config/environment'; -let App; - -App = Ember.Application.extend({ +const App = Application.extend({ modulePrefix: config.modulePrefix, podModulePrefix: config.podModulePrefix, Resolver diff --git a/app/authenticators/application.js b/app/authenticators/application.js index 3ae0d7d..075eeff 100644 --- a/app/authenticators/application.js +++ b/app/authenticators/application.js @@ -1,9 +1,11 @@ -import Ember from 'ember'; +import { Promise } from 'rsvp'; +import $ from 'jquery'; +import { get } from '@ember/object'; +import { isEmpty } from '@ember/utils'; +import { run } from '@ember/runloop'; import BaseAuthenticator from 'ember-simple-auth/authenticators/base'; import config from '../config/environment'; -const { RSVP: { Promise }, $, get, isEmpty, run } = Ember; - export default BaseAuthenticator.extend({ serverTokenEndpoint: `${config.APP.API_HOST}/api/auth/login/`, tokenAttributeName: 'data.attributes.auth-token', diff --git a/app/authorizers/application.js b/app/authorizers/application.js index b8c154c..740f446 100644 --- a/app/authorizers/application.js +++ b/app/authorizers/application.js @@ -1,8 +1,7 @@ -import Ember from 'ember'; +import { isEmpty } from '@ember/utils'; +import { get } from '@ember/object'; import BaseAuthorizer from 'ember-simple-auth/authorizers/base'; -const { isEmpty, get } = Ember; - export default BaseAuthorizer.extend({ authorize(data, block) { const accessToken = get(data, 'data.attributes.auth-token'); diff --git a/app/components/action-button.js b/app/components/action-button.js new file mode 100644 index 0000000..061b53b --- /dev/null +++ b/app/components/action-button.js @@ -0,0 +1,40 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: 'a', + classNames: ['btn'], + classNameBindings: [ + // Styles + 'isDefault:btn-default', + 'isPrimary:btn-primary', + 'isSuccess:btn-success', + 'isInfo:btn-info', + 'isWarning:btn-warning', + 'isDanger:btn-danger', + 'isLink:btn-link', + // Sizes + 'isLarge:btn-lg', + 'isSmall:btn-sm', + 'isXSmall:btn-xs', + ], + + // ARGS + // Styles + isDefault: false, + isPrimary: false, + isSuccess: false, + isInfo: false, + isWarning: false, + isDanger: false, + isLink: false, + // Sizes + isLarge: false, + isSmall: false, + isXSmall: false, + + label: 'LABEL', + + click() { + this.get('onClick')(); + } +}); diff --git a/app/components/admin-section-list.js b/app/components/admin-section-list.js index 00d6fa3..5570647 100644 --- a/app/components/admin-section-list.js +++ b/app/components/admin-section-list.js @@ -1,5 +1,3 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({}); diff --git a/app/components/ccdb-filter.js b/app/components/ccdb-filter.js new file mode 100644 index 0000000..db2ed63 --- /dev/null +++ b/app/components/ccdb-filter.js @@ -0,0 +1,3 @@ +import Component from '@ember/component'; + +export default Component.extend({ }); diff --git a/app/components/ccdb-pagination.js b/app/components/ccdb-pagination.js index 1ef4a00..233ca4c 100644 --- a/app/components/ccdb-pagination.js +++ b/app/components/ccdb-pagination.js @@ -1,5 +1,41 @@ -import Ember from 'ember'; +import Component from '@ember/component'; +import { alias } from '@ember/object/computed'; +import { computed } from '@ember/object'; -export default Ember.Component.extend({ - classNames: ['row'], +export default Component.extend({ + // ARGS + model: null, + + // COMPUTED + meta: alias('model.meta'), + links: alias('meta.links'), + + currentPage: alias('meta.pagination.page'), + totalRecords: alias('meta.pagination.count'), + + firstLink: alias('links.first'), + lastLink: alias('links.last'), + nextLink: alias('links.next'), + prevLink: alias('links.prev'), + + _getPage(link) { + link = this.get(link); + if (link === null) { + return null; + } + const url = new URL(link); + return parseInt(url.searchParams.get('page')); + }, + + _notEqual(a, b) { + return this.get(a) !== this.get(b); + }, + + first: computed('firstLink', function() { return this._getPage('firstLink'); }), + last: computed('lastLink', function() { return this._getPage('lastLink'); }), + next: computed('nextLink', function() { return this._getPage('nextLink'); }), + prev: computed('prevLink', function() { return this._getPage('prevLink'); }), + + notOnFirst: computed('first', 'currentPage', function() { return this._notEqual('first', 'currentPage'); }), + notOnLast: computed('last', 'currentPage', function() { return this._notEqual('last', 'currentPage'); }), }); diff --git a/app/components/ccdb-table.js b/app/components/ccdb-table.js index fce3474..8aade24 100644 --- a/app/components/ccdb-table.js +++ b/app/components/ccdb-table.js @@ -1,16 +1,13 @@ -import Ember from 'ember'; +import Component from '@ember/component'; import Table from 'ember-light-table'; -const { Component } = Ember; - export default Component.extend({ + // ARGS model: null, columns: null, + table: null, - - classNames: ['row'], - - init() { + didReceiveAttrs() { this._super(...arguments); const table = new Table(this.get('columns'), this.get('model')); this.set('table', table); diff --git a/app/components/collection/create-container.js b/app/components/collection/create-container.js new file mode 100644 index 0000000..fd599fa --- /dev/null +++ b/app/components/collection/create-container.js @@ -0,0 +1,93 @@ +import { getProperties, set } from '@ember/object'; +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { debounce } from '@ember/runloop'; +import RSVP from 'rsvp'; +import Changeset from 'ember-changeset'; +import lookupValidator from 'ember-changeset-validations'; +import config from 'ccdb-web/config/environment'; + +export default Component.extend({ + store: service(), + + init() { + this._super(...arguments); + const model = this.get('model'); + const validations = this.get('validations'); + const hasMany = this.get('hasMany'); + + let changesets = {}; + changesets['new'] = []; + changesets['delete'] = []; + changesets['hasMany'] = {}; + changesets['model'] = new Changeset(model, + lookupValidator(validations['collection']), + validations['collection']); + + hasMany.forEach((hasMany) => { + let relatedChangesets = []; + let validation = validations[hasMany]; + const related = model.get(hasMany); + related.forEach((r) => { + const changeset = new Changeset(r, lookupValidator(validation), + validation); + relatedChangesets.push({ model: r, changeset: changeset }); + }); + changesets['hasMany'][hasMany] = relatedChangesets; + }); + + this.set('changesets', changesets); + this.set('newStudyLocationAdmin', `${config.APP.API_HOST}/admin/locations/studylocation/add/`); + }, + + actions: { + addHasMany(modelName, relatedName) { + const store = this.get('store'); + let changesets = this.get('changesets'); + const validations = this.get('validations'); + const validation = validations[relatedName]; + const model = this.get('model'); + const related = store.createRecord(modelName, { collection: model }); + model.get(relatedName).pushObject(related); + changesets['new'].pushObject(related); + const changeset = new Changeset(related, lookupValidator(validation), validation); + changesets['hasMany'][relatedName].pushObject({ model: related, changeset: changeset }); + }, + + deleteHasMany(changesetRecord, relatedName) { + let changesets = this.get('changesets'); + changesets['delete'].pushObject(changesetRecord.model); + changesets['hasMany'][relatedName].removeObject(changesetRecord); + }, + + // Gross, this side-effects by saving immediately. Someday I should clean + // this up, but for now, you have been warned. + addOption(relatedModelName, optionName, collectionAttrName, relatedAttrName, term) { + const props = getProperties(this, 'store', 'options', 'changesets'); + const { store, options, changesets: { model } } = props; + let payload = {}; + payload[relatedAttrName] = term; + const record = store.createRecord(relatedModelName, payload) + record.save().then((record) => { + set(options, optionName, store.peekAll(relatedModelName)); + set(model, collectionAttrName, record); + }); + }, + + updateDatasheet(changeset, event) { + changeset.set('datasheet', event.target.files[0]); + }, + + searchStudyLocation(term) { + return new RSVP.Promise((resolve, reject) => { + debounce(this, this._performSearch, 'study-location', { page_size: 500, code: term }, resolve, reject, 400); + }); + }, + }, + + _performSearch(model, payload, resolve, reject) { + this.get('store').query(model, payload).then((results) => { + resolve(results); + }, reject); + }, +}); diff --git a/app/components/collection/detail-container.js b/app/components/collection/detail-container.js new file mode 100644 index 0000000..66f9ba1 --- /dev/null +++ b/app/components/collection/detail-container.js @@ -0,0 +1,34 @@ +import Component from '@ember/component'; + +export default Component.extend({ + // ARGS + model: null, + + mainColumns: [ + { label: 'Project', valuePath: 'project.name', }, + { label: 'IACUC', valuePath: 'project.iacucNumber', }, + { label: 'Region', valuePath: 'studyLocation.site.region.name', }, + { label: 'Site', valuePath: 'studyLocation.site.name', }, + { label: 'Study Location', valuePath: 'studyLocation.code', }, + { label: 'Method', valuePath: 'collectionMethod.code', }, + { label: 'Type', valuePath: 'collectionType.name', }, + { label: '# of Traps', valuePath: 'numberOfTraps', }, + { label: 'Start', valuePath: 'startDateTime', }, + { label: 'End', valuePath: 'endDateTime', }, + { label: 'ADFG Permit', valuePath: 'adfgPermit.name', }, + ], + + collectionSpeciesColumns: [ + { label: 'Species', valuePath: 'species.commonName' }, + { label: 'Count', valuePath: 'count' }, + { label: 'Count Estimated?', valuePath: 'countEstimated' }, + { label: 'Sex', valuePath: 'sex.name' }, + ], + + envMeasColumns: [ + { label: 'Date Measured', valuePath: 'dateMeasured', }, + { label: 'Time Measured', valuePath: 'timeMeasured', }, + { label: 'Water Temp (deg C)', valuePath: 'waterTempC', }, + { label: 'Air Temp (deg C)', valuePath: 'airTempC', }, + ], +}); diff --git a/app/components/collection/list-container.js b/app/components/collection/list-container.js new file mode 100644 index 0000000..184a11d --- /dev/null +++ b/app/components/collection/list-container.js @@ -0,0 +1,20 @@ +import Component from '@ember/component'; + +export default Component.extend({ + // ARGS + model: null, + + columns: [ + { label: 'Project', valuePath: 'project.name', }, + { label: 'IACUC', valuePath: 'project.iacucNumber', }, + { label: 'Species', valuePath: 'speciesAndCounts', }, + { label: 'Region', valuePath: 'studyLocation.site.region.name', }, + { label: 'Site', valuePath: 'studyLocation.site.name', }, + { label: 'Study Location', valuePath: 'studyLocation.code', }, + { label: 'Method', valuePath: 'collectionMethod.name', }, + { label: '# of Traps', valuePath: 'numberOfTraps', }, + { label: 'Start', valuePath: 'startDateTime', }, + { label: 'End', valuePath: 'endDateTime', }, + { label: 'ADFG Permit', valuePath: 'adfgPermit.name', }, + ], +}); diff --git a/app/components/collections-container.js b/app/components/collections-container.js deleted file mode 100644 index 376c977..0000000 --- a/app/components/collections-container.js +++ /dev/null @@ -1,15 +0,0 @@ -import Ember from 'ember'; - -const { Component } = Ember; - -export default Component.extend({ - columns: [ - { label: 'Project', valuePath: 'project.name', }, - { label: 'Study Location', valuePath: 'studyLocation.code', }, - { label: 'Method', valuePath: 'collectionMethod.code', }, - { label: 'Type', valuePath: 'collectionType.name', }, - { label: '# of Traps', valuePath: 'numberOfTraps', }, - { label: 'Start', valuePath: 'startDateTime', }, - { label: 'End', valuePath: 'endDateTime', }, - ], -}); diff --git a/app/components/confirm-button.js b/app/components/confirm-button.js new file mode 100644 index 0000000..89e921a --- /dev/null +++ b/app/components/confirm-button.js @@ -0,0 +1,23 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: 'span', + showConfirm: false, + initialLabel: 'LABEL', + confirmLabel: 'CONFIRM LABEL', + cancelLabel: 'Cancel', + + actions: { + initial() { + this.set('showConfirm', true); + }, + + cancel() { + this.set('showConfirm', false); + }, + + confirm() { + this.get('onClick')(); + }, + }, +}); diff --git a/app/components/crud-form.js b/app/components/crud-form.js new file mode 100644 index 0000000..889c937 --- /dev/null +++ b/app/components/crud-form.js @@ -0,0 +1,6 @@ +import Component from '@ember/component'; + +export default Component.extend({ + // ARGS + changesets: null, +}); diff --git a/app/components/filter-collections.js b/app/components/filter-collections.js deleted file mode 100644 index 16e66b5..0000000 --- a/app/components/filter-collections.js +++ /dev/null @@ -1,5 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Component.extend({ - tagName: 'form', -}); diff --git a/app/components/form-content.js b/app/components/form-content.js new file mode 100644 index 0000000..9d2bc5a --- /dev/null +++ b/app/components/form-content.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: 'form', +}); diff --git a/app/components/loading-spinner.js b/app/components/loading-spinner.js index f65d12f..8ee064f 100644 --- a/app/components/loading-spinner.js +++ b/app/components/loading-spinner.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; - -const { Component } = Ember; +import Component from '@ember/component'; export default Component.extend({ classNames: ['spinner'], diff --git a/app/components/validated-field.js b/app/components/validated-field.js new file mode 100644 index 0000000..a505dcf --- /dev/null +++ b/app/components/validated-field.js @@ -0,0 +1,18 @@ +import Component from '@ember/component'; +import { get, computed } from '@ember/object'; +import { isEmpty } from '@ember/utils'; + +export default Component.extend({ + classNames: ['form-group'], + classNameBindings: ['isValid::has-error'], + + isValid: computed('changeset.error', 'property', function() { + const changeset = this.get('changeset'); + const property = this.get('property'); + return isEmpty(get(changeset, `error.${property}`)); + }), + + hasLabel: computed('label', function() { + return !isEmpty(get(this, 'label')); + }), +}); diff --git a/app/controllers/application.js b/app/controllers/application.js index 59d2822..5e8a1e1 100644 --- a/app/controllers/application.js +++ b/app/controllers/application.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Controller, inject: { service }} = Ember; +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; export default Controller.extend({ session: service('session'), diff --git a/app/controllers/collections/create.js b/app/controllers/collections/create.js new file mode 100644 index 0000000..9234268 --- /dev/null +++ b/app/controllers/collections/create.js @@ -0,0 +1,46 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import CollectionValidations from 'ccdb-web/validations/collection'; +import CollectionSpeciesValidations from 'ccdb-web/validations/collection-species'; +import CollectionMeasurementValidations from 'ccdb-web/validations/collection-measurement'; +import DatasheetValidations from 'ccdb-web/validations/datasheet'; +import ValidationMixin from 'ccdb-web/mixins/validation'; + +export default Controller.extend(ValidationMixin, { + CollectionValidations, + CollectionSpeciesValidations, + DatasheetValidations, + CollectionMeasurementValidations, + + hasMany: ['collectionSpecies', 'datasheets', 'envMeasurements'], + + options: computed('projectOptions', 'studyLocationOptions', + 'collectionTypeOptions', 'collectionMethodOptions', + 'speciesOptions', 'adfgPermitOptions', 'sexOptions', + function() { + return { + projects: this.get('projectOptions'), + studyLocations: this.get('studyLocationOptions'), + collectionTypes: this.get('collectionTypeOptions'), + collectionMethods: this.get('collectionMethodOptions'), + species: this.get('speciesOptions'), + adfgPermits: this.get('adfgPermitOptions'), + sexes: this.get('sexOptions'), + }; + }), + + actions: { + onSave(changeset) { + const postSave = () => { this.transitionToRoute('collections.index'); }; + return this.transitionToRoute('loading').then(() => { + return this.validationSave(changeset, postSave); + }); + }, + onCancel(changeset) { + const postCancel = () => { this.transitionToRoute('collections.index'); }; + return this.transitionToRoute('loading').then(() => { + return this.validationCancel(changeset, postCancel); + }); + }, + }, +}); diff --git a/app/controllers/collections/detail/edit.js b/app/controllers/collections/detail/edit.js new file mode 100644 index 0000000..6133707 --- /dev/null +++ b/app/controllers/collections/detail/edit.js @@ -0,0 +1,52 @@ +import Controller from '@ember/controller'; +import { computed } from '@ember/object'; +import CollectionValidations from 'ccdb-web/validations/collection'; +import CollectionSpeciesValidations from 'ccdb-web/validations/collection-species'; +import CollectionMeasurementValidations from 'ccdb-web/validations/collection-measurement'; +import DatasheetValidations from 'ccdb-web/validations/datasheet'; +import ValidationMixin from 'ccdb-web/mixins/validation'; + +export default Controller.extend(ValidationMixin, { + CollectionValidations, + CollectionSpeciesValidations, + DatasheetValidations, + CollectionMeasurementValidations, + + hasMany: ['collectionSpecies', 'datasheets', 'envMeasurements'], + + options: computed('projectOptions', 'studyLocationOptions', + 'collectionTypeOptions', 'collectionMethodOptions', + 'speciesOptions', 'adfgPermitOptions', 'sexOptions', + function() { + return { + projects: this.get('projectOptions'), + studyLocations: this.get('studyLocationOptions'), + collectionTypes: this.get('collectionTypeOptions'), + collectionMethods: this.get('collectionMethodOptions'), + species: this.get('speciesOptions'), + adfgPermits: this.get('adfgPermitOptions'), + sexes: this.get('sexOptions'), + }; + }), + + actions: { + onSave(changesets) { + const postSave = () => { + // Use the model's ID here because of the ArrayProxy in the route + this.transitionToRoute('collections.detail', this.get('model.id')); + }; + return this.transitionToRoute('loading').then(() => { + return this.validationSave(changesets, postSave); + }); + }, + onCancel(changesets) { + const postCancel = () => { + // Use the model's ID here because of the ArrayProxy in the route + return this.transitionToRoute('collections.detail', this.get('model.id')); + }; + return this.transitionToRoute('loading').then(() => { + return this.validationCancel(changesets, postCancel); + }); + }, + }, +}); diff --git a/app/controllers/collections/detail/index.js b/app/controllers/collections/detail/index.js new file mode 100644 index 0000000..e0bc42c --- /dev/null +++ b/app/controllers/collections/detail/index.js @@ -0,0 +1,14 @@ +import Controller from '@ember/controller'; + +export default Controller.extend({ + actions: { + editCollection() { + this.transitionToRoute('collections.detail.edit', this.get('model')); + }, + deleteCollection() { + this.get('model')[0].destroyRecord().then(() => { + this.transitionToRoute('collections'); + }); + }, + }, +}); diff --git a/app/controllers/collections/index.js b/app/controllers/collections/index.js new file mode 100644 index 0000000..d4a8cdf --- /dev/null +++ b/app/controllers/collections/index.js @@ -0,0 +1,85 @@ +import Controller from '@ember/controller'; +import { set, get, computed } from '@ember/object'; + + +export default Controller.extend({ + queryParams: ['page', 'project', 'region', 'site', 'study_location', + 'collection_method', 'number_of_traps', 'collection_start_date', + 'collection_end_date', 'adfg_permit', 'species'], + page: 1, + project: [], + region: [], + site: [], + study_location: [], + collection_method: [], + adfg_permit: [], + species: [], + number_of_traps: '', + collection_start_date: '', + collection_end_date: '', + + options: computed('projectOptions', 'regionOptions', 'siteOptions', + 'studyLocationOptions', 'collectionMethodOptions', + 'adfgPermitOptions', 'speciesOptions', function() { + return { + projects: this.get('projectOptions'), + regions: this.get('regionOptions'), + sites: this.get('siteOptions'), + studyLocations: this.get('studyLocationOptions'), + collectionMethods: this.get('collectionMethodOptions'), + adfgPermits: this.get('adfgPermitOptions'), + species: this.get('speciesOptions'), + }; + }), + + _coerceId(model) { + return +get(model, 'id'); + }, + + actions: { + changePage(page) { + this.set('page', page); + }, + rowClick(row) { + this.transitionToRoute('collections.detail', row.get('id')); + }, + createCollection() { + this.transitionToRoute('collections.create'); + }, + resetFilter() { + set(this, 'page', 1); + ['project', 'region', 'site', 'study_location', 'collection_method', + 'adfg_permit', 'species'].forEach((field) => { + set(this, field, []); + }); + ['number_of_traps', 'collection_start_date', 'collection_end_date'].forEach((field) => { + set(this, field, ''); + }); + }, + changeFilter(filter) { + // Need to reset the page so that things don't get weird + set(this, 'page', 1); + + const filterModelFields = ['project', 'region', 'site', 'study_location', + 'collection_method', 'adfg_permit', 'species']; + + filterModelFields.forEach((field) => { + let fields = get(filter, field); + fields = fields.map(this._coerceId); + set(this, field, fields); + }); + + set(this, 'number_of_traps', get(filter, 'number_of_traps')); + + ['collection_start_date', 'collection_end_date'].forEach((field) => { + let value = get(filter, field); + if (value) { + value = value.toJSON().split('T')[0]; + } else { + value = ''; + } + set(this, field, value); + }); + }, + }, +}); diff --git a/app/controllers/login.js b/app/controllers/login.js index e5bb9c8..7311a02 100644 --- a/app/controllers/login.js +++ b/app/controllers/login.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Controller, inject: { service } } = Ember; +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; export default Controller.extend({ session: service(), diff --git a/app/index.html b/app/index.html index e01418d..edae105 100644 --- a/app/index.html +++ b/app/index.html @@ -17,8 +17,8 @@ {{content-for "body"}} - - + + {{content-for "body-footer"}} diff --git a/app/mixins/file-upload.js b/app/mixins/file-upload.js new file mode 100644 index 0000000..5117782 --- /dev/null +++ b/app/mixins/file-upload.js @@ -0,0 +1,62 @@ +import Mixin from '@ember/object/mixin'; +import { isArray } from '@ember/array'; +const { keys } = Object; + +// Portions borrowed from https://github.com/funtusov/ember-cli-form-data +// (that project has an MIT license listed, but no copyright holder explicitly identified) +export default Mixin.create({ + formDataTypes: ['POST', 'PUT', 'PATCH'], + + ajaxOptions(url, type, options) { + let data; + if (options && 'data' in options) { data = options.data; } + let hash = this._super.apply(this, arguments); + + if (typeof FormData !== 'undefined' && data && this.formDataTypes.indexOf(type) >= 0) { + hash.processData = false; + hash.contentType = false; + hash.data = this._getFormData(data); + } + return hash; + }, + + getFormFields(data) { + this._root = this._root || keys(data)[0]; + return data[this._root]; + }, + + getFormKey(key) { + return `${this._root}[${key}]`; + }, + + getFormValue(key, value) { + return value; + }, + + _getFormData(data) { + let formData = new FormData(); + const fields = this.getFormFields(data); + + keys(fields).forEach((key) => { + this._appendValue( + this.getFormValue(key, fields[key]), + this.getFormKey(key, fields[key]), + formData); + }); + return formData; + }, + + _appendValue(value, formKey, formData) { + if (isArray(value)) { + value.forEach((item) => { + this._appendValue(item, `${formKey}[]`, formData); + }); + } else if (value && value.constructor === Object) { + keys(value).forEach((key) => { + this._appendValue(value[key], `${formKey}[${key}]`, formData); + }); + } else if (typeof value !== 'undefined'){ + formData.append(formKey, value === null ? '' : value); + } + }, +}); diff --git a/app/mixins/validation.js b/app/mixins/validation.js new file mode 100644 index 0000000..7e8c1cf --- /dev/null +++ b/app/mixins/validation.js @@ -0,0 +1,73 @@ +import Mixin from '@ember/object/mixin'; +import { get } from '@ember/object'; +import RSVP from 'rsvp'; +const { keys } = Object; + +export default Mixin.create({ + validationSave(changesets, postSave) { + let promises = [], changes = [], saves = [], isValid = true; + + let modelChangeset = changesets['model']; + + // first, delete anything that needs to be removed + for (const model of changesets['delete']) { + promises.push(model.destroyRecord()); + } + + // second, handle changes on parent model (this is important if new) + modelChangeset.validate().then(() => { + if (modelChangeset.get('isValid')) { + return modelChangeset.save(); + } + }).then(() => { + for (const hasMany of keys(changesets['hasMany'])) { + for (const { changeset } of changesets['hasMany'][hasMany]) { + promises.push(changeset.validate()); + changes.push(changeset); + } + } + return RSVP.all(promises); + }).then(() => { // don't need the promises, just that they are done. + for (let changeset of changes) { + if (get(changeset, 'isValid')) { + let saver = changeset.save().catch((error) => { + /* eslint-disable no-console */ + console.log(error); + /* eslint-enable no-console */ + // TODO: do something with server-side non-attr errors + }); + saves.push(saver); + } else { + isValid = false; + } + } + return RSVP.all(saves); + }).then(() => { + if (isValid) { return postSave(); } + }); + }, + + validationCancel(changesets, postCancel) { + delete changesets['delete']; + for (const key of keys(changesets)) { + if (key === 'new') { + for (const model of changesets[key]) { + model.destroyRecord(); + } + } else if (key === 'hasMany') { + const hasMany = changesets[key]; + for (const hasManyKey of keys(changesets[key])) { + const hasManyChangesets = hasMany[hasManyKey]; + for (const changeset of hasManyChangesets) { + changeset.rollback(); + } + } + } else { // single + const changeset = changesets[key]; + changeset.rollback(); + } + } + + return postCancel(); + }, +}); diff --git a/app/models/adfg-permit.js b/app/models/adfg-permit.js new file mode 100644 index 0000000..de48a20 --- /dev/null +++ b/app/models/adfg-permit.js @@ -0,0 +1,10 @@ +import DS from 'ember-data'; + +const { Model, attr, hasMany } = DS; + +export default Model.extend({ + name: attr('string'), + sortOrder: attr('number'), + + collection: hasMany('collection'), +}); diff --git a/app/models/collection-measurement.js b/app/models/collection-measurement.js new file mode 100644 index 0000000..41eef75 --- /dev/null +++ b/app/models/collection-measurement.js @@ -0,0 +1,12 @@ +import DS from 'ember-data'; + +const { Model, attr, belongsTo } = DS; + +export default Model.extend({ + dateMeasured: attr('ccdb-date'), + timeMeasured: attr('string'), + waterTempC: attr('number'), + airTempC: attr('number'), + + collection: belongsTo('collection'), +}); diff --git a/app/models/collection-species.js b/app/models/collection-species.js new file mode 100644 index 0000000..5839d7c --- /dev/null +++ b/app/models/collection-species.js @@ -0,0 +1,12 @@ +import DS from 'ember-data'; + +const { Model, attr, belongsTo } = DS; + +export default Model.extend({ + sex: belongsTo('sex'), + count: attr('number'), + countEstimated: attr('boolean', { defaultValue: false }), + + collection: belongsTo('collection'), + species: belongsTo('species'), +}); diff --git a/app/models/collection.js b/app/models/collection.js index c2fc7c9..05c90fd 100644 --- a/app/models/collection.js +++ b/app/models/collection.js @@ -1,21 +1,41 @@ -import Ember from 'ember'; +import { mapBy } from '@ember/object/computed'; +import { computed } from '@ember/object'; import DS from 'ember-data'; -const { computed } = Ember; -const { Model, attr, belongsTo } = DS; +const { Model, attr, belongsTo, hasMany } = DS; export default Model.extend({ displayName: attr('string'), numberOfTraps: attr('number'), - collectionStartDate: attr('string-null-to-empty'), + collectionStartDate: attr('ccdb-date'), collectionStartTime: attr('string-null-to-empty'), - collectionEndDate: attr('string-null-to-empty'), + collectionEndDate: attr('ccdb-date'), collectionEndTime: attr('string-null-to-empty'), + notes: attr('string', { defaultValue: '' }), - project: belongsTo('project'), - studyLocation: belongsTo('study-location'), - collectionMethod: belongsTo('collection-method'), - collectionType: belongsTo('collection-type'), + project: belongsTo('project'), + studyLocation: belongsTo('study-location'), + collectionMethod: belongsTo('collection-method'), + collectionType: belongsTo('collection-type'), + adfgPermit: belongsTo('adfg-permit'), + + collectionSpecies: hasMany('collection-species'), + datasheets: hasMany('datasheet-attachment'), + envMeasurements: hasMany('collection-measurement'), + + // computed + species: mapBy('collectionSpecies', 'species'), + + speciesNames: mapBy('species', 'commonName'), + + counts: mapBy('collectionSpecies', 'count'), + + speciesAndCounts: computed('speciesNames', 'counts', function() { + const speciesNames = this.get('speciesNames'); + let counts = this.get('counts'); + counts = counts.map(c => c !== null ? c : 'No Count'); + return speciesNames.map((n, i) => `${n} (${counts[i]})`).join(', '); + }), startDateTime: computed('collectionStartDate', 'collectionStartTime', function() { return this._mergeDateTime('Start'); }), diff --git a/app/models/datasheet-attachment.js b/app/models/datasheet-attachment.js new file mode 100644 index 0000000..2c02202 --- /dev/null +++ b/app/models/datasheet-attachment.js @@ -0,0 +1,9 @@ +import DS from 'ember-data'; + +const { Model, attr, belongsTo } = DS; + +export default Model.extend({ + datasheet: attr('file'), + + collection: belongsTo('collection'), +}); diff --git a/app/models/region.js b/app/models/region.js new file mode 100644 index 0000000..3530700 --- /dev/null +++ b/app/models/region.js @@ -0,0 +1,11 @@ +import DS from 'ember-data'; + +const { Model, attr, hasMany } = DS; + +export default Model.extend({ + name: attr('string'), + code: attr('string'), + sortOrder: attr('number'), + + site: hasMany('site'), +}); diff --git a/app/models/sex.js b/app/models/sex.js new file mode 100644 index 0000000..6f3b265 --- /dev/null +++ b/app/models/sex.js @@ -0,0 +1,8 @@ +import DS from 'ember-data'; + +const { Model, attr } = DS; + +export default Model.extend({ + name: attr('string'), + sortOrder: attr('number'), +}); diff --git a/app/models/site.js b/app/models/site.js new file mode 100644 index 0000000..04402e7 --- /dev/null +++ b/app/models/site.js @@ -0,0 +1,13 @@ +import DS from 'ember-data'; + +const { Model, attr, hasMany, belongsTo } = DS; + +export default Model.extend({ + name: attr('string'), + code: attr('string'), + description: attr('string'), + sortOrder: attr('number'), + + region: belongsTo('region'), + studyLocation: hasMany('study-location'), +}); diff --git a/app/models/species.js b/app/models/species.js new file mode 100644 index 0000000..e66c4f9 --- /dev/null +++ b/app/models/species.js @@ -0,0 +1,11 @@ +import DS from 'ember-data'; + +const { Model, attr } = DS; + +export default Model.extend({ + commonName: attr('string'), + genus: attr('string'), + species: attr('string'), + parasite: attr('boolean'), + sortOrder: attr('number'), +}); diff --git a/app/models/study-location.js b/app/models/study-location.js index 7568218..28969aa 100644 --- a/app/models/study-location.js +++ b/app/models/study-location.js @@ -1,6 +1,6 @@ import DS from 'ember-data'; -const { Model, attr } = DS; +const { Model, attr, belongsTo } = DS; export default Model.extend({ name: attr('string'), @@ -10,4 +10,6 @@ export default Model.extend({ collectingLocation: attr('string'), description: attr('string'), sortOrder: attr('number'), + + site: belongsTo('site'), }); diff --git a/app/router.js b/app/router.js index 03cadfc..af47bd2 100644 --- a/app/router.js +++ b/app/router.js @@ -1,7 +1,7 @@ -import Ember from 'ember'; +import EmberRouter from '@ember/routing/router'; import config from './config/environment'; -const Router = Ember.Router.extend({ +const Router = EmberRouter.extend({ location: config.locationType, rootURL: config.rootURL }); @@ -10,8 +10,10 @@ Router.map(function() { this.route('login'); this.route('logout'); this.route('collections', function() { - this.route('1'); - this.route('new'); + this.route('create'); + this.route('detail', { path: '/:collection_id' }, function() { + this.route('edit'); + }); }); }); diff --git a/app/routes/application.js b/app/routes/application.js index 38bb6b3..b83c2c8 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; +import Route from '@ember/routing/route'; import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin'; -const { Route } = Ember; - export default Route.extend(ApplicationRouteMixin, {}); diff --git a/app/routes/collections/1.js b/app/routes/collections/1.js deleted file mode 100644 index 26d9f31..0000000 --- a/app/routes/collections/1.js +++ /dev/null @@ -1,4 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Route.extend({ -}); diff --git a/app/routes/collections/create.js b/app/routes/collections/create.js new file mode 100644 index 0000000..4c0ba55 --- /dev/null +++ b/app/routes/collections/create.js @@ -0,0 +1,23 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + model() { + const store = this.get('store'); + return RSVP.hash({ + model: store.createRecord('collection'), + projectOptions: store.findAll('project'), + studyLocationOptions: store.query('study-location', { page_size: 500 }), + collectionTypeOptions: store.findAll('collection-type'), + collectionMethodOptions: store.findAll('collection-method'), + speciesOptions: store.query('species', { page_size: 500 }), + adfgPermitOptions: store.findAll('adfg-permit'), + sexOptions: store.findAll('sex'), + }); + }, + + setupController(controller, models) { + this._super(...arguments); + controller.setProperties(models); + }, +}); diff --git a/app/routes/collections/detail.js b/app/routes/collections/detail.js new file mode 100644 index 0000000..354115c --- /dev/null +++ b/app/routes/collections/detail.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + model(params) { + return RSVP.all([ + this.get('store').findRecord('collection', params.collection_id, { + include: 'collection-species,datasheets,env-measurements', + }) + ]); + }, +}); diff --git a/app/routes/collections/detail/edit.js b/app/routes/collections/detail/edit.js new file mode 100644 index 0000000..21a6bd4 --- /dev/null +++ b/app/routes/collections/detail/edit.js @@ -0,0 +1,26 @@ +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; + +export default Route.extend({ + model() { + const store = this.get('store'); + const model = this.modelFor('collections.detail'); + return RSVP.hash({ + model: model, + projectOptions: store.findAll('project'), + studyLocationOptions: store.query('study-location', { page_size: 500 }), + collectionTypeOptions: store.findAll('collection-type'), + collectionMethodOptions: store.findAll('collection-method'), + speciesOptions: store.query('species', { page_size: 500 }), + adfgPermitOptions: store.findAll('adfg-permit'), + sexOptions: store.findAll('sex'), + }); + }, + + setupController(controller, models) { + this._super(...arguments); + // Unwrap the parent route's listified model + models.model = models.model[0]; + controller.setProperties(models); + }, +}); diff --git a/app/routes/collections/index.js b/app/routes/collections/index.js index 9b3a78a..2f65aba 100644 --- a/app/routes/collections/index.js +++ b/app/routes/collections/index.js @@ -1,11 +1,96 @@ -import Ember from 'ember'; - -const { Route } = Ember; +import Route from '@ember/routing/route'; +import RSVP from 'rsvp'; export default Route.extend({ - model() { - return this.get('store').findAll('collection', { - include: 'project,study-location,collection-method,collection-type' + queryParams: { + // qps are snake_case for the django api + page: { refreshModel: true }, + project: { refreshModel: true }, + region: { refreshModel: true }, + site: { refreshModel: true }, + study_location: { refreshModel: true }, + collection_method: { refreshModel: true }, + number_of_traps: { refreshModel: true }, + collection_start_date: { refreshModel: true }, + collection_end_date: { refreshModel: true }, + adfg_permit: { refreshModel: true }, + species: { refreshModel: true }, + }, + + model(params) { + const store = this.get('store'); + const includes = ['project', 'study-location', 'study-location.site', 'site', + 'collection-method', 'adfg-permit', 'collection-species', 'collection-species.species']; + const opts = { + include: includes.join(','), + }; + + return RSVP.hash({ + projectOptions: store.findAll('project'), + regionOptions: store.findAll('region'), + siteOptions: store.findAll('site'), + studyLocationOptions: store.query('study-location', { page_size: 500 }), + collectionMethodOptions: store.findAll('collection-method'), + adfgPermitOptions: store.findAll('adfg-permit'), + speciesOptions: store.query('species', { page_size: 500 }), + model: store.query('collection', Object.assign(params, opts)), }); - } + }, + + setupController(controller, models) { + this._super(...arguments); + controller.setProperties(models); + + const store = this.get('store'); + + /* eslint-disable no-console */ + + let project = controller.get('project'); + console.log('project', project); + project = project.map(id => store.peekRecord('project', id)); + + let region = controller.get('region'); + console.log('region', region); + region = region.map(id => store.peekRecord('region', id)); + + let site = controller.get('site'); + console.log('site', site); + site = site.map(id => store.peekRecord('site', id)); + + let studyLocation = controller.get('study_location'); + console.log('studyLocation', studyLocation); + studyLocation = studyLocation.map(id => store.peekRecord('study-location', id)); + + let collectionMethod = controller.get('collection_method'); + console.log('collectionMethod', collectionMethod); + collectionMethod = collectionMethod.map(id => store.peekRecord('collection-method', id)); + + let adfgPermit = controller.get('adfg_permit'); + console.log('adfgPermit', adfgPermit); + adfgPermit = adfgPermit.map(id => store.peekRecord('adfg-permit', id)); + + let species = controller.get('species'); + console.log('species', species); + species = species.map(id => store.peekRecord('species', id)); + + /* eslint-enable no-console */ + + const numberOfTraps = controller.get('number_of_traps'); + const collectionStartDate = controller.get('collection_start_date'); + const collectionEndDate = controller.get('collection_end_date'); + + let filter = { + project, + region, + site, + study_location: studyLocation, + collection_method: collectionMethod, + number_of_traps: numberOfTraps, + collection_start_date: collectionStartDate, + collection_end_date: collectionEndDate, + adfg_permit: adfgPermit, + species, + } + controller.set('filters', filter); + }, }); diff --git a/app/routes/collections/new.js b/app/routes/collections/new.js deleted file mode 100644 index 26d9f31..0000000 --- a/app/routes/collections/new.js +++ /dev/null @@ -1,4 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Route.extend({ -}); diff --git a/app/routes/index.js b/app/routes/index.js index ed916b6..6d9dcb7 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -1,6 +1,8 @@ -import Ember from 'ember'; +import Route from '@ember/routing/route'; import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin'; -const { Route } = Ember; - -export default Route.extend(AuthenticatedRouteMixin, {}); +export default Route.extend(AuthenticatedRouteMixin, { + afterModel() { + this.transitionTo('collections'); + }, +}); diff --git a/app/routes/login.js b/app/routes/login.js index a9687fd..e05233c 100644 --- a/app/routes/login.js +++ b/app/routes/login.js @@ -1,6 +1,4 @@ -import Ember from 'ember'; +import Route from '@ember/routing/route'; import UnauthenticatedRouteMixin from 'ember-simple-auth/mixins/unauthenticated-route-mixin'; -const { Route } = Ember; - export default Route.extend(UnauthenticatedRouteMixin, {}); diff --git a/app/routes/logout.js b/app/routes/logout.js index c83dc10..0056d12 100644 --- a/app/routes/logout.js +++ b/app/routes/logout.js @@ -1,6 +1,5 @@ -import Ember from 'ember'; - -const { Route, inject: { service }} = Ember; +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; export default Route.extend({ session: service('session'), diff --git a/app/serializers/application.js b/app/serializers/application.js new file mode 100644 index 0000000..d8d8701 --- /dev/null +++ b/app/serializers/application.js @@ -0,0 +1,16 @@ +import { capitalize } from '@ember/string'; +import DS from 'ember-data'; + +const { JSONAPISerializer } = DS; + +export default JSONAPISerializer.extend({ + payloadTypeFromModelName(modelName) { + return modelName.split('-').map(key => capitalize(key)).join(''); + }, + + normalizeArrayResponse(store, primaryModelClass, payload, id, requestType) { + let normalizedDocument = this._super(store, primaryModelClass, payload, id, requestType); + normalizedDocument.meta.links = normalizedDocument.links; + return normalizedDocument; + }, +}); diff --git a/app/styles/app.css b/app/styles/app.css index b74ca4b..1a2d097 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -1,12 +1,13 @@ -.content { - padding-left: 40px; - padding-right: 40px; - padding-top: 20px; - padding-bottom: 20px; +[data-ember-action]:not(:disabled) { + cursor: pointer; } -.top-buffer { - padding-top: 20px; +.table-nav .pager { + margin-top: 10px; +} + +.table-stats { + margin-top: 10px; } .form-signin { @@ -49,6 +50,10 @@ border-top-right-radius: 0; } +.top-buffer { + margin-top: 20px; +} + /* Sidebar */ .sidebar { position: fixed; diff --git a/app/templates/application.hbs b/app/templates/application.hbs index 28f30f7..51cd191 100644 --- a/app/templates/application.hbs +++ b/app/templates/application.hbs @@ -3,10 +3,6 @@