fix member edit modal ui regression from field component migration

This commit is contained in:
Gregor Vostrak
2026-02-18 17:57:11 +01:00
parent cf98fabe0a
commit 26524c5f40
3 changed files with 225 additions and 26 deletions

View File

@@ -9,6 +9,7 @@ import {
createPlaceholderMemberViaImportApi, createPlaceholderMemberViaImportApi,
getMembersViaApi, getMembersViaApi,
updateMemberBillableRateViaApi, updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { getTableRowNames } from './utils/table'; import { getTableRowNames } from './utils/table';
@@ -84,8 +85,8 @@ test('test that organization billable rate can be updated with all existing time
const newBillableRate = Math.round(Math.random() * 10000); const newBillableRate = Math.round(Math.random() * 10000);
await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click(); await page.getByRole('menuitem').getByText('Edit').click();
await page.getByText('Organization Default Rate').click(); await page.getByRole('combobox').last().click();
await page.getByText('Custom Rate').click(); await page.getByRole('option', { name: 'Custom Rate' }).click();
await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString());
await page.getByRole('button', { name: 'Update Member' }).click(); await page.getByRole('button', { name: 'Update Member' }).click();
@@ -107,6 +108,136 @@ test('test that organization billable rate can be updated with all existing time
]); ]);
}); });
test('test that switching member billable rate from custom back to default rate works', async ({
page,
ctx,
}) => {
// Set a known org billable rate
await updateOrganizationSettingViaApi(ctx, { billable_rate: 12000 });
// Create a placeholder member with a custom billable rate
await createPlaceholderMemberViaImportApi(ctx, 'CustomToDefault Member');
const members = await getMembersViaApi(ctx);
const member = members.find((m) => m.name === 'CustomToDefault Member');
expect(member).toBeDefined();
await updateMemberBillableRateViaApi(ctx, member!.id, 25000);
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'CustomToDefault Member' });
await expect(memberRow).toBeVisible();
// Open edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Verify it starts on Custom Rate
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
await expect(billableCombobox).toContainText('Custom Rate');
// Switch to Default Rate
await billableCombobox.click();
await page.getByRole('option', { name: 'Default Rate' }).click();
await expect(billableCombobox).toContainText('Default Rate');
// Verify the billable rate input is disabled
await expect(page.getByPlaceholder('Billable Rate')).toBeDisabled();
// Submit — billable_rate changes from 25000 to null, so confirmation dialog appears
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
await expect(page.getByText('the default rate of the organization')).toBeVisible();
// Confirm the update
await Promise.all([
page.getByRole('button', { name: 'Yes, update existing time' }).click(),
page.waitForRequest(
(request) =>
request.url().includes('/members/') &&
request.method() === 'PUT' &&
request.postDataJSON().billable_rate === null
),
]);
// Verify both dialogs are closed
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that default rate shows disabled input with organization billable rate', async ({
page,
ctx,
}) => {
// Set a known org billable rate (150.00)
await updateOrganizationSettingViaApi(ctx, { billable_rate: 15000 });
await goToMembersPage(page);
// Open edit modal for the owner (who uses default rate by default)
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Verify it's on Default Rate
const billableCombobox = page.getByRole('dialog').getByRole('combobox').last();
await expect(billableCombobox).toContainText('Default Rate');
// Verify the input is disabled and shows the org rate (formatted with currency)
const billableInput = page.getByPlaceholder('Billable Rate');
await expect(billableInput).toBeDisabled();
await expect(billableInput).toHaveAttribute('aria-valuenow', '150');
// Close the dialog
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('test that cancelling the billable rate confirmation dialog does not update the member', async ({
page,
ctx,
}) => {
// Create a placeholder member with a custom billable rate
await createPlaceholderMemberViaImportApi(ctx, 'CancelConfirm Member');
const members = await getMembersViaApi(ctx);
const member = members.find((m) => m.name === 'CancelConfirm Member');
expect(member).toBeDefined();
await updateMemberBillableRateViaApi(ctx, member!.id, 10000);
await goToMembersPage(page);
const memberRow = page.getByRole('row').filter({ hasText: 'CancelConfirm Member' });
await expect(memberRow).toBeVisible();
// Open edit modal
await memberRow.getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').click();
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
// Change the billable rate
await page.getByPlaceholder('Billable Rate').fill('200');
// Click Update Member — confirmation dialog should appear
await page.getByRole('button', { name: 'Update Member' }).click();
await expect(page.getByRole('heading', { name: 'Update Member Billable Rate' })).toBeVisible();
// Set up listener to verify no PUT request is sent after cancel
let putRequestSent = false;
page.on('request', (request) => {
if (request.url().includes('/members/') && request.method() === 'PUT') {
putRequestSent = true;
}
});
// Click Cancel on the confirmation dialog
await page.getByRole('button', { name: 'Cancel' }).click();
// Verify confirmation dialog is closed
await expect(
page.getByRole('heading', { name: 'Update Member Billable Rate' })
).not.toBeVisible();
// Verify no API call was made
expect(putRequestSent).toBe(false);
});
test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => { test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => {
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);

16
e2e/utils/table.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { Locator } from '@playwright/test';
/**
* Extract the first cell's text content from each row in a table.
* Useful for reading the ordered names/labels from a sorted table.
*/
export async function getTableRowNames(table: Locator): Promise<string[]> {
const rows = table.getByRole('row');
const count = await rows.count();
const names: string[] = [];
for (let i = 0; i < count; i++) {
const text = await rows.nth(i).locator('div').first().textContent();
if (text) names.push(text.trim());
}
return names;
}

View File

@@ -1,20 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue'; import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import type { Member, UpdateMemberBody } from '@/packages/api/src'; import type { Member, UpdateMemberBody } from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers'; import { type MemberBillableKey, useMembersStore } from '@/utils/useMembers';
import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue'; import BillableRateInput from '@/packages/ui/src/Input/BillableRateInput.vue';
import { Field, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue'; import MemberBillableRateModal from '@/Components/Common/Member/MemberBillableRateModal.vue';
import MemberBillableSelect from '@/Components/Common/Member/MemberBillableSelect.vue';
import { onMounted, watch } from 'vue';
import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue'; import MemberRoleSelect from '@/Components/Common/Member/MemberRoleSelect.vue';
import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue'; import MemberOwnershipTransferConfirmModal from '@/Components/Common/Member/MemberOwnershipTransferConfirmModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { updateMember } = useMembersStore(); const { updateMember } = useMembersStore();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const show = defineModel('show', { default: false }); const show = defineModel('show', { default: false });
const saving = ref(false); const saving = ref(false);
@@ -75,10 +90,26 @@ watch(billableRateSelect, () => {
if (billableRateSelect.value === 'default-rate') { if (billableRateSelect.value === 'default-rate') {
memberBody.value.billable_rate = null; memberBody.value.billable_rate = null;
} else if (billableRateSelect.value === 'custom-rate') { } else if (billableRateSelect.value === 'custom-rate') {
memberBody.value.billable_rate = props.member.billable_rate ?? 0; if (!memberBody.value.billable_rate) {
memberBody.value.billable_rate = organization.value?.billable_rate ?? 0;
}
} }
}); });
const displayedRate = computed({
get() {
if (billableRateSelect.value === 'default-rate') {
return organization.value?.billable_rate ?? null;
}
return memberBody.value.billable_rate;
},
set(value: number | null) {
if (billableRateSelect.value === 'custom-rate') {
memberBody.value.billable_rate = value;
}
},
});
const roleDescriptionTexts = { const roleDescriptionTexts = {
'owner': 'owner':
'The owner has full access of the organization. The owner is the only role that can: delete the organization, transfer the ownership to another user and access to the billing settings', 'The owner has full access of the organization. The owner is the only role that can: delete the organization, transfer the ownership to another user and access to the billing settings',
@@ -120,34 +151,55 @@ const roleDescription = computed(() => {
<template #content> <template #content>
<div class="pb-5 pt-2 divide-y divide-border-secondary"> <div class="pb-5 pt-2 divide-y divide-border-secondary">
<div class="pb-5 flex space-x-6"> <div class="pb-5">
<Field> <Field>
<FieldLabel for="role">Role</FieldLabel> <FieldLabel for="role">Role</FieldLabel>
<MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect> <MemberRoleSelect v-model="memberBody.role" name="role"></MemberRoleSelect>
<FieldDescription v-if="roleDescription">{{
roleDescription
}}</FieldDescription>
</Field> </Field>
<div class="flex-1 text-xs flex items-center pt-6">
<p>{{ roleDescription }}</p>
</div>
</div> </div>
<div class="flex items-center space-x-4 pt-5"> <div class="pt-5">
<div class="col-span-6 sm:col-span-4 flex-1 flex space-x-5"> <Field>
<Field> <FieldLabel :icon="BillableIcon" for="billableRateType"
<FieldLabel for="billableType">Billable</FieldLabel> >Billable Rate</FieldLabel
<MemberBillableSelect >
v-model="billableRateSelect" <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
name="billableType"></MemberBillableSelect> <Select v-model="billableRateSelect">
</Field> <SelectTrigger id="billableRateType">
<Field v-if="billableRateSelect === 'custom-rate'" class="flex-1"> <SelectValue />
<FieldLabel for="memberBillableRate">Billable Rate</FieldLabel> </SelectTrigger>
<SelectContent>
<SelectItem value="default-rate">Default Rate</SelectItem>
<SelectItem value="custom-rate">Custom Rate</SelectItem>
</SelectContent>
</Select>
<TooltipProvider v-if="billableRateSelect === 'default-rate'">
<Tooltip>
<TooltipTrigger as-child>
<div>
<BillableRateInput
v-model="displayedRate"
:currency="getOrganizationCurrencyString()"
disabled
name="memberBillableRate" />
</div>
</TooltipTrigger>
<TooltipContent
>Uses the default rate of the organization</TooltipContent
>
</Tooltip>
</TooltipProvider>
<BillableRateInput <BillableRateInput
v-model="memberBody.billable_rate" v-else
v-model="displayedRate"
focus focus
class="w-full"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
name="memberBillableRate" name="memberBillableRate"
@keydown.enter="saveWithChecks()"></BillableRateInput> @keydown.enter="saveWithChecks()" />
</Field> </div>
</div> </Field>
</div> </div>
</div> </div>
</template> </template>