ENH: Collection Edit (parity with reading) (#48)
This commit is contained in:
parent
bfae4422f4
commit
cb3bc081a6
19 changed files with 337 additions and 121 deletions
|
@ -6,6 +6,7 @@ export default Component.extend({
|
|||
tagName: 'a',
|
||||
classNames: ['btn'],
|
||||
classNameBindings: [
|
||||
// Styles
|
||||
'isDefault:btn-default',
|
||||
'isPrimary:btn-primary',
|
||||
'isSuccess:btn-success',
|
||||
|
@ -13,9 +14,14 @@ export default Component.extend({
|
|||
'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,
|
||||
|
@ -23,6 +29,10 @@ export default Component.extend({
|
|||
isWarning: false,
|
||||
isDanger: false,
|
||||
isLink: false,
|
||||
// Sizes
|
||||
isLarge: false,
|
||||
isSmall: false,
|
||||
isXSmall: false,
|
||||
|
||||
label: 'LABEL',
|
||||
|
||||
|
|
|
@ -2,13 +2,57 @@ import Ember from 'ember';
|
|||
import Changeset from 'ember-changeset';
|
||||
import lookupValidator from 'ember-changeset-validations';
|
||||
|
||||
const { Component } = Ember;
|
||||
const { Component, inject: { service } } = Ember;
|
||||
|
||||
export default Component.extend({
|
||||
store: service(),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
const model = this.get('model');
|
||||
const validations = this.get('validations');
|
||||
this.set('changeset', new Changeset(model, lookupValidator(validations), validations));
|
||||
|
||||
let changesets = {};
|
||||
changesets['new'] = [];
|
||||
changesets['delete'] = [];
|
||||
changesets['hasMany'] = [];
|
||||
changesets['model'] = new Changeset(model,
|
||||
lookupValidator(validations['collection']),
|
||||
validations['collection']);
|
||||
|
||||
// TODO: gross, just grab these data in the route.
|
||||
model.get('collectionSpecies').then((collectionSpecies) => {
|
||||
let collectionSpeciesChangesets = [];
|
||||
collectionSpecies.forEach((cs) => {
|
||||
const changeset = new Changeset(cs,
|
||||
lookupValidator(validations['collectionSpecies']),
|
||||
validations['collectionSpecies']);
|
||||
collectionSpeciesChangesets.push({ model: cs, changeset: changeset });
|
||||
});
|
||||
changesets['hasMany']['collectionSpecies'] = collectionSpeciesChangesets;
|
||||
this.set('changesets', changesets);
|
||||
});
|
||||
},
|
||||
|
||||
actions: {
|
||||
addCollectionSpecies() {
|
||||
const store = this.get('store');
|
||||
let changesets = this.get('changesets');
|
||||
const validations = this.get('validations');
|
||||
const collection = this.get('model');
|
||||
const cs = store.createRecord('collection-species', { collection: collection });
|
||||
collection.get('collectionSpecies').pushObject(cs);
|
||||
changesets['new'].pushObject(cs);
|
||||
const changeset = new Changeset(cs,
|
||||
lookupValidator(validations['collectionSpecies']),
|
||||
validations['collectionSpecies']);
|
||||
changesets['hasMany']['collectionSpecies'].pushObject({ model: cs, changeset: changeset });
|
||||
},
|
||||
|
||||
deleteCollectionSpecies(changesetRecord) {
|
||||
let changesets = this.get('changesets');
|
||||
changesets['delete'].pushObject(changesetRecord.model);
|
||||
changesets['hasMany']['collectionSpecies'].removeObject(changesetRecord);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ export default Component.extend({
|
|||
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', },
|
||||
|
|
|
@ -4,5 +4,5 @@ const { Component } = Ember;
|
|||
|
||||
export default Component.extend({
|
||||
// ARGS
|
||||
changeset: null,
|
||||
changesets: null,
|
||||
});
|
||||
|
|
|
@ -11,4 +11,8 @@ export default Component.extend({
|
|||
const property = this.get('property');
|
||||
return isEmpty(get(changeset, `error.${property}`));
|
||||
}),
|
||||
|
||||
hasLabel: computed('label', function() {
|
||||
return !isEmpty(get(this, 'label'));
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -1,17 +1,31 @@
|
|||
import Ember from 'ember';
|
||||
import CollectionValidations from 'ccdb-web/validations/collection';
|
||||
import { schema } from 'ccdb-web/models/collection';
|
||||
import CollectionSpeciesValidations from 'ccdb-web/validations/collection-species';
|
||||
import ValidationMixin from 'ccdb-web/mixins/validation';
|
||||
|
||||
const { Controller } = Ember;
|
||||
const { Controller, computed } = Ember;
|
||||
|
||||
export default Controller.extend(ValidationMixin, {
|
||||
CollectionValidations,
|
||||
CollectionSpeciesValidations,
|
||||
|
||||
options: computed('projectOptions', 'studyLocationOptions',
|
||||
'collectionTypeOptions', 'collectionMethodOptions',
|
||||
'speciesOptions', 'adfgPermitOptions', 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'),
|
||||
};
|
||||
}),
|
||||
|
||||
actions: {
|
||||
onSave(changeset) {
|
||||
const postSave = () => { this.transitionToRoute('collections.index'); };
|
||||
return this.validationSave(changeset, schema, postSave);
|
||||
return this.validationSave(changeset, postSave);
|
||||
},
|
||||
onCancel(changeset) {
|
||||
const postCancel = () => { this.transitionToRoute('collections.index'); };
|
||||
|
|
|
@ -1,27 +1,41 @@
|
|||
import Ember from 'ember';
|
||||
import CollectionValidations from 'ccdb-web/validations/collection';
|
||||
import { schema } from 'ccdb-web/models/collection';
|
||||
import CollectionSpeciesValidations from 'ccdb-web/validations/collection-species';
|
||||
import ValidationMixin from 'ccdb-web/mixins/validation';
|
||||
|
||||
const { Controller } = Ember;
|
||||
const { Controller, computed } = Ember;
|
||||
|
||||
export default Controller.extend(ValidationMixin, {
|
||||
CollectionValidations,
|
||||
CollectionSpeciesValidations,
|
||||
|
||||
options: computed('projectOptions', 'studyLocationOptions',
|
||||
'collectionTypeOptions', 'collectionMethodOptions',
|
||||
'speciesOptions', 'adfgPermitOptions', 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'),
|
||||
};
|
||||
}),
|
||||
|
||||
actions: {
|
||||
onSave(changeset) {
|
||||
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.validationSave(changeset, schema, postSave);
|
||||
return this.validationSave(changesets, postSave);
|
||||
},
|
||||
onCancel(changeset) {
|
||||
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.validationCancel(changeset, postCancel);
|
||||
return this.validationCancel(changesets, postCancel);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,30 +1,70 @@
|
|||
import Ember from 'ember';
|
||||
|
||||
const { Mixin, get } = Ember;
|
||||
const { Mixin, get, RSVP } = Ember;
|
||||
const { keys } = Object;
|
||||
const { isArray } = Array;
|
||||
|
||||
export default Mixin.create({
|
||||
validationSave(changeset, schema, postSave) {
|
||||
return changeset
|
||||
.cast(keys(schema))
|
||||
.validate()
|
||||
.then(() => {
|
||||
if (changeset.get('isValid')) {
|
||||
return changeset.save().then(postSave);
|
||||
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());
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
// 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 */
|
||||
get(this, 'model.errors').forEach(({ attribute, message }) => {
|
||||
changeset.pushErrors(attribute, message);
|
||||
// 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(changeset, postCancel) {
|
||||
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 (isArray(changesets[key])) { // hasMany
|
||||
for (const { changeset } of changesets[key]) {
|
||||
changeset.rollback();
|
||||
}
|
||||
} else { // single
|
||||
const changeset = changesets[key];
|
||||
changeset.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
return postCancel();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ const { Model, attr, belongsTo } = DS;
|
|||
export default Model.extend({
|
||||
sex: attr('string'),
|
||||
count: attr('number'),
|
||||
countEstimated: attr('boolean'),
|
||||
countEstimated: attr('boolean', { defaultValue: false }),
|
||||
|
||||
collection: belongsTo('collection'),
|
||||
species: belongsTo('species'),
|
||||
|
|
|
@ -4,7 +4,7 @@ import DS from 'ember-data';
|
|||
const { computed } = Ember;
|
||||
const { Model, attr, belongsTo, hasMany } = DS;
|
||||
|
||||
export const schema = {
|
||||
export default Model.extend({
|
||||
displayName: attr('string'),
|
||||
numberOfTraps: attr('number'),
|
||||
collectionStartDate: attr('string-null-to-empty'),
|
||||
|
@ -18,20 +18,22 @@ export const schema = {
|
|||
collectionType: belongsTo('collection-type'),
|
||||
adfgPermit: belongsTo('adfg-permit'),
|
||||
|
||||
collectionSpecies: hasMany('collection-species', { async: false }),
|
||||
collectionSpecies: hasMany('collection-species'),
|
||||
|
||||
// computed
|
||||
species: computed.mapBy('collectionSpecies', 'species'),
|
||||
|
||||
speciesNames: computed.mapBy('species', 'commonName'),
|
||||
|
||||
counts: computed.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(', ');
|
||||
}),
|
||||
};
|
||||
|
||||
export default Model.extend(Object.assign({}, schema, {
|
||||
startDateTime: computed('collectionStartDate', 'collectionStartTime',
|
||||
function() { return this._mergeDateTime('Start'); }),
|
||||
|
||||
|
@ -43,4 +45,4 @@ export default Model.extend(Object.assign({}, schema, {
|
|||
const time = this.get(`collection${timepoint}Time`);
|
||||
return `${date} ${time}`.trim();
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -11,6 +11,8 @@ export default Route.extend({
|
|||
studyLocationOptions: store.findAll('study-location'),
|
||||
collectionTypeOptions: store.findAll('collection-type'),
|
||||
collectionMethodOptions: store.findAll('collection-method'),
|
||||
speciesOptions: store.findAll('species'),
|
||||
adfgPermitOptions: store.findAll('adfg-permit'),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@ export default Route.extend({
|
|||
studyLocationOptions: store.findAll('study-location'),
|
||||
collectionTypeOptions: store.findAll('collection-type'),
|
||||
collectionMethodOptions: store.findAll('collection-method'),
|
||||
speciesOptions: store.findAll('species'),
|
||||
adfgPermitOptions: store.findAll('adfg-permit'),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
{{
|
||||
collection/create-container
|
||||
model=model
|
||||
validations=CollectionValidations
|
||||
projectOptions=projectOptions
|
||||
studyLocationOptions=studyLocationOptions
|
||||
collectionTypeOptions=collectionTypeOptions
|
||||
collectionMethodOptions=collectionMethodOptions
|
||||
validations=(hash
|
||||
collection=CollectionValidations
|
||||
collectionSpecies=CollectionSpeciesValidations)
|
||||
options=options
|
||||
onSave=(action 'onSave')
|
||||
onCancel=(action 'onCancel')
|
||||
}}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
{{
|
||||
collection/create-container
|
||||
model=model
|
||||
validations=CollectionValidations
|
||||
projectOptions=projectOptions
|
||||
studyLocationOptions=studyLocationOptions
|
||||
collectionTypeOptions=collectionTypeOptions
|
||||
collectionMethodOptions=collectionMethodOptions
|
||||
validations=(hash
|
||||
collection=CollectionValidations
|
||||
collectionSpecies=CollectionSpeciesValidations)
|
||||
options=options
|
||||
onSave=(action 'onSave')
|
||||
onCancel=(action 'onCancel')
|
||||
}}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
{{#crud-form
|
||||
changeset=changeset
|
||||
changesets=changesets
|
||||
onSave=(action onSave)
|
||||
onCancel=(action onCancel) as |f|
|
||||
}}
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="well">
|
||||
{{#f.content class='form'}}
|
||||
{{#with changesets.model as |changeset|}}
|
||||
{{#validated-field property='project' label='Project' changeset=changeset}}
|
||||
{{#power-select
|
||||
options=projectOptions
|
||||
options=options.projects
|
||||
selected=changeset.project
|
||||
onchange=(action (mut changeset.project))
|
||||
searchField='name'
|
||||
|
@ -17,9 +20,21 @@
|
|||
{{/power-select}}
|
||||
{{/validated-field}}
|
||||
|
||||
{{#validated-field property='adfgPermit' label='ADFG Permit' changeset=changeset}}
|
||||
{{#power-select
|
||||
options=options.adfgPermits
|
||||
selected=changeset.adfgPermit
|
||||
onchange=(action (mut changeset.adfgPermit))
|
||||
searchField='name'
|
||||
as |adfgPermit|
|
||||
}}
|
||||
{{adfgPermit.name}}
|
||||
{{/power-select}}
|
||||
{{/validated-field}}
|
||||
|
||||
{{#validated-field property='studyLocation' label='Study location' changeset=changeset}}
|
||||
{{#power-select
|
||||
options=studyLocationOptions
|
||||
options=options.studyLocations
|
||||
selected=changeset.studyLocation
|
||||
onchange=(action (mut changeset.studyLocation))
|
||||
searchField='name'
|
||||
|
@ -31,7 +46,7 @@
|
|||
|
||||
{{#validated-field property='collectionType' label='Collection type' changeset=changeset}}
|
||||
{{#power-select
|
||||
options=collectionTypeOptions
|
||||
options=options.collectionTypes
|
||||
selected=changeset.collectionType
|
||||
onchange=(action (mut changeset.collectionType))
|
||||
searchField='name'
|
||||
|
@ -43,7 +58,7 @@
|
|||
|
||||
{{#validated-field property='collectionMethod' label='Collection method' changeset=changeset}}
|
||||
{{#power-select
|
||||
options=collectionMethodOptions
|
||||
options=options.collectionMethods
|
||||
selected=changeset.collectionMethod
|
||||
onchange=(action (mut changeset.collectionMethod))
|
||||
searchField='name'
|
||||
|
@ -72,9 +87,64 @@
|
|||
{{#validated-field property='collectionEndTime' label='Collection end time' changeset=changeset}}
|
||||
{{input value=changeset.collectionEndTime type='time' class='form-control'}}
|
||||
{{/validated-field}}
|
||||
|
||||
{{/with}}
|
||||
{{/f.content}}
|
||||
|
||||
{{f.save}} {{f.cancel}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<table class="table">
|
||||
<caption>
|
||||
Species / Count Info
|
||||
{{action-button isSuccess=true isXSmall=true label='+' onClick=(action 'addCollectionSpecies')}}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-md-3">Species</th>
|
||||
<th class="col-md-3">Count</th>
|
||||
<th class="col-md-3">Count Estimated</th>
|
||||
<th class="col-md-3">Sex</th>
|
||||
<th class="col-md-1">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each changesets.hasMany.collectionSpecies as |cs|}}
|
||||
<tr class="form">
|
||||
<td class="col-md-3">
|
||||
{{#validated-field property='species' changeset=cs.changeset}}
|
||||
{{#power-select
|
||||
options=options.species
|
||||
selected=cs.changeset.species
|
||||
onchange=(action (mut cs.changeset.species))
|
||||
searchField='commonName'
|
||||
as |species|
|
||||
}}
|
||||
{{species.commonName}}
|
||||
{{/power-select}}
|
||||
{{/validated-field}}
|
||||
</td>
|
||||
<td class="col-md-3">
|
||||
{{#validated-field property='count' changeset=cs.changeset}}
|
||||
{{input value=cs.changeset.count}}
|
||||
{{/validated-field}}
|
||||
</td>
|
||||
<td class="col-md-3">
|
||||
{{#validated-field property='countEstimated' changeset=cs.changeset}}
|
||||
{{input checked=cs.changeset.countEstimated type='checkbox'}}
|
||||
{{/validated-field}}
|
||||
</td>
|
||||
<td class="col-md-3">
|
||||
{{#validated-field property='sex' changeset=cs.changeset}}
|
||||
{{input value=cs.changeset.sex}}
|
||||
{{/validated-field}}
|
||||
</td>
|
||||
<th class="col-md-2">
|
||||
{{action-button isDanger=true isXSmall=true label='X' onClick=(action 'deleteCollectionSpecies' cs)}}
|
||||
</th>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{f.save}} {{f.cancel}}
|
||||
{{/crud-form}}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
{{#if hasBlock}}
|
||||
{{yield (hash
|
||||
content=(component 'form-content' changeset=changeset)
|
||||
content=(component 'form-content')
|
||||
cancel=(component 'action-button'
|
||||
label='Cancel'
|
||||
isDanger=true
|
||||
onClick=(action onCancel changeset))
|
||||
onClick=(action onCancel changesets))
|
||||
save=(component 'action-button'
|
||||
label='Save'
|
||||
isSuccess=true
|
||||
onClick=(action onSave changeset))
|
||||
onClick=(action onSave changesets))
|
||||
)}}
|
||||
{{else}}
|
||||
MISSING CONTENT BLOCK
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
{{#if hasLabel}}
|
||||
<label class="control-label">{{label}}</label>
|
||||
{{/if}}
|
||||
|
||||
{{yield}}
|
||||
|
||||
{{#if (get changeset.error property)}}
|
||||
|
|
|
@ -8,6 +8,6 @@ export default Transform.extend({
|
|||
},
|
||||
|
||||
serialize(deserialized) {
|
||||
return deserialized;
|
||||
return deserialized === '' ? null : deserialized;
|
||||
}
|
||||
});
|
||||
|
|
12
app/validations/collection-species.js
Normal file
12
app/validations/collection-species.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import {
|
||||
validatePresence,
|
||||
validateNumber,
|
||||
} from 'ember-changeset-validations/validators';
|
||||
|
||||
export default {
|
||||
sex: validatePresence(true),
|
||||
count: validateNumber({ allowBlank: true, integer: true, positive: true }),
|
||||
countEstimated: validatePresence(true),
|
||||
species: validatePresence(true),
|
||||
collection: validatePresence(true),
|
||||
}
|
Loading…
Add table
Reference in a new issue