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:
+2
-1
@@ -5,7 +5,8 @@ module.exports = {
|
||||
|
||||
rules: {
|
||||
'simple-unless': false,
|
||||
'no-nested-interactive': false
|
||||
'no-nested-interactive': false,
|
||||
'no-html-comments': false
|
||||
},
|
||||
|
||||
ignore: [
|
||||
|
||||
@@ -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> <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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export default helper(function satsToBtc(amount/*, hash*/) {
|
||||
return amount / 100000000;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class BudgetExpensesRoute extends Route {
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
export default class ContributionsNewRoute extends Route {
|
||||
|
||||
model (params) {
|
||||
return { params };
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class ReimbursementsNewRoute extends Route {
|
||||
|
||||
model (params) {
|
||||
return { params };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<main id="budget">
|
||||
|
||||
<div id="aside">
|
||||
<!-- <section id="main-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>
|
||||
@@ -0,0 +1 @@
|
||||
{{outlet}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function isValidAmount(inputAmount) {
|
||||
const amount = parseFloat(inputAmount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return false;
|
||||
} else {
|
||||
return amount > 0;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true
|
||||
}
|
||||
}
|
||||
Generated
+1538
-557
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
-2
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user