Skip to content

Commit

Permalink
feat(wallet): Approval Policy aka Named Rules UI (#488)
Browse files Browse the repository at this point in the history
changes - full frontend support for named rules aka Approval policies.
- Approval policies page
- rule summary table column for both request policies and approval
policies
- slight change to named rules API: edit named rule input will take `opt
opt text` for description

potential todo:
- [ ] put named rules on a tab on the request policies page
  • Loading branch information
olaszakos authored Feb 6, 2025
1 parent cd302a3 commit b30f195
Show file tree
Hide file tree
Showing 57 changed files with 3,043 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import AccountRequestPolicySettings from './AccountRequestPolicySettings.vue';
vi.mock('~/services/station.service', () => {
const mock: Partial<StationService> = {
withStationId: vi.fn().mockReturnThis(),
listNamedRules: vi.fn().mockImplementation(() =>
Promise.resolve({
named_rules: [],
}),
),
};

return {
Expand Down
22 changes: 22 additions & 0 deletions apps/wallet/src/components/error/ErrorTooltip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<VTooltip :text="text" location="bottom">
<template #activator="{ props: activatorProps }">
<VIcon class="ml-2" v-bind="activatorProps" :icon="icon" />
</template>
</VTooltip>
</template>

<script setup lang="ts">
import { mdiAlertCircleOutline } from '@mdi/js';
import { VIcon } from 'vuetify/components';
withDefaults(
defineProps<{
text: string;
icon?: string;
}>(),
{
icon: mdiAlertCircleOutline,
},
);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<template v-if="loading">
<VProgressCircular indeterminate />
</template>
<template v-else-if="error !== null"> {{ id }} <ErrorTooltip :text="error" /> </template>
<template v-else-if="namedRule">
<VTooltip v-if="tooltip" location="bottom" content-class="white-space-pre-wrap" :text="tooltip">
<template #activator="{ props: activatorProps }">
<span v-bind="activatorProps" class="underline-dotted font-weight-bold">
{{ namedRule.name }}
</span>
</template>
</VTooltip>
</template>
<template v-else>
{{ id }}
</template>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { onMounted } from 'vue';
import { computed } from 'vue';
import { toRefs } from 'vue';
import { useRuleToTooltip } from '~/composables/request-policies.composable';
import { NamedRule, UUID } from '~/generated/station/station.did';
import { services } from '~/plugins/services.plugin';
import { useAppStore } from '~/stores/app.store';
import { getErrorMessage } from '~/utils/error.utils';
const input = defineProps<{
id: UUID;
}>();
const props = toRefs(input);
const namedRule = ref<NamedRule | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const appStore = useAppStore();
const rule = computed(() => (namedRule.value ? namedRule.value.rule : null));
const tooltip = useRuleToTooltip(rule);
onMounted(async () => {
loading.value = true;
try {
namedRule.value = (await services().station.getNamedRule(props.id.value)).named_rule;
} catch (e) {
appStore.sendErrorNotification(e);
error.value = getErrorMessage(e);
} finally {
loading.value = false;
}
});
</script>
184 changes: 184 additions & 0 deletions apps/wallet/src/components/request-policies/NamedRuleDialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { flushPromises } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { services } from '~/plugins/services.plugin';
import { StationService } from '~/services/station.service';
import { mount } from '~/test.utils';
import NamedRuleDialog from './NamedRuleDialog.vue';
import NamedRuleForm from './NamedRuleForm.vue';
import { VCard } from 'vuetify/components';

vi.mock('~/services/station.service', () => {
const mock: Partial<StationService> = {
withStationId: vi.fn().mockReturnThis(),
getNamedRule: vi.fn().mockImplementation(() =>
Promise.resolve({
named_rule: {
id: '1',
name: 'Test Rule',
description: ['Test Description'],
rule: {
AutoApproved: null,
},
},
}),
),
addNamedRule: vi.fn().mockImplementation(() => Promise.resolve({} as Request)),
editNamedRule: vi.fn().mockImplementation(() => Promise.resolve({} as Request)),
listNamedRules: vi.fn().mockImplementation(() =>
Promise.resolve({
named_rules: [],
}),
),
};

return {
StationService: vi.fn(() => mock),
};
});

describe('NamedRuleDialog', () => {
it('renders correctly', () => {
const wrapper = mount(NamedRuleDialog, {
props: {
open: true,
},
});
expect(wrapper.exists()).toBe(true);
});

it('loads and displays existing named rule', async () => {
const wrapper = mount(NamedRuleDialog, {
props: {
open: true,
namedRuleId: '1',
},
});

await flushPromises();

// expect getNamedRule to be called
expect(services().station.getNamedRule).toHaveBeenCalledWith('1');

const form = wrapper.findComponent(NamedRuleForm);

const name = form.find('input[name="name"]').element as HTMLInputElement;
const description = form.find('input[name="description"]').element as HTMLInputElement;

expect(name.value).toBe('Test Rule');
expect(description.value).toBe('Test Description');
});

it('creates new named rule', async () => {
const wrapper = mount(NamedRuleDialog, {
props: {
open: true,
},
});

await flushPromises();

const dialogContents = wrapper.findComponent(VCard);

const form = wrapper.findComponent(NamedRuleForm);

// Fill out form
await form.find('input[name="name"]').setValue('New Rule');
await form.find('input[name="description"]').setValue('New Description');

// Set a simple rule
const ruleBuilder = form.findComponent({ name: 'RuleBuilder' });
await ruleBuilder.vm.$emit('update:modelValue', {
AutoApproved: null,
});

await flushPromises();

// Find and click save button
const saveButton = dialogContents.find('button[data-test-id="save-named-rule"]');
expect(saveButton.exists()).toBe(true);
await saveButton.trigger('click');

await flushPromises();

// Verify addNamedRule was called with correct data
expect(services().station.addNamedRule).toHaveBeenCalledWith({
name: 'New Rule',
description: ['New Description'],
rule: {
AutoApproved: null,
},
});
});

it('edits existing named rule', async () => {
const wrapper = mount(NamedRuleDialog, {
props: {
open: true,
namedRuleId: '1',
},
});

await flushPromises();

const dialogContents = wrapper.findComponent(VCard);

const form = wrapper.findComponent(NamedRuleForm);

// Update form fields
await form.find('input[name="name"]').setValue('Updated Rule');
await form.find('input[name="description"]').setValue('Updated Description');

// Update rule
const ruleBuilder = form.findComponent({ name: 'RuleBuilder' });
await ruleBuilder.vm.$emit('update:modelValue', {
AutoApproved: null,
});

await flushPromises();

// Find and click save button
const saveButton = dialogContents.find('button[data-test-id="save-named-rule"]');
await saveButton.trigger('click');

await flushPromises();

// Verify editNamedRule was called with correct data
expect(services().station.editNamedRule).toHaveBeenCalledWith({
named_rule_id: '1',
name: ['Updated Rule'],
description: [['Updated Description']],
rule: [
{
AutoApproved: null,
},
],
});
});

it('handles readonly mode correctly', async () => {
const wrapper = mount(NamedRuleDialog, {
props: {
open: true,
namedRuleId: '1',
readonly: true,
},
});

await flushPromises();

const dialogContents = wrapper.findComponent(VCard);

const form = wrapper.findComponent(NamedRuleForm);

// Verify inputs are disabled
const nameInput = form.find('input[name="name"]');
expect(nameInput.attributes('disabled')).toBeDefined();

const descriptionInput = form.find('input[name="description"]');
expect(descriptionInput.attributes('disabled')).toBeDefined();

// Verify save button is not present
const saveButton = dialogContents.find('button[data-test-id="save-named-rule"]');
expect(saveButton.exists()).toBe(false);
});
});
Loading

0 comments on commit b30f195

Please sign in to comment.