From 26524c5f4048e01d58c1eadd7ec6d8676b24cc5c Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 18 Feb 2026 17:57:11 +0100 Subject: [PATCH] fix member edit modal ui regression from field component migration --- e2e/members.spec.ts | 135 +++++++++++++++++- e2e/utils/table.ts | 16 +++ .../Common/Member/MemberEditModal.vue | 100 +++++++++---- 3 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 e2e/utils/table.ts diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 50086d6a..6c426c4e 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -9,6 +9,7 @@ import { createPlaceholderMemberViaImportApi, getMembersViaApi, updateMemberBillableRateViaApi, + updateOrganizationSettingViaApi, } from './utils/api'; 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); await page.getByRole('row').first().getByRole('button').click(); await page.getByRole('menuitem').getByText('Edit').click(); - await page.getByText('Organization Default Rate').click(); - await page.getByText('Custom Rate').click(); + await page.getByRole('combobox').last().click(); + await page.getByRole('option', { name: 'Custom Rate' }).click(); await page.getByPlaceholder('Billable Rate').fill(newBillableRate.toString()); 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 }) => { const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); diff --git a/e2e/utils/table.ts b/e2e/utils/table.ts new file mode 100644 index 00000000..e35626bc --- /dev/null +++ b/e2e/utils/table.ts @@ -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 { + 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; +} diff --git a/resources/js/Components/Common/Member/MemberEditModal.vue b/resources/js/Components/Common/Member/MemberEditModal.vue index 07a46161..638ce3ff 100644 --- a/resources/js/Components/Common/Member/MemberEditModal.vue +++ b/resources/js/Components/Common/Member/MemberEditModal.vue @@ -1,20 +1,35 @@