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'; import Component from 'ember-component';
import computed, { and } from 'ember-computed';
const { import injectService from 'ember-service/inject';
Component, import isPresent from 'kredits-web/utils/cps/is-present';
isPresent,
inject: {
service
},
computed
} = Ember;
export default Component.extend({ 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, didInsertElement() {
contributors: null, this._super(...arguments);
inProgress: false, this.reset();
},
isValidRecipient: computed('proposal.recipientAddress', function() { contributors: [],
// TODO: add proper address validation
return this.get('proposal.recipientAddress') !== ''; 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() { reset: function() {
return parseInt(this.get('proposal.amount'), 10) > 0; this.setProperties(this.get('attributes'));
}), },
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'),
actions: { actions: {
save() { submit() {
if (! this.get('isValid')) { if (!this.get('isValid')) {
alert('Invalid data. Please review and try again.'); 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 let attributes = Object.keys(this.get('attributes'));
// contribution object (which is to be stored in IPFS as well) let proposal = this.getProperties(attributes);
let contributor = this.get('contributors').findBy('address', proposal.get('recipientAddress')); let saved = this.save(proposal);
proposal.set('recipientProfile', contributor.get('ipfsHash'));
this.get('kredits').addProposal(proposal) // The promise handles inProgress
.then(() => { this.set('inProgress', saved);
this.attrs.onSave();
}).catch((error) => { saved.then(() => {
Ember.Logger.error('[add-proposal] error creating the proposal', error); this.reset();
alert('Something went wrong.'); window.scroll(0,0);
}).finally(() => { window.alert('Contributor added.');
this.set('inProgress', false); });
});
} }
} }
}); });
+15 -13
View File
@@ -1,41 +1,43 @@
<form {{action "save" on="submit"}}> <form {{action "submit" on="submit"}}>
<p> <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> <option value="" selected disabled hidden>Contributor</option>
{{#each contributors as |contributor|}} {{#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}} {{/each}}
</select> </select>
</p> </p>
<p> <p>
<select required onchange={{action (mut proposal.kind) value="target.value"}}> <select required onchange={{action (mut kind) value="target.value"}}>
<option value="community" selected={{eq proposal.kind "community"}}>Community</option> <option value="community" selected={{eq kind "community"}}>Community</option>
<option value="design" selected={{eq proposal.kind "design"}}>Design</option> <option value="design" selected={{eq kind "design"}}>Design</option>
<option value="dev" selected={{eq proposal.kind "dev"}}>Development</option> <option value="dev" selected={{eq kind "dev"}}>Development</option>
<option value="docs" selected={{eq proposal.kind "docs"}}>Documentation</option> <option value="docs" selected={{eq kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq proposal.kind "ops"}}>IT Operations</option> <option value="ops" selected={{eq kind "ops"}}>IT Operations</option>
</select> </select>
</p> </p>
<p> <p>
{{input type="text" {{input type="text"
placeholder="100" placeholder="100"
value=proposal.amount value=amount
class=(if isValidAmount 'valid' '')}} class=(if isValidAmount 'valid' '')}}
</p> </p>
<p> <p>
{{input type="text" {{input type="text"
placeholder="Description" placeholder="Description"
value=proposal.description value=description
class=(if isValidDescription 'valid' '')}} class=(if isValidDescription 'valid' '')}}
</p> </p>
<p> <p>
{{input type="text" {{input type="text"
placeholder="URL (optional)" placeholder="URL (optional)"
value=proposal.url value=url
class=(if isValidUrl 'valid' '')}} class=(if isValidUrl 'valid' '')}}
</p> </p>
<p class="actions"> <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}} {{#link-to 'index'}}Back{{/link-to}}
</p> </p>
</form> </form>
+17 -22
View File
@@ -1,29 +1,24 @@
import Ember from 'ember'; import Controller from 'ember-controller';
import QueryParams from 'ember-parachute'; import { filterBy } from 'ember-computed';
import injectService from 'ember-service/inject';
export const queryParams = new QueryParams({ export default Controller.extend({
recipient: { kredits: injectService(),
defaultValue: ''
},
amount: {
defaultValue: ''
},
url: {
defaultValue: ''
},
ipfsHash: {
defaultValue: ''
}
});
export default Ember.Controller.extend(queryParams.Mixin, { contributors: [],
minedContributors: filterBy('contributors', 'id'),
contributors: null,
actions: { actions: {
onSave() { save(proposal) {
this.transitionToRoute('index'); // 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 ContributorSerializer from './serializers/contributor';
import { fromBytes32, toBytes32 } from './utils/multihash'; import { fromBytes32, toBytes32 } from './utils/multihash';
export { export {
ContributionSerializer,
ContributorSerializer, ContributorSerializer,
fromBytes32, fromBytes32,
toBytes32 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'; import EmberObject from 'ember-object';
const {
isPresent,
isEmpty,
} = Ember;
export default Ember.Object.extend({
export default EmberObject.extend({
// Contract
id: null, id: null,
creatorAddress: null, creatorAddress: null,
recipientAddress: null,
recipientId: null, recipientId: null,
recipientName: null, amount: null,
recipientProfile: null,
votesCount: null, votesCount: null,
votesNeeded: null, votesNeeded: null,
amount: null,
executed: null, executed: null,
contribution: null,
kind: null,
description: null,
url: null,
details: null,
ipfsHash: null, ipfsHash: null,
/** // IPFS
* Loads the contribution details from IPFS and sets local instance kind: null,
* properties from it description: null,
* details: {},
* @method url: null,
* @public ipfsData: '',
*/
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);
this.setProperties({ // Deprecated
kind: contribution.get('kind'), recipientAddress: null,
description: contribution.get('description'), recipientName: null,
url: contribution.get('url') recipientProfile: null,
});
// 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());
}
// TODO: add contributor relation
}); });
+9 -31
View File
@@ -1,37 +1,15 @@
import Ember from 'ember'; import injectService from 'ember-service/inject';
import Proposal from 'kredits-web/models/proposal'; import Route from 'ember-route';
const {
Route,
RSVP,
inject: {
service
}
} = Ember;
export default Route.extend({ export default Route.extend({
kredits: injectService(),
kredits: service(), setupController(controller) {
this._super(...arguments);
model(params) { this.get('kredits').getContributors()
const proposal = Proposal.create({ .then((contributors) => {
recipientAddress: params.recipient, controller.set('contributors', contributors);
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);
} }
}); });
+77 -49
View File
@@ -8,11 +8,11 @@ import computed, { alias } from 'ember-computed';
import { isEmpty, isPresent } from 'ember-utils'; import { isEmpty, isPresent } from 'ember-utils';
import config from 'kredits-web/config/environment'; import config from 'kredits-web/config/environment';
import Proposal from 'kredits-web/models/proposal';
import abis from 'contracts/abis'; import abis from 'contracts/abis';
import addresses from 'contracts/addresses'; import addresses from 'contracts/addresses';
import { import {
ContributionSerializer,
ContributorSerializer, ContributorSerializer,
fromBytes32, fromBytes32,
toBytes32 toBytes32
@@ -22,7 +22,6 @@ const {
getOwner, getOwner,
Logger: { Logger: {
debug, debug,
warn,
error error
} }
} = Ember; } = Ember;
@@ -82,7 +81,11 @@ export default Service.extend({
registryContract: computed('ethProvider', function() { registryContract: computed('ethProvider', function() {
let networkId = this.get('ethProvider').chainId; 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; return registry;
}), }),
@@ -115,6 +118,14 @@ export default Service.extend({
return contributor; return contributor;
}, },
buildProposal(attributes) {
debug('[kredits] buildProposal', attributes);
let proposal = getOwner(this).lookup('model:proposal');
proposal.setProperties(attributes);
return proposal;
},
getContributorById(id) { getContributorById(id) {
return this.get('contributorsContract') return this.get('contributorsContract')
.then((contract) => contract.getContributorById(id)) .then((contract) => contract.getContributorById(id))
@@ -124,7 +135,7 @@ export default Service.extend({
let isCurrentUser = this.get('currentUserAccounts').includes(address); let isCurrentUser = this.get('currentUserAccounts').includes(address);
return { return {
id, id: id.toString(),
address, address,
balance: balance.toNumber(), balance: balance.toNumber(),
ipfsHash, ipfsHash,
@@ -191,44 +202,50 @@ export default Service.extend({
}); });
}, },
getProposalData(i) { getProposalById(id) {
return this.get('kreditsContract') return this.get('kreditsContract')
.then((contract) => contract.proposals(i)) .then((contract) => contract.proposals(id))
.then(p => { .then(this.reassembleIpfsHash)
let { ipfsHash: digest, hashFunction, hashSize } = p; // Set basic data
let ipfsHash = fromBytes32({ digest, hashFunction, hashSize }); .then(({
creator: creatorAddress,
let proposal = Proposal.create({ recipientId,
id : i, votesCount,
creatorAddress : p.creator, votesNeeded,
recipientId : p.recipientId.toNumber(), amount,
votesCount : p.votesCount.toNumber(), executed,
votesNeeded : p.votesNeeded.toNumber(), ipfsHash,
amount : p.amount.toNumber(), }) => {
executed : p.executed, return {
id: id.toString(),
creatorAddress,
recipientId: recipientId.toNumber(),
votesCount: votesCount.toNumber(),
votesNeeded: votesNeeded.toNumber(),
amount: amount.toNumber(),
executed,
ipfsHash ipfsHash
}); };
})
if (proposal.get('ipfsHash')) { // Fetch IPFS data if available
// TODO: move ipfs into model .then((data) => {
return proposal return this.fetchAndMergeIpfsData(data, ContributionSerializer);
.loadContribution(this.get('ipfs')) })
.then(() => { return proposal; }); .then((attributes) => {
} else { return this.buildProposal(attributes);
warn('[kredits] proposal from blockchain is missing IPFS hash', proposal);
return proposal;
}
}); });
}, },
getProposals() { getProposals() {
return this.get('kreditsContract') return this.get('kreditsContract')
.then((contract) => contract.proposalsCount()) .then((contract) => contract.proposalsCount())
.then(proposalsCount => { .then((count) => {
count = count.toNumber();
debug('[kredits] proposals count:', count);
let proposals = []; let proposals = [];
for(var i = 0; i < proposalsCount.toNumber(); i++) { for(var i = 0; i < count; i++) {
proposals.push(this.getProposalData(i)); proposals.push(this.getProposalById(i));
} }
return RSVP.all(proposals); return RSVP.all(proposals);
@@ -283,29 +300,40 @@ export default Service.extend({
}); });
}, },
addProposal(proposal) { addProposal(attributes) {
const { debug('[kredits] add proposal', attributes);
recipientAddress,
amount, let json = ContributionSerializer.serialize(attributes);
url // TODO: validate against schema
} = proposal.getProperties('recipientAddress', 'amount', 'url');
return this.get('ipfs') return this.get('ipfs')
.storeFile(proposal.serializeContribution()) .storeFile(json)
.then(ipfsHash => { // Set ipfsHash
.then((ipfsHash) => {
delete attributes.contributorIpfsHash;
attributes.ipfsHash = ipfsHash;
return attributes;
})
.then((attributes) => {
return this.get('kreditsContract') return this.get('kreditsContract')
.then((contract) => { .then((contract) => {
return contract.addProposal( let { recipientId, amount, ipfsHash } = attributes;
recipientAddress, let { digest, hashFunction, hashSize } = toBytes32(ipfsHash);
let proposal = [
recipientId,
amount, amount,
url, digest,
ipfsHash hashFunction,
); hashSize,
}) ];
.then((data) => { debug('[kredits] addProposal', ...proposal);
debug('[kredits] add proposal response', data); return contract.addProposal(...proposal);
return data;
}); });
})
.then((data) => {
debug('[kredits] add proposal response', data);
return this.buildProposal(attributes);
}); });
}, },
+1 -1
View File
@@ -4,7 +4,7 @@
</header> </header>
<div class="content"> <div class="content">
{{add-proposal proposal=model contributors=contributors onSave=(action 'onSave')}} {{add-proposal contributors=minedContributors save=(action 'save')}}
</div> </div>
</section> </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 { moduleFor, test } from 'ember-qunit';
import schemas from 'npm:kosmos-schemas'; //
import tv4 from 'npm:tv4'; // moduleFor('model:proposal', 'Unit | Model | proposal');
//
moduleFor('model:proposal', 'Unit | Model | proposal'); // test('contributor relation', function(assert) {
// // TODO: Test contributor relation
test('#toJSON() requires a recipient profile IPFS hash to be set', function(assert) { // assert.ok(true);
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']));
});