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: {
|
rules: {
|
||||||
'simple-unless': false,
|
'simple-unless': false,
|
||||||
'no-nested-interactive': false
|
'no-nested-interactive': false,
|
||||||
|
'no-html-comments': false
|
||||||
},
|
},
|
||||||
|
|
||||||
ignore: [
|
ignore: [
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import { computed } from '@ember/object';
|
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 { assign } from '@ember/polyfills';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
|
kredits: service(),
|
||||||
|
|
||||||
attributes: null,
|
attributes: null,
|
||||||
contributors: Object.freeze([]),
|
contributors: alias('kredits.contributorsSorted'),
|
||||||
|
|
||||||
isValidContributor: notEmpty('contributorId'),
|
isValidContributor: notEmpty('contributorId'),
|
||||||
isValidKind: notEmpty('kind'),
|
isValidKind: notEmpty('kind'),
|
||||||
@@ -72,7 +74,5 @@ export default Component.extend({
|
|||||||
})
|
})
|
||||||
.finally(() => this.set('inProgress', false));
|
.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>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<ul class="contribution-list {{if @loading 'loading'}}">
|
<ul class="item-list contribution-list {{if @loading 'loading'}}">
|
||||||
{{#each this.contributionsFiltered as |contribution|}}
|
{{#each this.contributionsFiltered as |contribution|}}
|
||||||
<li role="button" data-contribution-id={{contribution.id}}
|
<li role="button" data-contribution-id={{contribution.id}}
|
||||||
{{action "openContributionDetails" contribution}}
|
{{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">
|
<p class="meta">
|
||||||
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
|
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
|
||||||
<span class="category {{contribution.kind}}">({{contribution.kind}})</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);
|
this.set('setupInProgress', true);
|
||||||
await this.kredits.setup();
|
await this.kredits.setup();
|
||||||
this.set('setupInProgress', false);
|
this.set('setupInProgress', false);
|
||||||
this.router.transitionTo('dashboard');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.set('setupInProgress', false);
|
this.set('setupInProgress', false);
|
||||||
console.log('Opening Ethereum wallet failed:', error);
|
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('eth-account');
|
||||||
this.route('complete');
|
this.route('complete');
|
||||||
});
|
});
|
||||||
|
this.route('budget', function() {
|
||||||
|
this.route('expenses');
|
||||||
|
|
||||||
|
this.route('reimbursements', function() {});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route('reimbursements', function() {
|
||||||
|
this.route('new');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-17
@@ -1,39 +1,37 @@
|
|||||||
import { inject as service } from '@ember/service';
|
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
import { schedule } from '@ember/runloop';
|
import { schedule } from '@ember/runloop';
|
||||||
|
|
||||||
export default Route.extend({
|
export default class ApplicationRoute extends Route {
|
||||||
kredits: service(),
|
@service kredits;
|
||||||
|
@service communityFunds;
|
||||||
|
|
||||||
beforeModel(/* transition */) {
|
beforeModel(/* transition */) {
|
||||||
const kredits = this.kredits;
|
const kredits = this.kredits;
|
||||||
|
|
||||||
return kredits.setup().then(() => {
|
return kredits.setup().then(() => {
|
||||||
kredits.get('kredits').preflightChecks().catch((error) => {
|
kredits.kredits.preflightChecks().catch((error) => {
|
||||||
console.error('Kredits preflight check failed!');
|
console.error('Kredits preflight check failed!');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.log('Error initializing Kredits', error);
|
console.log('Error initializing Kredits', error);
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
|
|
||||||
|
model() {
|
||||||
|
return this.kredits.loadInitialData().then(() => {
|
||||||
|
this.kredits.addContractEventHandlers()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
afterModel() {
|
afterModel() {
|
||||||
return this.kredits.loadInitialData()
|
|
||||||
.then(() => {
|
|
||||||
this.kredits.addContractEventHandlers();
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
if (this.kredits.contributorsNeedSync) {
|
if (this.kredits.contributorsNeedSync) {
|
||||||
schedule('afterRender', this.kredits.syncContributors,
|
schedule('afterRender', this.kredits.syncContributors,
|
||||||
this.kredits.syncContributors.perform);
|
this.kredits.syncContributors.perform);
|
||||||
}
|
}
|
||||||
if (this.kredits.contributionsNeedSync) {
|
|
||||||
schedule('afterRender', this.kredits.syncContributions,
|
schedule('afterRender', this.communityFunds.fetchBalances,
|
||||||
this.kredits.syncContributions.perform);
|
this.communityFunds.fetchBalances.perform);
|
||||||
}
|
}
|
||||||
schedule('afterRender', this.kredits.fetchMissingContributions,
|
|
||||||
this.kredits.fetchMissingContributions.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';
|
import Route from '@ember/routing/route';
|
||||||
|
|
||||||
export default Route.extend({
|
export default class ContributionsNewRoute extends Route {
|
||||||
|
|
||||||
model (params) {
|
model (params) {
|
||||||
return { 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);
|
super(...arguments);
|
||||||
this.stores = {
|
this.stores = {
|
||||||
contributors: createStore('contributors'),
|
contributors: createStore('contributors'),
|
||||||
contributions: createStore('contributions')
|
contributions: createStore('contributions'),
|
||||||
|
reimbursements: createStore('reimbursements')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,4 +25,8 @@ export default class BrowserCacheService extends Service {
|
|||||||
get contributions() {
|
get contributions() {
|
||||||
return this.stores.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 Service from '@ember/service';
|
||||||
import EmberObject from '@ember/object';
|
import EmberObject from '@ember/object';
|
||||||
import { computed } 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 { isEmpty, isPresent } from '@ember/utils';
|
||||||
import { inject as service } from '@ember/service';
|
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 groupBy from 'kredits-web/utils/group-by';
|
||||||
import processContributorData from 'kredits-web/utils/process-contributor-data';
|
import processContributorData from 'kredits-web/utils/process-contributor-data';
|
||||||
import processContributionData from 'kredits-web/utils/process-contribution-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 formatKredits from 'kredits-web/utils/format-kredits';
|
||||||
|
|
||||||
import config from 'kredits-web/config/environment';
|
import config from 'kredits-web/config/environment';
|
||||||
import Contributor from 'kredits-web/models/contributor'
|
import Contributor from 'kredits-web/models/contributor';
|
||||||
import Contribution from 'kredits-web/models/contribution'
|
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({
|
export default Service.extend({
|
||||||
|
|
||||||
@@ -28,64 +32,27 @@ export default Service.extend({
|
|||||||
currentUser: null,
|
currentUser: null,
|
||||||
contributors: null,
|
contributors: null,
|
||||||
contributions: null,
|
contributions: null,
|
||||||
|
reimbursements: null,
|
||||||
githubAccessToken: null,
|
githubAccessToken: null,
|
||||||
|
|
||||||
currentUserIsContributor: notEmpty('currentUser'),
|
currentUserIsContributor: notEmpty('currentUser'),
|
||||||
currentUserIsCore: alias('currentUser.isCore'),
|
currentUserIsCore: alias('currentUser.isCore'),
|
||||||
hasAccounts: notEmpty('currentUserAccounts'),
|
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
|
// When data was loaded from cache, we need to fetch updates from the network
|
||||||
contributorsNeedSync: false,
|
contributorsNeedSync: false,
|
||||||
contributionsNeedSync: false,
|
contributionsNeedSync: false,
|
||||||
|
reimbursementsNeedSync: 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;
|
|
||||||
}),
|
|
||||||
|
|
||||||
init () {
|
init () {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
this.set('contributors', []);
|
this.set('contributors', []);
|
||||||
this.set('contributions', []);
|
this.set('contributions', []);
|
||||||
|
this.set('reimbursements', []);
|
||||||
},
|
},
|
||||||
|
|
||||||
// This is called in the application route's beforeModel(). So it is
|
// 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() {
|
totalSupply: computed(function() {
|
||||||
return this.kredits.Token.functions.totalSupply().then(total => {
|
return this.kredits.Token.functions.totalSupply().then(total => {
|
||||||
return formatKredits(total);
|
return formatKredits(total);
|
||||||
@@ -179,10 +162,66 @@ export default Service.extend({
|
|||||||
.then(total => total.toNumber());
|
.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 () {
|
async loadInitialData () {
|
||||||
const numCachedContributors = await this.browserCache.contributors.length();
|
const numCachedContributors = await this.browserCache.contributors.length();
|
||||||
if (numCachedContributors > 0) {
|
if (numCachedContributors > 0) {
|
||||||
await this.loadContributorsFromCache();
|
await this.loadObjectsFromCache('Contributor');
|
||||||
this.set('contributorsNeedSync', true);
|
this.set('contributorsNeedSync', true);
|
||||||
} else {
|
} else {
|
||||||
await this.fetchContributors();
|
await this.fetchContributors();
|
||||||
@@ -190,7 +229,7 @@ export default Service.extend({
|
|||||||
|
|
||||||
const numCachedContributions = await this.browserCache.contributions.length();
|
const numCachedContributions = await this.browserCache.contributions.length();
|
||||||
if (numCachedContributions > 0) {
|
if (numCachedContributions > 0) {
|
||||||
await this.loadContributionsFromCache();
|
await this.loadObjectsFromCache('Contribution');
|
||||||
this.set('contributionsNeedSync', true);
|
this.set('contributionsNeedSync', true);
|
||||||
} else {
|
} else {
|
||||||
await this.fetchContributions({ page: { size: 30 } });
|
await this.fetchContributions({ page: { size: 30 } });
|
||||||
@@ -267,6 +306,7 @@ export default Service.extend({
|
|||||||
|
|
||||||
syncContributors: task(function * () {
|
syncContributors: task(function * () {
|
||||||
yield this.fetchContributors();
|
yield this.fetchContributors();
|
||||||
|
this.set('contributorsNeedSync', false);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
addContribution (attributes) {
|
addContribution (attributes) {
|
||||||
@@ -278,7 +318,7 @@ export default Service.extend({
|
|||||||
attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
|
attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
|
||||||
const contribution = Contribution.create(attributes);
|
const contribution = Contribution.create(attributes);
|
||||||
contribution.set('pendingTx', data);
|
contribution.set('pendingTx', data);
|
||||||
contribution.set('confirmedAtBlock', data.blockNumber + 40320);
|
contribution.set('confirmedAtBlock', this.currentBlock + 40320);
|
||||||
this.contributions.pushObject(contribution);
|
this.contributions.pushObject(contribution);
|
||||||
return contribution;
|
return contribution;
|
||||||
});
|
});
|
||||||
@@ -333,6 +373,7 @@ export default Service.extend({
|
|||||||
syncContributions: task(function * () {
|
syncContributions: task(function * () {
|
||||||
yield this.fetchNewContributions.perform();
|
yield this.fetchNewContributions.perform();
|
||||||
yield this.syncUnconfirmedContributions.perform();
|
yield this.syncUnconfirmedContributions.perform();
|
||||||
|
this.set('contributionsNeedSync', false);
|
||||||
}).group('contributionTasks'),
|
}).group('contributionTasks'),
|
||||||
|
|
||||||
fetchNewContributions: task(function * () {
|
fetchNewContributions: task(function * () {
|
||||||
@@ -406,23 +447,185 @@ export default Service.extend({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() {
|
//
|
||||||
if (isEmpty(this.currentUserAccounts)) {
|
// Generic data handling (for objects that can be vetoed)
|
||||||
return Promise.resolve();
|
//
|
||||||
}
|
|
||||||
return this.kredits.Contributor
|
fetchObjects(objectClass, options = { page: { size: 200 } }) {
|
||||||
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
|
const objectClassLowerCase = objectClass.toLowerCase();
|
||||||
.then((id) => {
|
console.debug(`[kredits] Fetching ${objectClassLowerCase}s from the network`);
|
||||||
// check if the user is a contributor or not
|
return this.kredits[objectClass].all(options)
|
||||||
if (id === 0) {
|
.then(objects => {
|
||||||
return Promise.resolve();
|
return objects.map(data => {
|
||||||
} else {
|
const classInstance = this[`load${objectClass}FromData`](data);
|
||||||
return this.kredits.Contributor.getById(id);
|
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
|
// Contract events
|
||||||
|
//
|
||||||
|
|
||||||
addContractEventHandlers () {
|
addContractEventHandlers () {
|
||||||
this.kredits.Contributor
|
this.kredits.Contributor
|
||||||
.on('ContributorProfileUpdated', this.handleContributorChange.bind(this))
|
.on('ContributorProfileUpdated', this.handleContributorChange.bind(this))
|
||||||
@@ -433,6 +636,10 @@ export default Service.extend({
|
|||||||
.on('ContributionAdded', this.handleContributionAdded.bind(this))
|
.on('ContributionAdded', this.handleContributionAdded.bind(this))
|
||||||
.on('ContributionVetoed', this.handleContributionVetoed.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
|
this.kredits.Token
|
||||||
.on('Transfer', this.handleTransfer.bind(this));
|
.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) {
|
handleTransfer (from, to, value) {
|
||||||
value = value.toNumber();
|
value = value.toNumber();
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ button, input[type=submit], .button {
|
|||||||
background-color: rgba(22, 21, 40, 0.8);
|
background-color: rgba(22, 21, 40, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active, &.active {
|
&:focus, &:active, &.active {
|
||||||
border-color: $primary-color;
|
border-color: $primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ button, input[type=submit], .button {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(40, 21, 21, 0.8);
|
background-color: rgba(40, 21, 21, 0.8);
|
||||||
}
|
}
|
||||||
&:active, &.active {
|
&:focus, &:active, &.active {
|
||||||
border-color: $red;
|
border-color: $red;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ button, input[type=submit], .button {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(21, 40, 21, 0.8);
|
background-color: rgba(21, 40, 21, 0.8);
|
||||||
}
|
}
|
||||||
&:active, &.active {
|
&:focus, &:active, &.active {
|
||||||
border-color: $green;
|
border-color: $green;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ $red: #fb6868;
|
|||||||
$primary-color: $blue;
|
$primary-color: $blue;
|
||||||
|
|
||||||
$body-text-color: #fff;
|
$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-contributor,
|
||||||
section#add-contribution,
|
section#add-contribution,
|
||||||
|
section#add-contribution,
|
||||||
|
section#add-item, // TODO use for all forms for adding data
|
||||||
section#signup {
|
section#signup {
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 1.5rem;
|
font-size: 1.2rem;
|
||||||
|
|
||||||
&.mg-bottom-md {
|
&.mg-bottom-md {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.label {
|
&.label {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
margin-bottom: .5rem;
|
margin-bottom: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.actions {
|
&.actions {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
a {
|
a {
|
||||||
@@ -29,9 +39,34 @@ section#signup {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
opacity: 0.7;
|
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%;
|
width: 100%;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -39,17 +74,24 @@ section#signup {
|
|||||||
background-color: rgba(22, 21, 40, 0.3);
|
background-color: rgba(22, 21, 40, 0.3);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
font-weight: normal;
|
||||||
transition: border-color 0.1s linear;
|
transition: border-color 0.1s linear;
|
||||||
|
|
||||||
&:focus, &.valid {
|
&:focus, &.valid {
|
||||||
background-color: rgba(22, 21, 40, 0.6);
|
background-color: rgba(22, 21, 40, 0.6);
|
||||||
}
|
}
|
||||||
&:focus {
|
&:focus :not(:invalid) {
|
||||||
border-color: $blue;
|
border-color: $blue;
|
||||||
}
|
}
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: rgba(238, 238, 238, 0.5);
|
color: rgba(238, 238, 238, 0.5);
|
||||||
}
|
}
|
||||||
|
&.invalid {
|
||||||
|
border-color: $red;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -109,7 +151,5 @@ section#signup {
|
|||||||
color: #fff;
|
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";
|
"contributions";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&#budget {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-row-gap: 2rem;
|
||||||
|
grid-template-areas:
|
||||||
|
"aside"
|
||||||
|
"content";
|
||||||
|
}
|
||||||
|
|
||||||
&.center-column {
|
&.center-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -29,6 +38,16 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div#content {
|
||||||
|
section {
|
||||||
|
&:first-of-type {
|
||||||
|
@include media-max(small) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
.content {
|
.content {
|
||||||
a {
|
a {
|
||||||
@@ -68,7 +87,7 @@ main {
|
|||||||
|
|
||||||
@include media-max(small) {
|
@include media-max(small) {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
font-size: 1.4rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@@ -111,6 +130,13 @@ main {
|
|||||||
"stats contributions details";
|
"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 {
|
section {
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.5rem;
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,13 +89,17 @@ section {
|
|||||||
@import "buttons";
|
@import "buttons";
|
||||||
@import "forms";
|
@import "forms";
|
||||||
@import "sugar";
|
@import "sugar";
|
||||||
|
@import "item-list";
|
||||||
|
|
||||||
@import "components/contribution-list";
|
@import "components/budget-balances";
|
||||||
@import "components/contribution-details";
|
@import "components/contribution-details";
|
||||||
|
@import "components/contribution-list";
|
||||||
@import "components/contributor-list";
|
@import "components/contributor-list";
|
||||||
@import "components/contributor-profile";
|
@import "components/contributor-profile";
|
||||||
|
@import "components/expense-list";
|
||||||
@import "components/external-account-link";
|
@import "components/external-account-link";
|
||||||
@import "components/loading-spinner";
|
@import "components/loading-spinner";
|
||||||
|
@import "components/reimbursement-list";
|
||||||
@import "components/topbar";
|
@import "components/topbar";
|
||||||
@import "components/topbar-account-panel";
|
@import "components/topbar-account-panel";
|
||||||
@import "components/user-avatar";
|
@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;
|
margin: 0 0 1.5rem;
|
||||||
padding: 2rem 2rem;
|
padding: 2rem 2rem;
|
||||||
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.2);
|
border-top: 1px solid $item-border-color;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
border-bottom: 1px solid $item-border-color;
|
||||||
background-color: rgba(255,255,255,0.1);
|
background-color: $item-background-color;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -67,7 +67,7 @@ section#contribution-details {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding-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;
|
font-size: 1.2rem;
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
|
|||||||
@@ -24,22 +24,13 @@ main section {
|
|||||||
ul.contribution-list {
|
ul.contribution-list {
|
||||||
clear: both;
|
clear: both;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
li {
|
li {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 5rem 5rem;
|
grid-template-columns: auto 5rem 5rem;
|
||||||
grid-row-gap: 0.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;
|
cursor: pointer;
|
||||||
|
|
||||||
&:first-of-type {
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.confirmed {
|
&.confirmed {
|
||||||
grid-template-columns: auto 5rem;
|
grid-template-columns: auto 5rem;
|
||||||
}
|
}
|
||||||
@@ -50,10 +41,6 @@ ul.contribution-list {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background-color: rgba(255,255,255,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
p {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -113,14 +100,4 @@ ul.contribution-list {
|
|||||||
margin-right: 0.5rem;
|
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;
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
background-color: rgba(255,255,255,0.1);
|
background-color: $item-background-color;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
border-bottom: 1px solid $item-border-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:first-of-type {
|
&:first-of-type {
|
||||||
border-top: 1px solid rgba(255,255,255,0.2);
|
border-top: 1px solid $item-border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
background-color: rgba(255,255,255,0.2);
|
background-color: $item-highlighted-background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.current-user {
|
&.current-user {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ section#contributor-profile {
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
margin: 0 auto;
|
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;
|
margin: -7.2rem 0 1.5rem;
|
||||||
padding: 6rem 1.2rem 2rem;
|
padding: 6rem 1.2rem 2rem;
|
||||||
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.2);
|
border-top: 1px solid $item-border-color;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
border-bottom: 1px solid $item-border-color;
|
||||||
background-color: rgba(255,255,255,0.1);
|
background-color: $item-background-color;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
text-align: center;
|
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 {
|
.loading-spinner {
|
||||||
margin-top: 12rem;
|
margin-top: 12rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.4rem;
|
font-size: 1.5rem;
|
||||||
color: $primary-color;
|
color: $primary-color;
|
||||||
|
|
||||||
@include media-max(small) {
|
@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>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<AddContribution @contributors={{this.sortedContributors}}
|
<AddContribution @attributes={{this.model.params}}
|
||||||
@attributes={{this.model.params}}
|
|
||||||
@save={{action "save"}} />
|
@save={{action "save"}} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<h3>{{this.model.description}}</h3>
|
<h3>{{this.model.description}}</h3>
|
||||||
<p>
|
<p>
|
||||||
Kind: {{this.model.kind}}
|
Kind: {{this.model.kind}}
|
||||||
<br>Status: {{contribution-status this.model}}
|
<br>Status: {{item-status this.model}}
|
||||||
</p>
|
</p>
|
||||||
{{#if this.model.url}}
|
{{#if this.model.url}}
|
||||||
<p>
|
<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',
|
port: '5444',
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
gatewayUrl: 'https://ipfs.kosmos.org/ipfs'
|
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') {
|
if (environment === 'development') {
|
||||||
@@ -66,6 +77,8 @@ module.exports = function(environment) {
|
|||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
gatewayUrl: 'http://localhost:8080/ipfs'
|
gatewayUrl: 'http://localhost:8080/ipfs'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ENV.corsProxy = 'https://cors-anywhere.herokuapp.com/';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (environment === 'test') {
|
if (environment === 'test') {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1535
-554
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -29,7 +29,7 @@
|
|||||||
"@ember/optional-features": "^1.3.0",
|
"@ember/optional-features": "^1.3.0",
|
||||||
"@glimmer/component": "^1.0.0",
|
"@glimmer/component": "^1.0.0",
|
||||||
"@glimmer/tracking": "^1.0.0",
|
"@glimmer/tracking": "^1.0.0",
|
||||||
"@kosmos/schemas": "^2.2.1",
|
"@kosmos/schemas": "^3.0.0",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-preset-es2015": "^6.22.0",
|
"babel-preset-es2015": "^6.22.0",
|
||||||
"babelify": "^7.3.0",
|
"babelify": "^7.3.0",
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
"ember-cli-sri": "^2.1.1",
|
"ember-cli-sri": "^2.1.1",
|
||||||
"ember-cli-uglify": "^3.0.0",
|
"ember-cli-uglify": "^3.0.0",
|
||||||
"ember-concurrency": "^1.1.7",
|
"ember-concurrency": "^1.1.7",
|
||||||
|
"ember-concurrency-decorators": "^2.0.1",
|
||||||
"ember-export-application-global": "^2.0.1",
|
"ember-export-application-global": "^2.0.1",
|
||||||
"ember-fetch": "^8.0.1",
|
"ember-fetch": "^8.0.1",
|
||||||
"ember-flatpickr": "^2.16.2",
|
"ember-flatpickr": "^2.16.2",
|
||||||
@@ -66,7 +67,8 @@
|
|||||||
"eslint-plugin-ember": "^8.5.2",
|
"eslint-plugin-ember": "^8.5.2",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-node": "^11.1.0",
|
||||||
"ethers": "^4.0.47",
|
"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",
|
"loader.js": "^4.7.0",
|
||||||
"localforage": "^1.7.3",
|
"localforage": "^1.7.3",
|
||||||
"ndjson": "github:hugomrdias/ndjson#feat/readable-stream3",
|
"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 config from '../config/environment';
|
||||||
import { setApplication } from '@ember/test-helpers';
|
import { setApplication } from '@ember/test-helpers';
|
||||||
import { start } from 'ember-qunit';
|
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));
|
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 { setupTest } from 'ember-qunit';
|
||||||
import Contribution from 'kredits-web/models/contribution';
|
import Contribution from 'kredits-web/models/contribution';
|
||||||
|
|
||||||
module('Unit | Helper | contribution-status', function (hooks) {
|
module('Unit | Helper | item-status', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
|
|
||||||
test('returns the appropriate status', function (assert) {
|
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');
|
const kredits = this.owner.lookup('service:kredits');
|
||||||
|
|
||||||
kredits.set('currentBlock', 23000);
|
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.amountUnconfirmed, 5000, 'correct amount unconfirmed');
|
||||||
assert.equal(c3.amountTotal, 5000, 'correct amount total');
|
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