Move all metadata to IPFS with proper schemas #16

Merged
raucao merged 23 commits from feature/ipfs-metadata into master 2017-06-08 22:28:15 +00:00
24 changed files with 478 additions and 177 deletions
+43 -31
View File
@@ -1,41 +1,58 @@
import Ember from 'ember'; import Ember from 'ember';
galfert commented 2017-06-08 13:40:36 +00:00 (Migrated from github.com)
Review

I think this is supposed to be newContributor.url instead of newContributor.name.

I think this is supposed to be `newContributor.url` instead of `newContributor.name`.
galfert commented 2017-06-08 13:43:45 +00:00 (Migrated from github.com)
Review

Not sure about this, but maybe it makes sense to add kredits.web3Instance as dependent key as well? So the property gets recalculated when kredits.web3Instance changes. Thinking of that race condition where the user provided web3 is only available after a while.

Not sure about this, but maybe it makes sense to add `kredits.web3Instance` as dependent key as well? So the property gets recalculated when `kredits.web3Instance` changes. Thinking of that race condition where the user provided web3 is only available after a while.
galfert commented 2017-06-08 15:03:56 +00:00 (Migrated from github.com)
Review

I think you missed this one.

I think you missed this one.
import Contributor from 'kredits-web/models/contributor';
export default Ember.Component.extend({ const {
Component,
isPresent,
inject: {
service
},
computed
} = Ember;
id: null, export default Component.extend({
realName: null,
address: null,
ipfsHash: null,
isCore: false,
kredits: service(),
newContributor: null,
inProgress: false, inProgress: false,
isValidId: function() {
return Ember.isPresent(this.get('id'));
}.property('id'),
isValidRealName: function() {
return Ember.isPresent(this.get('realName'));
}.property('realName'),
isValidAddress: function() { isValidAddress: function() {
return this.get('kredits.web3Instance').isAddress(this.get('address')); return this.get('kredits.web3')
}.property('address'), .isAddress(this.get('newContributor.address'));
}.property('kredits.web3', 'newContributor.address'),
isValid: function() { isValidName: function() {
return this.get('isValidId') && this.get('isValidRealName') && this.get('isValidAddress'); return isPresent(this.get('newContributor.name'));
}.property('isValidAddress', 'isValidId', 'isValidRealName'), }.property('newContributor.name'),
isValidURL: function() {
return isPresent(this.get('newContributor.url'));
}.property('newContributor.url'),
isValidGithubUID: function() {
return isPresent(this.get('newContributor.github_uid'));
}.property('newContributor.github_uid'),
isValidGithubUsername: function() {
return isPresent(this.get('newContributor.github_username'));
}.property('newContributor.github_username'),
isValidWikiUsername: function() {
return isPresent(this.get('newContributor.wiki_username'));
}.property('newContributor.wiki_username'),
isValid: computed.and(
'isValidAddress',
'isValidName',
bumi commented 2017-06-08 21:02:31 +00:00 (Migrated from github.com)
Review

could we validate these using some json schema validators?

could we validate these using some json schema validators?
raucao commented 2017-06-08 21:07:23 +00:00 (Migrated from github.com)
Review

In theory yes (there's e.g. "format": "uri"), but it'd be much easier here to just have some simple regexp for HTTP URIs imo (which also keeps the build size down).

In theory yes (there's e.g. `"format": "uri"`), but it'd be much easier here to just have some simple regexp for HTTP URIs imo (which also keeps the build size down).
'isValidGithubUID'
),
reset: function() { reset: function() {
this.setProperties({ this.setProperties({
id: null, newContributor: Contributor.create({ kind: 'person' }),
realName: null,
address: null,
ipfsHash: null,
isCore: true,
inProgress: false inProgress: false
}); });
}, },
actions: { actions: {
@@ -49,12 +66,7 @@ export default Ember.Component.extend({
if (this.get('isValid')) { if (this.get('isValid')) {
this.set('inProgress', true); this.set('inProgress', true);
this.get('kredits').addContributor( this.get('kredits').addContributor(this.get('newContributor')).then(contributor => {
this.get('address'),
this.get('realName'),
this.get('isCore'),
this.get('id')
).then(contributor => {
this.reset(); this.reset();
this.get('contributors').pushObject(contributor); this.get('contributors').pushObject(contributor);
window.scroll(0,0); window.scroll(0,0);
+43 -16
View File
@@ -1,31 +1,58 @@
<form {{action "save" on="submit"}}> <form {{action "save" on="submit"}}>
<p> <p>
{{input type="checkbox" name="is-core" id="is-core" checked=isCore}} {{input type="checkbox" name="is-core" id="is-core" checked=newContributor.isCore}}
<label for="is-core" class="checkbox"> <label for="is-core" class="checkbox">
Core team member (can add contributors) Core team member (can add contributors)
</label> </label>
</p> </p>
<p>
{{input name="id"
type="text"
placeholder="GitHub UID (123)"
value=id
class=(if isValidId 'valid' '')}}
</p>
<p>
{{input name="realname"
type="text"
placeholder="GitHub username"
value=realName
class=(if isValidRealName 'valid' '')}}
</p>
<p> <p>
{{input name="address" {{input name="address"
type="text" type="text"
placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4" placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4"
value=address value=newContributor.address
class=(if isValidAddress 'valid' '')}} class=(if isValidAddress 'valid' '')}}
</p> </p>
<p>
<select required onchange={{action (mut newContributor.kind) value="target.value"}}>
<option value="person" selected={{eq newContributor.kind "person"}}>Person</option>
<option value="organization" selected={{eq newContributor.kind "organization"}}>Organization</option>
</select>
</p>
<p>
{{input name="name"
type="text"
placeholder="Name"
value=newContributor.name
class=(if isValidName 'valid' '')}}
</p>
<p>
{{input name="url"
type="text"
placeholder="URL"
value=newContributor.url
class=(if isValidURL 'valid' '')}}
</p>
<p>
{{input name="github_uid"
type="text"
placeholder="GitHub UID (123)"
value=newContributor.github_uid
class=(if isValidGithubUID 'valid' '')}}
</p>
<p>
{{input name="github_username"
type="text"
placeholder="GitHub username"
value=newContributor.github_username
class=(if isValidGithubUsername 'valid' '')}}
</p>
<p>
{{input name="wiki_username"
type="text"
placeholder="Wiki Username"
value=newContributor.wiki_username
class=(if isValidWikiUsername 'valid' '')}}
</p>
<p class="actions"> <p class="actions">
{{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}} {{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
</p> </p>
+22 -16
View File
@@ -2,6 +2,7 @@ import Ember from 'ember';
const { const {
Component, Component,
isPresent,
inject: { inject: {
service service
}, },
@@ -13,47 +14,52 @@ export default Component.extend({
kredits: service(), kredits: service(),
galfert commented 2017-06-08 13:52:17 +00:00 (Migrated from github.com)
Review

You should always use a radix parameter of 10 with parseInt.

You should always use a radix parameter of `10` with `parseInt`.
raucao commented 2017-06-08 14:23:30 +00:00 (Migrated from github.com)
Review

What does that mean?

What does that mean?
galfert commented 2017-06-08 14:36:58 +00:00 (Migrated from github.com)
Review

The second parameter of parseInt should always be 10 if you want it to be parsed as decimal number. So in this case it should be parseInt(this.get('proposal.amount'), 10).
Otherwise, in certain situations the number will be interpreted with a different base (e.g. when the number starts with a 0, it will be octal).

The second parameter of `parseInt` should always be `10` if you want it to be parsed as decimal number. So in this case it should be `parseInt(this.get('proposal.amount'), 10)`. Otherwise, in certain situations the number will be interpreted with a different base (e.g. when the number starts with a 0, it will be octal).
raucao commented 2017-06-08 14:54:04 +00:00 (Migrated from github.com)
Review

I see, thanks.

I see, thanks.
proposal: null, proposal: null,
contributors: null,
inProgress: false, inProgress: false,
isValidRecipient: computed('proposal.recipientAddress', function() { isValidRecipient: computed('proposal.recipientAddress', function() {
return this.get('kredits.web3Instance').isAddress(this.get('proposal.recipientAddress')); return this.get('kredits.web3').isAddress(this.get('proposal.recipientAddress'));
}), }),
isValidAmount: computed('proposal.amount', function() { isValidAmount: computed('proposal.amount', function() {
// TODO return parseInt(this.get('proposal.amount'), 10) > 0;
return true;
}), }),
isValidUrl: computed('proposal.url', function() { isValidUrl: computed('proposal.url', function() {
// TODO return isPresent(this.get('proposal.url'));
return true;
}), }),
galfert commented 2017-06-08 13:50:55 +00:00 (Migrated from github.com)
Review

Is this supposed to be kredits.web3 now? Because in the add-contributor component it's still using kredits.web3Instance.

Is this supposed to be `kredits.web3` now? Because in the `add-contributor` component it's still using `kredits.web3Instance`.
raucao commented 2017-06-08 14:23:11 +00:00 (Migrated from github.com)
Review

Not "now", but in general, yes. I don't know why it's not in the add-contributor component.

Not "now", but in general, yes. I don't know why it's not in the add-contributor component.
isValidIpfsHash: computed('proposal.ipfsHash', function() { isValidDescription: computed('proposal.description', function() {
// TODO return isPresent(this.get('proposal.description'));
return true;
}), }),
isValid: computed.and('isValidRecipient', 'isValidAmount', 'isValidUrl', isValid: computed.and('isValidRecipient',
'isValidIpfsHash'), 'isValidAmount',
'isValidDescription'),
actions: { actions: {
save() { save() {
if (this.get('isValid')) { if (! this.get('isValid')) {
alert('Invalid data. Please review and try again.');
return false;
}
this.set('inProgress', true); this.set('inProgress', true);
let proposal = this.get('proposal');
this.get('kredits').addProposal(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'));
this.get('kredits').addProposal(proposal)
.then(() => { .then(() => {
this.attrs.onSave(); this.attrs.onSave();
}).catch((error) => { }).catch((error) => {
Ember.Logger.error('Error creating the proposal', error); Ember.Logger.error('[add-proposal] error creating the proposal', error);
alert('Something went wrong.'); alert('Something went wrong.');
}).finally(() => { }).finally(() => {
this.set('inProgress', false); this.set('inProgress', false);
}); });
} else {
alert('Invalid data. Please review and try again.');
}
} }
} }
+15 -6
View File
@@ -7,6 +7,15 @@
{{/each}} {{/each}}
</select> </select>
</p> </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>
</p>
<p> <p>
{{input type="text" {{input type="text"
placeholder="100" placeholder="100"
@@ -15,15 +24,15 @@
</p> </p>
<p> <p>
{{input type="text" {{input type="text"
placeholder="URL" placeholder="Description"
value=proposal.url value=proposal.description
class=(if isValidUrl 'valid' '')}} class=(if isValidDescription 'valid' '')}}
</p> </p>
<p> <p>
{{input type="text" {{input type="text"
placeholder="IPFS Hash" placeholder="URL (optional)"
value=proposal.ipfsHash value=proposal.url
class=(if isValidIpfsHash '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" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
+5 -7
View File
@@ -3,7 +3,7 @@
<tr class="{{if contributor.isCurrentUser 'current-user'}}" {{action "toggleContributorInfo" contributor}}> <tr class="{{if contributor.isCurrentUser 'current-user'}}" {{action "toggleContributorInfo" contributor}}>
<td class="person"> <td class="person">
<img class="avatar" src={{contributor.avatarURL}}> <img class="avatar" src={{contributor.avatarURL}}>
{{contributor.github_username}} {{contributor.name}}
</td> </td>
<td class="kredits"> <td class="kredits">
<span class="amount">{{contributor.kredits}}</span> <span class="amount">{{contributor.kredits}}</span>
@@ -12,14 +12,12 @@
</tr> </tr>
<tr class="metadata {{if contributor.isCurrentUser 'current-user'}} {{if contributor.showMetadata 'visible'}}"> <tr class="metadata {{if contributor.isCurrentUser 'current-user'}} {{if contributor.showMetadata 'visible'}}">
<td colspan="2"> <td colspan="2">
<dl> <ul>
<dt>Ethereum address</dt> <li><a href="https://testnet.etherscan.io/address/{{contributor.address}}">Inspect Ethereum transactions</a></li>
<dd><a href="https://testnet.etherscan.io/address/{{contributor.address}}">{{contributor.address}}</a></dd>
{{#if contributor.ipfsHash}} {{#if contributor.ipfsHash}}
<dt>IPFS profile data</dt> <li><a href="https://ipfs.io/ipfs/{{contributor.ipfsHash}}">Inspect IPFS profile</a></li>
<dd><a href="https://ipfs.io/ipfs/{{contributor.ipfsHash}}">{{contributor.ipfsHash}}</a></dd>
{{/if}} {{/if}}
</dl> </ul>
</td> </td>
</tr> </tr>
{{/each}} {{/each}}
+3 -4
View File
@@ -1,9 +1,8 @@
{{#each proposals as |proposal|}} {{#each proposals as |proposal|}}
<li> <li data-proposal-id={{proposal.id}} title="({{proposal.kind}}) {{proposal.description}}">
<span class="id">#{{proposal.id}}</span> <span class="category {{proposal.kind}}">♥</span>
Issue{{#if proposal.executed}}d{{/if}}
<span class="amount">{{proposal.amount}}</span><span class="symbol">₭S</span> <span class="amount">{{proposal.amount}}</span><span class="symbol">₭S</span>
to <span class="recipient" title="{{proposal.recipientAddress}}">{{proposal.recipientName}}</span> for <span class="recipient">{{proposal.recipientName}}</span>
<span class="votes">({{proposal.votesCount}}/{{proposal.votesNeeded}} votes)</span> <span class="votes">({{proposal.votesCount}}/{{proposal.votesNeeded}} votes)</span>
{{#unless proposal.executed}}<button {{action "confirm" proposal.id}}>+1</button>{{/unless}} {{#unless proposal.executed}}<button {{action "confirm" proposal.id}}>+1</button>{{/unless}}
+2 -2
View File
@@ -23,7 +23,7 @@ export default Ember.Controller.extend({
let proposals = this.get('model.proposals') let proposals = this.get('model.proposals')
.filterBy('executed', false) .filterBy('executed', false)
.map(p => { .map(p => {
p.set('recipientName', this.findContributorByAddress(p.get('recipientAddress')).github_username); p.set('recipientName', this.findContributorByAddress(p.get('recipientAddress')).name);
return p; return p;
}); });
return proposals; return proposals;
@@ -33,7 +33,7 @@ export default Ember.Controller.extend({
let proposals = this.get('model.proposals') let proposals = this.get('model.proposals')
.filterBy('executed', true) .filterBy('executed', true)
.map(p => { .map(p => {
p.set('recipientName', this.findContributorByAddress(p.get('recipientAddress')).github_username); p.set('recipientName', this.findContributorByAddress(p.get('recipientAddress')).name);
return p; return p;
}); });
return proposals; return proposals;
+98 -10
View File
@@ -1,10 +1,18 @@
import Ember from 'ember'; import Ember from 'ember';
const {
isPresent,
} = Ember;
export default Ember.Object.extend({ export default Ember.Object.extend({
address: null, address: null,
name: null,
kind: null,
url: null,
github_username: null, github_username: null,
github_uid: null, github_uid: null,
wiki_username: null,
ipfsHash: null, ipfsHash: null,
kredits: null, kredits: null,
isCore: false, isCore: false,
@@ -14,18 +22,98 @@ export default Ember.Object.extend({
return `https\:\/\/avatars2.githubusercontent.com/u/${this.get('github_uid')}?v=3&s=128`; return `https\:\/\/avatars2.githubusercontent.com/u/${this.get('github_uid')}?v=3&s=128`;
}.property('github_uid'), }.property('github_uid'),
serialize() {
return JSON.stringify({ /**
profiles: { * Loads the contributor's profile data from IPFS and sets local instance
'github.com': { * properties from it
uid: this.get('github_uid'), *
username: this.get('github_username'), * @method
} * @public
// 'wiki.kosmos.org': { */
// username: this.get('wiki_username') loadProfile(ipfs) {
// } let promise = new Ember.RSVP.Promise((resolve, reject) => {
ipfs.getFile(this.get('ipfsHash')).then(content => {
let profileJSON = JSON.parse(content);
let profile = Ember.Object.create(profileJSON);
this.setProperties({
name: profile.get('name'),
kind: profile.get('kind')
});
let accounts = profile.get('accounts');
let github = accounts.findBy('site', 'github.com');
let wiki = accounts.findBy('site', 'wiki.kosmos.org');
if (isPresent(github)) {
this.setProperties({
github_username: github.username,
github_uid: github.uid,
});
} }
if (isPresent(wiki)) {
this.setProperties({
wiki_username: wiki.username
}); });
} }
Ember.Logger.debug('[contributor] loaded contributor profile', profile);
resolve();
}).catch((err) => {
Ember.Logger.error('[contributor] error trying to load contributor profile', this.get('ipfsHash'), err);
reject(err);
});
});
return promise;
},
/**
* Creates a JSON-LD object of the contributor, according to
* https://github.com/67P/kosmos-schemas/blob/master/schemas/contributor.json
*
* @method
* @public
*/
toJSON() {
let contributor = {
"@context": "https://schema.kosmos.org",
"@type": "Contributor",
"kind": this.get('kind'),
"name": this.get('name'),
"accounts": []
};
if (Ember.isPresent(this.get('url'))) {
contributor["url"] = this.get('url');
}
if (Ember.isPresent(this.get('github_uid'))) {
contributor.accounts.push({
"site": "github.com",
"uid": this.get('github_uid'),
"username": this.get('github_username'),
"url": `https://github.com/${this.get('github_username')}`
});
}
if (Ember.isPresent(this.get('wiki_username'))) {
contributor.accounts.push({
"site": "wiki.kosmos.org",
"username": this.get('wiki_username'),
"url": `https://wiki.kosmos.org/User:${this.get('wiki_username')}`
});
}
return contributor;
},
/**
* Returns the JSON-LD representation of the model as a string
*
* @method
* @public
*/
serialize() {
return JSON.stringify(this.toJSON());
}
}); });
+87 -1
View File
@@ -1,16 +1,102 @@
import Ember from 'ember'; import Ember from 'ember';
const {
isPresent,
isEmpty,
} = Ember;
export default Ember.Object.extend({ export default Ember.Object.extend({
id: null, id: null,
creatorAddress: null, creatorAddress: null,
recipientAddress: null, recipientAddress: null,
recipientName: null, recipientName: null,
recipientProfile: null,
votesCount: null, votesCount: null,
votesNeeded: null, votesNeeded: null,
amount: null, amount: null,
executed: null, executed: null,
contribution: null,
kind: null,
description: null,
url: null, url: null,
ipfsHash: 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);
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());
}
}); });
+3 -1
View File
@@ -1,4 +1,5 @@
import Ember from 'ember'; import Ember from 'ember';
import Contributor from 'kredits-web/models/contributor';
export default Ember.Route.extend({ export default Ember.Route.extend({
@@ -10,7 +11,8 @@ export default Ember.Route.extend({
return Ember.RSVP.hash({ return Ember.RSVP.hash({
contributors: kredits.getContributors(), contributors: kredits.getContributors(),
totalSupply: kredits.getValueFromContract('tokenContract', 'totalSupply'), totalSupply: kredits.getValueFromContract('tokenContract', 'totalSupply'),
proposals: kredits.getProposals() proposals: kredits.getProposals(),
newContributor: Contributor.create({ kind: 'person' })
}); });
} }
+1
View File
@@ -18,6 +18,7 @@ export default Route.extend({
recipientAddress: params.recipient, recipientAddress: params.recipient,
amount: params.amount, amount: params.amount,
url: params.url, url: params.url,
kind: params.kind || 'dev',
ipfsHash: params.ipfsHash ipfsHash: params.ipfsHash
}); });
+3
View File
@@ -26,6 +26,9 @@ export default Ember.Service.extend({
getFile(hash) { getFile(hash) {
galfert commented 2017-06-08 14:06:28 +00:00 (Migrated from github.com)
Review

I think you want to throw the error here. Otherwise the getFile Promise will resolve with the err as value instead of being rejected.

I think you want to `throw` the error here. Otherwise the `getFile` Promise will resolve with the `err` as value instead of being rejected.
raucao commented 2017-06-08 14:40:03 +00:00 (Migrated from github.com)
Review

Good catch! Pun indubitably intended.

Good catch! Pun indubitably intended.
return this.get('ipfs').cat(hash, { buffer: true }).then(res => { return this.get('ipfs').cat(hash, { buffer: true }).then(res => {
return res.toString(); return res.toString();
}, err => {
Ember.Logger.error('[ipfs] error trying to fetch file', hash, err);
throw err;
}); });
} }
+28 -19
View File
@@ -4,6 +4,7 @@ import config from 'kredits-web/config/environment';
import Contributor from 'kredits-web/models/contributor'; import Contributor from 'kredits-web/models/contributor';
import Proposal from 'kredits-web/models/proposal'; import Proposal from 'kredits-web/models/proposal';
import kreditsContracts from 'npm:kredits-contracts'; import kreditsContracts from 'npm:kredits-contracts';
import uuid from 'npm:uuid';
const { const {
Service, Service,
@@ -101,17 +102,19 @@ export default Service.extend({
this.getValueFromContract('kreditsContract', 'contributorAddresses', i).then(address => { this.getValueFromContract('kreditsContract', 'contributorAddresses', i).then(address => {
this.getValueFromContract('kreditsContract', 'contributors', address).then(person => { this.getValueFromContract('kreditsContract', 'contributors', address).then(person => {
this.getValueFromContract('tokenContract', 'balanceOf', address).then(balance => { this.getValueFromContract('tokenContract', 'balanceOf', address).then(balance => {
Ember.Logger.debug('person', person); Ember.Logger.debug('[kredits] person', address, person);
let contributor = Contributor.create({ let contributor = Contributor.create({
address: address, address: address,
github_username: person[1],
github_uid: person[0],
ipfsHash: person[2], ipfsHash: person[2],
kredits: balance.toNumber(), kredits: balance.toNumber(),
isCurrentUser: this.get('currentUserAccounts').includes(address) isCurrentUser: this.get('currentUserAccounts').includes(address)
}); });
Ember.Logger.debug('[kredits] contributor', contributor);
resolve(contributor); contributor.loadProfile(this.get('ipfs')).then(
() => resolve(contributor),
err => reject(err)
);
}); });
}); });
}).catch(err => reject(err)); }).catch(err => reject(err));
@@ -145,8 +148,16 @@ export default Service.extend({
url : p[6], url : p[6],
ipfsHash : p[7] ipfsHash : p[7]
}); });
Ember.Logger.debug('[kredits] proposal', proposal);
if (proposal.get('ipfsHash')) {
proposal.loadContribution(this.get('ipfs')).then(
() => resolve(proposal),
err => reject(err)
);
} else {
Ember.Logger.warn('[kredits] proposal from blockchain is missing IPFS hash', proposal);
resolve(proposal); resolve(proposal);
}
}).catch(err => reject(err)); }).catch(err => reject(err));
}); });
return promise; return promise;
@@ -175,23 +186,20 @@ export default Service.extend({
}); });
}, },
addContributor(address, name, isCore, id) { addContributor(contributor) {
Ember.Logger.debug('[kredits] add contributor', name, address); Ember.Logger.debug('[kredits] add contributor', contributor);
let contributor = Contributor.create({ contributor.setProperties({
address: address,
github_username: name,
github_uid: id,
kredits: 0, kredits: 0,
isCore: isCore, isCurrentUser: this.get('currentUserAccounts').includes(contributor.address)
isCurrentUser: this.get('currentUserAccounts').includes(address)
}); });
let id = uuid.v4();
return new Ember.RSVP.Promise((resolve, reject) => { return new Ember.RSVP.Promise((resolve, reject) => {
this.get('ipfs').storeFile(contributor.serialize()).then(ipfsHash => { this.get('ipfs').storeFile(contributor.serialize()).then(ipfsHash => {
contributor.set('ipfsHash', ipfsHash); contributor.set('ipfsHash', ipfsHash);
Ember.Logger.debug('ADD', address, name, ipfsHash, isCore, id); this.get('kreditsContract').addContributor(contributor.address, contributor.name, contributor.ipfsHash, contributor.isCore, id, (err, data) => {
this.get('kreditsContract').addContributor(address, name, ipfsHash, isCore, id, (err, data) => {
if (err) { reject(err); return; } if (err) { reject(err); return; }
Ember.Logger.debug('[kredits] add contributor response', data); Ember.Logger.debug('[kredits] add contributor response', data);
resolve(contributor); resolve(contributor);
@@ -205,16 +213,17 @@ export default Service.extend({
const { const {
recipientAddress, recipientAddress,
amount, amount,
url, url
ipfsHash } = proposal.getProperties('recipientAddress', 'amount', 'url');
} = proposal.getProperties('recipientAddress', 'amount', 'url', 'ipfsHash');
this.get('ipfs').storeFile(proposal.serializeContribution()).then(ipfsHash => {
this.get('kreditsContract').addProposal(recipientAddress, amount, url, ipfsHash, (err, data) => { this.get('kreditsContract').addProposal(recipientAddress, amount, url, ipfsHash, (err, data) => {
if (err) { reject(err); return; } if (err) { reject(err); return; }
Ember.Logger.debug('[kredits] add proposal response', data); Ember.Logger.debug('[kredits] add proposal response', data);
resolve(); resolve();
}); });
}); });
});
}, },
// logKreditsContract: function() { // logKreditsContract: function() {
+8
View File
@@ -0,0 +1,8 @@
$blue: #68d7fb;
$purple: #8f68fb;
$pink: #e068fb;
$green: #97fb68;
$yellow: #fbe468;
$red: #fb6868;
$primaryColor: $blue;
+7 -6
View File
@@ -15,6 +15,7 @@
@import "neat"; @import "neat";
@import "settings/breakpoints"; @import "settings/breakpoints";
@import "layout"; @import "layout";
@import "colors";
$font-family-sans: 'Open Sans', sans-serif; $font-family-sans: 'Open Sans', sans-serif;
@@ -23,7 +24,7 @@ body {
background-repeat: none; background-repeat: none;
background-attachment: fixed; background-attachment: fixed;
font-family: $font-family-sans; font-family: $font-family-sans;
font-size: 16px; font-size: 14px;
font-weight: 300; font-weight: 300;
color: #fff; color: #fff;
} }
@@ -43,7 +44,7 @@ h1, h2, h3, h4, h5, input, button {
section { section {
h2 { h2 {
font-size: 2.8rem; font-size: 2.8rem;
color: lightblue; color: $primaryColor;
@include media($mobile) { @include media($mobile) {
font-size: 2rem; font-size: 2rem;
} }
@@ -54,7 +55,7 @@ section {
p.stats { p.stats {
padding-top: 3rem; padding-top: 3rem;
font-size: 1rem; font-size: 1rem;
color: lightblue; color: white;
text-align: center; text-align: center;
span.number { span.number {
font-weight: 600; font-weight: 600;
@@ -70,14 +71,14 @@ section {
.actions { .actions {
padding-top: 3rem; padding-top: 3rem;
font-size: 1rem; font-size: 1rem;
color: lightblue; color: $primaryColor;
text-align: center; text-align: center;
@include media($mobile) { @include media($mobile) {
padding-top: 2rem; padding-top: 2rem;
} }
a { a {
color: lightblue; color: $primaryColor;
} }
} }
} }
@@ -87,7 +88,7 @@ button, input[type=submit] {
display: inline-block; display: inline-block;
border: 1px solid rgba(22, 21, 40, 1); border: 1px solid rgba(22, 21, 40, 1);
background-color: rgba(22, 21, 40, 0.6); background-color: rgba(22, 21, 40, 0.6);
color: lightblue; color: $primaryColor;
border-radius: 3px; border-radius: 3px;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
+1 -1
View File
@@ -9,7 +9,7 @@ section#add-contributor, section#add-proposal {
padding-top: 1rem; padding-top: 1rem;
text-align: center; text-align: center;
a { a {
color: lightblue; color: $primaryColor;
margin-left: 1rem; margin-left: 1rem;
} }
} }
+11 -25
View File
@@ -15,56 +15,42 @@ table.contributor-list {
&.metadata { &.metadata {
height: 0; height: 0;
transition: all 0.2s ease-out;
td { td {
padding: 0 1.2rem; padding: 0 1.2rem;
transition: all 0.2s ease-out;
} }
a { a {
color: lightblue; color: $primaryColor;
&:hover, &:active { &:hover, &:active {
color: #fff; color: #fff;
} }
} }
dl { ul {
list-style: none;
display: block; display: block;
width: 100%;
font-size: 1rem;
dt, dd {
display: inline-block;
overflow: hidden; overflow: hidden;
height: 0; height: 0;
line-height: 1.6rem;
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
li {
display: inline;
&+li {
margin-left: 1rem;
} }
dt {
clear: both;
float: left;
width: 30%;
}
dd {
float: right;
width: 70%;
text-align: right;
} }
} }
&.visible { &.visible {
td { height: auto;
padding: 1rem 1.2rem; ul {
}
dl {
dt, dd {
height: auto; height: auto;
} }
} }
} }
}
td { td {
padding: 0 1.2rem; padding: 0 1.2rem;
line-height: 4.2rem; line-height: 4.2rem;
+2 -2
View File
@@ -11,7 +11,7 @@
margin-top: 12rem; margin-top: 12rem;
text-align: center; text-align: center;
font-size: 1.4rem; font-size: 1.4rem;
color: lightblue; color: $primaryColor;
@include media($mobile) { @include media($mobile) {
margin-top: 6rem; margin-top: 6rem;
@@ -23,7 +23,7 @@
margin-bottom: 2rem; margin-bottom: 2rem;
#path-comet { #path-comet {
fill: lightblue; fill: $primaryColor;
opacity: 0.1; opacity: 0.1;
animation-name: pulse; animation-name: pulse;
+9 -4
View File
@@ -14,9 +14,14 @@ ul.proposal-list {
border-top: 1px solid rgba(255,255,255,0.2); border-top: 1px solid rgba(255,255,255,0.2);
} }
.id { .category {
color: lightblue; color: $blue;
padding-right: 0.2rem; padding-right: 0.2rem;
&.community { color: $red; }
&.dev { color: $pink; }
&.design { color: $yellow; }
&.docs { color: $green; }
&.ops { color: $purple; }
} }
.amount { .amount {
font-weight: 500; font-weight: 500;
@@ -31,8 +36,8 @@ ul.proposal-list {
} }
.votes { .votes {
font-size: 1rem; font-size: 1rem;
color: lightblue; color: $primaryColor;
padding-left: 1rem; padding-left: 0.5rem;
} }
button { button {
+2 -2
View File
@@ -54,8 +54,8 @@
</header> </header>
<div class="content"> <div class="content">
{{add-contributor kredits=kredits {{add-contributor contributors=model.contributors
contributors=model.contributors newContributor=model.newContributor
contractInteractionEnabled=contractInteractionEnabled}} contractInteractionEnabled=contractInteractionEnabled}}
</div> </div>
</section> </section>
+2 -1
View File
@@ -23,6 +23,7 @@ module.exports = function(environment) {
}, },
browserify: { browserify: {
tests: true,
transform: [ transform: [
["babelify", { ["babelify", {
presets: ["es2015"], presets: ["es2015"],
@@ -38,7 +39,7 @@ module.exports = function(environment) {
ipfs: { ipfs: {
host: 'ipfs.kosmos.org', host: 'ipfs.kosmos.org',
port: '5444', port: '5444',
protocol: 'http' protocol: 'https'
} }
}; };
+4 -1
View File
@@ -3,7 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"description": "Contribution dashboard of the Kosmos project", "description": "Contribution dashboard of the Kosmos project",
"license": "MIT", "license": "MIT",
"author": "Kosmos Community", "author": "Kosmos Contributors <mail@kosmos.org>",
"contributors": [ "contributors": [
"Garret Alfert <alfert@wevelop.de>", "Garret Alfert <alfert@wevelop.de>",
"Sebastian Kippe <sebastian@kip.pe>", "Sebastian Kippe <sebastian@kip.pe>",
@@ -55,8 +55,11 @@
"ember-resolver": "^2.0.3", "ember-resolver": "^2.0.3",
"ember-truth-helpers": "1.3.0", "ember-truth-helpers": "1.3.0",
"ipfs-api": "^12.1.7", "ipfs-api": "^12.1.7",
"kosmos-schemas": "~1.1.1",
"kredits-contracts": "^2.4.0", "kredits-contracts": "^2.4.0",
"loader.js": "^4.0.10", "loader.js": "^4.0.10",
"tv4": "^1.3.0",
"uuid": "^3.0.1",
"web3": "^0.18.2" "web3": "^0.18.2"
}, },
"engines": { "engines": {
+18 -1
View File
@@ -1,9 +1,26 @@
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:contributor', 'Unit | Model | contributor'); moduleFor('model:contributor', 'Unit | Model | contributor');
test('avatarURL returns correct URL', function(assert) { test('#avatarURL() returns correct URL', function(assert) {
let model = this.subject(); let model = this.subject();
model.set('github_uid', '318'); model.set('github_uid', '318');
assert.equal(model.get('avatarURL'), 'https://avatars2.githubusercontent.com/u/318?v=3&s=128'); assert.equal(model.get('avatarURL'), 'https://avatars2.githubusercontent.com/u/318?v=3&s=128');
}); });
test('#toJSON() returns a valid JSON-LD representation of the model', function(assert) {
let model = this.subject();
model.setProperties({
name: 'Satoshi Nakamoto',
kind: 'person',
github_uid: 123,
github_username: 'therealsatoshi',
wiki_username: 'Satoshi',
});
assert.ok(tv4.validate(model.toJSON(), schemas['contributor']));
});
+43 -3
View File
@@ -1,9 +1,49 @@
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('it exists', function(assert) { test('#toJSON() requires a recipient profile IPFS hash to be set', function(assert) {
let model = this.subject(); let model = this.subject();
// let store = this.store();
assert.ok(!!model); 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']));
}); });