Introduce budget, reimbursements for expenses #195

Merged
raucao merged 57 commits from feature/expenses into master 2021-06-03 14:23:45 +00:00
76 changed files with 3322 additions and 765 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ module.exports = {
rules: {
'simple-unless': false,
'no-nested-interactive': false
'no-nested-interactive': false,
'no-html-comments': false
},
ignore: [
+4 -4
View File
@@ -1,13 +1,15 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed';
import { alias, and, notEmpty } from '@ember/object/computed';
import { assign } from '@ember/polyfills';
import moment from 'moment';
import { inject as service } from '@ember/service';
export default Component.extend({
kredits: service(),
attributes: null,
contributors: Object.freeze([]),
contributors: alias('kredits.contributorsSorted'),
isValidContributor: notEmpty('contributorId'),
isValidKind: notEmpty('kind'),
@@ -72,7 +74,5 @@ export default Component.extend({
})
.finally(() => this.set('inProgress', false));
}
}
});
@@ -0,0 +1,86 @@
import Component from '@glimmer/component';
galfert commented 2020-11-13 14:49:01 +00:00 (Migrated from github.com)
Review

Is this intended? If it just returns true, why is it needed?

Is this intended? If it just returns true, why is it needed?
raucao commented 2020-11-17 12:39:47 +00:00 (Migrated from github.com)
Review

I guess it's just a leftover from before the proper form validation.

I guess it's just a leftover from before the proper form validation.
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)))
galfert commented 2020-11-13 15:20:34 +00:00 (Migrated from github.com)
Review

What's the reason for this stringify/parse combo? Probably a good idea to add a code comment for this.

What's the reason for this `stringify`/`parse` combo? Probably a good idea to add a code comment for this.
raucao commented 2020-11-17 11:47:13 +00:00 (Migrated from github.com)
Review

It's to get rid of the EmberObject properties and only retain the pure expense data in the object handed to the wrapper.

