From cc7c47cbab9892c66fff1dc42fc82a207377b1e0 Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 8 Apr 2018 02:00:46 +0200 Subject: [PATCH 1/6] Refactor proposal form --- app/components/add-proposal/component.js | 95 +++++++++++------------- app/components/add-proposal/template.hbs | 28 +++---- app/templates/proposals/new.hbs | 2 +- 3 files changed, 60 insertions(+), 65 deletions(-) diff --git a/app/components/add-proposal/component.js b/app/components/add-proposal/component.js index 7b7541f..e605fd6 100644 --- a/app/components/add-proposal/component.js +++ b/app/components/add-proposal/component.js @@ -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.'); + }); } } - }); diff --git a/app/components/add-proposal/template.hbs b/app/components/add-proposal/template.hbs index f04db3b..c7bb975 100644 --- a/app/components/add-proposal/template.hbs +++ b/app/components/add-proposal/template.hbs @@ -1,41 +1,43 @@ -
+

- {{#each contributors as |contributor|}} - + {{/each}}

- + + + + +

{{input type="text" placeholder="100" - value=proposal.amount + value=amount class=(if isValidAmount 'valid' '')}}

{{input type="text" placeholder="Description" - value=proposal.description + value=description class=(if isValidDescription 'valid' '')}}

{{input type="text" placeholder="URL (optional)" - value=proposal.url + value=url class=(if isValidUrl 'valid' '')}}

- {{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}}

diff --git a/app/templates/proposals/new.hbs b/app/templates/proposals/new.hbs index 820d989..24a9fe9 100644 --- a/app/templates/proposals/new.hbs +++ b/app/templates/proposals/new.hbs @@ -4,7 +4,7 @@
- {{add-proposal proposal=model contributors=contributors onSave=(action 'onSave')}} + {{add-proposal contributors=minedContributors save=(action 'save')}}
From 45044c0f6192e1bf60c354633b9b440b517c0b46 Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 8 Apr 2018 01:57:54 +0200 Subject: [PATCH 2/6] Refactor controller and route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I’m not sure how the queryParams are used so I removed them. I’m happy to readd them. --- app/controllers/proposals/new.js | 39 ++++++++++++++------------------ app/routes/proposals/new.js | 38 +++++++------------------------ 2 files changed, 25 insertions(+), 52 deletions(-) diff --git a/app/controllers/proposals/new.js b/app/controllers/proposals/new.js index fc6c6da..dbea973 100644 --- a/app/controllers/proposals/new.js +++ b/app/controllers/proposals/new.js @@ -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; + }); } } - }); diff --git a/app/routes/proposals/new.js b/app/routes/proposals/new.js index f5870d9..4c3110c 100644 --- a/app/routes/proposals/new.js +++ b/app/routes/proposals/new.js @@ -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: service(), - - 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() - }); - }, + kredits: injectService(), setupController(controller, model) { - this._super(controller, model.proposal); + this._super(...arguments); - controller.set('contributors', model.contributors); + this.get('kredits').getContributors() + .then((contributors) => { + this.controller.set('contributors', contributors); + }); } - }); From ad92ffa447d9381876689d60da26ba8ccaf6cd1b Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 8 Apr 2018 01:58:34 +0200 Subject: [PATCH 3/6] Extract serializer --- app/lib/kredits/index.js | 2 + app/lib/kredits/serializers/contribution.js | 64 ++++++++++++ app/models/proposal.js | 106 +++----------------- 3 files changed, 81 insertions(+), 91 deletions(-) create mode 100644 app/lib/kredits/serializers/contribution.js diff --git a/app/lib/kredits/index.js b/app/lib/kredits/index.js index 327bed5..7042bcf 100644 --- a/app/lib/kredits/index.js +++ b/app/lib/kredits/index.js @@ -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 diff --git a/app/lib/kredits/serializers/contribution.js b/app/lib/kredits/serializers/contribution.js new file mode 100644 index 0000000..0802eea --- /dev/null +++ b/app/lib/kredits/serializers/contribution.js @@ -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); + } +} diff --git a/app/models/proposal.js b/app/models/proposal.js index fa9fc71..4b49981 100644 --- a/app/models/proposal.js +++ b/app/models/proposal.js @@ -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 }); From b7316e15a2348a2f7637df0a11028c6b3a9ab348 Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 8 Apr 2018 02:00:02 +0200 Subject: [PATCH 4/6] Align *Proposal to *Contributor methods --- app/services/kredits.js | 126 ++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 49 deletions(-) diff --git a/app/services/kredits.js b/app/services/kredits.js index 897d418..2dfb3e0 100644 --- a/app/services/kredits.js +++ b/app/services/kredits.js @@ -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); }); }, From 31a1f21dcd3ff8d767886b48d1dfa16f7a68005c Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 8 Apr 2018 02:13:07 +0200 Subject: [PATCH 5/6] Fix linting --- app/routes/proposals/new.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/proposals/new.js b/app/routes/proposals/new.js index 4c3110c..657e6b2 100644 --- a/app/routes/proposals/new.js +++ b/app/routes/proposals/new.js @@ -4,12 +4,12 @@ import Route from 'ember-route'; export default Route.extend({ kredits: injectService(), - setupController(controller, model) { + setupController(controller) { this._super(...arguments); this.get('kredits').getContributors() .then((contributors) => { - this.controller.set('contributors', contributors); + controller.set('contributors', contributors); }); } }); From adcc81ba52cd0a40bcfe535e222a9f7789f28283 Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 8 Apr 2018 02:27:56 +0200 Subject: [PATCH 6/6] Move tests --- .../kredits/serializers/contribution-test.js | 43 ++++++++++++++ tests/unit/models/proposal-test.js | 57 +++---------------- 2 files changed, 51 insertions(+), 49 deletions(-) create mode 100644 tests/unit/lib/kredits/serializers/contribution-test.js diff --git a/tests/unit/lib/kredits/serializers/contribution-test.js b/tests/unit/lib/kredits/serializers/contribution-test.js new file mode 100644 index 0000000..5069a13 --- /dev/null +++ b/tests/unit/lib/kredits/serializers/contribution-test.js @@ -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); +}); diff --git a/tests/unit/models/proposal-test.js b/tests/unit/models/proposal-test.js index 023708b..acc9659 100644 --- a/tests/unit/models/proposal-test.js +++ b/tests/unit/models/proposal-test.js @@ -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); +// });