Skip to content

Commit

Permalink
feat: add description field to wallet (#151)
Browse files Browse the repository at this point in the history
Enable users to add an optional summary to transfer requests. Summary
data is stored in Proposal::summary.

Updated create transfer form, it is also prepared to load and show an
existing transfer along with the summary from its proposal. To support
this I added the proposal_id to the Transfer DTO.

![image](https://github.com/dfinity/orbit-wallet/assets/9403182/fafa7ec4-6ff5-432e-88d0-dba340a9d939)

Display the summary on the Proposal details dialog:

![image](https://github.com/dfinity/orbit-wallet/assets/9403182/f0094810-1599-4613-9f30-8ddf295dd77d)
  • Loading branch information
olaszakos authored Mar 5, 2024
1 parent 27facc7 commit ef0be31
Show file tree
Hide file tree
Showing 12 changed files with 282 additions and 54 deletions.
39 changes: 31 additions & 8 deletions canisters/integration-tests/src/transfer_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ use std::time::Duration;
use wallet_api::{
AccountPoliciesDTO, AddAccountOperationInput, ApiErrorDTO, ApprovalThresholdDTO,
CreateProposalInput, CreateProposalResponse, CriteriaDTO, GetProposalInput,
GetProposalResponse, ListAccountTransfersInput, ListAccountTransfersResponse, MeResponse,
ProposalExecutionScheduleDTO, ProposalOperationDTO, ProposalOperationInput, ProposalStatusDTO,
TransferOperationInput, UserSpecifierDTO,
GetProposalResponse, GetTransfersInput, GetTransfersResponse, ListAccountTransfersInput,
ListAccountTransfersResponse, MeResponse, ProposalExecutionScheduleDTO, ProposalOperationDTO,
ProposalOperationInput, ProposalStatusDTO, TransferOperationInput, UserSpecifierDTO,
};

#[test]
Expand Down Expand Up @@ -163,7 +163,7 @@ fn make_transfer_successful() {

// check transfer proposal status
let get_proposal_args = GetProposalInput {
proposal_id: proposal_dto.id,
proposal_id: proposal_dto.id.clone(),
};
let res: (Result<GetProposalResponse, ApiErrorDTO>,) = update_candid_as(
&env,
Expand All @@ -185,15 +185,38 @@ fn make_transfer_successful() {
};

// proposal has the transfer id filled out
match new_proposal_dto.operation {
ProposalOperationDTO::Transfer(transfer) => {
transfer.transfer_id.expect("transfer id must be set")
}
let transfer_id = match new_proposal_dto.operation {
ProposalOperationDTO::Transfer(transfer) => transfer
.transfer_id
.expect("transfer id must be set for completed transfer"),
_ => {
panic!("proposal must be Transfer");
}
};

// fetch the transfer and check if its proposal id matches the proposal id that created it
let res: (Result<GetTransfersResponse, ApiErrorDTO>,) = query_candid_as(
&env,
canister_ids.wallet,
WALLET_ADMIN_USER,
"get_transfers",
(GetTransfersInput {
transfer_ids: vec![transfer_id],
},),
)
.unwrap();

let proposal_id_in_transfer_dto = res
.0
.unwrap()
.transfers
.first()
.expect("One transaction must be returned")
.proposal_id
.clone();

assert_eq!(proposal_id_in_transfer_dto, proposal_dto.id);

// check beneficiary balance after completed transfer
let new_beneficiary_balance = get_icp_balance(&env, beneficiary_id);
assert_eq!(new_beneficiary_balance, ICP);
Expand Down
167 changes: 167 additions & 0 deletions canisters/ui/src/components/accounts/TransferDialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '~/test.utils';
import TransferDialog from './TransferDialog.vue';
import { Account, GetProposalResult, Proposal, Transfer } from '~/generated/wallet/wallet.did';
import DataLoaderVue from '../DataLoader.vue';
import { flushPromises } from '@vue/test-utils';
import { services } from '~/plugins/services.plugin';
import { ExtractOk } from '~/types/helper.types';

vi.mock('~/services/wallet.service', () => ({
WalletService: vi.fn().mockImplementation(() => {
return {
transfer: vi.fn(() => {
return Promise.resolve({} as Proposal);
}),
};
}),
}));

describe('TransferDialog', () => {
it('renders correctly', () => {
const wrapper = mount(TransferDialog, {
props: {
account: {
id: '1',
decimals: 1,
} as Account,
open: true,
},
});
expect(wrapper.exists()).toBe(true);
});

it('shows empty form when transferId not specified', async () => {
const wrapper = mount(TransferDialog, {
props: {
account: {
id: '1',
decimals: 1,
} as Account,
open: true,
},
});
await wrapper.vm.$nextTick();
await flushPromises();
await wrapper.vm.$nextTick();

const dataLoader = wrapper.findComponent(DataLoaderVue);
const form = dataLoader.find(`[data-test-id="transfer-dialog-form"]`);

const transferId = form.find(`[data-test-id="transfer-form-transfer-id"]`);
const amount = form.find(`[data-test-id="transfer-form-amount"]`);
const destination = form.find(`[data-test-id="transfer-form-destination-address"]`);
const summary = form.find(`[data-test-id="transfer-dialog-proposal-summary"]`);

// transferId should not be visible when not specified as a prop
expect(transferId.exists()).toBe(false);

expect(amount.exists()).toBe(true);
expect(destination.exists()).toBe(true);
expect(summary.exists()).toBe(true);

// amount field should be empty
expect(amount.find('input').element.value).toBe('');

// destination field should be empty
expect(destination.find('input').element.value).toBe('');

// summary should be empty
expect(summary.find('input').element.value).toBe('');
});

it('creates transfer proposal with summary', async () => {
const wrapper = mount(TransferDialog, {
props: {
account: {
id: '1',
decimals: 1,
} as Account,
open: true,
},
});
await wrapper.vm.$nextTick();
await flushPromises();
await wrapper.vm.$nextTick();

const dataLoader = wrapper.findComponent(DataLoaderVue);
const form = dataLoader.find(`[data-test-id="transfer-dialog-form"]`);

const amount = form.find(`[data-test-id="transfer-form-amount"]`);
const destination = form.find(`[data-test-id="transfer-form-destination-address"]`);
const summary = form.find(`[data-test-id="transfer-dialog-proposal-summary"]`);

const submitButton = form.find(`[data-test-id="transfer-dialog-save-button"]`);

amount.find('input').setValue('1');
destination.find('input').setValue('destination address');
summary.find('input').setValue('test summary');

await flushPromises();

await submitButton.trigger('click');

await wrapper.vm.$nextTick();
await flushPromises();

expect(services().wallet.transfer).toHaveBeenCalledWith(
expect.objectContaining({
amount: 10n,
to: 'destination address',
}),
'test summary',
);
});

it('loads the corresponding objects to display the transfer and summary if transferId is specified', async () => {
services().wallet.getProposal = vi.fn(() =>
Promise.resolve({
proposal: {
summary: ['test summary'], // it's an opt
} as unknown as Proposal,
} as ExtractOk<GetProposalResult>),
);
services().wallet.getTransfer = vi.fn(() =>
Promise.resolve({
id: 'transfer-id',
to: 'destination address',
amount: 123n,
proposal_id: 'proposal-id',
} as Transfer),
);

const wrapper = mount(TransferDialog, {
props: {
account: {
id: '1',
decimals: 2,
} as Account,
open: true,
transferId: 'transfer-id',
},
});
await wrapper.vm.$nextTick();
await flushPromises();
await wrapper.vm.$nextTick();

expect(services().wallet.getTransfer).toHaveBeenCalledWith('transfer-id');
expect(services().wallet.getProposal).toHaveBeenCalledWith({
proposal_id: 'proposal-id',
});

const dataLoader = wrapper.findComponent(DataLoaderVue);
const form = dataLoader.find(`[data-test-id="transfer-dialog-form"]`);

const transferId = form.find(`[data-test-id="transfer-form-transfer-id"]`);
const amount = form.find(`[data-test-id="transfer-form-amount"]`);
const destination = form.find(`[data-test-id="transfer-form-destination-address"]`);
const summary = form.find(`[data-test-id="transfer-dialog-proposal-summary"]`);

expect(transferId.exists()).toBe(true);
expect(transferId.find('input').element.value).toBe('transfer-id');

expect(amount.find('input').element.value).toBe('1.23');
expect(destination.find('input').element.value).toBe('destination address');
expect(summary.find('input').element.value).toBe('test summary');
});
});
76 changes: 57 additions & 19 deletions canisters/ui/src/components/accounts/TransferDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
v-slot="{ data }"
:load="loadTransfer"
@loading="loading = $event"
@loaded="transfer = $event.transfer"
@loaded="
transfer = $event.transfer;
proposal = $event.proposal;
"
>
<VCard :loading="loading">
<VCard :loading="loading" data-test-id="transfer-dialog-form">
<VToolbar dark color="surface">
<VToolbarTitle>{{ $t('terms.transfer') }}</VToolbarTitle>
<VBtn :disabled="loading || saving" :icon="mdiClose" dark @click="openModel = false" />
Expand All @@ -23,17 +26,35 @@
v-model="transfer"
v-model:trigger-submit="triggerSubmit"
:account="props.account.value"
:disabled="props.readonly.value"
:mode="props.readonly.value ? 'view' : 'edit'"
@submit="save"
@valid="valid = $event"
/>

<VRow>
<VCol :cols="12">
<VTextField
v-model="summary"
:label="$t('terms.summary')"
variant="underlined"
density="compact"
class="mb-2"
name="to"
:disabled="props.readonly.value"
type="text"
:prepend-icon="mdiComment"
data-test-id="transfer-dialog-proposal-summary"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pa-3">
<VSpacer />
<VBtn
v-if="!props.readonly.value"
:disabled="!canSave"
:loading="saving"
data-test-id="transfer-dialog-save-button"
@click="triggerSubmit = true"
>
{{ props.transferId.value ? $t('terms.save') : $t('terms.create') }}
Expand All @@ -44,7 +65,7 @@
</VDialog>
</template>
<script lang="ts" setup>
import { mdiClose } from '@mdi/js';
import { mdiClose, mdiComment } from '@mdi/js';
import { computed, ref, toRefs } from 'vue';
import DataLoader from '~/components/DataLoader.vue';
import TransferForm from './TransferForm.vue';
Expand All @@ -53,9 +74,9 @@ import {
useOnSuccessfulOperation,
} from '~/composables/notifications.composable';
import logger from '~/core/logger.core';
import { Account, Transfer, UUID } from '~/generated/wallet/wallet.did';
import { useWalletStore } from '~/stores/wallet.store';
import { Account, Proposal, Transfer, UUID } from '~/generated/wallet/wallet.did';
import { assertAndReturn } from '~/utils/helper.utils';
import { services } from '~/plugins/services.plugin';
const input = withDefaults(
defineProps<{
Expand All @@ -82,26 +103,40 @@ const valid = ref(true);
const loading = ref(false);
const saving = ref(false);
const transfer = ref<Partial<Transfer>>({});
const proposal = ref<Partial<Proposal>>({});
const openModel = computed({
get: () => props.open.value,
set: value => emit('update:open', value),
});
const wallet = useWalletStore();
const summary = computed({
get: () => proposal.value.summary?.[0],
set: value => {
proposal.value.summary = !value ? [] : [value];
},
});
const walletService = services().wallet;
const loadTransfer = async (): Promise<{
transfer: Partial<Transfer>;
proposal: Partial<Proposal>;
}> => {
if (props.transferId.value === undefined) {
const createModel: Partial<Transfer> = {
from_account_id: props.account.value.id,
};
return { transfer: createModel };
return { transfer: createModel, proposal: {} };
}
const result = await wallet.service.getTransfer(props.transferId.value);
return { transfer: result };
const transfer = await walletService.getTransfer(props.transferId.value);
const { proposal } = await walletService.getProposal({
proposal_id: transfer.proposal_id,
});
return { transfer, proposal };
};
const canSave = computed(() => {
Expand All @@ -118,16 +153,19 @@ const save = async (): Promise<void> => {
try {
saving.value = true;
const proposal = await wallet.service.transfer({
from_account_id: assertAndReturn(transfer.value.from_account_id, 'from_account_id'),
amount: assertAndReturn(transfer.value.amount, 'amount'),
to: assertAndReturn(transfer.value.to, 'to'),
fee: transfer.value.fee ? [transfer.value.fee] : [],
metadata: transfer.value.metadata ?? [],
network: transfer.value.network ? [transfer.value.network] : [],
});
const newProposal = await walletService.transfer(
{
from_account_id: assertAndReturn(transfer.value.from_account_id, 'from_account_id'),
amount: assertAndReturn(transfer.value.amount, 'amount'),
to: assertAndReturn(transfer.value.to, 'to'),
fee: transfer.value.fee ? [transfer.value.fee] : [],
metadata: transfer.value.metadata ?? [],
network: transfer.value.network ? [transfer.value.network] : [],
},
summary.value,
);
useOnSuccessfulOperation(proposal);
useOnSuccessfulOperation(newProposal);
openModel.value = false;
} catch (error) {
Expand Down
Loading

0 comments on commit ef0be31

Please sign in to comment.