It's to get rid of the EmberObject properties and only retain the pure expense data in the object handed to the wrapper.
}
this.inProgress = true;
this.kredits.addReimbursement(attributes)
.then((/* reimbursement */) => {
this.router.transitionTo('budget');
})
.catch(e => {
console.error('Could not add reimbursement:', e);
window.alert('Something went wrong. Please check the browser console.')
})
.finally(() => {
this.inProgress = false;
});
}
}
@@ -0,0 +1,89 @@
<form {{on "submit" this.submit}} novalidate>
<label>
<p class="label">Contributor:</p>
<p>
<select required {{on "change" this.updateContributor}}>
<option value="" selected disabled hidden></option>
{{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</p>
</label>
<fieldset class="horizontal thirds total-amounts">
<label>
<p class="label">Total amount (WBTC):</p>
<p>
<Input @type="text"
@placeholder="0.0015"
@value={{this.total}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.totalInputClass}} />
</p>
</label>
<label>
<p class="label">EUR total</p>
<p>
<Input @type="text"
@name="total-eur"
@value={{this.totalEUR}}
@disabled={{true}} />
</p>
</label>
<label>
<p class="label">USD total</p>
<p>
<Input @type="text"
@name="total-usd"
@value={{this.totalUSD}}
@disabled={{true}} />
</p>
</label>
</fieldset>
<h3>Expense items</h3>
{{#if this.expenses}}
<ul class="expense-list">
{{#each this.expenses as |expense|}}
<li>
<div class="description" rowspan="2">
<h4>
<span class="date">{{expense.date}}:</span>
<span class="title">{{expense.title}}</span>
</h4>
<p class="description">{{expense.description}}</p>
</div>
<div class="amount">
{{fmt-fiat-currency expense.amount expense.currency}}
</div>
<div class="actions">
<button {{on "click" (fn this.removeExpenseItem expense)}}
class="danger small" type="button">delete</button>
</div>
</li>
{{/each}}
</ul>
<p class="actions">
<button {{on "click" this.showExpenseForm}}
class="green small" type="button">+ Add another item</button>
</p>
{{else}}
<p>No line items yet.</p>
{{/if}}
<p class="actions">
{{#if this.inProgress}}
<Input @type="submit" @value="Submitting..." @disabled={{true}}
@title="Submit/propose this reimbursement" />
{{else}}
<Input @type="submit" @value="Submit" @disabled={{this.submitButtonDisabled}}
@title="Submit/propose this reimbursement" />
{{/if}}
</p>
{{#if this.expenseFormVisible}}
<h3 id="new-expense-item">New expense item</h3>
<AddExpenseItem @addExpenseItem={{fn this.addExpenseItem}} />
{{/if}}
</form>
@@ -0,0 +1,12 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default class BudgetBalancesComponent extends Component {
@service communityFunds
@alias('communityFunds.balances') balances;
get loading () {
return !this.communityFunds.balancesLoaded;
}
}
@@ -0,0 +1,18 @@
<table class="token-balances {{if this.loading 'loading'}}">
<thead>
<tr>
<th>Token</th>
<th>Amount</th>
<th>Fiat value</th>
</tr>
</thead>
<tbody>
{{#each this.balances as |balance|}}
<tr>
<th>{{balance.token.symbol}}</th>
<td class="amount">{{fmt-crypto-currency balance.balance balance.token.symbol}}</td>
<td class="fiat-amount">~{{balance.balanceUsd}} USD</td>
</tr>
{{/each}}
</tbody>
</table>
@@ -29,11 +29,11 @@
</div>
{{/if}}
<ul class="contribution-list {{if @loading 'loading'}}">
<ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.contributionsFiltered as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="{{contribution-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
@@ -0,0 +1,7 @@
import Component from '@glimmer/component';
import { sort } from '@ember/object/computed';
export default class ReimbursementListComponent extends Component {
itemSorting = Object.freeze(['pendingStatus:asc', 'id:desc']);
@sort('args.items', 'itemSorting') itemsSorted;
}
@@ -0,0 +1,31 @@
<ul class="item-list spaced reimbursement-list {{if @loading 'loading'}}">
{{#each this.itemsSorted as |reimbursement|}}
<li data-reimbursement-id={{reimbursement.id}}
class="{{item-status reimbursement}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{reimbursement.contributor}} /></span>
<span class="title">Expenses covered by {{reimbursement.contributor.name}}</span>
</p>
<p class="token-amount">
<span class="amount">
{{sats-to-btc reimbursement.amount}}</span>&#8239;<span class="symbol">WBTC</span>
</p>
<ul class="expense-list">
{{#each reimbursement.expenses as |expense|}}
<li>
<div class="description" rowspan="2">
<h4>
<span class="date">{{expense.date}}:</span>
<span class="title">{{expense.title}}</span>
</h4>
<p class="description">{{expense.description}}</p>
</div>
<div class="amount">
{{fmt-fiat-currency expense.amount expense.currency}}
</div>
</li>
{{/each}}
</ul>
</li>
{{/each}}
</ul>
@@ -34,7 +34,6 @@ export default Component.extend({
this.set('setupInProgress', true);
galfert commented 2020-11-13 15:45:31 +00:00 (Migrated from github.com)
Review
```suggestion ```
await this.kredits.setup();
this.set('setupInProgress', false);
this.router.transitionTo('dashboard');
} catch (error) {
this.set('setupInProgress', false);
console.log('Opening Ethereum wallet failed:', error);
+10
View File
@@ -0,0 +1,10 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
export default class BudgetController extends Controller {
@service kredits;
@alias('kredits.reimbursementsUnconfirmed') reimbursementsUnconfirmed;
@alias('kredits.reimbursementsConfirmed') reimbursementsConfirmed;
}
-56
View File
@@ -1,56 +0,0 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([contribution]) {
this.setupRecompute(contribution);
let status = [];
if (contribution.vetoed) {
status.push('vetoed');
} else if (contribution.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (contribution.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (contribution) {
if (this.teardown) this.teardown();
contribution.addObserver('vetoed' , this, this.triggerRecompute);
contribution.addObserver('confirmedAt' , this, this.triggerRecompute);
contribution.addObserver('currentBlock' , this, this.triggerRecompute);
contribution.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
contribution.removeObserver('vetoed', this, this.triggerRecompute);
contribution.removeObserver('confirmedAt', this, this.triggerRecompute);
contribution.removeObserver('currentBlock', this, this.triggerRecompute);
contribution.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
+18
View File
@@ -0,0 +1,18 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtCryptoCurrency(params/*, hash*/) {
let fmtAmount;
const amount = params[0];
const code = params[1];
switch(code) {
case 'ETH':
fmtAmount = amount / 1000000000000000000;
break;
case 'WBTC':
fmtAmount = amount / 100000000;
break;
}
return fmtAmount;
});
+11
View File
@@ -0,0 +1,11 @@
import { helper } from '@ember/component/helper';
export default helper(function fmtFiatCurrency(params) {
const lang = navigator.language || navigator.userLanguage;
const formatter = new Intl.NumberFormat(lang, {
style: 'currency',
currency: params[1] || 'EUR',
currencyDisplay: 'code'
})
return formatter.format(params[0]);
});
+56
View File
@@ -0,0 +1,56 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { alias } from '@ember/object/computed';
import { once } from '@ember/runloop';
export default Helper.extend({
kredits: service(),
currentBlock: alias('kredits.currentBlock'),
compute([item]) {
this.setupRecompute(item);
let status = [];
if (item.vetoed) {
status.push('vetoed');
} else if (item.confirmedAt > this.currentBlock) {
status.push('unconfirmed');
} else {
status.push('confirmed');
}
if (item.hasPendingChanges) {
status.push('pending');
}
return status.join(' ');
},
destroy () {
if (this.teardown) this.teardown();
this._super(...arguments);
},
setupRecompute (item) {
if (this.teardown) this.teardown();
item.addObserver('vetoed' , this, this.triggerRecompute);
item.addObserver('confirmedAt' , this, this.triggerRecompute);
item.addObserver('currentBlock' , this, this.triggerRecompute);
item.addObserver('hasPendingChanges' , this, this.triggerRecompute);
this.teardown = () => {
item.removeObserver('vetoed', this, this.triggerRecompute);
item.removeObserver('confirmedAt', this, this.triggerRecompute);
item.removeObserver('currentBlock', this, this.triggerRecompute);
item.removeObserver('hasPendingChanges', this, this.triggerRecompute);
};
},
triggerRecompute () {
once(this, this.recompute);
}
});
+5
View File
@@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
export default helper(function satsToBtc(amount/*, hash*/) {
return amount / 100000000;
});
+47
View File
@@ -0,0 +1,47 @@
import EmberObject, { computed } from '@ember/object';
import moment from 'moment';
import { isPresent } from '@ember/utils';
export default EmberObject.extend({
// Contract
id: null,
recipientId: null,
token: null,
amount: null,
confirmedAt: null,
vetoed: null,
ipfsHash: null,
// contributor model instance
recipient: null,
// TODO contributor who submitted the reimbursement
// recordedBy: null,
// IPFS
expenses: null, // Array of expense objects
pendingTx: null,
iso8601Date: computed('date', 'time', function() {
return this.time ? `${this.date}T${this.time}` : this.date;
}),
jsDate: computed('iso8601Date', function() {
return moment(this.iso8601Date).toDate();
}),
hasPendingChanges: computed('pendingTx', function() {
return isPresent(this.pendingTx);
}),
pendingStatus: computed('pendingTx', function() {
return isPresent(this.pendingTx) ? 'isPending' : 'notPending';
}),
serialize () {
return JSON.stringify(this);
}
});
+9
View File
@@ -29,4 +29,13 @@ Router.map(function() {
this.route('eth-account');
this.route('complete');
});
this.route('budget', function() {
this.route('expenses');
this.route('reimbursements', function() {});
});
this.route('reimbursements', function() {
this.route('new');
});
});
+20 -22
View File
@@ -1,39 +1,37 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { schedule } from '@ember/runloop';
export default Route.extend({
kredits: service(),
export default class ApplicationRoute extends Route {
@service kredits;
@service communityFunds;
beforeModel(/* transition */) {
const kredits = this.kredits;
return kredits.setup().then(() => {
kredits.get('kredits').preflightChecks().catch((error) => {
kredits.kredits.preflightChecks().catch((error) => {
console.error('Kredits preflight check failed!');
console.error(error);
});
}).catch((error) => {
console.log('Error initializing Kredits', error);
});
},
}
model() {
return this.kredits.loadInitialData().then(() => {
this.kredits.addContractEventHandlers()
});
}
afterModel() {
return this.kredits.loadInitialData()
.then(() => {
this.kredits.addContractEventHandlers();
})
.then(() => {
if (this.kredits.contributorsNeedSync) {
schedule('afterRender', this.kredits.syncContributors,
this.kredits.syncContributors.perform);
}
if (this.kredits.contributionsNeedSync) {
schedule('afterRender', this.kredits.syncContributions,
this.kredits.syncContributions.perform);
}
schedule('afterRender', this.kredits.fetchMissingContributions,
this.kredits.fetchMissingContributions.perform);
});
if (this.kredits.contributorsNeedSync) {
schedule('afterRender', this.kredits.syncContributors,
this.kredits.syncContributors.perform);
}
schedule('afterRender', this.communityFunds.fetchBalances,
this.communityFunds.fetchBalances.perform);
}
});
}
+34
View File
@@ -0,0 +1,34 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { isEmpty, isPresent } from '@ember/utils';
import { schedule } from '@ember/runloop';
export default class BudgetRoute extends Route {
@service browserCache;
@service kredits;
async model () {
if (isPresent(this.kredits.reimbursements) &&
isEmpty(this.kredits.reimbursementsPending)) {
console.debug('[route:budget] Reimbursements loaded before, no need to sync or load');
return;
} else {
const numCachedReimbursements = await this.browserCache.reimbursements.length();
if (numCachedReimbursements > 0) {
await this.kredits.loadObjectsFromCache('Reimbursement');
this.kredits.set('reimbursementsNeedSync', true);
} else {
await this.kredits.fetchObjects('Reimbursement', { page: { size: 10 } });
}
}
}
afterModel() {
if (this.kredits.reimbursementsNeedSync) {
schedule('afterRender', this.kredits.syncReimbursements,
this.kredits.syncReimbursements.perform);
}
schedule('afterRender', this.kredits.fetchMissingReimbursements,
this.kredits.fetchMissingReimbursements.perform);
}
}
+4
View File
@@ -0,0 +1,4 @@
import Route from '@ember/routing/route';
export default class BudgetExpensesRoute extends Route {
}
+2 -2
View File
@@ -1,9 +1,9 @@
import Route from '@ember/routing/route';
export default Route.extend({
export default class ContributionsNewRoute extends Route {
model (params) {
return { params };
}
});
}
+18
View File
@@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import { schedule } from '@ember/runloop';
import { inject as service } from '@ember/service';
export default class DashboardRoute extends Route {
@service kredits;
afterModel() {
if (this.kredits.contributionsNeedSync) {
schedule('afterRender', this.kredits.syncContributions,
this.kredits.syncContributions.perform);
}
schedule('afterRender', this.kredits.fetchMissingContributions,
this.kredits.fetchMissingContributions.perform);
}
}
+9
View File
@@ -0,0 +1,9 @@
import Route from '@ember/routing/route';
export default class ReimbursementsNewRoute extends Route {
model (params) {
return { params };
}
}
+6 -1
View File
@@ -13,7 +13,8 @@ export default class BrowserCacheService extends Service {
super(...arguments);
this.stores = {
contributors: createStore('contributors'),
contributions: createStore('contributions')
contributions: createStore('contributions'),
reimbursements: createStore('reimbursements')
}
}
@@ -24,4 +25,8 @@ export default class BrowserCacheService extends Service {
get contributions() {
return this.stores.contributions;
}
get reimbursements() {
return this.stores.reimbursements;
}
}
+45
View File
@@ -0,0 +1,45 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { A } from '@ember/array';
import { task } from 'ember-concurrency-decorators';
import config from 'kredits-web/config/environment';
const txServiceBaseUrl = `${config.gnosisSafe.txServiceHost}/api/v1/safes/${config.gnosisSafe.address}`;
export default class CommunityFundsService extends Service {
@tracked balancesLoaded = false;
@tracked balances = A([]);
@task
*fetchBalances () {
const uri = `${txServiceBaseUrl}/balances/usd/`;
yield fetch(uri).then(res => res.json())
.then(res => this.processBalances(res))
.catch(err => {
galfert commented 2020-11-13 16:02:52 +00:00 (Migrated from github.com)
Review

fetch doesn't reject the promise on normal HTTP errors (i.e. 400 or 500 errors), but only on network failures. So this should probably also check if res.ok is true.

`fetch` doesn't reject the promise on normal HTTP errors (i.e. 400 or 500 errors), but only on network failures. So this should probably also check if `res.ok` is `true`.
raucao commented 2020-11-17 12:37:45 +00:00 (Migrated from github.com)
Review

This gracefully fails by catching the exception in processBalances I think.

This gracefully fails by catching the exception in `processBalances` I think.
console.log(`[community-funds] Fetching balances failed:`);
console.error(err);
});
}
processBalances (res) {
for (const balance of res) {
// Format and round the approximate USD value
const lang = navigator.language || navigator.userLanguage;
balance.balanceUsd = Math.round(balance.balanceUsd).toLocaleString(lang);
if (balance.token) {
// ERC20 token, has all meta data
this.balances.pushObject(balance);
} else {
// ETH, missing meta data
this.balances.pushObject({
...balance,
...{ token: { name: 'Ether', symbol: 'ETH'} }
});
}
}
this.balancesLoaded = true;
}
}
+29
View File
@@ -0,0 +1,29 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import config from 'kredits-web/config/environment';
// Need to go through proxy for CORS headers
const bitstampBaseUrl = `${config.corsProxy}https://www.bitstamp.net/api/v2`;
async function fetchFromBitstamp(currencyPair) {
try {
const res = await fetch(`${bitstampBaseUrl}/ticker/${currencyPair}/`).then(r => r.json());
return res.vwap; // Last 24 hours volume weighted average price
} catch(e) {
galfert commented 2020-11-13 16:05:44 +00:00 (Migrated from github.com)
Review

Same as the comment in the community-funds service. This should probably check if the response is ok.

Same as the comment in the `community-funds` service. This should probably check if the response is ok.
raucao commented 2020-11-17 11:49:20 +00:00 (Migrated from github.com)
Review

If it is not OK, then this already just gracefully fails by not automatically converting numbers.

If it is not OK, then this already just gracefully fails by not automatically converting numbers.
console.error('Could not fetch exchange rate from Bitstamp:', e);
return 0;
}
}
export default class ExchangeRatesService extends Service {
@tracked btceur = 0;
@tracked btcusd = 0;
async fetchRates (source='bitstamp') {
switch(source) {
case 'bitstamp':
this.btceur = await fetchFromBitstamp('btceur');
this.btcusd = await fetchFromBitstamp('btcusd');
}
}
}
+303 -63
View File
@@ -4,7 +4,7 @@ import Kredits from 'kredits-contracts';
import Service from '@ember/service';
import EmberObject from '@ember/object';
import { computed } from '@ember/object';
import { alias, notEmpty } from '@ember/object/computed';
import { alias, filterBy, notEmpty, sort } from '@ember/object/computed';
import { isEmpty, isPresent } from '@ember/utils';
import { inject as service } from '@ember/service';
@@ -13,11 +13,15 @@ import { task, taskGroup } from 'ember-concurrency';
import groupBy from 'kredits-web/utils/group-by';
import processContributorData from 'kredits-web/utils/process-contributor-data';
import processContributionData from 'kredits-web/utils/process-contribution-data';
import processReimbursementData from 'kredits-web/utils/process-reimbursement-data';
import formatKredits from 'kredits-web/utils/format-kredits';
import config from 'kredits-web/config/environment';
import Contributor from 'kredits-web/models/contributor'
import Contribution from 'kredits-web/models/contribution'
import Contributor from 'kredits-web/models/contributor';
import Contribution from 'kredits-web/models/contribution';
import Reimbursement from 'kredits-web/models/reimbursement';
// Lets us access the model classes dynamically
const models = { Contributor, Contribution, Reimbursement };
export default Service.extend({
@@ -28,64 +32,27 @@ export default Service.extend({
currentUser: null,
contributors: null,
contributions: null,
reimbursements: null,
githubAccessToken: null,
currentUserIsContributor: notEmpty('currentUser'),
currentUserIsCore: alias('currentUser.isCore'),
hasAccounts: notEmpty('currentUserAccounts'),
contributorsMined: filterBy('contributors', 'id'),
contributorsSorting: Object.freeze(['name:asc']),
contributorsSorted: sort('contributorsMined', 'contributorsSorting'),
// When data was loaded from cache, we need to fetch updates from the network
contributorsNeedSync: false,
contributionsNeedSync: false,
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions.filter(contribution => {
return contribution.confirmedAt > this.currentBlock;
});
}),
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filterBy('vetoed', false)
.filter(contribution => {
return contribution.confirmedAt <= this.currentBlock;
});
}),
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value.toString());
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
const kreditsByContributor = contributionsGrouped.map(c => {
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
const contributor = this.contributors.findBy('id', c.value.toString());
return EmberObject.create({
contributor: contributor,
amountUnconfirmed: amountUnconfirmed,
amountConfirmed: contributor.totalKreditsEarned,
amountTotal: contributor.totalKreditsEarned + amountUnconfirmed
})
});
contributorsWithOnlyConfirmed.forEach(c => {
kreditsByContributor.push(EmberObject.create({
contributor: c,
amountUnconfirmed: 0,
amountConfirmed: c.totalKreditsEarned,
amountTotal: c.totalKreditsEarned
}));
})
return kreditsByContributor;
}),
reimbursementsNeedSync: false,
init () {
this._super(...arguments);
this.set('contributors', []);
this.set('contributions', []);
this.set('reimbursements', []);
},
// This is called in the application route's beforeModel(). So it is
@@ -168,6 +135,22 @@ export default Service.extend({
});
},
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() {
if (isEmpty(this.currentUserAccounts)) {
return Promise.resolve();
}
return this.kredits.Contributor
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
.then((id) => {
// check if the user is a contributor or not
if (id === 0) {
return Promise.resolve();
} else {
return this.kredits.Contributor.getById(id);
}
});
}),
totalSupply: computed(function() {
return this.kredits.Token.functions.totalSupply().then(total => {
return formatKredits(total);
@@ -179,10 +162,66 @@ export default Service.extend({
.then(total => total.toNumber());
}),
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value.toString());
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
const kreditsByContributor = contributionsGrouped.map(c => {
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
const contributor = this.contributors.findBy('id', c.value.toString());
return EmberObject.create({
contributor: contributor,
amountUnconfirmed: amountUnconfirmed,
amountConfirmed: contributor.totalKreditsEarned,
amountTotal: contributor.totalKreditsEarned + amountUnconfirmed
})
});
contributorsWithOnlyConfirmed.forEach(c => {
kreditsByContributor.push(EmberObject.create({
contributor: c,
amountUnconfirmed: 0,
amountConfirmed: c.totalKreditsEarned,
amountTotal: c.totalKreditsEarned
}));
})
return kreditsByContributor;
}),
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filter(c => c.confirmedAt > this.currentBlock);
}),
contributionsConfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions
.filterBy('vetoed', false)
.filter(c => c.confirmedAt <= this.currentBlock);
}),
reimbursementsUnconfirmed: computed('reimbursements.[]', 'currentBlock', function() {
return this.reimbursements
.filter(r => r.confirmedAt > this.currentBlock);
}),
reimbursementsConfirmed: computed('reimbursements.[]', 'currentBlock', function() {
return this.reimbursements
.filterBy('vetoed', false)
.filter(r => r.confirmedAt <= this.currentBlock);
}),
reimbursementsPending: computed('reimbursements.[]', 'pendingTx', function() {
return this.reimbursements.filter(r => !r.id);
}),
async loadInitialData () {
const numCachedContributors = await this.browserCache.contributors.length();
if (numCachedContributors > 0) {
await this.loadContributorsFromCache();
await this.loadObjectsFromCache('Contributor');
this.set('contributorsNeedSync', true);
} else {
await this.fetchContributors();
@@ -190,7 +229,7 @@ export default Service.extend({
const numCachedContributions = await this.browserCache.contributions.length();
if (numCachedContributions > 0) {
await this.loadContributionsFromCache();
await this.loadObjectsFromCache('Contribution');
this.set('contributionsNeedSync', true);
} else {
await this.fetchContributions({ page: { size: 30 } });
@@ -267,6 +306,7 @@ export default Service.extend({
syncContributors: task(function * () {
yield this.fetchContributors();
this.set('contributorsNeedSync', false);
}),
addContribution (attributes) {
@@ -278,7 +318,7 @@ export default Service.extend({
attributes.contributor = this.contributors.findBy('id', attributes.contributorId);
const contribution = Contribution.create(attributes);
contribution.set('pendingTx', data);
contribution.set('confirmedAtBlock', data.blockNumber + 40320);
contribution.set('confirmedAtBlock', this.currentBlock + 40320);
this.contributions.pushObject(contribution);
return contribution;
});
@@ -333,6 +373,7 @@ export default Service.extend({
syncContributions: task(function * () {
yield this.fetchNewContributions.perform();
yield this.syncUnconfirmedContributions.perform();
this.set('contributionsNeedSync', false);
}).group('contributionTasks'),
fetchNewContributions: task(function * () {
@@ -406,23 +447,185 @@ export default Service.extend({
});
galfert commented 2020-11-16 12:49:13 +00:00 (Migrated from github.com)
Review

Returning a promise here is redundant, because that's the default for async functions.

Returning a promise here is redundant, because that's the default for async functions. ```suggestion ```
raucao commented 2020-11-17 12:38:26 +00:00 (Migrated from github.com)
Review

Didn't know that. Not sure I like it being implicit here...

Didn't know that. Not sure I like it being implicit here...
},
getCurrentUser: computed('kredits.provider', 'currentUserAccounts.[]', function() {
if (isEmpty(this.currentUserAccounts)) {
return Promise.resolve();
}
return this.kredits.Contributor
.functions.getContributorIdByAddress(this.currentUserAccounts.firstObject)
.then((id) => {
// check if the user is a contributor or not
if (id === 0) {
return Promise.resolve();
} else {
return this.kredits.Contributor.getById(id);
}
//
// Generic data handling (for objects that can be vetoed)
//
fetchObjects(objectClass, options = { page: { size: 200 } }) {
const objectClassLowerCase = objectClass.toLowerCase();
console.debug(`[kredits] Fetching ${objectClassLowerCase}s from the network`);
return this.kredits[objectClass].all(options)
.then(objects => {
return objects.map(data => {
const classInstance = this[`load${objectClass}FromData`](data);
return classInstance;
});
})
.then(objects => {
const cacheWrites = objects.map(o => {
return this.browserCache[objectClassLowerCase+'s']
.setItem(o.id.toString(), o.serialize());
});
return Promise.all(cacheWrites).then(() => {
console.debug(`[kredits] Cached ${objects.length} ${objectClassLowerCase+'s'} in browser storage`);
});
});
},
removeObjectFromCollectionIfLoaded (collection, objectId) {
const loadedObj = this[collection].findBy('id', objectId);
if (loadedObj) { this[collection].removeObject(loadedObj); }
},
async cacheLoadedObjects (collection) {
for (const o of this[collection]) {
await this.browserCache[collection].setItem(o.id, o.serialize());
}
console.debug(`[kredits] Cached ${this[collection].length} ${collection} in browser storage`);
},
async loadObjectsFromCache (objectClass) {
const collection = objectClass.toLowerCase()+'s';
return this.browserCache[collection].iterate((value/*, key , iterationNumber */) => {
const obj = models[objectClass].create(JSON.parse(value));
this.removeObjectFromCollectionIfLoaded(collection, obj.id)
this[collection].pushObject(obj);
}).then((/* result */) => {
console.debug(`[kredits] Loaded ${this[collection].length} ${collection} from cache`);
});
},
syncTaskGroup: taskGroup().enqueue(),
fetchNewObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
const lastKnownObjectId = Math.max.apply(null, this[collection].mapBy('id'));
const toFetch = count - lastKnownObjectId;
if (toFetch > 0) {
console.debug(`[kredits] Fetching ${toFetch} new ${collection}`);
for (let id = lastKnownObjectId; id <= count; id++) {
const data = yield this.kredits[objectClass].getById(id);
const o = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
}
} else {
console.debug(`[kredits] No new ${collection} to fetch`);
}
}),
fetchMissingObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
const count = yield this.kredits[objectClass].functions[`${collection}Count`]();
const allIds = [...Array(count+1).keys()];
allIds.shift(); // remove first item, which is 0
const loadedObjects = new Set(this[collection].mapBy('id'));
const toFetch = allIds.filter(id => !loadedObjects.has(id));
if (toFetch.length === 0) {
console.debug(`[kredits] No ${collection} left to fetch`);
return;
}
console.debug(`[kredits] Fetching ${toFetch.length} past ${collection}`);
let countFetched = 0;
for (let id = count; id > 0; id--) {
if (loadedObjects.has(id)) {
continue;
} else {
const data = yield this.kredits[objectClass].getById(id);
const o = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection].setItem(o.id.toString(), o.serialize());
countFetched++;
if (countFetched % 20 === 0) {
console.debug(`[kredits] Fetched ${countFetched} more ${collection}`);
}
}
}
console.debug(`[kredits] Cached ${countFetched} past ${collection}`);
}),
syncUnconfirmedObjects: task(function * (objectClass) {
const collection = objectClass.toLowerCase()+'s';
if (this.get(`${collection}Unconfirmed`).length > 0) {
console.debug(`[kredits] Syncing unconfirmed ${collection}`);
for (const o of this[`${collection}Unconfirmed`]) {
if (isEmpty(o.id)) return;
const data = yield this.kredits[objectClass].getById(o.id);
const object = this[`load${objectClass}FromData`](data);
yield this.browserCache[collection]
.setItem(o.id.toString(), object.serialize());
}
} else {
console.debug(`[kredits] No unconfirmed ${collection} to sync`);
}
}),
vetoAgainstObject (objectClass, objectId) {
console.debug(`[kredits] veto against ${objectClass.toLowerCase()}`, objectId);
const collection = objectClass.toLowerCase()+'s';
const object = this[collection].findBy('id', objectId);
return this.kredits[objectClass].functions.veto(objectId, { gasLimit: 300000 })
.then(data => {
console.debug('[kredits] veto response', data);
object.set('pendingTx', data);
return data;
});
},
//
// Reimbursements
//
loadReimbursementFromData(data) {
const obj = Reimbursement.create(processReimbursementData(data));
obj.set('contributor', this.contributors.findBy('id', data.contributorId.toString()));
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
this.reimbursements.pushObject(obj);
return obj;
},
addReimbursement (attributes) {
console.debug('[kredits] add reimbursement', attributes);
return this.kredits.Reimbursement.add(attributes, { gasLimit: 300000 })
.then(data => {
console.debug('[kredits] add reimbursement response', data);
const reimbursement = Reimbursement.create(attributes);
reimbursement.setProperties({
contributor: this.contributors.findBy('id', attributes.contributorId.toString()),
pendingTx: data,
confirmedAt: this.currentBlock + 40320
});
this.reimbursements.pushObject(reimbursement);
// Listen to tx mining/execution status
data.wait()
.then(d => console.debug('[kredits] tx successful', d))
.catch(e => {
window.alert('The transaction failed to execute. Please check the browser console.');
console.log('[kredits] tx error', e);
});
return reimbursement;
});
},
syncReimbursements: task(function * () {
yield this.fetchNewObjects.perform('Reimbursement');
yield this.syncUnconfirmedObjects.perform('Reimbursement');
this.set('reimbursementsNeedSync', false);
}).group('syncTaskGroup'),
fetchMissingReimbursements: task(function * () {
yield this.fetchMissingObjects.perform('Reimbursement');
}).group('syncTaskGroup'),
//
// Contract events
//
addContractEventHandlers () {
this.kredits.Contributor
.on('ContributorProfileUpdated', this.handleContributorChange.bind(this))
@@ -433,6 +636,10 @@ export default Service.extend({
.on('ContributionAdded', this.handleContributionAdded.bind(this))
.on('ContributionVetoed', this.handleContributionVetoed.bind(this))
this.kredits.Reimbursement
.on('ReimbursementAdded', this.handleReimbursementAdded.bind(this))
.on('ReimbursementVetoed', this.handleReimbursementVetoed.bind(this))
this.kredits.Token
.on('Transfer', this.handleTransfer.bind(this));
},
@@ -482,6 +689,39 @@ export default Service.extend({
}
},
//
// TODO test when reimbursement txs are successful
//
async handleReimbursementAdded (id, addedByAccount, amount) {
console.debug('[kredits] ReimbursementAdded event received', { id, addedByAccount, amount });
const pendingReimbursement = this.reimbursementsPending.find(r => {
return r.amount.toString() === amount.toString();
});
if (pendingReimbursement) {
console.debug('[kredits] Found a pending reimbursement matching the event. Replacing it with the final record...');
this.reimbursements.removeObject(pendingReimbursement);
}
const data = await this.kredits.Reimbursement.getById(id);
this.loadReimbursementFromData(data);
},
//
// TODO test when reimbursement txs are successful and veto is implemented
//
handleReimbursementVetoed (id) {
console.debug('[kredits] ReimbursementVetoed received for ', id);
const reimbursement = this.reimbursements.findBy('id', id);
console.debug('[kredits] reimbursement', this.reimbursement);
if (reimbursement) {
reimbursement.set('vetoed', true);
reimbursement.set('pendingTx', null);
}
},
handleTransfer (from, to, value) {
value = value.toNumber();
+3 -3
View File
@@ -20,7 +20,7 @@ button, input[type=submit], .button {
background-color: rgba(22, 21, 40, 0.8);
}
&:active, &.active {
&:focus, &:active, &.active {
border-color: $primary-color;
}
@@ -41,7 +41,7 @@ button, input[type=submit], .button {
&:hover {
background-color: rgba(40, 21, 21, 0.8);
}
&:active, &.active {
&:focus, &:active, &.active {
border-color: $red;
}
}
@@ -54,7 +54,7 @@ button, input[type=submit], .button {
&:hover {
background-color: rgba(21, 40, 21, 0.8);
}
&:active, &.active {
&:focus, &:active, &.active {
border-color: $green;
}
}
+4
View File
@@ -8,3 +8,7 @@ $red: #fb6868;
$primary-color: $blue;
$body-text-color: #fff;
$item-background-color: rgba(255,255,255,0.1);
$item-highlighted-background-color: rgba(255,255,255,0.2);
$item-border-color: rgba(255,255,255,0.2);
+46 -6
View File
@@ -1,21 +1,31 @@
section#add-contributor,
section#add-contribution,
section#add-contribution,
section#add-item, // TODO use for all forms for adding data
section#signup {
form {
h3 {
font-size: 1.5rem;
font-weight: normal;
margin-top: 2em;
margin-bottom: 1em;
}
p {
margin-bottom: 1.5rem;
font-size: 1.2rem;
&.mg-bottom-md {
margin-bottom: 2rem;
}
&.label {
margin-bottom: 1.5rem;
font-size: 1rem;
margin-bottom: .5rem;
}
&.actions {
margin-bottom: 1.5rem;
padding-top: 1.5rem;
text-align: center;
a {
@@ -29,9 +39,34 @@ section#signup {
display: block;
margin-bottom: 0.5rem;
opacity: 0.7;
> p {
margin-bottom: 1.5rem;
}
}
input[type=text], select {
fieldset {
border: none;
&.horizontal {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 2rem;
&.thirds {
grid-template-columns: 1fr 1fr 1fr;
}
&.total-amounts {
grid-template-columns: 2fr 1fr 1fr;
}
label {
}
}
}
input[type=text], input[type=url], select {
width: 100%;
padding: 1rem;
border: none;
@@ -39,17 +74,24 @@ section#signup {
background-color: rgba(22, 21, 40, 0.3);
color: #fff;
font-size: 1.2rem;
font-weight: normal;
transition: border-color 0.1s linear;
&:focus, &.valid {
background-color: rgba(22, 21, 40, 0.6);
}
&:focus {
&:focus :not(:invalid) {
border-color: $blue;
}
&::placeholder {
color: rgba(238, 238, 238, 0.5);
}
&.invalid {
border-color: $red;
}
&:disabled {
color: rgba(255, 255, 255, 0.6);
}
}
select {
@@ -109,7 +151,5 @@ section#signup {
color: #fff;
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
ul.item-list {
list-style: none;
> li {
padding: 0.8rem 1.2rem;
font-size: 1.2rem;
background-color: $item-background-color;
border-bottom: 1px solid $item-border-color;
&:first-of-type {
border-top: 1px solid $item-border-color;
}
&.selected {
background-color: $item-highlighted-background-color;
}
}
&.loading {
@include loading-border-top;
> li {
&:first-of-type {
border-top: none;
}
}
}
&.spaced {
> li {
border-top: 1px solid $item-border-color;
margin-bottom: 2rem;
}
}
}
+27 -1
View File
@@ -14,6 +14,15 @@ main {
"contributions";
}
&#budget {
width: 100%;
display: grid;
grid-row-gap: 2rem;
grid-template-areas:
"aside"
"content";
}
&.center-column {
display: flex;
flex-direction: column;
@@ -29,6 +38,16 @@ main {
}
}
div#content {
section {
&:first-of-type {
@include media-max(small) {
margin-top: 0;
}
}
}
}
section {
.content {
a {
@@ -68,7 +87,7 @@ main {
@include media-max(small) {
padding: 2rem 1rem;
font-size: 1.4rem;
font-size: 1.5rem;
}
h2 {
@@ -111,6 +130,13 @@ main {
"stats contributions details";
}
}
&#budget {
grid-column-gap: 3rem;
grid-template-columns: 2fr 4fr 2fr;
grid-template-areas:
"aside content empty";
}
}
}
+6 -2
View File
@@ -47,7 +47,7 @@ a {
section {
h2 {
font-size: 1.4rem;
font-size: 1.5rem;
color: $primary-color;
}
@@ -89,13 +89,17 @@ section {
@import "buttons";
@import "forms";
@import "sugar";
@import "item-list";
@import "components/contribution-list";
@import "components/budget-balances";
@import "components/contribution-details";
@import "components/contribution-list";
@import "components/contributor-list";
@import "components/contributor-profile";
@import "components/expense-list";
@import "components/external-account-link";
@import "components/loading-spinner";
@import "components/reimbursement-list";
@import "components/topbar";
@import "components/topbar-account-panel";
@import "components/user-avatar";
@@ -0,0 +1,41 @@
section#funds {
@include media-max(small) {
margin-bottom: 2rem;
}
table.token-balances {
opacity: 1;
transition: opacity 0.3s linear;
&.loading {
opacity: 0;
}
thead {
display: none;
}
th, td {
font-size: 1.2rem;
vertical-align: text-bottom;
}
th {
text-align: left;
padding-right: 1.2rem;
}
td {
text-align: right;
&.amount {
font-size: 1.5rem;
padding-right: 1.2rem;
}
&.fiat-amount {
color: rgba(255,255,255,0.8);
}
}
}
}
@@ -16,9 +16,9 @@ section#contribution-details {
margin: 0 0 1.5rem;
padding: 2rem 2rem;
border-top: 1px solid rgba(255,255,255,0.2);
border-bottom: 1px solid rgba(255,255,255,0.2);
background-color: rgba(255,255,255,0.1);
border-top: 1px solid $item-border-color;
border-bottom: 1px solid $item-border-color;
background-color: $item-background-color;
h3 {
font-size: 1.5rem;
@@ -67,7 +67,7 @@ section#contribution-details {
overflow: auto;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(255,255,255,0.2);
border-top: 1px solid $item-border-color;
font-size: 1.2rem;
.icon {
@@ -24,22 +24,13 @@ main section {
ul.contribution-list {
clear: both;
width: 100%;
list-style: none;
li {
display: grid;
grid-template-columns: auto 5rem 5rem;
grid-row-gap: 0.5rem;
padding: 0.8rem 1.2rem;
background-color: rgba(255,255,255,0.1);
font-size: 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.2);
cursor: pointer;
&:first-of-type {
border-top: 1px solid rgba(255,255,255,0.2);
}
&.confirmed {
grid-template-columns: auto 5rem;
}
@@ -50,10 +41,6 @@ ul.contribution-list {
opacity: 0.6;
}
&.selected {
background-color: rgba(255,255,255,0.2);
}
p {
align-self: center;
margin: 0;
@@ -113,14 +100,4 @@ ul.contribution-list {
margin-right: 0.5rem;
}
}
&.loading {
@include loading-border-top;
li {
&:first-of-type {
border-top: none;
}
}
}
}
+4 -4
View File
@@ -5,16 +5,16 @@ table.contributor-list {
margin-bottom: 1.5rem;
tr {
background-color: rgba(255,255,255,0.1);
border-bottom: 1px solid rgba(255,255,255,0.2);
background-color: $item-background-color;
border-bottom: 1px solid $item-border-color;
cursor: pointer;
&:first-of-type {
border-top: 1px solid rgba(255,255,255,0.2);
border-top: 1px solid $item-border-color;
}
&.selected {
background-color: rgba(255,255,255,0.2);
background-color: $item-highlighted-background-color;
}
&.current-user {
@@ -8,7 +8,7 @@ section#contributor-profile {
img {
margin: 0 auto;
border: 3px solid rgba(255,255,255,0.2);
border: 3px solid $item-border-color;
}
}
@@ -18,9 +18,9 @@ section#contributor-profile {
margin: -7.2rem 0 1.5rem;
padding: 6rem 1.2rem 2rem;
border-top: 1px solid rgba(255,255,255,0.2);
border-bottom: 1px solid rgba(255,255,255,0.2);
background-color: rgba(255,255,255,0.1);
border-top: 1px solid $item-border-color;
border-bottom: 1px solid $item-border-color;
background-color: $item-background-color;
h2 {
text-align: center;
+48
View File
@@ -0,0 +1,48 @@
ul.expense-list {
grid-column-start: span 2;
width: 100%;
margin-top: 0.8rem;
border-collapse: collapse;
li {
display: grid;
grid-template-columns: auto 10rem;
grid-row-gap: 0.5rem;
padding-top: 1.2rem;
border-top: 1px solid $item-border-color;
font-size: 1.2rem;
&:not(:last-child) {
padding-bottom: 1.2rem;
}
}
.description {
grid-row-start: 1;
grid-row-end: 3;
}
.amount {
justify-self: end;
// font-weight: normal;
}
.actions {
justify-self: end;
}
h4 {
font-size: 1.2rem;
font-weight: normal;
line-height: 2rem;
}
p {
line-height: 2rem;
&.description {
font-size: 1rem;
opacity: 0.7;
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
.loading-spinner {
margin-top: 12rem;
text-align: center;
font-size: 1.4rem;
font-size: 1.5rem;
color: $primary-color;
@include media-max(small) {
@@ -0,0 +1,29 @@
ul.reimbursement-list {
width: 100%;
> li {
display: grid;
grid-template-columns: auto 12rem;
grid-row-gap: 0.5rem;
padding-top: 1.6rem;
.token-amount {
text-align: right;
img {
height: 1em;
vertical-align: middle;
margin-top: -2px;
}
.amount {
font-size: 1.5rem;
}
.symbol {
font-size: 1rem;
padding-left: 0.2rem;
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
<main id="budget">
<div id="aside">
<!-- <section id="main&#45;nav"> -->
<!-- <header> -->
<!-- <h2>Budget</h2> -->
<!-- </header> -->
<!-- <div class="content"> -->
<!-- <ul> -->
<!-- <li> -->
<!-- <LinkTo @route="budget.expenses">Expenses</LinkTo> -->
<!-- </li> -->
<!-- <li> -->
<!-- <a href="#">Rewards</a> -->
<!-- </li> -->
<!-- </ul> -->
<!-- </div> -->
<!-- </section> -->
<section id="funds">
<header>
<h2>Community funds</h2>
</header>
<div class="content">
<BudgetBalances />
</div>
</section>
</div>
<div id="content">
{{#if this.reimbursementsUnconfirmed}}
<section id="expenses-unconfirmed">
<header class="with-nav">
<h2>Proposed Reimbursements</h2>
<nav>
<LinkTo @route="reimbursements.new" @title="Submit a reimbursement" class="button small green">add</LinkTo>
</nav>
</header>
<div class="content">
<ReimbursementList @items={{this.reimbursementsUnconfirmed}} />
</div>
</section>
{{/if}}
{{#if this.reimbursementsConfirmed}}
<section id="expenses-confirmed">
<header class="with-nav">
<h2>Confirmed Expenses</h2>
<nav>
<LinkTo @route="reimbursements.new" @title="Submit a reimbursement" class="button small green">add</LinkTo>
</nav>
</header>
<div class="content">
<ReimbursementList @items={{this.reimbursementsConfirmed}} />
</div>
</section>
{{/if}}
</div>
<div id="empty">
</div>
</main>
+1
View File
@@ -0,0 +1 @@
{{outlet}}
+1 -2
View File
@@ -6,8 +6,7 @@
</header>
<div class="content">
<AddContribution @contributors={{this.sortedContributors}}
@attributes={{this.model.params}}
<AddContribution @attributes={{this.model.params}}
@save={{action "save"}} />
</div>
</section>
@@ -19,7 +19,7 @@
<h3>{{this.model.description}}</h3>
<p>
Kind: {{this.model.kind}}
<br>Status: {{contribution-status this.model}}
<br>Status: {{item-status this.model}}
</p>
{{#if this.model.url}}
<p>
+13
View File
@@ -0,0 +1,13 @@
<main class="center-column">
<section id="add-item">
<header>
<h2>Submit a Reimbursement</h2>
</header>
<div class="content">
<AddReimbursement @attributes={{this.model.params}} />
</div>
</section>
</main>
+8
View File
@@ -0,0 +1,8 @@
export default function isValidAmount(inputAmount) {
const amount = parseFloat(inputAmount);
if (Number.isNaN(amount)) {
return false;
} else {
return amount > 0;
}
}
+22
View File
@@ -0,0 +1,22 @@
export default function processReimbursementData(data) {
const processed = {
amount: data.amount.toNumber()
}
if (data.confirmedAtBlock && (typeof data.confirmedAtBlock.toNumber === 'function')) {
processed.confirmedAt = data.confirmedAtBlock.toNumber();
} else if (data.confirmedAt !== 'undefined') {
processed.confirmedAt = data.confirmedAt;
}
const otherProperties = [
'id', 'contributorId', 'token', 'vetoed', 'ipfsHash',
'expenses', 'pendingTx'
];
otherProperties.forEach(prop => {
processed[prop] = data[prop];
});
return processed;
}
+14 -1
View File
@@ -46,7 +46,18 @@ module.exports = function(environment) {
port: '5444',
protocol: 'https',
gatewayUrl: 'https://ipfs.kosmos.org/ipfs'
}
},
tokens: {
'WBTC': '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599'
},
gnosisSafe: {
txServiceHost: 'https://safe-transaction.mainnet.gnosis.io',
address: '0x9CC29b8373FF92B01C1f09F31B5DD862350c167E'
},
corsProxy: 'https://cors.5apps.com/?uri='
};
if (environment === 'development') {
@@ -66,6 +77,8 @@ module.exports = function(environment) {
protocol: 'http',
gatewayUrl: 'http://localhost:8080/ipfs'
};
ENV.corsProxy = 'https://cors-anywhere.herokuapp.com/';
}
if (environment === 'test') {
+5
View File
@@ -0,0 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true
}
}
+1538 -557
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -29,7 +29,7 @@
"@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0",
"@kosmos/schemas": "^2.2.1",
"@kosmos/schemas": "^3.0.0",
"babel-eslint": "^10.1.0",
"babel-preset-es2015": "^6.22.0",
"babelify": "^7.3.0",
@@ -49,6 +49,7 @@
"ember-cli-sri": "^2.1.1",
"ember-cli-uglify": "^3.0.0",
"ember-concurrency": "^1.1.7",
"ember-concurrency-decorators": "^2.0.1",
"ember-export-application-global": "^2.0.1",
"ember-fetch": "^8.0.1",
"ember-flatpickr": "^2.16.2",
@@ -66,7 +67,8 @@
"eslint-plugin-ember": "^8.5.2",
"eslint-plugin-node": "^11.1.0",
"ethers": "^4.0.47",
"kredits-contracts": "^5.5.0",
"fetch-mock": "^9.10.7",
"kredits-contracts": "github:67P/kredits-contracts#feature/expenses",
"loader.js": "^4.7.0",
"localforage": "^1.7.3",
"ndjson": "github:hugomrdias/ndjson#feat/readable-stream3",
+7
View File
@@ -0,0 +1,7 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="64" width="64" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<g transform="translate(0.00630876,-0.00301984)">
<path fill="#f7931a" d="m63.033,39.744c-4.274,17.143-21.637,27.576-38.782,23.301-17.138-4.274-27.571-21.638-23.295-38.78,4.272-17.145,21.635-27.579,38.775-23.305,17.144,4.274,27.576,21.64,23.302,38.784z"/>
<path fill="#FFF" d="m46.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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