Finish Reimbursement UI (MVP) #209

Merged
raucao merged 7 commits from feature/veto_reimbursements into master 2023-01-09 03:34:37 +00:00
9 changed files with 98 additions and 20 deletions
+3 -1
View File
@@ -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);
+19 -1
View File
@@ -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);
galfert commented 2023-01-09 01:13:35 +00:00 (Migrated from github.com)
Review

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 access this will then throw an exception.

One solution would be to use an ember-concurrency task instead.

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 access `this` will then throw an exception. One solution would be to use an `ember-concurrency` task instead.
raucao commented 2023-01-09 03:33:50 +00:00 (Migrated from github.com)
Review

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.

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.
}
}
@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>
+1 -1
View File
@@ -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);
});
}
}
+17 -2
View File
@@ -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
View File
@@ -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;
+8
View File
@@ -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)
})
}