Skip to content

Commit

Permalink
feat(wallet): requesters can cancel their requests (#481)
Browse files Browse the repository at this point in the history
When a request is pending approval the requester can cancel it from the
request dialog.

<img width="818" alt="image"
src="https://github.com/user-attachments/assets/efc3a2b4-0539-49b1-a90a-670be3d8b94d"
/>
  • Loading branch information
olaszakos authored Jan 15, 2025
1 parent 036a34c commit c59bbc2
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 13 deletions.
1 change: 1 addition & 0 deletions apps/wallet/src/components/requests/OpenRequestOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
v-model:open="open"
:request-id="requestId"
@approved="open = false"
@cancelled="open = false"
@request-changed="updateRequestId"
/>
</template>
Expand Down
4 changes: 3 additions & 1 deletion apps/wallet/src/components/requests/PolicyRuleResultView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,13 @@ function getApprovalSummary(approverIds: string[], status: EvaluationStatus): st
n: approvals,
m: rejections,
});
} else {
} else if (variantIs(status, 'Pending')) {
append = i18n.t('requests.evaluation.approval_summary_pending', {
n: approvals,
m: rejections,
});
} else {
unreachable(status);
}
return append;
Expand Down
90 changes: 90 additions & 0 deletions apps/wallet/src/components/requests/RequestDetailView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { mount } from '~/test.utils';
import RequestDetailView from './RequestDetailView.vue';
import { variantIs } from '~/utils/helper.utils';
import { useStationStore } from '~/stores/station.store';
import { flushPromises } from '@vue/test-utils';

type RequestDetailViewProps = InstanceType<typeof RequestDetailView>['$props'];

Expand Down Expand Up @@ -192,6 +194,52 @@ const failedProps: RequestDetailViewProps = {
},
};

const cancelledProps: RequestDetailViewProps = {
details: {
can_approve: false,
requester_name: 'requester',
approvers: [
{ id: 'approver-1-id', name: '' },
{ id: 'approver-2-id', name: '' },
],
},
request: {
status: { Cancelled: { reason: ['cancellation reason'] } },
approvals: [
{
approver_id: 'approver-1-id',
status: { Approved: null },
decided_at: '',
status_reason: [],
},
{
approver_id: 'approver-2-id',
status: { Rejected: null },
decided_at: '',
status_reason: ['Test comment'],
},
],
operation: {
AddUser: {
user: [],
input: {
groups: [],
identities: [],
name: 'test',
status: { Active: null },
},
},
},
created_at: '',
id: '',
execution_plan: { Immediate: null },
expiration_dt: '',
requested_by: 'approver-1-id',
summary: [],
title: '',
},
};

