Merge pull request #38 from 67P/refactor/proposal

Refactor proposals
This commit was merged in pull request #38.
This commit is contained in:
2018-04-08 12:02:28 +00:00
committed by GitHub
11 changed files with 295 additions and 307 deletions
+44 -51
View File
@@ -1,67 +1,60 @@
import Ember from 'ember';
const {
Component,
isPresent,
inject: {
service
},
computed
} = Ember;
import Component from 'ember-component';
import computed, { and } from 'ember-computed';
import injectService from 'ember-service/inject';
import isPresent from 'kredits-web/utils/cps/is-present';
export default Component.extend({
kredits: injectService(),
kredits: service(),
// Default attributes used by reset
attributes: {
recipientId: null,
kind: 'community',
amount: null,
description: null,
url: null,
},
proposal: null,
contributors: null,
inProgress: false,
didInsertElement() {
this._super(...arguments);
this.reset();
},
isValidRecipient: computed('proposal.recipientAddress', function() {
// TODO: add proper address validation
return this.get('proposal.recipientAddress') !== '';
contributors: [],
isValidRecipient: isPresent('recipientId'),
isValidAmount: computed('amount', function() {
return parseInt(this.get('amount'), 10) > 0;
}),
isValidDescription: isPresent('description'),
isValidUrl: isPresent('url'),
isValid: and('isValidRecipient',
'isValidAmount',
'isValidDescription'),
isValidAmount: computed('proposal.amount', function() {
return parseInt(this.get('proposal.amount'), 10) > 0;
}),
isValidUrl: computed('proposal.url', function() {
return isPresent(this.get('proposal.url'));
}),
isValidDescription: computed('proposal.description', function() {
return isPresent(this.get('proposal.description'));
}),
isValid: computed.and('isValidRecipient',
'isValidAmount',
'isValidDescription'),
reset: function() {
this.setProperties(this.get('attributes'));
},
actions: {
save() {
if (! this.get('isValid')) {
submit() {
if (!this.get('isValid')) {
alert('Invalid data. Please review and try again.');
return false;
return;
}
this.set('inProgress', true);
let proposal = this.get('proposal');
// Set the recipient's IPFS profile hash so it can be used in the
// contribution object (which is to be stored in IPFS as well)
let contributor = this.get('contributors').findBy('address', proposal.get('recipientAddress'));
proposal.set('recipientProfile', contributor.get('ipfsHash'));
let attributes = Object.keys(this.get('attributes'));
let proposal = this.getProperties(attributes);
let saved = this.save(proposal);
this.get('kredits').addProposal(proposal)
.then(() => {
this.attrs.onSave();
}).catch((error) => {
Ember.Logger.error('[add-proposal] error creating the proposal', error);
alert('Something went wrong.');
}).finally(() => {
this.set('inProgress', false);
});
// The promise handles inProgress
this.set('inProgress', saved);
saved.then(() => {
this.reset();
window.scroll(0,0);
window.alert('Contributor added.');
});
}
}
});
+15 -13
View File
@@ -1,41 +1,43 @@
<form {{action "save" on="submit"}}>
<form {{action "submit" on="submit"}}>
<p>
<select required onchange={{action (mut proposal.recipientAddress) value="target.value"}}>
<select required onchange={{action (mut recipientId) value="target.value"}}>
<option value="" selected disabled hidden>Contributor</option>
{{#each contributors as |contributor|}}
<option value={{contributor.address}} selected={{eq proposal.recipientAddress contributor.address}}>{{contributor.github_username}}</option>
<option value={{contributor.id}} selected={{eq recipientId contributor.id}}>{{contributor.github_username}}</option>
{{/each}}
</select>
</p>
<p>
<select required onchange={{action (mut proposal.kind) value="target.value"}}>
<option value="community" selected={{eq proposal.kind "community"}}>Community</option>
<option value="design" selected={{eq proposal.kind "design"}}>Design</option>
<option value="dev" selected={{eq proposal.kind "dev"}}>Development</option>
<option value="docs" selected={{eq proposal.kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq proposal.kind "ops"}}>IT Operations</option>
<select required onchange={{action (mut kind) value="target.value"}}>
<option value="community" selected={{eq kind "community"}}>Community</option>
<option value="design" selected={{eq kind "design"}}>Design</option>
<option value="dev" selected={{eq kind "dev"}}>Development</option>
<option value="docs" selected={{eq kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq kind "ops"}}>IT Operations</option>
</select>
</p>
<p>
{{input type="text"
placeholder="100"
value=proposal.amount
value=amount
class=(if isValidAmount 'valid' '')}}
</p>
<p>
{{input type="text"
placeholder="Description"
value=proposal.description
value=description
class=(if isValidDescription 'valid' '')}}
</p>
<p>
{{input type="text"
placeholder="URL (optional)"
value=proposal.url
value=url
class=(if isValidUrl 'valid' '')}}
</p>
<p class="actions">
{{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
{{input type="submit"
disabled=(is-pending inProgress)
value=(if (is-pending inProgress) 'Processing' 'Save')}}
{{#link-to 'index'}}Back{{/link-to}}
</p>
</form>
+17 -22
View File
@@ -1,29 +1,24 @@
import Ember from 'ember';
import QueryParams from 'ember-parachute';
import Controller from 'ember-controller';
import { filterBy } from 'ember-computed';
import injectService from 'ember-service/inject';
export const queryParams = new QueryParams({
recipient: {
defaultValue: ''
},
amount: {
defaultValue: ''
},
url: {
defaultValue: ''
},
ipfsHash: {
defaultValue: ''
}
});
export default Controller.extend({
kredits: injectService(),
export default Ember.Controller.extend(queryParams.Mixin, {
contributors: null,
contributors: [],
minedContributors: filterBy('contributors', 'id'),
actions: {
onSave() {
this.transitionToRoute('index');
save(proposal) {
// contributorIpfsHash is needed for the proposal ipfs data. I'm not happy to do this here but I think to load all the contributors in addProposal again is a bit too much. I hope we can refactor it later.
let contributor = this.get('contributors').findBy('id', proposal.recipientId);
proposal.contributorIpfsHash = contributor.get('ipfsHash');
return this.get('kredits').addProposal(proposal)
.then((proposal) => {
this.transitionToRoute('index');
return proposal;
});
}
}
});
+2
View File
@@ -1,7 +1,9 @@
import ContributionSerializer from './serializers/contribution';
import ContributorSerializer from './serializers/contributor';
import { fromBytes32, toBytes32 } from './utils/multihash';
export {
ContributionSerializer,
ContributorSerializer,
fromBytes32,
toBytes32
@@ -0,0 +1,64 @@
/**
* Handle serialization for JSON-LD object of the contribution, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contribution.json
*
* @class
* @public
*/
export default class Contributor {
/**
* Deserialize JSON to object
*
* @method
* @public
*/
static deserialize(serialized) {
let {
kind,
description,
details,
url,
} = JSON.parse(serialized);
return {
kind,
description,
details,
url,
ipfsData: serialized,
};
}
/**
* Serialize object to JSON
*
* @method
* @public
*/
static serialize(deserialized) {
let {
contributorIpfsHash,
kind,
description,
url,
} = deserialized;
let data = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
"contributor": {
"ipfs": contributorIpfsHash
},
kind,
description,
"details": {}
};
if (url) {
data["url"] = url;
}
// Write it pretty to ipfs
return JSON.stringify(data, null, 2);
}
}
+15 -91
View File
@@ -1,103 +1,27 @@
import Ember from 'ember';
const {
isPresent,
isEmpty,
} = Ember;
export default Ember.Object.extend({
import EmberObject from 'ember-object';
export default EmberObject.extend({
// Contract
id: null,
creatorAddress: null,
recipientAddress: null,
recipientId: null,
recipientName: null,
recipientProfile: null,
amount: null,
votesCount: null,
votesNeeded: null,
amount: null,
executed: null,
contribution: null,
kind: null,
description: null,
url: null,
details: null,
ipfsHash: null,
/**
* Loads the contribution details from IPFS and sets local instance
* properties from it
*
* @method
* @public
*/
loadContribution(ipfs) {
let promise = new Ember.RSVP.Promise((resolve, reject) => {
ipfs.getFile(this.get('ipfsHash')).then(content => {
let contributionJSON = JSON.parse(content);
let contribution = Ember.Object.create(contributionJSON);
// IPFS
kind: null,
description: null,
details: {},
url: null,
ipfsData: '',
this.setProperties({
kind: contribution.get('kind'),
description: contribution.get('description'),
url: contribution.get('url')
});
// TODO load details
// let details = profile.get('accounts');
Ember.Logger.debug('[proposal] loaded contribution details', contributionJSON);
resolve();
}).catch((err) => {
Ember.Logger.error('[proposal] error trying to load contribution details', this.get('ipfsHash'), err);
reject(err);
});
});
return promise;
},
/**
* Creates a JSON-LD object of the contribution, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contribution.json
*
* @method
* @public
*/
contributionToJSON() {
if (isEmpty(this.get('recipientProfile'))) {
throw new Error('IPFS hash for recipient profile missing from proposal object');
}
if (isEmpty(this.get('kind')) || isEmpty(this.get('description'))) {
throw new Error('Missing one or more required properties: kind, description');
}
let contribution = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
"contributor": {
"ipfs": this.get('recipientProfile')
},
"kind": this.get('kind'),
"description": this.get('description'),
"details": {}
};
if (isPresent(this.get('url'))) {
contribution["url"] = this.get('url');
}
return contribution;
},
/**
* Returns the JSON-LD representation of the contribution as a string
*
* @method
* @public
*/
serializeContribution() {
return JSON.stringify(this.contributionToJSON());
}
// Deprecated
recipientAddress: null,
recipientName: null,
recipientProfile: null,
// TODO: add contributor relation
});
+9 -31
View File
@@ -1,37 +1,15 @@
import Ember from 'ember';
import Proposal from 'kredits-web/models/proposal';
const {
Route,
RSVP,
inject: {
service
}
} = Ember;
import injectService from 'ember-service/inject';
import Route from 'ember-route';
export default Route.extend({
kredits: injectService(),
kredits: service(),
setupController(controller) {
this._super(...arguments);
model(params) {
const proposal = Proposal.create({
recipientAddress: params.recipient,
amount: params.amount,
url: params.url,
kind: params.kind || 'dev',
ipfsHash: params.ipfsHash
});
return RSVP.hash({
proposal,
contributors: this.get('kredits').getContributors()
});
},
setupController(controller, model) {
this._super(controller, model.proposal);
controller.set('contributors', model.contributors);
this.get('kredits').getContributors()
.then((contributors) => {
controller.set('contributors', contributors);
});
}
});
+77 -49
View File
@@ -8,11 +8,11 @@ import computed, { alias } from 'ember-computed';
import { isEmpty, isPresent } from 'ember-utils';
import config from 'kredits-web/config/environment';
import Proposal from 'kredits-web/models/proposal';
import abis from 'contracts/abis';
import addresses from 'contracts/addresses';
import {
ContributionSerializer,
ContributorSerializer,
fromBytes32,
toBytes32
@@ -22,7 +22,6 @@ const {
getOwner,
Logger: {
debug,
warn,
error
}
} = Ember;
@@ -82,7 +81,11 @@ export default Service.extend({
registryContract: computed('ethProvider', function() {
let networkId = this.get('ethProvider').chainId;
let registry = new ethers.Contract(addresses['Registry'][networkId], abis['Registry'], this.get('ethProvider'));
let registry = new ethers.Contract(
addresses['Registry'][networkId],
abis['Registry'],
this.get('ethProvider')
);
return registry;
}),
@@ -115,6 +118,14 @@ export default Service.extend({
return contributor;
},
buildProposal(attributes) {
debug('[kredits] buildProposal', attributes);
let proposal = getOwner(this).lookup('model:proposal');
proposal.setProperties(attributes);
return proposal;
},
getContributorById(id) {
return this.get('contributorsContract')
.then((contract) => contract.getContributorById(id))
@@ -124,7 +135,7 @@ export default Service.extend({
let isCurrentUser = this.get('currentUserAccounts').includes(address);
return {
id,
id: id.toString(),
address,
balance: balance.toNumber(),
ipfsHash,
@@ -191,44 +202,50 @@ export default Service.extend({
});
},
getProposalData(i) {
getProposalById(id) {
return this.get('kreditsContract')
.then((contract) => contract.proposals(i))
.then(p => {
let { ipfsHash: digest, hashFunction, hashSize } = p;
let ipfsHash = fromBytes32({ digest, hashFunction, hashSize });
let proposal = Proposal.create({
id : i,
creatorAddress : p.creator,
recipientId : p.recipientId.toNumber(),
votesCount : p.votesCount.toNumber(),
votesNeeded : p.votesNeeded.toNumber(),
amount : p.amount.toNumber(),
executed : p.executed,
.then((contract) => contract.proposals(id))
.then(this.reassembleIpfsHash)
// Set basic data
.then(({
creator: creatorAddress,
recipientId,
votesCount,
votesNeeded,
amount,
executed,
ipfsHash,
}) => {
return {
id: id.toString(),
creatorAddress,
recipientId: recipientId.toNumber(),
votesCount: votesCount.toNumber(),
votesNeeded: votesNeeded.toNumber(),
amount: amount.toNumber(),
executed,
ipfsHash
});
if (proposal.get('ipfsHash')) {
// TODO: move ipfs into model
return proposal
.loadContribution(this.get('ipfs'))
.then(() => { return proposal; });
} else {
warn('[kredits] proposal from blockchain is missing IPFS hash', proposal);
return proposal;
}
};
})
// Fetch IPFS data if available
.then((data) => {
return this.fetchAndMergeIpfsData(data, ContributionSerializer);
})
.then((attributes) => {
return this.buildProposal(attributes);
});
},
getProposals() {
return this.get('kreditsContract')
.then((contract) => contract.proposalsCount())
.then(proposalsCount => {
.then((count) => {
count = count.toNumber();
debug('[kredits] proposals count:', count);
let proposals = [];
for(var i = 0; i < proposalsCount.toNumber(); i++) {
proposals.push(this.getProposalData(i));
for(var i = 0; i < count; i++) {
proposals.push(this.getProposalById(i));
}
return RSVP.all(proposals);
@@ -283,29 +300,40 @@ export default Service.extend({
});
},
addProposal(proposal) {
const {
recipientAddress,
amount,
url
} = proposal.getProperties('recipientAddress', 'amount', 'url');
addProposal(attributes) {
debug('[kredits] add proposal', attributes);
let json = ContributionSerializer.serialize(attributes);
// TODO: validate against schema
return this.get('ipfs')
.storeFile(proposal.serializeContribution())
.then(ipfsHash => {
.storeFile(json)
// Set ipfsHash
.then((ipfsHash) => {
delete attributes.contributorIpfsHash;
attributes.ipfsHash = ipfsHash;
return attributes;
})
.then((attributes) => {
return this.get('kreditsContract')
.then((contract) => {
return contract.addProposal(
recipientAddress,
let { recipientId, amount, ipfsHash } = attributes;
let { digest, hashFunction, hashSize } = toBytes32(ipfsHash);
let proposal = [
recipientId,
amount,
url,
ipfsHash
);
})
.then((data) => {
debug('[kredits] add proposal response', data);
return data;
digest,
hashFunction,
hashSize,
];
debug('[kredits] addProposal', ...proposal);
return contract.addProposal(...proposal);
});
})
.then((data) => {
debug('[kredits] add proposal response', data);
return this.buildProposal(attributes);
});
},
+1 -1
View File
@@ -4,7 +4,7 @@
</header>
<div class="content">
{{add-proposal proposal=model contributors=contributors onSave=(action 'onSave')}}
{{add-proposal contributors=minedContributors save=(action 'save')}}
</div>
</section>
@@ -0,0 +1,43 @@
import { module, test } from 'ember-qunit';
import schemas from 'npm:kosmos-schemas';
import tv4 from 'npm:tv4';
import { ContributionSerializer } from 'kredits-web/lib/kredits';
module('Serializers contribution');
test('#serialize returns a valid JSON-LD representation', function(assert) {
let serialized = ContributionSerializer.serialize({
recipientAddress: '0xd4a64570b12da659ee4bbd41c3509b7b1f9c51ac',
kind: 'design',
description: 'New logo design',
url: 'http://opensourcedesign.org',
contributorIpfsHash: 'QmT2A7rY4e7uoKktkcFHQNN7BD1oXdZTgd8wNkr1u9nNVE'
});
let valid = tv4.validate(JSON.parse(serialized), schemas['contribution']);
assert.ok(valid);
});
test('#deserialize returns a valid object representation', function(assert) {
let json = JSON.stringify({
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
"contributor": {
"ipfs": "QmT2A7rY4e7uoKktkcFHQNN7BD1oXdZTgd8wNkr1u9nNVE"
},
"kind": "design",
"description": "New logo design",
"details": {},
"url": "http://opensourcedesign.org"
});
let deserialized = ContributionSerializer.deserialize(json);
let expected = {
kind: 'design',
description: 'New logo design',
details: {},
url: 'http://opensourcedesign.org',
ipfsData: json,
};
assert.deepEqual(expected, deserialized);
});
+8 -49
View File
@@ -1,49 +1,8 @@
import { moduleFor, test } from 'ember-qunit';
import schemas from 'npm:kosmos-schemas';
import tv4 from 'npm:tv4';
moduleFor('model:proposal', 'Unit | Model | proposal');
test('#toJSON() requires a recipient profile IPFS hash to be set', function(assert) {
let model = this.subject();
model.setProperties({
recipientAddress: '0xd4a64570b12da659ee4bbd41c3509b7b1f9c51ac'
});
try {
let json = model.contributionToJSON();
assert.notOk(json, true);
} catch(e) {
assert.ok(e.message.match(/IPFS hash .* missing/i));
}
});
test('#toJSON() requires kind and description to be set', function(assert) {
let model = this.subject();
model.setProperties({
recipientProfile: 'QmT2A7rY4e7uoKktkcFHQNN7BD1oXdZTgd8wNkr1u9nNVE'
});
try {
let json = model.contributionToJSON();
assert.notOk(json, true);
} catch(e) {
assert.ok(e.message.match(/Missing .* kind.*description/i));
}
});
test('#toJSON() returns a valid JSON-LD representation of the model', function(assert) {
let model = this.subject();
model.setProperties({
recipientAddress: '0xd4a64570b12da659ee4bbd41c3509b7b1f9c51ac',
kind: 'design',
description: 'New logo design',
url: 'http://opensourcedesign.org',
recipientProfile: 'QmT2A7rY4e7uoKktkcFHQNN7BD1oXdZTgd8wNkr1u9nNVE'
});
assert.ok(tv4.validate(model.contributionToJSON(), schemas['contribution']));
});
// import { moduleFor, test } from 'ember-qunit';
//
// moduleFor('model:proposal', 'Unit | Model | proposal');
//
// test('contributor relation', function(assert) {
// // TODO: Test contributor relation
// assert.ok(true);
// });