Merge pull request #195 from 67P/feature/expenses

Introduce budget, reimbursements for expenses
This commit was merged in pull request #195.
This commit is contained in:
2021-06-03 16:23:45 +02:00
committed by GitHub
76 changed files with 3322 additions and 765 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ module.exports = {
rules: {
'simple-unless': false,
'no-nested-interactive': false
'no-nested-interactive': false,
'no-html-comments': false
},
ignore: [
+4 -4
View File
@@ -1,13 +1,15 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed';
import { alias, and, notEmpty } from '@ember/object/computed';
import { assign } from '@ember/polyfills';
import moment from 'moment';
import { inject as service } from '@ember/service';
export default Component.extend({
kredits: service(),
attributes: null,
contributors: Object.freeze([]),
contributors: alias('kredits.contributorsSorted'),
isValidContributor: notEmpty('contributorId'),
isValidKind: notEmpty('kind'),
@@ -72,7 +74,5 @@ export default Component.extend({
})
.finally(() => this.set('inProgress', false));
}
}
});
@@ -0,0 +1,86 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import moment from 'moment';
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import { isPresent } from '@ember/utils';
export default class AddExpenseItemComponent extends Component {
@tracked amount = '0';
@tracked currency = 'EUR';
@tracked date = moment().startOf('hour').toDate();
@tracked title = '';
@tracked description = '';
@tracked url = '';
@tracked tags = '';
defaultDate = moment().startOf('hour').toDate();
currencies = [
{ code: 'EUR' },
{ code: 'USD' }
];
get isValidAmount () {
return isValidAmount(this.amount);
}
get amountInputClass () {
return this.isValidTotal ? 'valid' : '';
}
validateForm () {
const formEl = document.querySelector('form#add-expense-item');
const inputFields = formEl.querySelectorAll('input');
inputFields.forEach(i => i.classList.remove('invalid'));
let validity = true;
if (!this.isValidAmount) {
document.querySelector('input[name=expense-amount]').classList.add('invalid');
validity = false;
}
if (!formEl.checkValidity()) {
inputFields.forEach(i => {
if (!i.validity.valid) {
i.classList.add('invalid');
validity = false;
}
})
}
return validity;
}
@action
updateCurrency(event) {
this.currency = event.target.value;
}
@action
submit (e) {
e.preventDefault();
let dateInput = (this.date instanceof Array) ?
this.date[0] : this.date;
const [ date ] = dateInput.toISOString().split('T');
const isValid = this.validateForm();
if (!isValid) return false;
const expense = {
amount: parseFloat(this.amount),
currency: this.currency,
date: date,
title: this.title,
description: isPresent(this.description) ? this.description : undefined,
url: isPresent(this.url) ? this.url : undefined,
}
if (isPresent(this.tags)) {
expense.tags = this.tags.split(',').map(t => t.trim());
}
this.args.addExpenseItem(expense);
}
}
@@ -0,0 +1,69 @@
<form id="add-expense-item" {{on "submit" this.submit}} novalidate>
<fieldset class="horizontal">
<label>
<p class="label">Amount:</p>
<p>
<Input @name="expense-amount"
@type="text"
@placeholder="10"
@value={{this.amount}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.amountInputClass}} />
</p>
</label>
<label>
<p class="label">Currency:</p>
<p>
<select required name="expense-currency" {{on "change" this.updateCurrency}}>
<option value="" selected disabled hidden></option>
{{#each this.currencies as |currency|}}
<option value={{currency.code}} selected={{eq this.currency currency.code}}>{{currency.code}}</option>
{{/each}}
</select>
</p>
</label>
</fieldset>
<label>
<p class="label">Date:</p>
<p>
<EmberFlatpickr @date={{this.date}}
@defaultDate={{this.defaultDate}}
@maxDate={{this.defaultDate}}
@enableTime={{false}}
@onChange={{fn (mut this.date)}} />
</p>
</label>
<label>
<p class="label">Title:</p>
<p>
<Input @name="expense-title"
@type="text"
@value={{this.title}}
@required={{true}} />
</p>
</label>
<label>
<p class="label">Description (optional):</p>
<p>
<Input @name="expense-description" @type="text" @value={{this.description}} />
</p>
</label>
<label>
<p class="label">URL (optional):</p>
<p>
<Input @name="expense-url" @type="url" @value={{this.url}} />
</p>
</label>
<label>
<p class="label">Tags (comma-separated, optional):</p>
<p>
<Input @name="expense-tags" @type="text" @value={{this.tags}} />
</p>
</label>
<p class="actions">
<Input @type="submit" @value="Add" @class="green"
@title="Add item to reimbursement" />
</p>
</form>
@@ -0,0 +1,147 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { A } from '@ember/array';
import { scheduleOnce } from '@ember/runloop';
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import config from 'kredits-web/config/environment';
export default class AddReimbursementComponent extends Component {
@service router;
@service kredits;
@service exchangeRates;
@alias('kredits.contributorsSorted') contributors;
@tracked contributorId = null;
@tracked title = '';
@tracked total = '0';
@tracked expenses = A([]);
@tracked expenseFormVisible = true;
constructor() {
super(...arguments);
this.exchangeRates.fetchRates();
}
get isValidTotal () {
return isValidAmount(this.total);
}
get totalInputClass () {
return this.isValidTotal ? 'valid' : '';
}
get totalEUR () {
const expenses = this.expenses.filterBy('currency', 'EUR');
if (expenses.length > 0) {
return expenses.mapBy('amount')
.reduce((summation, current) => summation + current);
} else {
return 0;
}
}
get totalUSD () {
const expenses = this.expenses.filterBy('currency', 'USD');
if (expenses.length > 0) {
return expenses.mapBy('amount')
.reduce((summation, current) => summation + current);
} else {
return 0;
}
}
get submitButtonEnabled () {
return this.isValidTotal &&
(this.expenses.length > 0);
}
get submitButtonDisabled () {
return !this.submitButtonEnabled;
}
scrollToExpenseItemForm () {
const anchor = document.getElementById('new-expense-item');
anchor.scrollIntoView();
}
updateTotalAmountFromFiat() {
let btcAmount = parseFloat(this.total);
if (this.exchangeRates.btceur > 0 && this.totalEUR > 0) {
btcAmount += (this.totalEUR / this.exchangeRates.btceur);
}
if (this.exchangeRates.btcusd > 0 && this.totalUSD > 0) {
btcAmount += (this.totalUSD / this.exchangeRates.btcusd);
}
if (this.totalUSD === 0 && this.totalEUR === 0) {
btcAmount = 0;
}
this.total = btcAmount.toFixed(8);
}
@action
updateContributor(event) {
this.contributorId = event.target.value;
}
@action
showExpenseForm () {
this.expenseFormVisible = true;
scheduleOnce('afterRender', this, this.scrollToExpenseItemForm);
}
@action
addExpenseItem (expenseItem) {
this.expenses.pushObject(expenseItem);
this.updateTotalAmountFromFiat();
this.expenseFormVisible = false;
}
@action
removeExpenseItem (expenseItem) {
this.expenses.removeObject(expenseItem);
this.updateTotalAmountFromFiat();
if (this.expenses.length === 0) {
this.expenseFormVisible = true;
}
}
@action
submit (e) {
e.preventDefault();
if (!this.kredits.currentUser) { window.alert('You need to connect your Ethereum account first.'); return false }
if (!this.kredits.currentUserIsCore) { window.alert('Only core contributors can submit reimbursements.'); return false }
const contributor = this.contributors.findBy('id', this.contributorId);
const attributes = {
amount: parseInt(parseFloat(this.total) * 100000000), // convert to sats
token: config.tokens['WBTC'],
contributorId: parseInt(this.contributorId),
title: `Expenses covered by ${contributor.name}`,
description: this.description,
url: this.url,
expenses: JSON.parse(JSON.stringify((this.expenses)))
}
this.inProgress = true;
this.kredits.addReimbursement(attributes)
.then((/* reimbursement */) => {
this.router.transitionTo('budget');
})
.catch(e => {
console.error('Could not add reimbursement:', e);
window.alert('Something went wrong. Please check the browser console.')
})
.finally(() => {
this.inProgress = false;
});
}
}
@@ -0,0 +1,89 @@
<form {{on "submit" this.submit}} novalidate>
<label>
<p class="label">Contributor:</p>
<p>
<select required {{on "change" this.updateContributor}}>
<option value="" selected disabled hidden></option>
{{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</p>
</label>
<fieldset class="horizontal thirds total-amounts">
<label>
<p class="label">Total amount (WBTC):</p>
<p>
<Input @type="text"
@placeholder="0.0015"
@value={{this.total}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.totalInputClass}} />
</p>
</label>
<label>
<p class="label">EUR total</p>
<p>
<Input @type="text"
@name="total-eur"
@value={{this.totalEUR}}
@disabled={{true}} />
</p>
</label>
<label>
<p class="label">USD total</p>
<p>
<Input @type="text"
@name="total-usd"
@value={{this.totalUSD}}
@disabled={{true}} />
</p>
</label>
</fieldset>
<h3>Expense items</h3>
{{#if this.expenses}}
<ul class="expense-list">
{{#each this.expenses as |expense|}}
<li>
<div class="description" rowspan="2">
<h4>
<span class="date">{{expense.date}}:</span>
<span class="title">{{expense.title}}</span>
</h4>
<p class="description">{{expense.description}}</p>
</div>
<div class="amount">
{{fmt-fiat-currency expense.amount expense.currency}}
</div>
<div class="actions">
<button {{on "click" (fn this.removeExpenseItem expense)}}
class="danger small" type="button">delete</button>
</div>
</li>
{{/each}}
</ul>
<p class="actions">
<button {{on "click" this.showExpenseForm}}
class="green small" type="button">+ Add another item</button>
</p>
{{else}}
<p>No line items yet.</p>
{{/if}}
<p class="actions">
{{#if this.inProgress}}
<Input @type="submit" @value="Submitting..." @disabled={{true}}
@title="Submit/propose this reimbursement" />
{{else}}
<Input @type="submit" @value="Submit" @disabled={{this.submitButtonDisabled}}
@title="Submit/propose this reimbursement" />
{{/if}}
</p>
{{#if this.expenseFormVisible}}
<h3 id="new-expense-item">New expense item</h3>
<AddExpenseItem @addExpenseItem={{fn this.addExpenseItem}} />
{{/if}}
</form>
@@ -0,0 +1,12 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default class BudgetBalancesComponent extends Component {
@service communityFunds
@alias('communityFunds.balances') balances;
get loading () {
return !this.communityFunds.balancesLoaded;
}
}
@@ -0,0 +1,18 @@
<table class="token-balances {{if this.loading 'loading'}}">
<thead>
<tr>
<th>Token</th>
<th>Amount</th>
<th>Fiat value</th>
</tr>
</thead>
<tbody>
{{#each this.balances as |balance|}}
<tr>
<th>{{balance.token.symbol}}</th>
<td class="amount">{{fmt-crypto-currency balance.balance balance.token.symbol}}</td>
<td class="fiat-amount">~{{balance.balanceUsd}} USD</td>
</tr>
{{/each}}
</tbody>
</table>
@@ -29,11 +29,11 @@
</div>
{{/if}}
<ul class="contribution-list {{if @loading 'loading'}}">
<ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.contributionsFiltered as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="{{contribution-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
@@ -0,0 +1,7 @@
import Component from '@glimmer/component';
import { sort } from '@ember/object/computed';
export default class ReimbursementListComponent extends Component {
itemSorting = Object.freeze(['pendingStatus:asc', 'id:desc']);
@sort('args.items', 'itemSorting') itemsSorted;
}
@@ -0,0 +1,31 @@
<ul class="item-list spaced reimbursement-list {{if @loading 'loading'}}">
{{#each this.itemsSorted as |reimbursement|}}
<li data-reimbursement-id={{reimbursement.id}}
class="{{item-status reimbursement}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{reimbursement.contributor}} /></span>
<span class="title">Expenses covered by {{reimbursement.contributor.name}}</span>
</p>
<p class="token-amount">
<span class="amount">
{{sats-to-btc reimbursement.amount}}</span>&#8239;<span class="symbol">WBTC</span>
</p>
<ul class="expense-list">
{{#each reimbursement.expenses as |expense|}}
<li>
<div class="description" rowspan="2">
<h4>
<span class="date">{{expense.date}}:</span>
<span class="title">{{expense.title}}</span>
</h4>
<p class="description">{{expense.description}}</p>
</div>
<div class="amount">
{{fmt-fiat-currency expense.amount expense.currency}}
</div>
</li>
{{/each}}
</ul>
</li>
{{/each}}
</ul>
@@ -34,7 +34,6 @@ export default Component.extend({
this.set('setupInProgress', true);
await this.kredits.setup();
this.set('setupInProgress', false);
this.router.transitionTo('dashboard');
} catch (error) {
this.set('setupInProgress', false);
console.log('Opening Ethereum wallet failed:', error);
+10
View File
@@ -0,0 +1,10 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default class BudgetController extends Controller {
@service kredits;
@alias('kredits.reimbursementsUnconfirmed') reimbursementsUnconfirmed;
@alias('kredits.reimbursementsConfirmed') reimbursementsConfirmed;
}
-56
View File
@@ -1,56 +0,0 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([contribution]) {
this.setupRecompute(contribution);
let status = [];
if (contribution.vetoed) {
status.push('vetoed');
} else if (contribution.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (contribution.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (contribution) {
if (this.teardown) this.teardown();
contribution.addObserver('vetoed' , this, this.triggerRecompute);
contribution.addObserver('confirmedAt' , this, this.triggerRecompute);
contribution.addObserver('currentBlock' , this, this.triggerRecompute);
contribution.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
contribution.removeObserver('vetoed', this, this.triggerRecompute);
contribution.removeObserver('confirmedAt', this, this.triggerRecompute);
contribution.removeObserver('currentBlock', this, this.triggerRecompute);
contribution.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
+18
View File
@@ -0,0 +1,18 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtCryptoCurrency(params/*, hash*/) {
let fmtAmount;
const amount = params[0];
const code = params[1];
switch(code) {
case 'ETH':
fmtAmount = amount / 1000000000000000000;
break;
case 'WBTC':
fmtAmount = amount / 100000000;
break;
}
return fmtAmount;
});
+11
View File
@@ -0,0 +1,11 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtFiatCurrency(params) {
const lang = navigator.language || navigator.userLanguage;
const formatter = new Intl.NumberFormat(lang, {
style: 'currency',
currency: params[1] || 'EUR',
currencyDisplay: 'code'
})
return formatter.format(params[0]);
});
+56
View File
@@ -0,0 +1,56 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([item]) {
this.setupRecompute(item);
let status = [];
if (item.vetoed) {
status.push('vetoed');
} else if (item.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (item.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (item) {
if (this.teardown) this.teardown();
item.addObserver('vetoed' , this, this.triggerRecompute);
item.addObserver('confirmedAt' , this, this.triggerRecompute);
item.addObserver('currentBlock' , this, this.triggerRecompute);
item.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
item.removeObserver('vetoed', this, this.triggerRecompute);
item.removeObserver('confirmedAt', this, this.triggerRecompute);
item.removeObserver('currentBlock', this, this.triggerRecompute);
item.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
+5
View File
@@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
export default helper(function satsToBtc(amount/*, hash*/) {
return amount / 100000000;
});
+47
View File
@@ -0,0 +1,47 @@
import EmberObject, { computed } from '@ember/object';
import moment from 'moment';
import { isPresent } from '@ember/utils';
export default EmberObject.extend({
// Contract
id: null,
recipientId: null,
token: null,
amount: null,
confirmedAt: null,
vetoed: null,
ipfsHash: null,
// contributor model instance
recipient: null,
// TODO contributor who submitted the reimbursement
// recordedBy: null,
// IPFS
expenses: null, // Array of expense objects
pendingTx: null,
iso8601Date: computed('date', 'time', function() {
return this.time ? `${this.date}T${this.time}` : this.date;
}),
jsDate: computed('iso8601Date', function() {
return moment(this.iso8601Date).toDate();
}),
hasPendingChanges: computed('pendingTx', function() {
return isPresent(this.pendingTx);
}),
pendingStatus: computed('pendingTx', function() {
return isPresent(this.pendingTx) ? 'isPending' : 'notPending';
}),
serialize () {
return JSON.stringify(this);
}
});
+9
View File
@@ -29,4 +29,13 @@ Router.map(function() {
this.route('eth-account');
this.route('complete');
});
this.route('budget', function() {
this.route('expenses');
this.route('reimbursements', function() {});
});
this.route('reimbursements', function() {
this.route('new');
});
});
+20 -22
View File
@@ -1,39 +1,37 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { schedule } from '@ember/runloop';
export default Route.extend({
kredits: service(),
export default class ApplicationRoute extends Route {
@service kredits;
@service communityFunds;
beforeModel(/* transition */) {
const kredits = this.kredits;
return kredits.setup().then(() => {
kredits.get('kredits').preflightChecks().catch((error) => {
kredits.kredits.preflightChecks().catch((error) => {
console.error('Kredits preflight check failed!');
console.error(error);
});
}).catch((error) => {
console.log('Error initializing Kredits', error);
});
},
}
model() {
return this.kredits.loadInitialData().then(() => {
this.kredits.addContractEventHandlers()
});
}
afterModel() {
return this.kredits.loadInitialData()
.then(() => {
this.kredits.addContractEventHandlers();
})
.then(() => {
if (this.kredits.contributorsNeedSync) {
schedule('afterRender', this.kredits.syncContributors,
this.kredits.syncContributors.perform);
}
if (this.kredits.contributionsNeedSync) {
schedule('afterRender', this.kredits.syncContributions,
this.kredits.syncContributions.perform);
}
schedule('afterRender', this.kredits.fetchMissingContributions,
this.kredits.fetchMissingContributions.perform);
});
if (this.kredits.contributorsNeedSync) {
schedule('afterRender', this.kredits.syncContributors,
this.kredits.syncContributors.perform);
}
schedule('afterRender', this.communityFunds.fetchBalances,
this.communityFunds.fetchBalances.perform);
}
});
}
+34
View File
@@ -0,0 +1,34 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { isEmpty, isPresent } from '@ember/utils';
import { schedule } from '@ember/runloop';
export default class BudgetRoute extends Route {
@service browserCache;
@service kredits;
async model () {
if (isPresent(this.kredits.reimbursements) &&
isEmpty(this.kredits.reimbursementsPending)) {
console.debug('[route:budget] Reimbursements loaded before, no need to sync or load');
return;
} else {
const numCachedReimbursements = await this.browserCache.reimbursements.length();
if (numCachedReimbursements > 0) {
await this.kredits.loadObjectsFromCache('Reimbursement');
this.kredits.set('reimbursementsNeedSync', true);
} else {
await this.kredits.fetchObjects('Reimbursement', { page: { size: 10 } });
}
}
}
afterModel() {
if (this.kredits.reimbursementsNeedSync) {
schedule('afterRender', this.kredits.syncReimbursements,
this.kredits.syncReimbursements.perform);
}
schedule('afterRender', this.kredits.fetchMissingReimbursements,
this.kredits.fetchMissingReimbursements.perform);
}
}
+4
View File
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class BudgetExpensesRoute extends Route {
}
+2 -2
View File
@@ -1,9 +1,9 @@
import Route from '@ember/routing/route';
export default Route.extend({
export default class ContributionsNewRoute extends Route {
model (params) {
return { params };
}
});
}
+18
View File
@@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import { schedule } from '@ember/runloop';
import { inject as service } from '@ember/service';
export default class DashboardRoute extends Route {
@service kredits;
afterModel() {
if (this.kredits.contributionsNeedSync) {
schedule('afterRender', this.kredits.syncContributions,
this.kredits.syncContributions.perform);
}
schedule('afterRender', this.kredits.fetchMissingContributions,
this.kredits.fetchMissingContributions.perform);
}
}
+9
View File
@@ -0,0 +1,9 @@
import Route from '@ember/routing/route';
export default class ReimbursementsNewRoute extends Route {
model (params) {
return { params };
}
}
+6 -1
View File
@@ -13,7 +13,8 @@ export default class BrowserCacheService extends Service {
super(...arguments);
this.stores = {
contributors: createStore('contributors'),
contributions: createStore('contributions')
contributions: createStore('contributions'),
reimbursements: createStore('reimbursements')
}
}
@@ -24,4 +25,8 @@ export default class BrowserCacheService extends Service {
get contributions() {
return this.stores.contributions;
}
get reimbursements() {
return this.stores.reimbursements;
}
}
+45
View File
@@ -0,0 +1,45 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { A } from '@ember/array';
import { task } from 'ember-concurrency-decorators';
import config from 'kredits-web/config/environment';
const txServiceBaseUrl = `${config.gnosisSafe.txServiceHost}/api/v1/safes/${config.gnosisSafe.address}`;
export default class CommunityFundsService extends Service {
@tracked balancesLoaded = false;
@tracked balances = A([]);
@task
*fetchBalances () {
const uri = `${txServiceBaseUrl}/balances/usd/`;
yield fetch(uri).then(res => res.json())
.then(res => this.processBalances(res))
.catch(err => {
console.log(`[community-funds] Fetching balances failed:`);
console.error(err);
});
}
processBalances (res) {
for (const balance of res) {
// Format and round the approximate USD value
const lang = navigator.language || navigator.userLanguage;
balance.balanceUsd = Math.round(balance.balanceUsd).toLocaleString(lang);
if (balance.token) {
// ERC20 token, has all meta data
this.balances.pushObject(balance);
} else {
// ETH, missing meta data
this.balances.pushObject({
...balance,
...{ token: { name: 'Ether', symbol: 'ETH'} }
});
}
}
this.balancesLoaded = true;
}
}
+29
View File
@@ -0,0 +1,29 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import config from 'kredits-web/config/environment';
// Need to go through proxy for CORS headers
const bitstampBaseUrl = `${config.corsProxy}https://www.bitstamp.net/api/v2`;
async function fetchFromBitstamp(currencyPair) {
try {
const res = await fetch(`${bitstampBaseUrl}/ticker/${currencyPair}/`).then(r => r.json());
return res.vwap; // Last 24 hours volume weighted average price
} catch(e) {
console.error('Could not fetch exchange rate from Bitstamp:', e);
return 0;
}
}
export default class ExchangeRatesService extends Service {
@tracked btceur = 0;
@tracked btcusd = 0;
async fetchRates (source='bitstamp') {
switch(source) {
case 'bitstamp':
this.btceur = await fetchFromBitstamp('btceur');
this.btcusd = await fetchFromBitstamp('btcusd');
}
}
}
+303 -63
View File
@@ -4,7 +4,7 @@ import Kredits from 'kredits-contracts';
import Service from '@ember/service';
import EmberObject from '@ember/object';
import { computed } from '@ember/object';
import { alias, notEmpty } from '@ember/object/computed';
import { alias, filterBy, notEmpty, sort } from '@ember/object/computed';
import { isEmpty, isPresent } from '@ember/utils';
import { inject as service } from '@ember/service';
@@ -13,11 +13,15 @@ import { task, taskGroup } from 'ember-concurrency';
import groupBy from 'kredits-web/utils/group-by';
import processContributorData from 'kredits-web/utils/process-contributor-data';
import processContributionData from 'kredits-web/utils/process-contribution-data';
import processReimbursementData from 'kredits-web/utils/process-reimbursement-data';
import formatKredits from 'kredits-web/utils/format-kredits';
import config from 'kredits-web/config/environment';
import Contributor from 'kredits-web/models/contributor'
import Contribution from 'kredits-web/models/contribution'
import Contributor from 'kredits-web/models/contributor';
import Contribution from 'kredits-web/models/contribution';
import Reimbursement from 'kredits-web/models/reimbursement';
// Lets us access the model classes dynamically
const models = { Contributor, Contribution, Reimbursement };
export default Service.extend({
@@ -28,64 +32,27 @@ export default Service.extend({
currentUser: null,
contributors: null,
contributions: null,
reimbursements: null,
githubAccessToken: null,
currentUserIsContributor: notEmpty('currentUser'),
currentUserIsCore: alias('currentUser.isCore'),
hasAccounts: notEmpty('currentUserAccounts'),
contributorsMined: filterBy('contributors', 'id'),
contributorsSorting: Object.freeze(['name:asc']),
contributorsSorted: sort('contributorsMined', 'contributorsSorting'),
// When data was loaded from cache, we need to fetch updates from the network
contributorsNeedSync: false,
contributionsNeedSync: false,
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions.filter(contribution => {
return contribution.confirmedAt > this.currentBlock;
});
}),
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filterBy('vetoed', false)
.filter(contribution => {
return contribution.confirmedAt <= this.currentBlock;
});
}),
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value.toString());
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
const kreditsByContributor = contributionsGrouped.map(c => {
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
const contributor = this.contributors.findBy('id', c.value.toString());
return EmberObject.create({
contributor: contributor,
amountUnconfirmed: amountUnconfirmed,
amountConfirmed: contributor.totalKreditsEarned,
amountTotal: contributor.totalKreditsEarned + amountUnconfirmed
})
});
contributorsWithOnlyConfirmed.forEach(c => {
kreditsByContributor.push(EmberObject.create({
contributor: c,
amountUnconfirmed: 0,
amountConfirmed: c.totalKreditsEarned,
amountTotal: c.totalKreditsEarned
}));
})
return kreditsByContributor;
}),
reimbursementsNeedSync: false,
init () {
this._super(...arguments);
this.set('contributors', []);
this.set('contributions', []);
this.set('reimbursements', []);
},
// This is called in the application route's beforeModel(). So it is
@@ -168,6 +135,22 @@ export default Service.extend({
});
},
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() {
if (isEmpty(this.currentUserAccounts)) {
return Promise.resolve();
}
return this.kredits.Contributor
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
.then((id) => {
// check if the user is a contributor or not
if (id === 0) {
return Promise.resolve();
} else {
return this.kredits.Contributor.getById(id);
}
});
}),
totalSupply: computed(function() {
return this.kredits.Token.functions.totalSupply().then(total => {
return formatKredits(total);
@@ -179,10 +162,66 @@ export default Service.extend({
.then(total => total.toNumber());
}),
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value.toString());
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
const kreditsByContributor = contributionsGrouped.map(c => {
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
const contributor = this.contributors.findBy('id', c.value.toString());
return EmberObject.create({
contributor: contributor,
amountUnconfirmed: amountUnconfirmed,
amountConfirmed: contributor.totalKreditsEarned,
amountTotal: contributor.totalKreditsEarned + amountUnconfirmed
})
});
contributorsWithOnlyConfirmed.forEach(c => {
kreditsByContributor.push(EmberObject.create({
contributor: c,
amountUnconfirmed: 0,
amountConfirmed: c.totalKreditsEarned,
amountTotal: c.totalKreditsEarned
}));
})
return kreditsByContributor;
}),
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filter(c => c.confirmedAt > this.currentBlock);
}),
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filterBy('vetoed', false)
.filter(c => c.confirmedAt <= this.currentBlock);
}),
reimbursementsUnconfirmed: computed('reimbursements.[]', 'currentBlock', function() {
return this.reimbursements
.filter(r => r.confirmedAt > this.currentBlock);
}),
reimbursementsConfirmed: computed('reimbursements.[]', 'currentBlock', function() {
return this.reimbursements
.filterBy('vetoed', false)
.filter(r => r.confirmedAt <= this.currentBlock);
}),
reimbursementsPending: computed('reimbursements.[]', 'pendingTx', function() {
return this.reimbursements.filter(r => !r.id);
}),
async loadInitialData () {
const numCachedContributors = await this.browserCache.contributors.length();
if (numCachedContributors > 0) {
await this.loadContributorsFromCache();
await this.loadObjectsFromCache('Contributor');
this.set('contributorsNeedSync', true);
} else {
await this.fetchContributors();
@@ -190,7 +229,7 @@ export default Service.extend({
const numCachedContributions = await this.browserCache.contributions.length();
if (numCachedContributions > 0) {
await this.loadContributionsFromCache();
await this.loadObjectsFromCache('Contribution');
this.set('contributionsNeedSync', true);
} else {
await this.fetchContributions({ page: { size: 30 } });
@@ -267,6 +306,7 @@ export default Service.extend({
syncContributors: task(function * () {
yield this.fetchContributors();
this.set('contributorsNeedSync', false);
}),
addContribution (attributes) {
@@ -278,7 +318,7 @@ export default Service.extend({
attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
const contribution = Contribution.create(attributes);
contribution.set('pendingTx', data);
contribution.set('confirmedAtBlock', data.blockNumber + 40320);
contribution.set('confirmedAtBlock', this.currentBlock + 40320);
this.contributions.pushObject(contribution);
return contribution;
});
@@ -333,6 +373,7 @@ export default Service.extend({
syncContributions: task(function * () {
yield this.fetchNewContributions.perform();
yield this.syncUnconfirmedContributions.perform();
this.set('contributionsNeedSync', false);
}).group('contributionTasks'),
fetchNewContributions: task(function * () {
@@ -406,23 +447,185 @@ export default Service.extend({
});
},
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() {
if (isEmpty(this.currentUserAccounts)) {
return Promise.resolve();
}
return this.kredits.Contributor
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
.then((id) => {
// check if the user is a contributor or not
if (id === 0) {
return Promise.resolve();
} else {
return this.kredits.Contributor.getById(id);
}
//
// Generic data handling (for objects that can be vetoed)
//
fetchObjects(objectClass, options = { page: { size: 200 } }) {
const objectClassLowerCase = objectClass.toLowerCase();
console.debug(`[kredits] Fetching ${objectClassLowerCase}s from the network`);
return this.kredits[objectClass].all(options)
.then(objects => {
return objects.map(data => {
const classInstance = this[`load${objectClass}FromData`](data);
return classInstance;
});
})
.then(objects => {
const cacheWrites = objects.map(o => {
return this.browserCache[objectClassLowerCase+'s']
.setItem(o.id.toString(), o.serialize());
});
return Promise.all(cacheWrites).then(() => {
console.debug(`[kredits] Cached ${objects.length} ${objectClassLowerCase+'s'} in browser storage`);
});
});
},
removeObjectFromCollectionIfLoaded (collection, objectId) {
const loadedObj = this[collection].findBy('id', objectId);
if (loadedObj) { this[collection].removeObject(loadedObj); }
},
async cacheLoadedObjects (collection) {
for (const o of this[collection]) {
await this.browserCache[collection].setItem(o.id, o.serialize());
}
console.debug(`[kredits] Cached ${this[collection].length} ${collection} in browser storage`);
},
async loadObjectsFromCache (objectClass) {
const collection = objectClass.toLowerCase()+'s';
return this.browserCache[collection].iterate((value/*, key , iterationNumber */) => {
const obj = models[objectClass].create(JSON.parse(value));
this.removeObjectFromCollectionIfLoaded(collection, obj.id)
this[collection].pushObject(obj);
}).then((/* result */) => {
console.debug(`[kredits] Loaded ${this[collection].length} ${collection} from cache`);
});
},
syncTaskGroup: taskGroup().enqueue(),
fetchNewObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
const lastKnownObjectId = Math.max.apply(null, this[collection].mapBy('id'));
const toFetch = count - lastKnownObjectId;
if (toFetch > 0) {
console.debug(`[kredits] Fetching ${toFetch} new ${collection}`);
for (let id = lastKnownObjectId; id <= count; id++) {
const data = yield this.kredits[objectClass].getById(id);
const o = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
}
} else {
console.debug(`[kredits] No new ${collection} to fetch`);
}
}),
fetchMissingObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
const allIds = [...Array(count+1).keys()];
allIds.shift(); // remove first item, which is 0
const loadedObjects = new Set(this[collection].mapBy('id'));
const toFetch = allIds.filter(id => !loadedObjects.has(id));
if (toFetch.length === 0) {
console.debug(`[kredits] No ${collection} left to fetch`);
return;
}
console.debug(`[kredits] Fetching ${toFetch.length} past ${collection}`);
let countFetched = 0;
for (let id = count; id > 0; id--) {
if (loadedObjects.has(id)) {
continue;
} else {
const data = yield this.kredits[objectClass].getById(id);
const o = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
countFetched++;
if (countFetched % 20 === 0) {
console.debug(`[kredits] Fetched ${countFetched} more ${collection}`);
}
}
}
console.debug(`[kredits] Cached ${countFetched} past ${collection}`);
}),
syncUnconfirmedObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
if (this.get(`${collection}Unconfirmed`).length > 0) {
console.debug(`[kredits] Syncing unconfirmed ${collection}`);
for (const o of this[`${collection}Unconfirmed`]) {
if (isEmpty(o.id)) return;
const data = yield this.kredits[objectClass].getById(o.id);
const object = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection]
.setItem(o.id.toString(), object.serialize());
}
} else {
console.debug(`[kredits] No unconfirmed ${collection} to sync`);
}
}),
vetoAgainstObject (objectClass, objectId) {
console.debug(`[kredits] veto against ${objectClass.toLowerCase()}`, objectId);
const collection = objectClass.toLowerCase()+'s';
const object = this[collection].findBy('id', objectId);
return this.kredits[objectClass].functions.veto(objectId, { gasLimit: 300000 })
.then(data => {
console.debug('[kredits] veto response', data);
object.set('pendingTx', data);
return data;
});
},
//
// Reimbursements
//
loadReimbursementFromData(data) {
const obj = Reimbursement.create(processReimbursementData(data));
obj.set('contributor', this.contributors.findBy('id', data.contributorId.toString()));
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
this.reimbursements.pushObject(obj);
return obj;
},
addReimbursement (attributes) {
console.debug('[kredits] add reimbursement', attributes);
return this.kredits.Reimbursement.add(attributes, { gasLimit: 300000 })
.then(data => {
console.debug('[kredits] add reimbursement response', data);
const reimbursement = Reimbursement.create(attributes);
reimbursement.setProperties({
contributor: this.contributors.findBy('id', attributes.contributorId.toString()),
pendingTx: data,
confirmedAt: this.currentBlock + 40320
});
this.reimbursements.pushObject(reimbursement);
// Listen to tx mining/execution status
data.wait()
.then(d => console.debug('[kredits] tx successful', d))
.catch(e => {
window.alert('The transaction failed to execute. Please check the browser console.');
console.log('[kredits] tx error', e);
});
return reimbursement;
});
},
syncReimbursements: task(function * () {
yield this.fetchNewObjects.perform('Reimbursement');
yield this.syncUnconfirmedObjects.perform('Reimbursement');
this.set('reimbursementsNeedSync', false);
}).group('syncTaskGroup'),
fetchMissingReimbursements: task(function * () {
yield this.fetchMissingObjects.perform('Reimbursement');
}).group('syncTaskGroup'),
//
// Contract events
//
addContractEventHandlers () {
this.kredits.Contributor
.on('ContributorProfileUpdated', this.handleContributorChange.bind(this))
@@ -433,6 +636,10 @@ export default Service.extend({
.on('ContributionAdded', this.handleContributionAdded.bind(this))
.on('ContributionVetoed', this.handleContributionVetoed.bind(this))
this.kredits.Reimbursement
.on('ReimbursementAdded', this.handleReimbursementAdded.bind(this))
.on('ReimbursementVetoed', this.handleReimbursementVetoed.bind(this))
this.kredits.Token
.on('Transfer', this.handleTransfer.bind(this));
},
@@ -482,6 +689,39 @@ export default Service.extend({
}
},
//
// TODO test when reimbursement txs are successful
//
async handleReimbursementAdded (id, addedByAccount, amount) {
console.debug('[kredits] ReimbursementAdded event received', { id, addedByAccount, amount });
const pendingReimbursement = this.reimbursementsPending.find(r => {
return r.amount.toString() === amount.toString();
});
if (pendingReimbursement) {
console.debug('[kredits] Found a pending reimbursement matching the event. Replacing it with the final record...');
this.reimbursements.removeObject(pendingReimbursement);
}
const data = await this.kredits.Reimbursement.getById(id);
this.loadReimbursementFromData(data);
},
//
// TODO test when reimbursement txs are successful and veto is implemented
//
handleReimbursementVetoed (id) {
console.debug('[kredits] ReimbursementVetoed received for ', id);
const reimbursement = this.reimbursements.findBy('id', id);
console.debug('[kredits] reimbursement', this.reimbursement);
if (reimbursement) {
reimbursement.set('vetoed', true);
reimbursement.set('pendingTx', null);
}
},
handleTransfer (from, to, value) {
value = value.toNumber();
+3 -3
View File
@@ -20,7 +20,7 @@ button, input[type=submit], .button {
background-color: rgba(22, 21, 40, 0.8);
}
&:active, &.active {
&:focus, &:active, &.active {
border-color: $primary-color;
}
@@ -41,7 +41,7 @@ button, input[type=submit], .button {
&:hover {
background-color: rgba(40, 21, 21, 0.8);
}
&:active, &.active {
&:focus, &:active, &.active {
border-color: $red;
}
}
@@ -54,7 +54,7 @@ button, input[type=submit], .button {
&:hover {
background-color: rgba(21, 40, 21, 0.8);
}
&:active, &.active {
&:focus, &:active, &.active {
border-color: $green;
}
}
+4
View File
@@ -8,3 +8,7 @@ $red: #fb6868;
$primary-color: $blue;
$body-text-color: #fff;
$item-background-color: rgba(255,255,255,0.1);
$item-highlighted-background-color: rgba(255,255,255,0.2);
$item-border-color: rgba(255,255,255,0.2);
+46 -6
View File
@@ -1,21 +1,31 @@
section#add-contributor,
section#add-contribution,
section#add-contribution,
section#add-item, // TODO use for all forms for adding data
section#signup {
form {
h3 {
font-size: 1.5rem;
font-weight: normal;
margin-top: 2em;
margin-bottom: 1em;
}
p {
margin-bottom: 1.5rem;
font-size: 1.2rem;
&.mg-bottom-md {
margin-bottom: 2rem;
}
&.label {
margin-bottom: 1.5rem;
font-size: 1rem;
margin-bottom: .5rem;
}
&.actions {
margin-bottom: 1.5rem;
padding-top: 1.5rem;
text-align: center;
a {
@@ -29,9 +39,34 @@ section#signup {
display: block;
margin-bottom: 0.5rem;
opacity: 0.7;
> p {
margin-bottom: 1.5rem;
}
}
input[type=text], select {
fieldset {
border: none;
&.horizontal {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 2rem;
&.thirds {
grid-template-columns: 1fr 1fr 1fr;
}
&.total-amounts {
grid-template-columns: 2fr 1fr 1fr;
}
label {
}
}
}
input[type=text], input[type=url], select {
width: 100%;
padding: 1rem;
border: none;
@@ -39,17 +74,24 @@ section#signup {
background-color: rgba(22, 21, 40, 0.3);
color: #fff;
font-size: 1.2rem;
font-weight: normal;
transition: border-color 0.1s linear;
&:focus, &.valid {
background-color: rgba(22, 21, 40, 0.6);
}
&:focus {
&:focus :not(:invalid) {
border-color: $blue;
}
&::placeholder {
color: rgba(238, 238, 238, 0.5);
}
&.invalid {
border-color: $red;
}
&:disabled {
color: rgba(255, 255, 255, 0.6);
}
}
select {
@@ -109,7 +151,5 @@ section#signup {
color: #fff;
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
ul.item-list {
list-style: none;
> li {
padding: 0.8rem 1.2rem;
font-size: 1.2rem;
background-color: $item-background-color;
border-bottom: 1px solid $item-border-color;
&:first-of-type {
border-top: 1px solid $item-border-color;
}
&.selected {
background-color: $item-highlighted-background-color;
}
}
&.loading {
@include loading-border-top;
> li {
&:first-of-type {
border-top: none;
}
}
}
&.spaced {
> li {
border-top: 1px solid $item-border-color;
margin-bottom: 2rem;
}
}
}
+27 -1
View File
@@ -14,6 +14,15 @@ main {
"contributions";
}
&#budget {
width: 100%;
display: grid;
grid-row-gap: 2rem;
grid-template-areas:
"aside"
"content";
}
&.center-column {
display: flex;
flex-direction: column;
@@ -29,6 +38,16 @@ main {
}
}
div#content {
section {
&:first-of-type {
@include media-max(small) {
margin-top: 0;
}
}
}
}
section {
.content {
a {
@@ -68,7 +87,7 @@ main {
@include media-max(small) {
padding: 2rem 1rem;
font-size: 1.4rem;
font-size: 1.5rem;
}
h2 {
@@ -111,6 +130,13 @@ main {
"stats contributions details";
}
}
&#budget {
grid-column-gap: 3rem;
grid-template-columns: 2fr 4fr 2fr;
grid-template-areas:
"aside content empty";
}
}
}
+6 -2
View File
@@ -47,7 +47,7 @@ a {
section {
h2 {
font-size: 1.4rem;
font-size: 1.5rem;
color: $primary-color;
}
@@ -89,13 +89,17 @@ section {
@import "buttons";
@import "forms";
@import "sugar";
@import "item-list";
@import "components/contribution-list";
@import "components/budget-balances";
@import "components/contribution-details";
@import "components/contribution-list";
@import "components/contributor-list";
@import "components/contributor-profile";
@import "components/expense-list";
@import "components/external-account-link";
@import "components/loading-spinner";
@import "components/reimbursement-list";
@import "components/topbar";
@import "components/topbar-account-panel";
@import "components/user-avatar";
@@ -0,0 +1,41 @@
section#funds {
@include media-max(small) {
margin-bottom: 2rem;
}
table.token-balances {
opacity: 1;
transition: opacity 0.3s linear;
&.loading {
opacity: 0;
}
thead {
display: none;
}
th, td {
font-size: 1.2rem;
vertical-align: text-bottom;
}
th {
text-align: left;
padding-right: 1.2rem;
}
td {
text-align: right;
&.amount {
font-size: 1.5rem;
padding-right: 1.2rem;
}
&.fiat-amount {
color: rgba(255,255,255,0.8);
}
}
}
}
@@ -16,9 +16,9 @@ section#contribution-details {
margin: 0 0 1.5rem;
padding: 2rem 2rem;
border-top: 1px solid rgba(255,255,255,0.2);
border-bottom: 1px solid rgba(255,255,255,0.2);
background-color: rgba(255,255,255,0.1);
border-top: 1px solid $item-border-color;
border-bottom: 1px solid $item-border-color;
background-color: $item-background-color;
h3 {
font-size: 1.5rem;
@@ -67,7 +67,7 @@ section#contribution-details {
overflow: auto;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(255,255,255,0.2);
border-top: 1px solid $item-border-color;
font-size: 1.2rem;
.icon {
@@ -24,22 +24,13 @@ main section {
ul.contribution-list {
clear: both;
width: 100%;
list-style: none;
li {
display: grid;
grid-template-columns: auto 5rem 5rem;
grid-row-gap: 0.5rem;
padding: 0.8rem 1.2rem;
background-color: rgba(255,255,255,0.1);
font-size: 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.2);
cursor: pointer;
&:first-of-type {
border-top: 1px solid rgba(255,255,255,0.2);
}
&.confirmed {
grid-template-columns: auto 5rem;
}
@@ -50,10 +41,6 @@ ul.contribution-list {
opacity: 0.6;
}
&.selected {
background-color: rgba(255,255,255,0.2);
}
p {
align-self: center;
margin: 0;
@@ -113,14 +100,4 @@ ul.contribution-list {
margin-right: 0.5rem;
}
}
&.loading {
@include loading-border-top;
li {
&:first-of-type {
border-top: none;
}
}
}
}
+4 -4
View File
@@ -5,16 +5,16 @@ table.contributor-list {
margin-bottom: 1.5rem;
tr {
background-color: rgba(255,255,255,0.1);
border-bottom: 1px solid rgba(255,255,255,0.2);
background-color: $item-background-color;
border-bottom: 1px solid $item-border-color;
cursor: pointer;
&:first-of-type {
border-top: 1px solid rgba(255,255,255,0.2);
border-top: 1px solid $item-border-color;
}
&.selected {
background-color: rgba(255,255,255,0.2);
background-color: $item-highlighted-background-color;
}
&.current-user {
@@ -8,7 +8,7 @@ section#contributor-profile {
img {
margin: 0 auto;
border: 3px solid rgba(255,255,255,0.2);
border: 3px solid $item-border-color;
}
}
@@ -18,9 +18,9 @@ section#contributor-profile {
margin: -7.2rem 0 1.5rem;
padding: 6rem 1.2rem 2rem;
border-top: 1px solid rgba(255,255,255,0.2);
border-bottom: 1px solid rgba(255,255,255,0.2);
background-color: rgba(255,255,255,0.1);
border-top: 1px solid $item-border-color;
border-bottom: 1px solid $item-border-color;
background-color: $item-background-color;
h2 {
text-align: center;
+48
View File
@@ -0,0 +1,48 @@
ul.expense-list {
grid-column-start: span 2;
width: 100%;
margin-top: 0.8rem;
border-collapse: collapse;
li {
display: grid;
grid-template-columns: auto 10rem;
grid-row-gap: 0.5rem;
padding-top: 1.2rem;
border-top: 1px solid $item-border-color;
font-size: 1.2rem;
&:not(:last-child) {
padding-bottom: 1.2rem;
}
}
.description {
grid-row-start: 1;
grid-row-end: 3;
}
.amount {
justify-self: end;
// font-weight: normal;
}
.actions {
justify-self: end;
}
h4 {
font-size: 1.2rem;
font-weight: normal;
line-height: 2rem;
}
p {
line-height: 2rem;
&.description {
font-size: 1rem;
opacity: 0.7;
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
.loading-spinner {
margin-top: 12rem;
text-align: center;
font-size: 1.4rem;
font-size: 1.5rem;
color: $primary-color;
@include media-max(small) {
@@ -0,0 +1,29 @@
ul.reimbursement-list {
width: 100%;
> li {
display: grid;
grid-template-columns: auto 12rem;
grid-row-gap: 0.5rem;
padding-top: 1.6rem;
.token-amount {
text-align: right;
img {
height: 1em;
vertical-align: middle;
margin-top: -2px;
}
.amount {
font-size: 1.5rem;
}
.symbol {
font-size: 1rem;
padding-left: 0.2rem;
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<main id="budget">
<div id="aside">
<!-- <section id="main&#45;nav"> -->
<!-- <header> -->
<!-- <h2>Budget</h2> -->
<!-- </header> -->
<!-- <div class="content"> -->
<!-- <ul> -->
<!-- <li> -->
<!-- <LinkTo @route="budget.expenses">Expenses</LinkTo> -->
<!-- </li> -->
<!-- <li> -->
<!-- <a href="#">Rewards</a> -->
<!-- </li> -->
<!-- </ul> -->
<!-- </div> -->
<!-- </section> -->
<section id="funds">
<header>
<h2>Community funds</h2>
</header>
<div class="content">
<BudgetBalances />
</div>
</section>
</div>
<div id="content">
{{#if this.reimbursementsUnconfirmed}}
<section id="expenses-unconfirmed">
<header class="with-nav">
<h2>Proposed Reimbursements</h2>
<nav>
<LinkTo @route="reimbursements.new" @title="Submit a reimbursement" class="button small green">add</LinkTo>
</nav>
</header>
<div class="content">
<ReimbursementList @items={{this.reimbursementsUnconfirmed}} />
</div>
</section>
{{/if}}
{{#if this.reimbursementsConfirmed}}
<section id="expenses-confirmed">
<header class="with-nav">
<h2>Confirmed Expenses</h2>
<nav>
<LinkTo @route="reimbursements.new" @title="Submit a reimbursement" class="button small green">add</LinkTo>
</nav>
</header>
<div class="content">
<ReimbursementList @items={{this.reimbursementsConfirmed}} />
</div>
</section>
{{/if}}
</div>
<div id="empty">
</div>
</main>
+1
View File
@@ -0,0 +1 @@
{{outlet}}
+1 -2
View File
@@ -6,8 +6,7 @@
</header>
<div class="content">
<AddContribution @contributors={{this.sortedContributors}}
@attributes={{this.model.params}}
<AddContribution @attributes={{this.model.params}}
@save={{action "save"}} />
</div>
</section>
@@ -19,7 +19,7 @@
<h3>{{this.model.description}}</h3>
<p>
Kind: {{this.model.kind}}
<br>Status: {{contribution-status this.model}}
<br>Status: {{item-status this.model}}
</p>
{{#if this.model.url}}
<p>
+13
View File
@@ -0,0 +1,13 @@
<main class="center-column">
<section id="add-item">
<header>
<h2>Submit a Reimbursement</h2>
</header>
<div class="content">
<AddReimbursement @attributes={{this.model.params}} />
</div>
</section>
</main>
+8
View File
@@ -0,0 +1,8 @@
export default function isValidAmount(inputAmount) {
const amount = parseFloat(inputAmount);
if (Number.isNaN(amount)) {
return false;
} else {
return amount > 0;
}
}
+22
View File
@@ -0,0 +1,22 @@
export default function processReimbursementData(data) {
const processed = {
amount: data.amount.toNumber()
}
if (data.confirmedAtBlock && (typeof data.confirmedAtBlock.toNumber === 'function')) {
processed.confirmedAt = data.confirmedAtBlock.toNumber();
} else if (data.confirmedAt !== 'undefined') {
processed.confirmedAt = data.confirmedAt;
}
const otherProperties = [
'id', 'contributorId', 'token', 'vetoed', 'ipfsHash',
'expenses', 'pendingTx'
];
otherProperties.forEach(prop => {
processed[prop] = data[prop];
});
return processed;
}
+14 -1
View File
@@ -46,7 +46,18 @@ module.exports = function(environment) {
port: '5444',
protocol: 'https',
gatewayUrl: 'https://ipfs.kosmos.org/ipfs'
}
},
tokens: {
'WBTC': '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599'
},
gnosisSafe: {
txServiceHost: 'https://safe-transaction.mainnet.gnosis.io',
address: '0x9CC29b8373FF92B01C1f09F31B5DD862350c167E'
},
corsProxy: 'https://cors.5apps.com/?uri='
};
if (environment === 'development') {
@@ -66,6 +77,8 @@ module.exports = function(environment) {
protocol: 'http',
gatewayUrl: 'http://localhost:8080/ipfs'
};
ENV.corsProxy = 'https://cors-anywhere.herokuapp.com/';
}
if (environment === 'test') {
+5
View File
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true
}
}
+1538 -557
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -29,7 +29,7 @@
"@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0",
"@kosmos/schemas": "^2.2.1",
"@kosmos/schemas": "^3.0.0",
"babel-eslint": "^10.1.0",
"babel-preset-es2015": "^6.22.0",
"babelify": "^7.3.0",
@@ -49,6 +49,7 @@
"ember-cli-sri": "^2.1.1",
"ember-cli-uglify": "^3.0.0",
"ember-concurrency": "^1.1.7",
"ember-concurrency-decorators": "^2.0.1",
"ember-export-application-global": "^2.0.1",
"ember-fetch": "^8.0.1",
"ember-flatpickr": "^2.16.2",
@@ -66,7 +67,8 @@
"eslint-plugin-ember": "^8.5.2",
"eslint-plugin-node": "^11.1.0",
"ethers": "^4.0.47",
"kredits-contracts": "^5.5.0",
"fetch-mock": "^9.10.7",
"kredits-contracts": "github:67P/kredits-contracts#feature/expenses",
"loader.js": "^4.7.0",
"localforage": "^1.7.3",
"ndjson": "github:hugomrdias/ndjson#feat/readable-stream3",
+7
View File
@@ -0,0 +1,7 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" width="64" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<g transform="translate(0.00630876,-0.00301984)">
<path fill="#f7931a" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="#FFF" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+8
View File
@@ -0,0 +1,8 @@
import { getContext } from '@ember/test-helpers';
export default function createComponent(lookupPath, named = {}) {
let { owner } = getContext();
let componentManager = owner.lookup('component-manager:glimmer');
let { class: componentClass } = owner.factoryFor(lookupPath);
return componentManager.createComponent(componentClass, { named });
}
@@ -0,0 +1,15 @@
// import { module, test } from 'qunit';
// import { setupRenderingTest } from 'ember-qunit';
// import { render } from '@ember/test-helpers';
// import { hbs } from 'ember-cli-htmlbars';
//
// module('Integration | Component | add-expense-item', function(hooks) {
// setupRenderingTest(hooks);
//
// test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
// await render(hbs`<AddExpenseItem />`);
// assert.equal(this.element.textContent.trim(), '');
// });
// });
@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | add-reimbursement', function(hooks) {
setupRenderingTest(hooks);
test('it works', async function(assert) {
await render(hbs`<AddReimbursement />`);
assert.ok(true);
});
});
@@ -0,0 +1,13 @@
// import { module, test } from 'qunit';
// import { setupRenderingTest } from 'ember-qunit';
// import { render } from '@ember/test-helpers';
// import { hbs } from 'ember-cli-htmlbars';
//
// module('Integration | Component | budget-balances', function(hooks) {
// setupRenderingTest(hooks);
//
// test('it renders', async function(assert) {
// await render(hbs`<BudgetBalances />`);
// assert.equal(this.element.textContent.trim(), '');
// });
// });
@@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | reimbursement-list', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<ReimbursementList />`);
assert.equal(this.element.textContent.trim(), '');
});
});
@@ -0,0 +1,20 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | fmt-crypto-currency', function(hooks) {
setupRenderingTest(hooks);
test('it converts Wei to ETH', async function(assert) {
this.set('balanceETH', '500000000000000000');
await render(hbs`{{fmt-crypto-currency balanceETH "ETH"}}`);
assert.equal(this.element.textContent.trim(), '0.5');
});
test('it converts Satoshis to (W)BTC', async function(assert) {
this.set('balanceWBTC', '117214976');
await render(hbs`{{fmt-crypto-currency balanceWBTC "WBTC"}}`);
assert.equal(this.element.textContent.trim(), '1.17214976');
});
});
@@ -0,0 +1,22 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | fmt-fiat-currency', function(hooks) {
setupRenderingTest(hooks);
test('it returns the number formatted as currency', async function(assert) {
this.set('amount', 13.9);
await render(hbs`{{fmt-fiat-currency this.amount}}`);
assert.ok(this.element.textContent.trim().match(/13.90/),
'formats the number with two decimals');
assert.ok(this.element.textContent.trim().match(/EUR/),
'using EUR by default');
await render(hbs`{{fmt-fiat-currency this.amount 'USD'}}`);
assert.ok(this.element.textContent.trim().match(/USD/),
'using defined currency when given');
});
});
@@ -0,0 +1,16 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | sats-to-btc', function(hooks) {
setupRenderingTest(hooks);
test('it converts satoshis to full BTC amounts', async function(assert) {
this.set('amount', '166800');
await render(hbs`{{sats-to-btc this.amount}}`);
assert.equal(this.element.textContent.trim(), '0.001668');
});
});
+13
View File
@@ -2,6 +2,19 @@ import Application from '../app';
import config from '../config/environment';
import { setApplication } from '@ember/test-helpers';
import { start } from 'ember-qunit';
import fetchMock from 'fetch-mock';
fetchMock.get(`${config.corsProxy}https://www.bitstamp.net/api/v2/ticker/btceur/`, {
"high": "9258.43", "last": "9165.14", "timestamp": "1601455909", "bid":
"9159.93", "vwap": "9167.57", "volume": "1542.54764854", "low": "9080.20",
"ask": "9165.14", "open": "9240.84"
});
fetchMock.get(`${config.corsProxy}https://www.bitstamp.net/api/v2/ticker/btcusd/`, {
"high": "10865.00", "last": "10714.62", "timestamp": "1601455914", "bid":
"10711.31", "vwap": "10749.70", "volume": "4460.32091975", "low": "10636.66",
"ask": "10715.99", "open": "10840.00"
});
setApplication(Application.create(config.APP));
@@ -0,0 +1,50 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import createComponent from 'kredits-web/tests/helpers/create-component';
const expenses = [
{
title: "Server rent",
description: "Dedicated server: andromeda.kosmos.org, April 2020",
currency: "EUR",
amount: 39.00,
date: "2020-05-06",
url: "https://wiki.kosmos.org/Infrastructure#Hetzner",
tags: ["infrastructure", "server", "hetzner"]
},
{
title: "Server rent",
description: "Dedicated server: centaurus.kosmos.org, April 2020",
currency: "EUR",
amount: 32.00,
date: "2020-05-06",
url: "https://wiki.kosmos.org/Infrastructure#Hetzner",
tags: ["infrastructure", "server", "hetzner"]
},
{
title: "Domain name kosmos.org",
currency: "USD",
amount: 59.00,
date: "2020-05-07",
tags: ["domain"]
}
];
module('Unit | Component | add-reimbursement', function(hooks) {
setupTest(hooks);
test('total sums of expenses in EUR and USD', function(assert) {
let component = createComponent('component:add-reimbursement');
component.expenses = expenses;
assert.equal(component.totalEUR, '71');
assert.equal(component.totalUSD, '59');
});
test('#updateTotalAmountFromFiat', async function(assert) {
let component = createComponent('component:add-reimbursement');
component.expenses = expenses;
await component.exchangeRates.fetchRates();
component.updateTotalAmountFromFiat();
assert.equal(component.total, '0.01323322', 'converts EUR and USD totals to BTC and rounds to 8 decimals');
});
});
+12
View File
@@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | budget', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:budget');
assert.ok(controller);
});
});
@@ -2,11 +2,11 @@ import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import Contribution from 'kredits-web/models/contribution';
module('Unit | Helper | contribution-status', function (hooks) {
module('Unit | Helper | item-status', function (hooks) {
setupTest(hooks);
test('returns the appropriate status', function (assert) {
const contributionStatus = this.owner.factoryFor('helper:contribution-status').create();
const contributionStatus = this.owner.factoryFor('helper:item-status').create();
const kredits = this.owner.lookup('service:kredits');
kredits.set('currentBlock', 23000);
+11
View File
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | budget', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:budget');
assert.ok(route);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | budget/expenses', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:budget/expenses');
assert.ok(route);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | dashboard', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:dashboard');
assert.ok(route);
});
});
@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Route | reimbursements/new', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
let route = this.owner.lookup('route:reimbursements/new');
assert.ok(route);
});
});
@@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | community-funds', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let service = this.owner.lookup('service:community-funds');
assert.ok(service);
});
});
@@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | exchange-rates', function(hooks) {
setupTest(hooks);
test('#fetchRates', async function(assert) {
let service = this.owner.lookup('service:exchange-rates');
await service.fetchRates();
assert.equal(service.btceur, 9167.57, 'fetches BTCEUR from Bitstamp');
assert.equal(service.btcusd, 10749.70, 'fetches BTCUSD from Bitstamp');
});
});
+8
View File
@@ -49,4 +49,12 @@ module('Unit | Service | kredits', function(hooks) {
assert.equal(c3.amountUnconfirmed, 5000, 'correct amount unconfirmed');
assert.equal(c3.amountTotal, 5000, 'correct amount total');
});
test('#contributorsSorted', function(assert) {
let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors);
assert.ok(service.contributorsSorted instanceof Array, 'is an array');
assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name');
});
});
+22
View File
@@ -0,0 +1,22 @@
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import { module, test } from 'qunit';
module('Unit | Utility | is-valid-amount', function() {
test('it returns true for valid amounts', function(assert) {
assert.ok(isValidAmount('1'));
assert.ok(isValidAmount('1.0'));
assert.ok(isValidAmount('0.3'));
assert.ok(isValidAmount('0.3333923'));
assert.ok(isValidAmount(1));
assert.ok(isValidAmount(1.0));
assert.ok(isValidAmount(0.3));
assert.ok(isValidAmount(0.3333923));
});
test('it returns false for invalid amounts', function(assert) {
assert.notOk(isValidAmount('0'));
assert.notOk(isValidAmount(0));
assert.notOk(isValidAmount(''));
assert.notOk(isValidAmount('foo'));
});
});