describe('RequestDetailView', () => {
it('renders properly', () => {
const wrapper = mount(RequestDetailView, {
Expand Down Expand Up @@ -312,4 +360,46 @@ describe('RequestDetailView', () => {

expect(wrapper.find('[data-test-id="request-acceptance-rules"]').exists()).toBeTruthy();
});

it('shows a cancel button for cancellable requests', async () => {
const wrapper = mount(RequestDetailView, {
props: pendingProps,
});
const station = useStationStore();
station.$patch({
user: { id: 'requester-id' },
});
await flushPromises();
expect(wrapper.find('[data-test-id="request-details-cancel"]').exists()).toBeTruthy();

await wrapper.find('[data-test-id="request-details-cancel"]').trigger('click');
expect(wrapper.emitted().cancel).toBeTruthy();
});

it("doesn't show cancel button for cancelled requests", async () => {
const wrapper = mount(RequestDetailView, {
props: cancelledProps,
});
const station = useStationStore();
station.$patch({
user: { id: 'requester-id' },
});

await flushPromises();

expect(wrapper.find('[data-test-id="request-details-cancel"]').exists()).toBeFalsy();
});

it("doesn't show cancel button for non-requester", async () => {
const wrapper = mount(RequestDetailView, {
props: pendingProps,
});
const station = useStationStore();
station.$patch({
user: { id: 'not-requester-id' },
});

await flushPromises();
expect(wrapper.find('[data-test-id="request-details-cancel"]').exists()).toBeFalsy();
});
});
20 changes: 19 additions & 1 deletion apps/wallet/src/components/requests/RequestDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
hide-details
rows="1"
auto-grow
:readonly="props.loading || !props.details.can_approve"
:readonly="props.loading || (!props.details.can_approve && !canCancel)"
/>
</VCardText>

Expand Down Expand Up @@ -173,6 +173,17 @@
:class="{ 'mt-8': props.details.can_approve }"
/>
<div class="d-flex flex-column flex-md-row ga-1 justify-end flex-grow-1 w-100 w-md-auto">
<VBtn
v-if="canCancel"
data-test-id="request-details-cancel"
variant="plain"
class="ma-0"
:disabled="props.loading"
@click="$emit('cancel', reasonOrUndefined)"
>
{{ $t('terms.cancel_request') }}
</VBtn>

<template v-if="props.details.can_approve">
<VBtn
data-test-id="request-details-reject"
Expand Down Expand Up @@ -261,8 +272,10 @@ import TransferOperation from './operations/TransferOperation.vue';
import UnsupportedOperation from './operations/UnsupportedOperation.vue';
import EditAssetOperation from './operations/EditAssetOperation.vue';
import RemoveAssetOperation from './operations/RemoveAssetOperation.vue';
import { useStationStore } from '~/stores/station.store';
const i18n = useI18n();
const store = useStationStore();
const props = withDefaults(
defineProps<{
Expand Down Expand Up @@ -315,6 +328,7 @@ const componentsMap: {
defineEmits<{
(event: 'approve', reason?: string): void;
(event: 'reject', reason?: string): void;
(event: 'cancel', reason?: string): void;
}>();
const detailView = computed<{
Expand All @@ -341,6 +355,10 @@ const detailView = computed<{
: null;
});
const canCancel = computed(() => {
return props.request.requested_by === store.user.id && variantIs(props.request.status, 'Created');
});
const requestType = computed(() => {
const keys = Object.keys(componentsMap) as KeysOfUnion<RequestOperation>[];
for (const key of keys) {
Expand Down
29 changes: 29 additions & 0 deletions apps/wallet/src/components/requests/RequestDialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { services } from '~/plugins/services.plugin';
import { mount } from '~/test.utils';
import { ExtractOk } from '~/types/helper.types';
import RequestDialog from './RequestDialog.vue';
import RequestDetailView from './RequestDetailView.vue';

const mockAsset: Asset = {
blockchain: 'icp',
Expand Down Expand Up @@ -265,4 +266,32 @@ describe('RequestDialog', () => {

vi.restoreAllMocks();
});

it('cancels the request when the cancel button is clicked', async () => {
vi.spyOn(services().station, 'getRequest').mockResolvedValueOnce(approvableRequestResponse);
vi.spyOn(services().station, 'cancelRequest').mockResolvedValueOnce(
approvableRequestResponse.request,
);

const wrapper = mount(RequestDialog, {
props: {
requestId: '123',
open: true,
},
});

await flushPromises();

const detailView = wrapper.findComponent(RequestDetailView);

expect(detailView.exists()).toBeTruthy();

detailView.vm.$emit('cancel');

expect(services().station.cancelRequest).toHaveBeenCalledWith({
request_id: '123',
reason: [],
});
vi.restoreAllMocks();
});
});
57 changes: 46 additions & 11 deletions apps/wallet/src/components/requests/RequestDialog.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<VDialog
v-model="openModel"
:persistent="loading || approving"
:persistent="loading || working"
transition="dialog-bottom-transition"
:max-width="props.dialogMaxWidth.value"
>
Expand Down Expand Up @@ -31,11 +31,12 @@
approvers: data.additionalInfo.approvers,
evaluationResult: data.additionalInfo.evaluation_result[0],
}"
:loading="approving || loading"
:loading="working || loading"
@closed="openModel = false"
@opened="openModel = true"
@approve="reason => onApproval(RequestApprovalStatusEnum.Approved, reason)"
@reject="reason => onApproval(RequestApprovalStatusEnum.Rejected, reason)"
@cancel="reason => onCancel(reason)"
>
<template #top-actions>
<VSwitch
Expand All @@ -46,13 +47,13 @@
class="flex-0-1"
:hide-details="true"
color="primary"
:disabled="approving"
:disabled="working"
/>
<VBtn :disabled="approving" :icon="mdiLinkVariant" @click="copyRequestUrl" />
<VBtn :disabled="approving" :icon="mdiClose" @click="openModel = false" />
<VBtn :disabled="working" :icon="mdiLinkVariant" @click="copyRequestUrl" />
<VBtn :disabled="working" :icon="mdiClose" @click="openModel = false" />
</template>
<template v-if="loadNext" #bottom-actions>
<VBtn variant="plain" :disabled="approving" class="ma-0" @click="skip">
<VBtn variant="plain" :disabled="working" class="ma-0" @click="skip">
{{ $t('terms.skip') }}
</VBtn>
</template>
Expand Down Expand Up @@ -122,13 +123,14 @@ const props = toRefs(input);
const emit = defineEmits<{
(event: 'update:open', payload: boolean): void;
(event: 'approved'): void;
(event: 'cancelled'): void;
(event: 'closed'): void;
(event: 'opened'): void;
(event: 'request-changed', payload: UUID): void;
}>();
const currentRequestId = ref<UUID | null>(props.requestId.value);
const preloadedData = ref<DataType | null>(null);
const approving = ref(false);
const working = ref(false);
const loading = ref(false);
const skippedRequestIds = ref<UUID[]>([]);
Expand Down Expand Up @@ -199,7 +201,7 @@ const loadRequest = async (): Promise<DataType> => {
};
const skip = async (): Promise<void> => {
approving.value = true;
working.value = true;
skippedRequestIds.value.push(currentRequestId.value!);
preloadedData.value = await loadNextRequest();
Expand All @@ -211,7 +213,7 @@ const skip = async (): Promise<void> => {
currentRequestId.value = null;
}
approving.value = false;
working.value = false;
};
const onRequestLoaded = (data: Awaited<ReturnType<typeof loadRequest>>): void => {
Expand Down Expand Up @@ -240,7 +242,7 @@ const onApproval = async (decision: RequestApprovalStatusEnum, reason?: string):
return;
}
approving.value = true;
working.value = true;
return station.service
.submitRequestApproval({
Expand Down Expand Up @@ -279,7 +281,40 @@ const onApproval = async (decision: RequestApprovalStatusEnum, reason?: string):
});
})
.finally(() => {
approving.value = false;
working.value = false;
});
};
const onCancel = async (reason?: string): Promise<void> => {
if (currentRequestId.value === null) {
return;
}
working.value = true;
return station.service
.cancelRequest({
request_id: currentRequestId.value,
reason: reason && reason.length ? [reason] : [],
})
.then(async () => {
app.sendNotification({
type: 'success',
message: i18n.t('app.action_save_success'),
});
emit('cancelled');
openModel.value = false;
})
.catch(err => {
logger.error(`Failed to cancel request:`, err);
app.sendNotification({
type: 'error',
message: i18n.t('app.action_save_failed'),
});
})
.finally(() => {
working.value = false;
});
};
Expand Down
1 change: 1 addition & 0 deletions apps/wallet/src/locales/en.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ export default {
see_all: 'See All',
requests: 'Requests',
cancel: 'Cancel',
cancel_request: 'Cancel Request',
checksum: 'Checksum',
module_checksum: 'Module Checksum',
rejected: 'Rejected',
Expand Down
1 change: 1 addition & 0 deletions apps/wallet/src/locales/fr.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ export default {
save: 'Sauvegarder',
see_all: 'Voir Tout',
cancel: 'Annuler',
cancel_request: 'Annuler la demande',
checksum: 'Checksum',
module_checksum: 'Checksum du Module',
rejected: 'Rejetté',
Expand Down
1 change: 1 addition & 0 deletions apps/wallet/src/locales/pt.locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,7 @@ export default {
approved: 'Aprovado',
confirm: 'Confirmar',
cancel: 'Cancelar',
cancel_request: 'Cancelar pedido',
see_all: 'Ver todos',
min: 'Mínimo',
rule: 'Regra',
Expand Down
Loading

0 comments on commit c59bbc2

Please sign in to comment.