mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 12:42:15 +01:00
fix member edit modal ui regression from field component migration
This commit is contained in:
@@ -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
16
e2e/utils/table.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user