diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index 843095cc..f225c188 100644 --- a/e2e/clients.spec.ts +++ b/e2e/clients.spec.ts @@ -132,6 +132,80 @@ test('test that deleting a client via actions menu works', async ({ page, ctx }) await expect(page.getByTestId('client_table')).not.toContainText(clientName); }); +// ============================================= +// Context Menu Tests +// ============================================= + +test('test that client context menu edit updates the client', async ({ page, ctx }) => { + const clientName = 'CtxEditClient ' + Math.floor(1 + Math.random() * 10000); + const updatedName = 'CtxUpdatedClient ' + Math.floor(1 + Math.random() * 10000); + await createClientViaApi(ctx, { name: clientName }); + await goToClientsOverview(page); + + const row = page.getByRole('row').filter({ hasText: clientName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByPlaceholder('Client Name').fill(updatedName); + await Promise.all([ + page.getByRole('button', { name: 'Update Client' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/clients') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + await expect(page.getByTestId('client_table')).toContainText(updatedName); + await expect(page.getByTestId('client_table')).not.toContainText(clientName); +}); + +test('test that client context menu archive archives the client', async ({ page, ctx }) => { + const clientName = 'CtxArchiveClient ' + Math.floor(1 + Math.random() * 10000); + await createClientViaApi(ctx, { name: clientName }); + await goToClientsOverview(page); + + const row = page.getByRole('row').filter({ hasText: clientName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/clients') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('menuitem', { name: 'Archive' }).click(), + ]); + await expect(page.getByTestId('client_table')).not.toContainText(clientName); +}); + +test('test that client context menu delete deletes the client', async ({ page, ctx }) => { + const clientName = 'CtxDeleteClient ' + Math.floor(1 + Math.random() * 10000); + await createClientViaApi(ctx, { name: clientName }); + await goToClientsOverview(page); + + const row = page.getByRole('row').filter({ hasText: clientName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/clients') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + await expect(page.getByTestId('client_table')).not.toContainText(clientName); +}); + // ============================================= // Sorting Tests // ============================================= diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 6c426c4e..4359cbd4 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -496,6 +496,158 @@ test('test that organization owner cannot be deleted', async ({ page }) => { await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible(); }); +// ============================================= +// Context Menu Tests +// ============================================= + +test('test that member context menu edit updates the member billable rate', async ({ + page, + ctx, +}) => { + const memberName = 'CtxEditMember ' + Math.floor(1 + Math.random() * 10000); + await createPlaceholderMemberViaImportApi(ctx, memberName); + await goToMembersPage(page); + + const row = page.getByRole('row').filter({ hasText: memberName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); + + // Change billable rate from default to custom + const billableRateSelect = page.getByRole('dialog').getByRole('combobox').last(); + await billableRateSelect.click(); + await page.getByRole('option', { name: 'Custom Rate' }).click(); + + // Set a custom billable rate + await page.getByPlaceholder('Billable Rate').fill('150'); + + // 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(); + + // Confirm the billable rate change + await Promise.all([ + page.getByRole('button', { name: 'Yes, update existing time entries' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Verify dialog closed + await expect(page.getByRole('dialog')).not.toBeVisible(); +}); + +test('test that member context menu merge merges the member', async ({ page, ctx }) => { + const memberName = 'CtxMergeMember ' + Math.floor(1 + Math.random() * 10000); + await createPlaceholderMemberViaImportApi(ctx, memberName); + await goToMembersPage(page); + + const row = page.getByRole('row').filter({ hasText: memberName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Merge' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible(); + + // Select the first available member as merge target + await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click(); + const firstOption = page.getByRole('option').first(); + await expect(firstOption).toBeVisible({ timeout: 10000 }); + await firstOption.click(); + + // Submit merge + await Promise.all([ + page.getByRole('button', { name: 'Merge Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/member/') && + response.url().includes('/merge-into') && + response.ok() + ), + ]); + + // Verify placeholder member is no longer visible + await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible(); + await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible(); +}); + +test('test that member context menu deactivate deactivates the member', async ({ + page, + browser, +}) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `member+${memberId}@deactivate.test`; + const memberName = 'Deactivate Target'; + + // Invite and accept a new Employee member + await inviteAndAcceptMember(page, browser, memberName, memberEmail, 'Employee'); + + await goToMembersPage(page); + const row = page.getByRole('row').filter({ hasText: memberName }).first(); + await expect(row).toBeVisible(); + + // Open context menu and click Deactivate + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Deactivate' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Deactivate User' })).toBeVisible(); + + // Confirm deactivation + await Promise.all([ + page.getByRole('button', { name: 'Deactivate' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/make-placeholder') && + response.request().method() === 'POST' && + response.ok() + ), + ]); + + // Verify dialog closed and member role changed to Placeholder + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(row.getByText('Placeholder', { exact: true })).toBeVisible(); +}); + +test('test that member context menu delete deletes the member', async ({ page, ctx }) => { + const memberName = 'CtxDeleteMember ' + Math.floor(1 + Math.random() * 10000); + await createPlaceholderMemberViaImportApi(ctx, memberName); + await goToMembersPage(page); + + const row = page.getByRole('row').filter({ hasText: memberName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible(); + + // Check the confirmation checkbox + await page.getByRole('checkbox').click(); + + // Click Delete Member button and wait for API response + await Promise.all([ + page.getByRole('button', { name: 'Delete Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'DELETE' && + response.ok() + ), + ]); + + // Verify modal closed and member removed from table + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole('main').getByText(memberName)).not.toBeVisible(); +}); + // ============================================= // Invitations Tab Tests // ============================================= diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index c5bc2fb4..531ca12e 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -800,6 +800,80 @@ test('test that editing a task name on the project detail page works', async ({ await expect(page.getByTestId('task_table')).not.toContainText(originalTaskName); }); +// ============================================= +// Context Menu Tests +// ============================================= + +test('test that project context menu edit updates the project', async ({ page, ctx }) => { + const projectName = 'CtxEditProject ' + Math.floor(1 + Math.random() * 10000); + const updatedName = 'CtxUpdatedProject ' + Math.floor(1 + Math.random() * 10000); + await createProjectViaApi(ctx, { name: projectName }); + await goToProjectsOverview(page); + + const row = page.getByRole('row').filter({ hasText: projectName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByPlaceholder('Project Name').fill(updatedName); + await Promise.all([ + page.getByRole('button', { name: 'Update Project' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/projects/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + await expect(page.getByTestId('project_table')).toContainText(updatedName); + await expect(page.getByTestId('project_table')).not.toContainText(projectName); +}); + +test('test that project context menu archive archives the project', async ({ page, ctx }) => { + const projectName = 'CtxArchiveProject ' + Math.floor(1 + Math.random() * 10000); + await createProjectViaApi(ctx, { name: projectName }); + await goToProjectsOverview(page); + + const row = page.getByRole('row').filter({ hasText: projectName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('menuitem', { name: 'Archive' }).click(), + ]); + await expect(page.getByTestId('project_table')).not.toContainText(projectName); +}); + +test('test that project context menu delete deletes the project', async ({ page, ctx }) => { + const projectName = 'CtxDeleteProject ' + Math.floor(1 + Math.random() * 10000); + await createProjectViaApi(ctx, { name: projectName }); + await goToProjectsOverview(page); + + const row = page.getByRole('row').filter({ hasText: projectName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/projects') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + await expect(page.getByTestId('project_table')).not.toContainText(projectName); +}); + // ============================================= // Employee Permission Tests // ============================================= diff --git a/e2e/tags.spec.ts b/e2e/tags.spec.ts index 0846f0cd..85c015f6 100644 --- a/e2e/tags.spec.ts +++ b/e2e/tags.spec.ts @@ -90,6 +90,59 @@ test('test that multiple tags can be created via API and displayed in the table' await expect(page.getByTestId('tag_table')).toContainText(tagName2); }); +// ============================================= +// Context Menu Tests +// ============================================= + +test('test that tag context menu edit updates the tag', async ({ page, ctx }) => { + const tagName = 'CtxEditTag ' + Math.floor(1 + Math.random() * 10000); + const updatedName = 'CtxUpdatedTag ' + Math.floor(1 + Math.random() * 10000); + await createTagViaApi(ctx, { name: tagName }); + await goToTagsOverview(page); + + const row = page.getByRole('row').filter({ hasText: tagName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByPlaceholder('Tag Name').fill(updatedName); + await Promise.all([ + page.getByRole('button', { name: 'Update Tag' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/tags') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + await expect(page.getByTestId('tag_table')).toContainText(updatedName); + await expect(page.getByTestId('tag_table')).not.toContainText(tagName); +}); + +test('test that tag context menu delete deletes the tag', async ({ page, ctx }) => { + const tagName = 'CtxDeleteTag ' + Math.floor(1 + Math.random() * 10000); + await createTagViaApi(ctx, { name: tagName }); + await goToTagsOverview(page); + + const row = page.getByRole('row').filter({ hasText: tagName }).first(); + await expect(row).toBeVisible(); + await row.click({ button: 'right' }); + await expect(page.getByRole('menu')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/tags') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + await expect(page.getByTestId('tag_table')).not.toContainText(tagName); +}); + // ============================================= // Sorting Tests // ============================================= diff --git a/resources/js/Components/Common/Client/ClientTableRow.vue b/resources/js/Components/Common/Client/ClientTableRow.vue index 20b6fc2d..39674c56 100644 --- a/resources/js/Components/Common/Client/ClientTableRow.vue +++ b/resources/js/Components/Common/Client/ClientTableRow.vue @@ -2,11 +2,24 @@ import type { Client } from '@/packages/api/src'; import { computed, ref } from 'vue'; import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; +import { + PencilSquareIcon, + ArchiveBoxIcon as ArchiveBoxIconSolid, + TrashIcon, +} from '@heroicons/vue/20/solid'; import { useClientsStore } from '@/utils/useClients'; import ClientMoreOptionsDropdown from '@/Components/Common/Client/ClientMoreOptionsDropdown.vue'; import { useProjectsQuery } from '@/utils/useProjectsQuery'; import TableRow from '@/Components/TableRow.vue'; import ClientEditModal from '@/Components/Common/Client/ClientEditModal.vue'; +import { canUpdateClients, canDeleteClients } from '@/utils/permissions'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/packages/ui/src'; const { projects } = useProjectsQuery(); @@ -33,38 +46,63 @@ const showEditModal = ref(false); diff --git a/resources/js/Components/Common/Member/MemberTableRow.vue b/resources/js/Components/Common/Member/MemberTableRow.vue index 741b8c94..4e3de739 100644 --- a/resources/js/Components/Common/Member/MemberTableRow.vue +++ b/resources/js/Components/Common/Member/MemberTableRow.vue @@ -2,12 +2,24 @@ import type { Member, Organization } from '@/packages/api/src'; import { api } from '@/packages/api/src'; import { CheckCircleIcon, UserCircleIcon } from '@heroicons/vue/24/outline'; +import { + PencilSquareIcon, + TrashIcon, + ArrowDownOnSquareStackIcon, + UserCircleIcon as UserCircleIconSolid, +} from '@heroicons/vue/20/solid'; import MemberMoreOptionsDropdown from '@/Components/Common/Member/MemberMoreOptionsDropdown.vue'; import TableRow from '@/Components/TableRow.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import { getCurrentOrganizationId } from '@/utils/useUser'; import { useNotificationsStore } from '@/utils/notification'; -import { canInvitePlaceholderMembers } from '@/utils/permissions'; +import { + canInvitePlaceholderMembers, + canUpdateMembers, + canDeleteMembers, + canMergeMembers, + canMakeMembersPlaceholders, +} from '@/utils/permissions'; import { computed, type ComputedRef, inject, ref } from 'vue'; import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue'; import MemberMergeModal from '@/Components/Common/Member/MemberMergeModal.vue'; @@ -15,6 +27,13 @@ import MemberMakePlaceholderModal from '@/Components/Common/Member/MemberMakePla import MemberDeleteModal from '@/Components/Common/Member/MemberDeleteModal.vue'; import { capitalizeFirstLetter } from '../../../utils/format'; import { formatCents } from '../../../packages/ui/src/utils/money'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/packages/ui/src'; const props = defineProps<{ member: Member; @@ -55,73 +74,112 @@ const userHasValidMailAddress = computed(() => { diff --git a/resources/js/Components/Common/Project/ProjectTableRow.vue b/resources/js/Components/Common/Project/ProjectTableRow.vue index 3d91c24d..d19586da 100644 --- a/resources/js/Components/Common/Project/ProjectTableRow.vue +++ b/resources/js/Components/Common/Project/ProjectTableRow.vue @@ -3,6 +3,11 @@ import ProjectMoreOptionsDropdown from '@/Components/Common/Project/ProjectMoreO import type { Project } from '@/packages/api/src'; import { computed, ref, inject, type ComputedRef } from 'vue'; import { CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/vue/24/outline'; +import { + PencilSquareIcon, + ArchiveBoxIcon as ArchiveBoxIconSolid, + TrashIcon, +} from '@heroicons/vue/20/solid'; import { useClientsQuery } from '@/utils/useClientsQuery'; import { useTasksQuery } from '@/utils/useTasksQuery'; import { useProjectsStore } from '@/utils/useProjects'; @@ -14,7 +19,15 @@ import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue'; import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue'; import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time'; import { isAllowedToPerformPremiumAction } from '@/utils/billing'; +import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions'; import type { Organization } from '@/packages/api/src'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/packages/ui/src'; const { clients } = useClientsQuery(); const { tasks } = useTasksQuery(); @@ -69,71 +82,99 @@ const showEditProjectModal = ref(false); - -
-
- - {{ project.name }} - - {{ projectTasksCount }} Tasks -
-
-
- {{ client?.name }} -
-
No client
-
-
-
- {{ - formatHumanReadableDuration( - project.spent_time, - organization?.interval_format, - organization?.number_format - ) - }} -
-
--
-
-
- - - -- -
-
- {{ billableRateInfo }} -
-
- - -
-
- -
-
+ + + +
+
+ + {{ project.name }} + + {{ projectTasksCount }} Tasks +
+
+
+ {{ client?.name }} +
+
No client
+
+
+
+ {{ + formatHumanReadableDuration( + project.spent_time, + organization?.interval_format, + organization?.number_format + ) + }} +
+
--
+
+
+ + + -- +
+
+ {{ billableRateInfo }} +
+
+ + +
+
+ +
+
+
+ + + + Edit + + + + {{ project.is_archived ? 'Unarchive' : 'Archive' }} + + + + + Delete + + +
diff --git a/resources/js/Components/Common/Tag/TagTableRow.vue b/resources/js/Components/Common/Tag/TagTableRow.vue index cb0918c8..b1ff1628 100644 --- a/resources/js/Components/Common/Tag/TagTableRow.vue +++ b/resources/js/Components/Common/Tag/TagTableRow.vue @@ -6,6 +6,14 @@ import TagEditModal from '@/Components/Common/Tag/TagEditModal.vue'; import TableRow from '@/Components/TableRow.vue'; import { canDeleteTags, canUpdateTags } from '@/utils/permissions'; import { ref } from 'vue'; +import { PencilSquareIcon, TrashIcon } from '@heroicons/vue/20/solid'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/packages/ui/src'; const props = defineProps<{ tag: Tag; @@ -19,23 +27,44 @@ function deleteTag() {