Add context menu actions and tests

This commit is contained in:
Gregor Vostrak
2026-03-23 01:50:48 +01:00
parent c07ac4b0e4
commit 12bbbf64e9
8 changed files with 701 additions and 182 deletions
+74
View File
@@ -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
// =============================================
+152
View File
@@ -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
// =============================================
+74
View File
@@ -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
// =============================================
+53
View File
@@ -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
// =============================================
@@ -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);
</script>
<template>
<TableRow>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ClientMoreOptionsDropdown
:client="client"
@edit="showEditModal = true"
@archive="archiveClient"
@delete="deleteClient"></ClientMoreOptionsDropdown>
</div>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow>
<ClientEditModal v-model:show="showEditModal" :client="client"></ClientEditModal>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ client.name }}
</span>
</div>
<div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ClientMoreOptionsDropdown
:client="client"
@edit="showEditModal = true"
@archive="archiveClient"
@delete="deleteClient"></ClientMoreOptionsDropdown>
</div>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateClients()"
class="space-x-3"
@select="showEditModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem v-if="canUpdateClients()" class="space-x-3" @select="archiveClient()">
<ArchiveBoxIconSolid class="w-4 h-4 text-icon-default" />
<span>{{ client.is_archived ? 'Unarchive' : 'Archive' }}</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteClients()" />
<ContextMenuItem
v-if="canDeleteClients()"
class="space-x-3 text-destructive"
@select="deleteClient()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>
@@ -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(() => {
</script>
<template>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
member.billable_rate
? formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
<template v-else>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
<span>Inactive</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
>Invite
</SecondaryButton>
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal v-model:show="showEditMemberModal" :member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
<MemberDeleteModal
v-model:show="showDeleteMemberModal"
:member="member"></MemberDeleteModal>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ member.name }}
</span>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ member.email }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ capitalizeFirstLetter(member.role) }}
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{
member.billable_rate
? formatCents(
member.billable_rate,
organization?.currency,
organization?.currency_format,
organization?.currency_symbol,
organization?.number_format
)
: '--'
}}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
<template v-else>
<UserCircleIcon class="w-4 text-icon-default"></UserCircleIcon>
<span>Inactive</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
>Invite
</SecondaryButton>
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"
@merge="showMergeMemberModal = true"
@make-placeholder="
showMakeMemberPlaceholderModal = true
"></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal
v-model:show="showMergeMemberModal"
:member="member"></MemberMergeModal>
<MemberMakePlaceholderModal
v-model:show="showMakeMemberPlaceholderModal"
:member="member"></MemberMakePlaceholderModal>
<MemberDeleteModal
v-model:show="showDeleteMemberModal"
:member="member"></MemberDeleteModal>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateMembers()"
class="space-x-3"
@select="showEditMemberModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem
v-if="member.role === 'placeholder' && canMergeMembers()"
class="space-x-3"
@select="showMergeMemberModal = true">
<ArrowDownOnSquareStackIcon class="w-4 h-4 text-icon-default" />
<span>Merge</span>
</ContextMenuItem>
<ContextMenuItem
v-if="member.role !== 'placeholder' && canMakeMembersPlaceholders()"
class="space-x-3"
@select="showMakeMemberPlaceholderModal = true">
<UserCircleIconSolid class="w-4 h-4 text-icon-default" />
<span>Deactivate</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteMembers()" />
<ContextMenuItem
v-if="canDeleteMembers()"
class="space-x-3 text-destructive"
@select="showDeleteMemberModal = true">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>
@@ -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);
<ProjectEditModal
v-model:show="showEditProjectModal"
:original-project="project"></ProjectEditModal>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
project.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div v-else>--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress>
<span v-else> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown
:project="project"
@edit="showEditProjectModal = true"
@archive="archiveProject"
@delete="deleteProject"></ProjectMoreOptionsDropdown>
</div>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<div
class="whitespace-nowrap min-w-0 flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<div
:style="{
backgroundColor: project.color,
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
}"
class="w-3 h-3 rounded-full"></div>
<span class="overflow-ellipsis overflow-hidden">
{{ project.name }}
</span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }}
</div>
<div v-else>No client</div>
</div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
<div v-if="project.spent_time">
{{
formatHumanReadableDuration(
project.spent_time,
organization?.interval_format,
organization?.number_format
)
}}
</div>
<div v-else>--</div>
</div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress
v-else-if="project.estimated_time"
:estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress>
<span v-else> -- </span>
</div>
<div
v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary">
{{ billableRateInfo }}
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span>
</template>
<template v-else>
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown
:project="project"
@edit="showEditProjectModal = true"
@archive="archiveProject"
@delete="deleteProject"></ProjectMoreOptionsDropdown>
</div>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateProjects()"
class="space-x-3"
@select="showEditProjectModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem
v-if="canUpdateProjects()"
class="space-x-3"
@select="archiveProject()">
<ArchiveBoxIconSolid class="w-4 h-4 text-icon-default" />
<span>{{ project.is_archived ? 'Unarchive' : 'Archive' }}</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteProjects()" />
<ContextMenuItem
v-if="canDeleteProjects()"
class="space-x-3 text-destructive"
@select="deleteProject()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>
@@ -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() {
</script>
<template>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ tag.name }}
</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<TagMoreOptionsDropdown
v-if="canDeleteTags() || canUpdateTags()"
:tag="tag"
@edit="showTagEditModal = true"
@delete="deleteTag"></TagMoreOptionsDropdown>
</div>
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
</TableRow>
<ContextMenu>
<ContextMenuTrigger as-child>
<TableRow>
<div
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-text-primary pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
<span>
{{ tag.name }}
</span>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<TagMoreOptionsDropdown
v-if="canDeleteTags() || canUpdateTags()"
:tag="tag"
@edit="showTagEditModal = true"
@delete="deleteTag"></TagMoreOptionsDropdown>
</div>
<TagEditModal v-model:show="showTagEditModal" :tag="tag"></TagEditModal>
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canUpdateTags()"
class="space-x-3"
@select="showTagEditModal = true">
<PencilSquareIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuSeparator v-if="canDeleteTags()" />
<ContextMenuItem
v-if="canDeleteTags()"
class="space-x-3 text-destructive"
@select="deleteTag()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</template>
<style scoped></style>