Finish Reimbursement UI (MVP) #209
@@ -81,7 +81,9 @@ export default class AddExpenseItemComponent extends Component {
|
||||
}
|
||||
|
||||
if (isPresent(this.tags)) {
|
||||
expense.tags = this.tags.split(',').map(t => t.trim());
|
||||
expense.tags = this.tags.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
this.args.addExpenseItem(expense);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 readFileContent from 'kredits-web/utils/read-file-content';
|
||||
import config from 'kredits-web/config/environment';
|
||||
|
||||
export default class AddReimbursementComponent extends Component {
|
||||
@@ -84,8 +85,25 @@ export default class AddReimbursementComponent extends Component {
|
||||
this.total = btcAmount.toFixed(8);
|
||||
}
|
||||
|
||||
// TODO use ember-concurrency here
|
||||
// https://github.com/67P/kredits-web/pull/209#discussion_r1064234421
|
||||
@action
|
||||
updateContributor(event) {
|
||||
async addExpensesFromFile (evt) {
|
||||
const content = await readFileContent(evt.target.files[0]);
|
||||
const expenses = JSON.parse(content);
|
||||
|
||||
if (expenses instanceof Array) {
|
||||
for (const item of expenses) {
|
||||
this.addExpenseItem(item);
|
||||
}
|
||||
} else {
|
||||
console.warn("Expenses in file must be a list of items:");
|
||||
console.debug(content);
|
||||
|
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
updateContributor (event) {
|
||||
this.recipientId = event.target.value;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,3 +87,9 @@
|
||||
<AddExpenseItem @addExpenseItem={{fn this.addExpenseItem}} />
|
||||
{{/if}}
|
||||
</form>
|
||||
<form id="add-expenses-from-file">
|
||||
<h3>Add expense items from file</h3>
|
||||
<input type="file" multiple="false"
|
||||
onchange={{fn this.addExpensesFromFile}}
|
||||
accept="application/json" />
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { inject as service } from '@ember/service';
|
||||
import { alias } from '@ember/object/computed';
|
||||
|
||||
export default class BudgetBalancesComponent extends Component {
|
||||
@service communityFunds
|
||||
@service communityFunds;
|
||||
@alias('communityFunds.balances') balances;
|
||||
|
||||
get loading () {
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { sort } from '@ember/object/computed';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import config from 'kredits-web/config/environment';
|
||||
|
||||
export default class ReimbursementListComponent extends Component {
|
||||
@service kredits;
|
||||
|
||||
itemSorting = Object.freeze(['pendingStatus:asc', 'id:desc']);
|
||||
@sort('args.items', 'itemSorting') itemsSorted;
|
||||
|
||||
get ipfsGatewayUrl () {
|
||||
return config.ipfs.gatewayUrl;
|
||||
}
|
||||
|
||||
@action
|
||||
veto (id) {
|
||||
this.kredits.vetoReimbursement(id).then(transaction => {
|
||||
console.debug('[controllers:budget] Veto submitted to chain: '+transaction.hash);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
<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>
|
||||
<span class="title">
|
||||
Expenses covered by {{reimbursement.contributor.name}}
|
||||
</span>
|
||||
<span class="recipient">
|
||||
<UserAvatar @contributor={{reimbursement.contributor}} />
|
||||
</span>
|
||||
</p>
|
||||
<p class="token-amount">
|
||||
<span class="amount">
|
||||
@@ -26,6 +30,17 @@
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if this.kredits.currentUserIsCore}}
|
||||
<p>
|
||||
<a href="{{this.ipfsGatewayUrl}}/{{reimbursement.ipfsHash}}"
|
||||
class="button small" target="_blank" rel="noopener noreferrer">
|
||||
Inspect IPFS data
|
||||
</a>
|
||||
<button {{on "click" (fn this.veto reimbursement.id)}}
|
||||
disabled={{reimbursement.vetoed}}
|
||||
class="button small danger">veto</button>
|
||||
</p>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
+23
-15
@@ -617,6 +617,18 @@ export default Service.extend({
|
||||
yield this.fetchMissingObjects.perform('Reimbursement');
|
||||
}).group('syncTaskGroup'),
|
||||
|
||||
vetoReimbursement (id) {
|
||||
console.debug('[kredits] veto against reimbursement', id);
|
||||
const reimbursement = this.reimbursements.findBy('id', id);
|
||||
|
||||
return this.kredits.Reimbursement.functions.veto(id, { gasLimit: 300000 })
|
||||
.then(data => {
|
||||
console.debug('[kredits] veto response', data);
|
||||
reimbursement.set('pendingTx', data);
|
||||
return data;
|
||||
});
|
||||
},
|
||||
|
||||
//
|
||||
// Contract events
|
||||
//
|
||||
@@ -676,7 +688,7 @@ export default Service.extend({
|
||||
},
|
||||
|
||||
async handleContributionVetoed (contributionId) {
|
||||
console.debug('[kredits] ContributionVetoed event received for ', contributionId);
|
||||
console.debug('[kredits] ContributionVetoed event received for #', contributionId);
|
||||
const c = this.contributions.findBy('id', contributionId);
|
||||
|
||||
if (c) {
|
||||
@@ -687,9 +699,6 @@ 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 });
|
||||
|
||||
@@ -703,20 +712,19 @@ export default Service.extend({
|
||||
}
|
||||
|
||||
const data = await this.kredits.Reimbursement.getById(id);
|
||||
this.loadReimbursementFromData(data);
|
||||
const r = this.loadReimbursementFromData(data);
|
||||
this.browserCache.reimbursements.setItem(r.id.toString(), r.serialize());
|
||||
},
|
||||
|
||||
//
|
||||
// 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);
|
||||
async handleReimbursementVetoed (id) {
|
||||
console.debug(`[kredits] ReimbursementVetoed received for #${id}`);
|
||||
const r = this.reimbursements.findBy('id', id);
|
||||
console.debug('[kredits] reimbursement', r);
|
||||
|
||||
if (reimbursement) {
|
||||
reimbursement.set('vetoed', true);
|
||||
reimbursement.set('pendingTx', null);
|
||||
if (r) {
|
||||
r.set('vetoed', true);
|
||||
r.set('pendingTx', null);
|
||||
this.browserCache.reimbursements.setItem(r.id.toString(), r.serialize());
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ ul.reimbursement-list {
|
||||
grid-row-gap: 0.5rem;
|
||||
padding-top: 1.6rem;
|
||||
|
||||
&.vetoed {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.token-amount {
|
||||
text-align: right;
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function (file) {
|
||||
const reader = new FileReader();
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onload = event => resolve(event.target.result)
|
||||
reader.onerror = error => reject(error)
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user
Async actions are an anti-pattern. While the asynchronous call (here
readFileContent()is still in progress, one might navigate away and the component will be destroyed. After the asynchronous call finishes, any later statement trying to accessthiswill then throw an exception.One solution would be to use an
ember-concurrencytask instead.Thanks! I added a note with a link to your comment for now, since this is kind of an undocumented and hidden feature for someone who knows what they're doing as of now. Mostly just added it so I could easily migrate the existing data, even though it may be useful for other imports in the future. It's basically instant and you wouldn't navigate away while adding a reimbursement, so I think it's OK to leave it as is for now and spend time on more important things.